feat: Add settings provider for theme and time range management

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

feat: Introduce Ingredient model

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

feat: Develop Archived Supplements Screen

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

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

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

View File

@@ -8,7 +8,8 @@
<application <application
android:label="supplements" android:label="supplements"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -46,6 +47,10 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Notification action receiver for handling action button clicks -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'providers/supplement_provider.dart'; import 'providers/supplement_provider.dart';
import 'providers/settings_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
void main() { void main() {
@@ -12,9 +13,18 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProvider( return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => SupplementProvider()..initialize(), create: (context) => SupplementProvider()..initialize(),
child: MaterialApp( ),
ChangeNotifierProvider(
create: (context) => SettingsProvider()..initialize(),
),
],
child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return MaterialApp(
title: 'Supplements Tracker', title: 'Supplements Tracker',
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
@@ -30,9 +40,11 @@ class MyApp extends StatelessWidget {
), ),
useMaterial3: true, useMaterial3: true,
), ),
themeMode: ThemeMode.system, // Follows system theme themeMode: settingsProvider.themeMode,
home: const HomeScreen(), home: const HomeScreen(),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
);
},
), ),
); );
} }

View File

@@ -0,0 +1,64 @@
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"
const Ingredient({
this.id,
required this.name,
required this.amount,
required this.unit,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'amount': amount,
'unit': unit,
};
}
factory Ingredient.fromMap(Map<String, dynamic> map) {
return Ingredient(
id: map['id'],
name: map['name'],
amount: map['amount']?.toDouble() ?? 0.0,
unit: map['unit'],
);
}
Ingredient copyWith({
int? id,
String? name,
double? amount,
String? unit,
}) {
return Ingredient(
id: id ?? this.id,
name: name ?? this.name,
amount: amount ?? this.amount,
unit: unit ?? this.unit,
);
}
@override
String toString() {
return '$amount$unit $name';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Ingredient &&
other.name == name &&
other.amount == amount &&
other.unit == unit;
}
@override
int get hashCode {
return name.hashCode ^ amount.hashCode ^ unit.hashCode;
}
}

View File

@@ -1,9 +1,12 @@
import 'ingredient.dart';
import 'dart:convert';
class Supplement { class Supplement {
final int? id; final int? id;
final String name; final String name;
final double dosageAmount; // Amount per unit (e.g., 187mg) final String? brand;
final List<Ingredient> ingredients;
final int numberOfUnits; // Number of units to take (e.g., 2 capsules) final int numberOfUnits; // Number of units to take (e.g., 2 capsules)
final String unit; // mg, g, ml, etc.
final String unitType; // capsules, tablets, ml, etc. final String unitType; // capsules, tablets, ml, etc.
final int frequencyPerDay; final int frequencyPerDay;
final List<String> reminderTimes; // e.g., ['08:00', '20:00'] final List<String> reminderTimes; // e.g., ['08:00', '20:00']
@@ -14,9 +17,9 @@ class Supplement {
Supplement({ Supplement({
this.id, this.id,
required this.name, required this.name,
required this.dosageAmount, this.brand,
this.ingredients = const [],
required this.numberOfUnits, required this.numberOfUnits,
required this.unit,
required this.unitType, required this.unitType,
required this.frequencyPerDay, required this.frequencyPerDay,
required this.reminderTimes, required this.reminderTimes,
@@ -25,16 +28,38 @@ class Supplement {
this.isActive = true, this.isActive = true,
}); });
// Helper getter for total dosage per intake // Helper getters
double get totalDosagePerIntake => dosageAmount * numberOfUnits; double get totalDosagePerIntake {
// This concept doesn't apply well to multi-ingredient supplements
// Return 0 as it should be handled per ingredient
return 0.0;
}
// Get formatted ingredients string for display
String get ingredientsDisplay {
if (ingredients.isEmpty) {
return 'No ingredients specified';
}
return ingredients.map((ingredient) =>
'${ingredient.amount * numberOfUnits}${ingredient.unit} ${ingredient.name}'
).join(', ');
}
// Get ingredients per single unit
String get ingredientsPerUnit {
if (ingredients.isEmpty) {
return 'No ingredients specified';
}
return ingredients.map((ingredient) => ingredient.toString()).join(', ');
}
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
'name': name, 'name': name,
'dosageAmount': dosageAmount, 'brand': brand,
'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()),
'numberOfUnits': numberOfUnits, 'numberOfUnits': numberOfUnits,
'unit': unit,
'unitType': unitType, 'unitType': unitType,
'frequencyPerDay': frequencyPerDay, 'frequencyPerDay': frequencyPerDay,
'reminderTimes': reminderTimes.join(','), 'reminderTimes': reminderTimes.join(','),
@@ -45,13 +70,29 @@ class Supplement {
} }
factory Supplement.fromMap(Map<String, dynamic> map) { factory Supplement.fromMap(Map<String, dynamic> map) {
List<Ingredient> ingredients = [];
// Try to parse ingredients if they exist
if (map['ingredients'] != null && map['ingredients'].isNotEmpty) {
try {
final ingredientsJson = map['ingredients'] as String;
final ingredientsList = jsonDecode(ingredientsJson) as List;
ingredients = ingredientsList
.map((ingredient) => Ingredient.fromMap(ingredient as Map<String, dynamic>))
.toList();
} catch (e) {
// If parsing fails, fall back to empty list
ingredients = [];
}
}
return Supplement( return Supplement(
id: map['id'], id: map['id'],
name: map['name'], name: map['name'],
dosageAmount: map['dosageAmount']?.toDouble() ?? map['dosage']?.toDouble() ?? 0.0, // Backwards compatibility brand: map['brand'],
numberOfUnits: map['numberOfUnits'] ?? 1, // Default to 1 for backwards compatibility ingredients: ingredients,
unit: map['unit'], numberOfUnits: map['numberOfUnits'] ?? 1,
unitType: map['unitType'] ?? 'units', // Default unit type for backwards compatibility unitType: map['unitType'] ?? 'units',
frequencyPerDay: map['frequencyPerDay'], frequencyPerDay: map['frequencyPerDay'],
reminderTimes: map['reminderTimes'].split(','), reminderTimes: map['reminderTimes'].split(','),
notes: map['notes'], notes: map['notes'],
@@ -63,9 +104,9 @@ class Supplement {
Supplement copyWith({ Supplement copyWith({
int? id, int? id,
String? name, String? name,
double? dosageAmount, String? brand,
List<Ingredient>? ingredients,
int? numberOfUnits, int? numberOfUnits,
String? unit,
String? unitType, String? unitType,
int? frequencyPerDay, int? frequencyPerDay,
List<String>? reminderTimes, List<String>? reminderTimes,
@@ -76,9 +117,9 @@ class Supplement {
return Supplement( return Supplement(
id: id ?? this.id, id: id ?? this.id,
name: name ?? this.name, name: name ?? this.name,
dosageAmount: dosageAmount ?? this.dosageAmount, brand: brand ?? this.brand,
ingredients: ingredients ?? this.ingredients,
numberOfUnits: numberOfUnits ?? this.numberOfUnits, numberOfUnits: numberOfUnits ?? this.numberOfUnits,
unit: unit ?? this.unit,
unitType: unitType ?? this.unitType, unitType: unitType ?? this.unitType,
frequencyPerDay: frequencyPerDay ?? this.frequencyPerDay, frequencyPerDay: frequencyPerDay ?? this.frequencyPerDay,
reminderTimes: reminderTimes ?? this.reminderTimes, reminderTimes: reminderTimes ?? this.reminderTimes,

View File

@@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum ThemeOption {
system,
light,
dark,
}
class SettingsProvider extends ChangeNotifier {
ThemeOption _themeOption = ThemeOption.system;
// Time range settings (stored as hours, 0-23)
int _morningStart = 5;
int _morningEnd = 10;
int _afternoonStart = 11;
int _afternoonEnd = 16;
int _eveningStart = 17;
int _eveningEnd = 22;
int _nightStart = 23;
int _nightEnd = 4;
// Persistent reminder settings
bool _persistentReminders = true;
int _reminderRetryInterval = 5; // minutes
int _maxRetryAttempts = 3;
ThemeOption get themeOption => _themeOption;
// Time range getters
int get morningStart => _morningStart;
int get morningEnd => _morningEnd;
int get afternoonStart => _afternoonStart;
int get afternoonEnd => _afternoonEnd;
int get eveningStart => _eveningStart;
int get eveningEnd => _eveningEnd;
int get nightStart => _nightStart;
int get nightEnd => _nightEnd;
// Persistent reminder getters
bool get persistentReminders => _persistentReminders;
int get reminderRetryInterval => _reminderRetryInterval;
int get maxRetryAttempts => _maxRetryAttempts;
// Helper method to get formatted time ranges for display
String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}';
String get afternoonRange => '${_formatHour(_afternoonStart)} - ${_formatHour((_afternoonEnd + 1) % 24)}';
String get eveningRange => '${_formatHour(_eveningStart)} - ${_formatHour((_eveningEnd + 1) % 24)}';
String get nightRange => '${_formatHour(_nightStart)} - ${_formatHour((_nightEnd + 1) % 24)}';
String _formatHour(int hour) {
return '${hour.toString().padLeft(2, '0')}:00';
}
ThemeMode get themeMode {
switch (_themeOption) {
case ThemeOption.light:
return ThemeMode.light;
case ThemeOption.dark:
return ThemeMode.dark;
case ThemeOption.system:
return ThemeMode.system;
}
}
Future<void> initialize() async {
final prefs = await SharedPreferences.getInstance();
final themeIndex = prefs.getInt('theme_option') ?? 0;
_themeOption = ThemeOption.values[themeIndex];
// Load time range settings
_morningStart = prefs.getInt('morning_start') ?? 5;
_morningEnd = prefs.getInt('morning_end') ?? 10;
_afternoonStart = prefs.getInt('afternoon_start') ?? 11;
_afternoonEnd = prefs.getInt('afternoon_end') ?? 16;
_eveningStart = prefs.getInt('evening_start') ?? 17;
_eveningEnd = prefs.getInt('evening_end') ?? 22;
_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;
notifyListeners();
}
Future<void> setThemeOption(ThemeOption option) async {
_themeOption = option;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('theme_option', option.index);
}
Future<void> setTimeRanges({
required int morningStart,
required int morningEnd,
required int afternoonStart,
required int afternoonEnd,
required int eveningStart,
required int eveningEnd,
required int nightStart,
required int nightEnd,
}) async {
// Validate ranges don't overlap (simplified validation)
if (!_areTimeRangesValid(
morningStart, morningEnd,
afternoonStart, afternoonEnd,
eveningStart, eveningEnd,
nightStart, nightEnd,
)) {
throw ArgumentError('Time ranges overlap or are invalid');
}
_morningStart = morningStart;
_morningEnd = morningEnd;
_afternoonStart = afternoonStart;
_afternoonEnd = afternoonEnd;
_eveningStart = eveningStart;
_eveningEnd = eveningEnd;
_nightStart = nightStart;
_nightEnd = nightEnd;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('morning_start', morningStart);
await prefs.setInt('morning_end', morningEnd);
await prefs.setInt('afternoon_start', afternoonStart);
await prefs.setInt('afternoon_end', afternoonEnd);
await prefs.setInt('evening_start', eveningStart);
await prefs.setInt('evening_end', eveningEnd);
await prefs.setInt('night_start', nightStart);
await prefs.setInt('night_end', nightEnd);
}
bool _areTimeRangesValid(
int morningStart, int morningEnd,
int afternoonStart, int afternoonEnd,
int eveningStart, int eveningEnd,
int nightStart, int nightEnd,
) {
// Basic validation - ensure start < end for non-wrapping periods
if (morningStart > morningEnd) return false;
if (afternoonStart > afternoonEnd) return false;
if (eveningStart > eveningEnd) return false;
// Night can wrap around midnight, so we allow nightStart > nightEnd
// Check for overlaps in sequential periods
if (morningEnd >= afternoonStart) return false;
if (afternoonEnd >= eveningStart) return false;
if (eveningEnd >= nightStart) return false;
return true;
}
// Method to determine time category based on current settings
String determineTimeCategory(List<String> reminderTimes) {
if (reminderTimes.isEmpty) {
return 'anytime';
}
// Convert reminder times to hours for categorization
final hours = reminderTimes.map((time) {
final parts = time.split(':');
return int.tryParse(parts[0]) ?? 12;
}).toList();
// Count how many times fall into each category
int morningCount = 0;
int afternoonCount = 0;
int eveningCount = 0;
int nightCount = 0;
for (final hour in hours) {
if (hour >= _morningStart && hour <= _morningEnd) {
morningCount++;
} else if (hour >= _afternoonStart && hour <= _afternoonEnd) {
afternoonCount++;
} else if (hour >= _eveningStart && hour <= _eveningEnd) {
eveningCount++;
} else if (_isInNightRange(hour)) {
nightCount++;
}
}
// If supplement is taken throughout the day (has times in multiple periods)
final periodsCount = (morningCount > 0 ? 1 : 0) +
(afternoonCount > 0 ? 1 : 0) +
(eveningCount > 0 ? 1 : 0) +
(nightCount > 0 ? 1 : 0);
if (periodsCount >= 2) {
// Categorize based on the earliest reminder time for consistency
final earliestHour = hours.reduce((a, b) => a < b ? a : b);
if (earliestHour >= _morningStart && earliestHour <= _morningEnd) {
return 'morning';
} else if (earliestHour >= _afternoonStart && earliestHour <= _afternoonEnd) {
return 'afternoon';
} else if (earliestHour >= _eveningStart && earliestHour <= _eveningEnd) {
return 'evening';
} else if (_isInNightRange(earliestHour)) {
return 'night';
}
}
// If all times are in one period, categorize accordingly
if (morningCount > 0) {
return 'morning';
} else if (afternoonCount > 0) {
return 'afternoon';
} else if (eveningCount > 0) {
return 'evening';
} else if (nightCount > 0) {
return 'night';
} else {
return 'anytime';
}
}
bool _isInNightRange(int hour) {
// Night range can wrap around midnight
if (_nightStart <= _nightEnd) {
// Normal range (doesn't wrap around midnight)
return hour >= _nightStart && hour <= _nightEnd;
} else {
// Wrapping range (e.g., 23:00 to 4:00)
return hour >= _nightStart || hour <= _nightEnd;
}
}
// Persistent reminder setters
Future<void> setPersistentReminders(bool enabled) async {
_persistentReminders = enabled;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('persistent_reminders', enabled);
}
Future<void> setReminderRetryInterval(int minutes) async {
_reminderRetryInterval = minutes;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('reminder_retry_interval', minutes);
}
Future<void> setMaxRetryAttempts(int attempts) async {
_maxRetryAttempts = attempts;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('max_retry_attempts', attempts);
}
}

View File

@@ -1,4 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../models/supplement_intake.dart'; import '../models/supplement_intake.dart';
import '../services/database_helper.dart'; import '../services/database_helper.dart';
@@ -12,6 +14,7 @@ class SupplementProvider with ChangeNotifier {
List<Map<String, dynamic>> _todayIntakes = []; List<Map<String, dynamic>> _todayIntakes = [];
List<Map<String, dynamic>> _monthlyIntakes = []; List<Map<String, dynamic>> _monthlyIntakes = [];
bool _isLoading = false; bool _isLoading = false;
Timer? _persistentReminderTimer;
List<Supplement> get supplements => _supplements; List<Supplement> get supplements => _supplements;
List<Map<String, dynamic>> get todayIntakes => _todayIntakes; List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
@@ -20,9 +23,115 @@ class SupplementProvider with ChangeNotifier {
Future<void> initialize() async { Future<void> initialize() async {
await _notificationService.initialize(); 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(); await _notificationService.requestPermissions();
} catch (e) {
if (kDebugMode) {
print('Error requesting notification permissions: $e');
}
// Continue without notifications rather than crashing
}
await loadSupplements(); await loadSupplements();
await loadTodayIntakes(); await loadTodayIntakes();
// Reschedule notifications for all active supplements to ensure persistence
await _rescheduleAllNotifications();
// Start periodic checking for persistent reminders (every 5 minutes)
_startPersistentReminderCheck();
}
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();
}
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 {
await _notificationService.checkPersistentReminders(
persistentReminders,
reminderRetryInterval,
maxRetryAttempts,
);
}
@override
void dispose() {
_persistentReminderTimer?.cancel();
super.dispose();
}
Future<void> _rescheduleAllNotifications() async {
if (kDebugMode) {
print('📱 Rescheduling notifications for all active supplements...');
}
for (final supplement in _supplements) {
if (supplement.reminderTimes.isNotEmpty) {
try {
await _notificationService.scheduleSupplementReminders(supplement);
} catch (e) {
if (kDebugMode) {
print('📱 Error rescheduling notifications for ${supplement.name}: $e');
}
}
}
}
if (kDebugMode) {
print('📱 Finished rescheduling notifications');
}
} }
Future<void> loadSupplements() async { Future<void> loadSupplements() async {
@@ -103,11 +212,11 @@ class SupplementProvider with ChangeNotifier {
} }
} }
Future<void> recordIntake(int supplementId, double dosage, {double? unitsTaken, String? notes}) async { Future<void> recordIntake(int supplementId, double dosage, {double? unitsTaken, String? notes, DateTime? takenAt}) async {
try { try {
final intake = SupplementIntake( final intake = SupplementIntake(
supplementId: supplementId, supplementId: supplementId,
takenAt: DateTime.now(), takenAt: takenAt ?? DateTime.now(),
dosageTaken: dosage, dosageTaken: dosage,
unitsTaken: unitsTaken ?? 1.0, unitsTaken: unitsTaken ?? 1.0,
notes: notes, notes: notes,
@@ -121,7 +230,7 @@ class SupplementProvider with ChangeNotifier {
final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : ''; final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : '';
await _notificationService.showInstantNotification( await _notificationService.showInstantNotification(
'Supplement Taken', 'Supplement Taken',
'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} ($dosage ${supplement.unit})', 'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})',
); );
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
@@ -167,10 +276,100 @@ class SupplementProvider with ChangeNotifier {
try { try {
await _databaseHelper.deleteIntake(intakeId); await _databaseHelper.deleteIntake(intakeId);
await loadTodayIntakes(); await loadTodayIntakes();
// Also refresh monthly intakes if they're loaded
if (_monthlyIntakes.isNotEmpty) {
await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month);
}
notifyListeners();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Error deleting intake: $e'); print('Error deleting intake: $e');
} }
} }
} }
bool hasBeenTakenToday(int supplementId) {
return _todayIntakes.any((intake) => intake['supplement_id'] == supplementId);
}
int getTodayIntakeCount(int supplementId) {
return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length;
}
// Archive functionality
List<Supplement> _archivedSupplements = [];
List<Supplement> get archivedSupplements => _archivedSupplements;
Future<void> loadArchivedSupplements() async {
try {
_archivedSupplements = await _databaseHelper.getArchivedSupplements();
notifyListeners();
} catch (e) {
if (kDebugMode) {
print('Error loading archived supplements: $e');
}
}
}
Future<void> archiveSupplement(int supplementId) async {
try {
await _databaseHelper.archiveSupplement(supplementId);
await loadSupplements(); // Refresh active supplements
await loadArchivedSupplements(); // Refresh archived supplements
} catch (e) {
if (kDebugMode) {
print('Error archiving supplement: $e');
}
}
}
Future<void> unarchiveSupplement(int supplementId) async {
try {
await _databaseHelper.unarchiveSupplement(supplementId);
await loadSupplements(); // Refresh active supplements
await loadArchivedSupplements(); // Refresh archived supplements
} catch (e) {
if (kDebugMode) {
print('Error unarchiving supplement: $e');
}
}
}
Future<void> deleteArchivedSupplement(int supplementId) async {
try {
await _databaseHelper.deleteSupplement(supplementId);
await loadArchivedSupplements(); // Refresh archived supplements
} catch (e) {
if (kDebugMode) {
print('Error deleting archived supplement: $e');
}
}
}
// Debug methods for notification testing
Future<void> testNotifications() async {
await _notificationService.testNotification();
}
Future<void> testScheduledNotification() async {
await _notificationService.testScheduledNotification();
}
Future<void> testNotificationActions() async {
await _notificationService.testNotificationWithActions();
}
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
return await _notificationService.getPendingNotifications();
}
// Debug method to test notification persistence
Future<void> rescheduleAllNotifications() async {
await _rescheduleAllNotifications();
}
// Debug method to cancel all notifications
Future<void> cancelAllNotifications() async {
await _notificationService.cancelAllReminders();
}
} }

View File

@@ -1,9 +1,36 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../models/ingredient.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
// Helper class to manage ingredient text controllers
class IngredientController {
final TextEditingController nameController;
final TextEditingController amountController;
String selectedUnit;
IngredientController({
String name = '',
double amount = 0.0,
this.selectedUnit = 'mg',
}) : nameController = TextEditingController(text: name),
amountController = TextEditingController(text: amount > 0 ? amount.toString() : '');
Ingredient toIngredient() {
return Ingredient(
name: nameController.text.trim(),
amount: double.tryParse(amountController.text) ?? 0.0,
unit: selectedUnit,
);
}
void dispose() {
nameController.dispose();
amountController.dispose();
}
}
class AddSupplementScreen extends StatefulWidget { class AddSupplementScreen extends StatefulWidget {
final Supplement? supplement; final Supplement? supplement;
@@ -16,11 +43,13 @@ class AddSupplementScreen extends StatefulWidget {
class _AddSupplementScreenState extends State<AddSupplementScreen> { class _AddSupplementScreenState extends State<AddSupplementScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _dosageAmountController = TextEditingController(); final _brandController = TextEditingController();
final _numberOfUnitsController = TextEditingController(); final _numberOfUnitsController = TextEditingController();
final _notesController = TextEditingController(); final _notesController = TextEditingController();
String _selectedUnit = 'mg'; // Multi-ingredient support with persistent controllers
List<IngredientController> _ingredientControllers = [];
String _selectedUnitType = 'capsules'; String _selectedUnitType = 'capsules';
int _frequencyPerDay = 1; int _frequencyPerDay = 1;
List<String> _reminderTimes = ['08:00']; List<String> _reminderTimes = ['08:00'];
@@ -35,19 +64,152 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
_initializeWithExistingSupplement(); _initializeWithExistingSupplement();
} else { } else {
_numberOfUnitsController.text = '1'; // Default to 1 unit _numberOfUnitsController.text = '1'; // Default to 1 unit
// Start with one empty ingredient
_ingredientControllers.add(IngredientController());
} }
} }
void _addIngredient() {
setState(() {
_ingredientControllers.add(IngredientController());
});
}
void _removeIngredient(int index) {
if (_ingredientControllers.length > 1) {
setState(() {
_ingredientControllers[index].dispose();
_ingredientControllers.removeAt(index);
});
}
}
void _updateIngredient(int index, String field, dynamic value) {
if (index < _ingredientControllers.length) {
setState(() {
if (field == 'unit') {
_ingredientControllers[index].selectedUnit = value as String;
}
// Note: name and amount are handled by the TextEditingControllers directly
});
}
}
Widget _buildIngredientRow(int index, IngredientController controller) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Ingredient ${index + 1}',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
if (_ingredientControllers.length > 1)
IconButton(
onPressed: () => _removeIngredient(index),
icon: const Icon(Icons.remove_circle_outline),
color: Colors.red,
tooltip: 'Remove ingredient',
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: controller.nameController,
decoration: const InputDecoration(
labelText: 'Ingredient Name *',
border: OutlineInputBorder(),
hintText: 'e.g., Vitamin D3, Magnesium',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter ingredient name';
}
return null;
},
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: controller.amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Amount *',
border: OutlineInputBorder(),
hintText: '100',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Enter amount';
}
if (double.tryParse(value) == null || double.parse(value) <= 0) {
return 'Enter valid amount';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: DropdownButtonFormField<String>(
value: controller.selectedUnit,
decoration: const InputDecoration(
labelText: 'Unit',
border: OutlineInputBorder(),
),
items: _units.map((unit) {
return DropdownMenuItem(
value: unit,
child: Text(unit),
);
}).toList(),
onChanged: (value) {
_updateIngredient(index, 'unit', value);
},
),
),
],
),
],
),
),
);
}
void _initializeWithExistingSupplement() { void _initializeWithExistingSupplement() {
final supplement = widget.supplement!; final supplement = widget.supplement!;
_nameController.text = supplement.name; _nameController.text = supplement.name;
_dosageAmountController.text = supplement.dosageAmount.toString(); _brandController.text = supplement.brand ?? '';
_numberOfUnitsController.text = supplement.numberOfUnits.toString(); _numberOfUnitsController.text = supplement.numberOfUnits.toString();
_notesController.text = supplement.notes ?? ''; _notesController.text = supplement.notes ?? '';
_selectedUnit = supplement.unit;
_selectedUnitType = supplement.unitType; _selectedUnitType = supplement.unitType;
_frequencyPerDay = supplement.frequencyPerDay; _frequencyPerDay = supplement.frequencyPerDay;
_reminderTimes = List.from(supplement.reminderTimes); _reminderTimes = List.from(supplement.reminderTimes);
// Initialize ingredient controllers from existing ingredients
_ingredientControllers.clear();
if (supplement.ingredients.isEmpty) {
// If no ingredients, start with one empty controller
_ingredientControllers.add(IngredientController());
} else {
for (final ingredient in supplement.ingredients) {
_ingredientControllers.add(IngredientController(
name: ingredient.name,
amount: ingredient.amount,
selectedUnit: ingredient.unit,
));
}
}
} }
@override @override
@@ -83,54 +245,37 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Dosage amount per unit // Brand field
Row( TextFormField(
children: [ controller: _brandController,
Expanded(
flex: 2,
child: TextFormField(
controller: _dosageAmountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Amount per unit *', labelText: 'Brand (Optional)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
hintText: '187', hintText: 'e.g., Nature Made, NOW Foods',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter amount per unit';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid number';
}
return null;
},
), ),
), ),
const SizedBox(width: 12), const SizedBox(height: 16),
Expanded(
flex: 1, // Ingredients section
child: DropdownButtonFormField<String>( Text(
value: _selectedUnit, 'Ingredients',
decoration: const InputDecoration( style: Theme.of(context).textTheme.titleMedium,
labelText: 'Unit',
border: OutlineInputBorder(),
), ),
items: _units.map((unit) { const SizedBox(height: 8),
return DropdownMenuItem( ..._ingredientControllers.asMap().entries.map((entry) {
value: unit, final index = entry.key;
child: Text(unit), final controller = entry.value;
); return _buildIngredientRow(index, controller);
}).toList(), }),
onChanged: (value) { const SizedBox(height: 8),
setState(() { OutlinedButton.icon(
_selectedUnit = value!; onPressed: _addIngredient,
}); icon: const Icon(Icons.add),
}, label: const Text('Add Ingredient'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
), ),
), ),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Number of units to take // Number of units to take
@@ -181,13 +326,39 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
// 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),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
'Total per intake: ${_dosageAmountController.text.isNotEmpty && _numberOfUnitsController.text.isNotEmpty ? (double.tryParse(_dosageAmountController.text) ?? 0) * (int.tryParse(_numberOfUnitsController.text) ?? 0) : 0} $_selectedUnit', 'Per ${_numberOfUnitsController.text.isNotEmpty ? _numberOfUnitsController.text : "1"} $_selectedUnitType:',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
..._ingredientControllers.where((c) => c.nameController.text.isNotEmpty && (double.tryParse(c.amountController.text) ?? 0) > 0).map((controller) {
final amount = double.tryParse(controller.amountController.text) ?? 0;
final totalAmount = amount * (int.tryParse(_numberOfUnitsController.text) ?? 1);
return Text(
'${totalAmount.toStringAsFixed(totalAmount % 1 == 0 ? 0 : 1)}${controller.selectedUnit} ${controller.nameController.text}',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic, ),
);
}),
],
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -232,30 +403,66 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
Expanded( Expanded(
child: InkWell( child: InkWell(
onTap: () => _selectTime(index), onTap: () => _selectTime(index),
borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.outline), border: Border.all(color: Theme.of(context).colorScheme.outline),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surface,
),
child: Row(
children: [
Icon(
Icons.access_time,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Text(
time,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const Spacer(),
Icon(
Icons.edit,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
), ),
child: Text(time),
), ),
), ),
), ),
if (_reminderTimes.length > 1) if (_reminderTimes.length > 1)
IconButton( Padding(
padding: const EdgeInsets.only(left: 8),
child: IconButton(
onPressed: () => _removeReminderTime(index), onPressed: () => _removeReminderTime(index),
icon: const Icon(Icons.remove_circle_outline), icon: const Icon(Icons.remove_circle_outline),
color: Colors.red,
tooltip: 'Remove reminder time',
),
), ),
], ],
), ),
); );
}), }),
if (_reminderTimes.length < _frequencyPerDay) if (_reminderTimes.length < _frequencyPerDay)
TextButton.icon( Padding(
padding: const EdgeInsets.only(top: 8),
child: OutlinedButton.icon(
onPressed: _addReminderTime, onPressed: _addReminderTime,
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Add Reminder Time'), label: const Text('Add Reminder Time'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -299,18 +506,36 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
} }
} }
void _selectTime(int index) { void _selectTime(int index) async {
DatePicker.showTimePicker( // Parse current time or use default
context, TimeOfDay currentTime = TimeOfDay(hour: 8, minute: 0);
showTitleActions: true, if (index < _reminderTimes.length) {
onConfirm: (time) { final timeParts = _reminderTimes[index].split(':');
setState(() { if (timeParts.length >= 2) {
_reminderTimes[index] = '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; currentTime = TimeOfDay(
}); hour: int.tryParse(timeParts[0]) ?? 8,
}, minute: int.tryParse(timeParts[1]) ?? 0,
currentTime: DateTime.now(),
); );
} }
}
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: currentTime,
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
child: child!,
);
},
);
if (picked != null) {
setState(() {
_reminderTimes[index] = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
});
}
}
void _addReminderTime() { void _addReminderTime() {
if (_reminderTimes.length < _frequencyPerDay) { if (_reminderTimes.length < _frequencyPerDay) {
@@ -330,12 +555,31 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
void _saveSupplement() async { void _saveSupplement() async {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
// Validate that we have at least one ingredient with name and amount
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();
if (validIngredients.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please add at least one ingredient with name and amount'),
),
);
return;
}
final supplement = Supplement( final supplement = Supplement(
id: widget.supplement?.id, id: widget.supplement?.id,
name: _nameController.text.trim(), name: _nameController.text.trim(),
dosageAmount: double.parse(_dosageAmountController.text), brand: _brandController.text.trim().isNotEmpty ? _brandController.text.trim() : null,
ingredients: validIngredients,
numberOfUnits: int.parse(_numberOfUnitsController.text), numberOfUnits: int.parse(_numberOfUnitsController.text),
unit: _selectedUnit,
unitType: _selectedUnitType, unitType: _selectedUnitType,
frequencyPerDay: _frequencyPerDay, frequencyPerDay: _frequencyPerDay,
reminderTimes: _reminderTimes, reminderTimes: _reminderTimes,
@@ -380,9 +624,15 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
@override @override
void dispose() { void dispose() {
_nameController.dispose(); _nameController.dispose();
_dosageAmountController.dispose(); _brandController.dispose();
_numberOfUnitsController.dispose(); _numberOfUnitsController.dispose();
_notesController.dispose(); _notesController.dispose();
// Dispose all ingredient controllers
for (final controller in _ingredientControllers) {
controller.dispose();
}
super.dispose(); super.dispose();
} }
} }

View File

@@ -0,0 +1,385 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart';
import '../models/supplement.dart';
class ArchivedSupplementsScreen extends StatefulWidget {
const ArchivedSupplementsScreen({super.key});
@override
State<ArchivedSupplementsScreen> createState() => _ArchivedSupplementsScreenState();
}
class _ArchivedSupplementsScreenState extends State<ArchivedSupplementsScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().loadArchivedSupplements();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Archived Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Consumer<SupplementProvider>(
builder: (context, provider, child) {
if (provider.archivedSupplements.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.archive_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No archived supplements',
style: TextStyle(
fontSize: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Archived supplements will appear here',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await provider.loadArchivedSupplements();
},
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: provider.archivedSupplements.length,
itemBuilder: (context, index) {
final supplement = provider.archivedSupplements[index];
return _ArchivedSupplementCard(
supplement: supplement,
onUnarchive: () => _unarchiveSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
);
},
),
);
},
),
);
}
void _unarchiveSupplement(BuildContext context, Supplement supplement) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Unarchive Supplement'),
content: Text('Are you sure you want to unarchive ${supplement.name}?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
context.read<SupplementProvider>().unarchiveSupplement(supplement.id!);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${supplement.name} unarchived'),
backgroundColor: Colors.green,
),
);
},
child: const Text('Unarchive'),
),
],
),
);
}
void _deleteSupplement(BuildContext context, Supplement supplement) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Supplement'),
content: Text(
'Are you sure you want to permanently delete ${supplement.name}? This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
context.read<SupplementProvider>().deleteArchivedSupplement(supplement.id!);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${supplement.name} deleted permanently'),
backgroundColor: Colors.red,
),
);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Delete', style: TextStyle(color: Colors.white)),
),
],
),
);
}
}
class _ArchivedSupplementCard extends StatelessWidget {
final Supplement supplement;
final VoidCallback onUnarchive;
final VoidCallback onDelete;
const _ArchivedSupplementCard({
required this.supplement,
required this.onUnarchive,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outline.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.archive,
color: Theme.of(context).colorScheme.outline,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
supplement.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (supplement.brand != null && supplement.brand!.isNotEmpty)
Text(
supplement.brand!,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.outline,
fontWeight: FontWeight.w500,
),
),
],
),
),
PopupMenuButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onSelected: (value) {
switch (value) {
case 'unarchive':
onUnarchive();
break;
case 'delete':
onDelete();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'unarchive',
child: Row(
children: [
Icon(Icons.unarchive, color: Colors.green),
SizedBox(width: 8),
Text('Unarchive'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_forever, color: Colors.red),
SizedBox(width: 8),
Text('Delete Permanently', style: TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
const SizedBox(height: 16),
// Supplement details in a muted style
if (supplement.ingredients.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ingredients per ${supplement.unitType}:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: supplement.ingredients.map((ingredient) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outline.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${ingredient.name} ${ingredient.amount}${ingredient.unit}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.outline,
),
),
);
}).toList(),
),
],
),
),
const SizedBox(height: 12),
],
// Dosage info
Row(
children: [
_InfoChip(
icon: Icons.schedule,
label: '${supplement.frequencyPerDay}x daily',
context: context,
),
const SizedBox(width: 8),
_InfoChip(
icon: Icons.medication,
label: '${supplement.numberOfUnits} ${supplement.unitType}',
context: context,
),
],
),
if (supplement.reminderTimes.isNotEmpty) ...[
const SizedBox(height: 8),
_InfoChip(
icon: Icons.notifications_off,
label: 'Was: ${supplement.reminderTimes.join(', ')}',
context: context,
fullWidth: true,
),
],
],
),
),
),
);
}
}
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

@@ -15,6 +15,7 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
int _selectedMonth = DateTime.now().month; int _selectedMonth = DateTime.now().month;
int _selectedYear = DateTime.now().year; int _selectedYear = DateTime.now().year;
int _refreshKey = 0; // Add this to force FutureBuilder refresh
@override @override
void initState() { void initState() {
@@ -90,6 +91,7 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
), ),
Expanded( Expanded(
child: FutureBuilder<List<Map<String, dynamic>>>( child: FutureBuilder<List<Map<String, dynamic>>>(
key: ValueKey('daily_view_$_refreshKey'), // Use refresh key to force rebuild
future: context.read<SupplementProvider>().getIntakesForDate(_selectedDate), future: context.read<SupplementProvider>().getIntakesForDate(_selectedDate),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
@@ -137,12 +139,10 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
final supplementIntakes = groupedIntakes[supplementName]!; final supplementIntakes = groupedIntakes[supplementName]!;
// Calculate totals // Calculate totals
double totalDosage = 0;
double totalUnits = 0; double totalUnits = 0;
final firstIntake = supplementIntakes.first; final firstIntake = supplementIntakes.first;
for (final intake in supplementIntakes) { for (final intake in supplementIntakes) {
totalDosage += intake['dosageTaken'] as double;
totalUnits += (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; totalUnits += (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
} }
@@ -161,14 +161,14 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'${totalDosage.toStringAsFixed(totalDosage % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnit']} total', '${totalUnits.toStringAsFixed(totalUnits % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnitType'] ?? 'units'} total',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
), ),
Text( Text(
'${totalUnits.toStringAsFixed(totalUnits % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnitType'] ?? 'units'}${supplementIntakes.length} intake${supplementIntakes.length > 1 ? 's' : ''}', '${supplementIntakes.length} intake${supplementIntakes.length > 1 ? 's' : ''}',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
@@ -181,9 +181,9 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0; final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(left: 72, right: 16), contentPadding: const EdgeInsets.only(left: 72, right: 8),
title: Text( title: Text(
'${(intake['dosageTaken'] as double).toStringAsFixed((intake['dosageTaken'] as double) % 1 == 0 ? 0 : 1)} ${intake['supplementUnit']}', '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'}',
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
subtitle: Column( subtitle: Column(
@@ -210,6 +210,19 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
), ),
], ],
), ),
trailing: IconButton(
icon: Icon(
Icons.delete_outline,
color: Colors.red.shade400,
size: 20,
),
onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
); );
}).toList(), }).toList(),
), ),
@@ -335,6 +348,7 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
const SizedBox(height: 8), const SizedBox(height: 8),
...dayIntakes.map((intake) { ...dayIntakes.map((intake) {
final takenAt = DateTime.parse(intake['takenAt']); final takenAt = DateTime.parse(intake['takenAt']);
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
child: Row( child: Row(
@@ -347,10 +361,23 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'${intake['supplementName']} - ${intake['dosageTaken']} ${intake['supplementUnit']} at ${DateFormat('HH:mm').format(takenAt)}', '${intake['supplementName']} - ${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'} at ${DateFormat('HH:mm').format(takenAt)}',
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
), ),
IconButton(
icon: Icon(
Icons.delete_outline,
color: Colors.red.shade400,
size: 18,
),
onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
),
], ],
), ),
); );
@@ -403,16 +430,27 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () async {
context.read<SupplementProvider>().deleteIntake(intakeId); await context.read<SupplementProvider>().deleteIntake(intakeId);
Navigator.of(context).pop(); Navigator.of(context).pop();
// Force refresh of the UI
setState(() {
_refreshKey++; // This will force FutureBuilder to rebuild
});
// Force refresh of the current view data
if (_tabController.index == 1) {
// Monthly view - refresh monthly intakes
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
}
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Intake deleted'), content: Text('$supplementName intake deleted'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
setState(() {}); // Refresh the view
}, },
style: ElevatedButton.styleFrom(backgroundColor: Colors.red), style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Delete'), child: const Text('Delete'),

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
import '../providers/settings_provider.dart';
import 'supplements_list_screen.dart'; import 'supplements_list_screen.dart';
import 'history_screen.dart'; import 'history_screen.dart';
import 'add_supplement_screen.dart'; import 'add_supplement_screen.dart';
import 'settings_screen.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -18,6 +20,7 @@ class _HomeScreenState extends State<HomeScreen> {
final List<Widget> _screens = [ final List<Widget> _screens = [
const SupplementsListScreen(), const SupplementsListScreen(),
const HistoryScreen(), const HistoryScreen(),
const SettingsScreen(),
]; ];
@override @override
@@ -25,9 +28,42 @@ class _HomeScreenState extends State<HomeScreen> {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().initialize(); context.read<SupplementProvider>().initialize();
_startPersistentReminderCheck();
}); });
} }
void _startPersistentReminderCheck() {
// Check immediately and then every 10 minutes
_checkPersistentReminders();
// Set up periodic checking
Future.doWhile(() async {
await Future.delayed(const Duration(minutes: 10));
if (mounted) {
await _checkPersistentReminders();
return true;
}
return false;
});
}
Future<void> _checkPersistentReminders() async {
if (!mounted) return;
try {
final supplementProvider = context.read<SupplementProvider>();
final settingsProvider = context.read<SettingsProvider>();
await supplementProvider.checkPersistentRemindersWithSettings(
persistentReminders: settingsProvider.persistentReminders,
reminderRetryInterval: settingsProvider.reminderRetryInterval,
maxRetryAttempts: settingsProvider.maxRetryAttempts,
);
} catch (e) {
print('Error checking persistent reminders: $e');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -39,6 +75,7 @@ class _HomeScreenState extends State<HomeScreen> {
_currentIndex = index; _currentIndex = index;
}); });
}, },
type: BottomNavigationBarType.fixed,
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.medication), icon: Icon(Icons.medication),
@@ -48,6 +85,10 @@ class _HomeScreenState extends State<HomeScreen> {
icon: Icon(Icons.history), icon: Icon(Icons.history),
label: 'History', label: 'History',
), ),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
], ],
), ),
floatingActionButton: _currentIndex == 0 floatingActionButton: _currentIndex == 0

View File

@@ -0,0 +1,601 @@
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';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
),
body: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme',
style: Theme.of(context).textTheme.titleMedium,
),
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) {
if (value != null) {
settingsProvider.setThemeOption(value);
}
},
),
RadioListTile<ThemeOption>(
title: const Text('Light Theme'),
subtitle: const Text('Always use light theme'),
value: ThemeOption.light,
groupValue: settingsProvider.themeOption,
onChanged: (value) {
if (value != null) {
settingsProvider.setThemeOption(value);
}
},
),
RadioListTile<ThemeOption>(
title: const Text('Dark Theme'),
subtitle: const Text('Always use dark theme'),
value: ThemeOption.dark,
groupValue: settingsProvider.themeOption,
onChanged: (value) {
if (value != null) {
settingsProvider.setThemeOption(value);
}
},
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Time Periods',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Customize when morning, afternoon, evening, and night periods occur',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_TimeRangeSelector(
title: 'Morning',
icon: Icons.wb_sunny,
color: Colors.orange,
startHour: settingsProvider.morningStart,
endHour: settingsProvider.morningEnd,
onChanged: (start, end) => _updateTimeRanges(
context, settingsProvider,
morningStart: start, morningEnd: end,
),
),
const SizedBox(height: 12),
_TimeRangeSelector(
title: 'Afternoon',
icon: Icons.light_mode,
color: Colors.blue,
startHour: settingsProvider.afternoonStart,
endHour: settingsProvider.afternoonEnd,
onChanged: (start, end) => _updateTimeRanges(
context, settingsProvider,
afternoonStart: start, afternoonEnd: end,
),
),
const SizedBox(height: 12),
_TimeRangeSelector(
title: 'Evening',
icon: Icons.nightlight_round,
color: Colors.indigo,
startHour: settingsProvider.eveningStart,
endHour: settingsProvider.eveningEnd,
onChanged: (start, end) => _updateTimeRanges(
context, settingsProvider,
eveningStart: start, eveningEnd: end,
),
),
const SizedBox(height: 12),
_TimeRangeSelector(
title: 'Night',
icon: Icons.bedtime,
color: Colors.purple,
startHour: settingsProvider.nightStart,
endHour: settingsProvider.nightEnd,
onChanged: (start, end) => _updateTimeRanges(
context, settingsProvider,
nightStart: start, nightEnd: end,
),
),
],
),
),
),
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: 5, label: Text('5')),
],
selected: {settingsProvider.maxRetryAttempts},
onSelectionChanged: (values) {
settingsProvider.setMaxRetryAttempts(values.first);
},
),
],
],
),
),
),
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'),
),
],
);
},
),
],
),
),
),
],
);
},
),
);
}
void _updateTimeRanges(
BuildContext context,
SettingsProvider settingsProvider, {
int? morningStart,
int? morningEnd,
int? afternoonStart,
int? afternoonEnd,
int? eveningStart,
int? eveningEnd,
int? nightStart,
int? nightEnd,
}) async {
try {
await settingsProvider.setTimeRanges(
morningStart: morningStart ?? settingsProvider.morningStart,
morningEnd: morningEnd ?? settingsProvider.morningEnd,
afternoonStart: afternoonStart ?? settingsProvider.afternoonStart,
afternoonEnd: afternoonEnd ?? settingsProvider.afternoonEnd,
eveningStart: eveningStart ?? settingsProvider.eveningStart,
eveningEnd: eveningEnd ?? settingsProvider.eveningEnd,
nightStart: nightStart ?? settingsProvider.nightStart,
nightEnd: nightEnd ?? settingsProvider.nightEnd,
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Invalid time ranges: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
}
class _TimeRangeSelector extends StatelessWidget {
final String title;
final IconData icon;
final Color color;
final int startHour;
final int endHour;
final void Function(int start, int end) onChanged;
const _TimeRangeSelector({
required this.title,
required this.icon,
required this.color,
required this.startHour,
required this.endHour,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
fontSize: 16,
),
),
const Spacer(),
Text(
'${_formatHour(startHour)} - ${_formatHour(endHour + 1)}',
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Start: ${_formatHour(startHour)}',
style: const TextStyle(fontSize: 12),
),
Slider(
value: startHour.toDouble(),
min: 0,
max: 23,
divisions: 23,
activeColor: color,
onChanged: (value) {
final newStart = value.round();
if (newStart != endHour) {
onChanged(newStart, endHour);
}
},
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'End: ${_formatHour(endHour)}',
style: const TextStyle(fontSize: 12),
),
Slider(
value: endHour.toDouble(),
min: 0,
max: 23,
divisions: 23,
activeColor: color,
onChanged: (value) {
final newEnd = value.round();
if (newEnd != startHour) {
onChanged(startHour, newEnd);
}
},
),
],
),
),
],
),
],
),
);
}
String _formatHour(int hour) {
final adjustedHour = hour % 24;
return '${adjustedHour.toString().padLeft(2, '0')}:00';
}
}

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
import '../providers/settings_provider.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../widgets/supplement_card.dart'; import '../widgets/supplement_card.dart';
import 'add_supplement_screen.dart'; import 'add_supplement_screen.dart';
import 'archived_supplements_screen.dart';
class SupplementsListScreen extends StatelessWidget { class SupplementsListScreen extends StatelessWidget {
const SupplementsListScreen({super.key}); const SupplementsListScreen({super.key});
@@ -15,9 +16,22 @@ class SupplementsListScreen extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
title: const Text('My Supplements'), title: const Text('My Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.archive),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ArchivedSupplementsScreen(),
), ),
body: Consumer<SupplementProvider>( );
builder: (context, provider, child) { },
tooltip: 'Archived Supplements',
),
],
),
body: Consumer2<SupplementProvider, SettingsProvider>(
builder: (context, provider, settingsProvider, child) {
if (provider.isLoading) { if (provider.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -56,86 +70,190 @@ class SupplementsListScreen extends StatelessWidget {
onRefresh: () async { onRefresh: () async {
await provider.loadSupplements(); await provider.loadSupplements();
}, },
child: Column( child: _buildGroupedSupplementsList(context, provider.supplements, settingsProvider),
children: [
// Today's Intakes Section
if (provider.todayIntakes.isNotEmpty) ...[
Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Theme.of(context).colorScheme.outline),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.check_circle, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
'Today\'s Intakes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
const SizedBox(height: 8),
...provider.todayIntakes.map((intake) {
final takenAt = DateTime.parse(intake['takenAt']);
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${intake['supplementName']} - ${intake['dosageTaken']} ${intake['supplementUnit']} at ${DateFormat('HH:mm').format(takenAt)}',
style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer),
),
);
}),
],
),
),
],
// Supplements List
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: provider.supplements.length,
itemBuilder: (context, index) {
final supplement = provider.supplements[index];
return SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
);
},
),
),
],
),
); );
}, },
), ),
); );
} }
Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider) {
final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider);
return ListView(
padding: const EdgeInsets.all(16),
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),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
),
),
const SizedBox(height: 16),
],
if (groupedSupplements['afternoon']!.isNotEmpty) ...[
_buildSectionHeader('Afternoon (${settingsProvider.afternoonRange})', Icons.light_mode, Colors.blue, groupedSupplements['afternoon']!.length),
...groupedSupplements['afternoon']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
),
),
const SizedBox(height: 16),
],
if (groupedSupplements['evening']!.isNotEmpty) ...[
_buildSectionHeader('Evening (${settingsProvider.eveningRange})', Icons.nightlight_round, Colors.indigo, groupedSupplements['evening']!.length),
...groupedSupplements['evening']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
),
),
const SizedBox(height: 16),
],
if (groupedSupplements['night']!.isNotEmpty) ...[
_buildSectionHeader('Night (${settingsProvider.nightRange})', Icons.bedtime, Colors.purple, groupedSupplements['night']!.length),
...groupedSupplements['night']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
),
),
const SizedBox(height: 16),
],
if (groupedSupplements['anytime']!.isNotEmpty) ...[
_buildSectionHeader('Anytime', Icons.schedule, Colors.grey, groupedSupplements['anytime']!.length),
...groupedSupplements['anytime']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
),
),
],
],
);
}
Widget _buildSectionHeader(String title, IconData icon, Color color, int count) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 20,
color: color,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.contains('(') ? title.split('(')[0].trim() : title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
if (title.contains('(')) ...[
Text(
'(${title.split('(')[1]}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: color.withOpacity(0.8),
),
),
],
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
count.toString(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: color,
),
),
),
],
),
);
}
Map<String, List<Supplement>> _groupSupplementsByTimeOfDay(List<Supplement> supplements, SettingsProvider settingsProvider) {
final Map<String, List<Supplement>> grouped = {
'morning': <Supplement>[],
'afternoon': <Supplement>[],
'evening': <Supplement>[],
'night': <Supplement>[],
'anytime': <Supplement>[],
};
for (final supplement in supplements) {
final category = settingsProvider.determineTimeCategory(supplement.reminderTimes);
grouped[category]!.add(supplement);
}
return grouped;
}
void _showTakeDialog(BuildContext context, Supplement supplement) { void _showTakeDialog(BuildContext context, Supplement supplement) {
final unitsController = TextEditingController(text: supplement.numberOfUnits.toString()); final unitsController = TextEditingController(text: supplement.numberOfUnits.toString());
final notesController = TextEditingController(); final notesController = TextEditingController();
DateTime selectedDateTime = DateTime.now();
bool useCustomTime = false;
showDialog( showDialog(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
final units = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble();
final totalDosage = supplement.dosageAmount * units;
return AlertDialog( return AlertDialog(
title: Text('Take ${supplement.name}'), title: Text('Take ${supplement.name}'),
content: Column( content: Column(
@@ -175,7 +293,7 @@ class SupplementsListScreen extends StatelessWidget {
), ),
), ),
Text( Text(
'${totalDosage.toStringAsFixed(totalDosage % 1 == 0 ? 0 : 1)} ${supplement.unit}', supplement.ingredientsDisplay,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -186,6 +304,162 @@ class SupplementsListScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), 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( TextField(
controller: notesController, controller: notesController,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -204,12 +478,15 @@ class SupplementsListScreen extends StatelessWidget {
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
final unitsTaken = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble(); final unitsTaken = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble();
final totalDosageTaken = supplement.dosageAmount * unitsTaken; // 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( context.read<SupplementProvider>().recordIntake(
supplement.id!, supplement.id!,
totalDosageTaken, totalDosageTaken,
unitsTaken: unitsTaken, unitsTaken: unitsTaken,
notes: notesController.text.isNotEmpty ? notesController.text : null, notes: notesController.text.isNotEmpty ? notesController.text : null,
takenAt: useCustomTime ? selectedDateTime : null,
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -219,7 +496,7 @@ class SupplementsListScreen extends StatelessWidget {
), ),
); );
}, },
child: const Text('Take'), child: const Text('Record'),
), ),
], ],
); );
@@ -265,4 +542,34 @@ class SupplementsListScreen extends StatelessWidget {
), ),
); );
} }
void _archiveSupplement(BuildContext context, Supplement supplement) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Archive Supplement'),
content: Text('Are you sure you want to archive ${supplement.name}? You can unarchive it later from the archived supplements list.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
context.read<SupplementProvider>().archiveSupplement(supplement.id!);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${supplement.name} archived'),
backgroundColor: Colors.orange,
),
);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
child: const Text('Archive'),
),
],
),
);
}
} }

View File

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

View File

@@ -2,6 +2,24 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tz; import 'package:timezone/data/latest.dart' as tz;
import '../models/supplement.dart'; 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 { class NotificationService {
static final NotificationService _instance = NotificationService._internal(); static final NotificationService _instance = NotificationService._internal();
@@ -9,15 +27,81 @@ class NotificationService {
NotificationService._internal(); NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); 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 { 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(); 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 AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings( const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
requestAlertPermission: true, requestAlertPermission: false, // We'll request these separately
requestBadgePermission: true, requestBadgePermission: false,
requestSoundPermission: true, requestSoundPermission: false,
); );
const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings( const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings(
defaultActionName: 'Open notification', defaultActionName: 'Open notification',
@@ -29,28 +113,347 @@ class NotificationService {
linux: linuxSettings, linux: linuxSettings,
); );
await _notifications.initialize(initSettings); print('📱 Initializing flutter_local_notifications...');
await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
);
// Test if notification response callback is working
print('📱 Callback function is set and ready');
_isInitialized = true;
print('📱 NotificationService initialization complete');
}
// Handle notification responses (when user taps on notification or action)
void _onNotificationResponse(NotificationResponse response) {
print('📱 === NOTIFICATION RESPONSE ===');
print('📱 Action ID: ${response.actionId}');
print('📱 Payload: ${response.payload}');
print('📱 Notification ID: ${response.id}');
print('📱 Input: ${response.input}');
print('📱 ===============================');
if (response.actionId == 'take_supplement') {
print('📱 Processing TAKE action...');
_handleTakeAction(response.payload, response.id);
} else if (response.actionId == 'snooze_10') {
print('📱 Processing SNOOZE action...');
_handleSnoozeAction(response.payload, 10, response.id);
} else {
print('📱 Default notification tap (no specific action)');
// Default tap (no actionId) opens the app normally
}
}
void _handleTakeAction(String? payload, int? notificationId) {
print('📱 === HANDLING TAKE ACTION ===');
print('📱 Payload received: $payload');
if (payload != null) {
try {
// Parse the payload to get supplement info
final parts = payload.split('|');
print('📱 Payload parts: $parts (length: ${parts.length})');
if (parts.length >= 4) {
final supplementId = int.parse(parts[0]);
final supplementName = parts[1];
final units = double.parse(parts[2]);
final unitType = parts[3];
print('📱 Parsed data:');
print('📱 - ID: $supplementId');
print('📱 - Name: $supplementName');
print('📱 - Units: $units');
print('📱 - Type: $unitType');
// Call the callback to record the intake
if (_onTakeSupplementCallback != null) {
print('📱 Calling supplement callback...');
_onTakeSupplementCallback!(supplementId, supplementName, units, unitType);
print('📱 Callback completed');
} else {
print('📱 ERROR: No callback registered!');
}
// Mark notification as taken in database (this will cancel any pending retries)
if (notificationId != null) {
print('📱 Marking notification $notificationId as taken');
DatabaseHelper.instance.markNotificationTaken(notificationId);
// Cancel any pending retry notifications for this notification
_cancelRetryNotifications(notificationId);
}
// Show a confirmation notification
print('📱 Showing confirmation notification...');
showInstantNotification(
'Supplement Taken!',
'$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}',
);
} else {
print('📱 ERROR: Invalid payload format - not enough parts');
}
} catch (e) {
print('📱 ERROR in _handleTakeAction: $e');
}
} else {
print('📱 ERROR: Payload is null');
}
print('📱 === TAKE ACTION COMPLETE ===');
}
void _cancelRetryNotifications(int notificationId) {
// Retry notifications use ID range starting from 200000
for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries
int retryId = 200000 + (notificationId * 10) + i;
_notifications.cancel(retryId);
print('📱 Cancelled retry notification ID: $retryId');
}
}
void _handleSnoozeAction(String? payload, int minutes, int? notificationId) {
print('📱 === HANDLING SNOOZE ACTION ===');
print('📱 Payload: $payload, Minutes: $minutes');
if (payload != null) {
try {
final parts = payload.split('|');
if (parts.length >= 2) {
final supplementId = int.parse(parts[0]);
final supplementName = parts[1];
print('📱 Snoozing supplement for $minutes minutes: $supplementName');
// Mark notification as snoozed in database (increment retry count)
if (notificationId != null) {
print('📱 Incrementing retry count for notification $notificationId');
DatabaseHelper.instance.incrementRetryCount(notificationId);
}
// Schedule a new notification for the snooze time
final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes));
print('📱 Snooze time: $snoozeTime');
_notifications.zonedSchedule(
supplementId * 1000 + minutes, // Unique ID for snooze notifications
'Reminder: $supplementName',
'Snoozed reminder - Take your $supplementName now',
snoozeTime,
NotificationDetails(
android: AndroidNotificationDetails(
'supplement_reminders',
'Supplement Reminders',
channelDescription: 'Notifications for supplement intake reminders',
importance: Importance.high,
priority: Priority.high,
actions: [
AndroidNotificationAction(
'take_supplement',
'Take',
),
AndroidNotificationAction(
'snooze_10',
'Snooze 10min',
),
],
),
iOS: const DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: payload,
);
showInstantNotification(
'Reminder Snoozed',
'$supplementName reminder snoozed for $minutes minutes',
);
print('📱 Snooze scheduled successfully');
}
} catch (e) {
print('📱 Error handling snooze action: $e');
}
}
print('📱 === SNOOZE ACTION COMPLETE ===');
}
/// Check for persistent reminders from app context with settings
Future<void> checkPersistentReminders(
bool persistentReminders,
int reminderRetryInterval,
int maxRetryAttempts,
) async {
await schedulePersistentReminders(
persistentReminders: persistentReminders,
reminderRetryInterval: reminderRetryInterval,
maxRetryAttempts: maxRetryAttempts,
);
}
/// Check for pending notifications that need retry and schedule them
Future<void> schedulePersistentReminders({
required bool persistentReminders,
required int reminderRetryInterval,
required int maxRetryAttempts,
}) async {
print('📱 Checking for pending notifications to retry...');
try {
if (!persistentReminders) {
print('📱 Persistent reminders disabled');
return;
}
print('📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts');
// Get all pending notifications from database
final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications();
print('📱 Found ${pendingNotifications.length} pending notifications');
final now = DateTime.now();
for (final notification in pendingNotifications) {
final scheduledTime = DateTime.parse(notification['scheduledTime']);
final retryCount = notification['retryCount'] as int;
final lastRetryTime = notification['lastRetryTime'] != null
? DateTime.parse(notification['lastRetryTime'])
: null;
// Check if notification is overdue
final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
// Check if we haven't exceeded max retry attempts
if (retryCount >= maxRetryAttempts) {
print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
continue;
}
// Check if enough time has passed since last retry
if (lastRetryTime != null) {
final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes;
if (timeSinceLastRetry < reminderRetryInterval) {
print('📱 Notification ${notification['notificationId']} not ready for retry yet');
continue;
}
}
if (shouldRetry) {
await _scheduleRetryNotification(notification, retryCount + 1);
}
}
} catch (e) {
print('📱 Error scheduling persistent reminders: $e');
}
}
Future<void> _scheduleRetryNotification(Map<String, dynamic> notification, int retryAttempt) async {
try {
final notificationId = notification['notificationId'] as int;
final supplementId = notification['supplementId'] as int;
// Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt)
final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt;
print('📱 Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)');
// Get supplement details from database
final supplements = await DatabaseHelper.instance.getAllSupplements();
final supplement = supplements.firstWhere((s) => s.id == supplementId && s.isActive, orElse: () => throw Exception('Supplement not found'));
// Schedule the retry notification immediately
await _notifications.show(
retryNotificationId,
'Reminder: ${supplement.name}',
'Don\'t forget to take your ${supplement.name}! (Retry #$retryAttempt)',
NotificationDetails(
android: AndroidNotificationDetails(
'supplement_reminders',
'Supplement Reminders',
channelDescription: 'Notifications for supplement intake reminders',
importance: Importance.high,
priority: Priority.high,
actions: [
AndroidNotificationAction(
'take_supplement',
'Take',
showsUserInterface: true,
icon: DrawableResourceAndroidBitmap('@drawable/ic_check'),
),
AndroidNotificationAction(
'snooze_10',
'Snooze 10min',
showsUserInterface: true,
icon: DrawableResourceAndroidBitmap('@drawable/ic_snooze'),
),
],
),
iOS: const DarwinNotificationDetails(),
),
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}|$notificationId',
);
// Update the retry count in database
await DatabaseHelper.instance.incrementRetryCount(notificationId);
print('📱 Retry notification scheduled successfully');
} catch (e) {
print('📱 Error scheduling retry notification: $e');
}
} }
Future<bool> requestPermissions() async { Future<bool> requestPermissions() async {
print('📱 Requesting notification permissions...');
if (_permissionsRequested) {
print('📱 Permissions already requested');
return true;
}
try {
_permissionsRequested = true;
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>(); final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) { if (androidPlugin != null) {
await androidPlugin.requestNotificationsPermission(); 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>(); final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) { if (iosPlugin != null) {
await iosPlugin.requestPermissions( print('📱 Requesting iOS permissions...');
final granted = await iosPlugin.requestPermissions(
alert: true, alert: true,
badge: true, badge: true,
sound: true, sound: true,
); );
print('📱 iOS permissions granted: $granted');
if (granted != true) {
_permissionsRequested = false;
return false;
}
} }
print('📱 All permissions granted successfully');
return true; return true;
} catch (e) {
_permissionsRequested = false;
print('📱 Error requesting permissions: $e');
return false;
}
} }
Future<void> scheduleSupplementReminders(Supplement supplement) async { Future<void> scheduleSupplementReminders(Supplement supplement) async {
print('📱 Scheduling reminders for ${supplement.name}');
print('📱 Reminder times: ${supplement.reminderTimes}');
// Cancel existing notifications for this supplement // Cancel existing notifications for this supplement
await cancelSupplementReminders(supplement.id!); await cancelSupplementReminders(supplement.id!);
@@ -61,25 +464,59 @@ class NotificationService {
final minute = int.parse(timeParts[1]); final minute = int.parse(timeParts[1]);
final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder 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( await _notifications.zonedSchedule(
notificationId, notificationId,
'Time for ${supplement.name}', 'Time for ${supplement.name}',
'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.totalDosagePerIntake} ${supplement.unit})', 'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.ingredientsPerUnit})',
_nextInstanceOfTime(hour, minute), scheduledTime,
const NotificationDetails( NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'supplement_reminders', 'supplement_reminders',
'Supplement Reminders', 'Supplement Reminders',
channelDescription: 'Notifications for supplement intake reminders', channelDescription: 'Notifications for supplement intake reminders',
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
actions: [
AndroidNotificationAction(
'take_supplement',
'Take',
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'),
showsUserInterface: true, // Changed to true to open app
), ),
iOS: DarwinNotificationDetails(), 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, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time, matchDateTimeComponents: DateTimeComponents.time,
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}',
); );
print('📱 Successfully scheduled notification ID $notificationId');
}
// Get all pending notifications to verify
final pendingNotifications = await _notifications.pendingNotificationRequests();
print('📱 Total pending notifications: ${pendingNotifications.length}');
for (final notification in pendingNotifications) {
print('📱 Pending: ID=${notification.id}, Title=${notification.title}');
} }
} }
@@ -89,6 +526,9 @@ class NotificationService {
final notificationId = supplementId * 100 + i; final notificationId = supplementId * 100 + i;
await _notifications.cancel(notificationId); await _notifications.cancel(notificationId);
} }
// Also clean up database tracking records for this supplement
await DatabaseHelper.instance.clearNotificationTracking(supplementId);
} }
Future<void> cancelAllReminders() async { Future<void> cancelAllReminders() async {
@@ -99,14 +539,22 @@ class NotificationService {
final tz.TZDateTime now = tz.TZDateTime.now(tz.local); final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute); 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)) { if (scheduledDate.isBefore(now)) {
scheduledDate = scheduledDate.add(const Duration(days: 1)); 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; return scheduledDate;
} }
Future<void> showInstantNotification(String title, String body) async { Future<void> showInstantNotification(String title, String body) async {
print('📱 Showing instant notification: $title - $body');
const NotificationDetails notificationDetails = NotificationDetails( const NotificationDetails notificationDetails = NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
'instant_notifications', 'instant_notifications',
@@ -124,5 +572,108 @@ class NotificationService {
body, body,
notificationDetails, 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

@@ -1,11 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
class SupplementCard extends StatelessWidget { class SupplementCard extends StatefulWidget {
final Supplement supplement; final Supplement supplement;
final VoidCallback onTake; final VoidCallback onTake;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback onArchive;
const SupplementCard({ const SupplementCard({
super.key, super.key,
@@ -13,51 +16,204 @@ class SupplementCard extends StatelessWidget {
required this.onTake, required this.onTake,
required this.onEdit, required this.onEdit,
required this.onDelete, required this.onDelete,
required this.onArchive,
}); });
@override
State<SupplementCard> createState() => _SupplementCardState();
}
class _SupplementCardState extends State<SupplementCard> {
bool _isExpanded = false;
@override @override
Widget build(BuildContext context) { 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;
// Get today's intake times for this supplement
final todayIntakes = 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,
};
}).toList();
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 16),
elevation: 2, elevation: 3,
child: Padding( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.all(16), child: Container(
child: Column( decoration: BoxDecoration(
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(16),
children: [ color: isCompletelyTaken
Row( ? Colors.green.shade800
mainAxisAlignment: MainAxisAlignment.spaceBetween, : 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),
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
? Colors.white
: isTakenToday
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.primary,
size: 20,
),
),
title: Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
supplement.name, widget.supplement.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${supplement.numberOfUnits} ${supplement.unitType} (${supplement.dosageAmount} ${supplement.unit} each)',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold,
color: isCompletelyTaken
? Colors.white
: 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,
), ),
), ),
], ],
), ),
), ),
PopupMenuButton( // 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: const Text(
'Complete',
style: TextStyle(
color: Colors.white,
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: Colors.white,
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),
),
),
],
],
],
),
trailing: PopupMenuButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert,
color: isCompletelyTaken
? Colors.white
: Theme.of(context).colorScheme.onSurfaceVariant,
),
onSelected: (value) { onSelected: (value) {
switch (value) { switch (value) {
case 'edit': case 'edit':
onEdit(); widget.onEdit();
break;
case 'archive':
widget.onArchive();
break; break;
case 'delete': case 'delete':
onDelete(); widget.onDelete();
break; break;
} }
}, },
@@ -72,6 +228,16 @@ class SupplementCard extends StatelessWidget {
], ],
), ),
), ),
const PopupMenuItem(
value: 'archive',
child: Row(
children: [
Icon(Icons.archive, color: Colors.orange),
SizedBox(width: 8),
Text('Archive'),
],
),
),
const PopupMenuItem( const PopupMenuItem(
value: 'delete', value: 'delete',
child: Row( child: Row(
@@ -84,116 +250,270 @@ class SupplementCard extends StatelessWidget {
), ),
], ],
), ),
], 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,
), ),
const SizedBox(height: 12), ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row( Row(
children: [ children: [
Icon(Icons.schedule, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), Icon(
const SizedBox(width: 4), Icons.check_circle_outline,
size: 16,
color: isCompletelyTaken
? Colors.green.shade200
: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text( Text(
'${supplement.frequencyPerDay}x daily', 'Taken today:',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600,
), color: isCompletelyTaken
), ? Colors.green.shade200
const SizedBox(width: 16), : Theme.of(context).colorScheme.onSecondaryContainer,
Icon(Icons.notifications, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
supplement.reminderTimes.join(', '),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
if (supplement.notes != null && supplement.notes!.isNotEmpty) ...[ const SizedBox(height: 6),
const SizedBox(height: 8), 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(),
),
],
),
),
const SizedBox(height: 16),
],
// Ingredients section
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
supplement.notes!, '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),
),
),
child: Text(
'${ingredient.name} ${ingredient.amount}${ingredient.unit}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
),
),
);
}).toList(),
),
],
),
),
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( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
), ),
),
], ],
const SizedBox(height: 12),
// Take supplement section const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12), // Take button
decoration: BoxDecoration( SizedBox(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), width: double.infinity,
borderRadius: BorderRadius.circular(8), child: ElevatedButton.icon(
border: Border.all( onPressed: isCompletelyTaken ? null : widget.onTake,
color: Theme.of(context).colorScheme.outline.withOpacity(0.5), 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),
), ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Take Supplement',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Row(
children: [
Text(
'Amount: ',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(
'${supplement.numberOfUnits} ${supplement.unitType}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
Text(
' (${supplement.totalDosagePerIntake} ${supplement.unit})',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
ElevatedButton.icon(
onPressed: onTake,
icon: const Icon(Icons.medication, size: 16),
label: const Text('Take', style: TextStyle(fontSize: 12)),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: isCompletelyTaken
foregroundColor: Theme.of(context).colorScheme.onPrimary, ? Colors.green.shade500
minimumSize: const Size(80, 32), : Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: isCompletelyTaken ? 0 : 2,
),
), ),
), ),
], ],
), ),
], ),
),
);
},
);
}
}
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.onSurfaceVariant,
),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
), ),
),
); );
} }
} }

View File

@@ -6,9 +6,11 @@ import FlutterMacOS
import Foundation import Foundation
import flutter_local_notifications import flutter_local_notifications
import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
} }

View File

@@ -81,19 +81,19 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_datetime_picker_plus:
dependency: "direct main"
description:
name: flutter_datetime_picker_plus
sha256: "7d82da02c4e070bb28a9107de119ad195e2319b45c786fecc13482a9ffcc51da"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -139,6 +139,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -235,6 +240,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -267,6 +296,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e"
url: "https://pub.dev"
source: hosted
version: "2.4.11"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -442,4 +527,4 @@ packages:
version: "6.6.1" version: "6.6.1"
sdks: sdks:
dart: ">=3.9.0 <4.0.0" dart: ">=3.9.0 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.27.0"

View File

@@ -43,6 +43,9 @@ dependencies:
# State management # State management
provider: ^6.1.1 provider: ^6.1.1
# Settings persistence
shared_preferences: ^2.2.2
# Local notifications # Local notifications
flutter_local_notifications: ^19.4.1 flutter_local_notifications: ^19.4.1
timezone: ^0.10.1 timezone: ^0.10.1
@@ -50,9 +53,6 @@ dependencies:
# Date time handling # Date time handling
intl: ^0.20.2 intl: ^0.20.2
# UI components
flutter_datetime_picker_plus: ^2.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter