mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
feat: Add settings provider for theme and time range management
- Implemented SettingsProvider to manage user preferences for theme options and time ranges for reminders. - Added persistent reminder settings with configurable retry intervals and maximum attempts. - Created UI for settings screen to allow users to customize their preferences. - Integrated shared_preferences for persistent storage of user settings. feat: Introduce Ingredient model - Created Ingredient model to represent nutritional components with properties for id, name, amount, and unit. - Added methods for serialization and deserialization of Ingredient objects. feat: Develop Archived Supplements Screen - Implemented ArchivedSupplementsScreen to display archived supplements with options to unarchive or delete. - Added UI components for listing archived supplements and handling user interactions. chore: Update dependencies in pubspec.yaml and pubspec.lock - Updated shared_preferences dependency to the latest version. - Removed flutter_datetime_picker_plus dependency and added file dependency. - Updated Flutter SDK constraint to >=3.27.0.
This commit is contained in:
@@ -8,7 +8,8 @@
|
||||
<application
|
||||
android:label="supplements"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -46,6 +47,10 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</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.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'providers/supplement_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
@@ -12,27 +13,38 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (context) => SupplementProvider()..initialize(),
|
||||
child: MaterialApp(
|
||||
title: 'Supplements Tracker',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => SupplementProvider()..initialize(),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => SettingsProvider()..initialize(),
|
||||
),
|
||||
themeMode: ThemeMode.system, // Follows system theme
|
||||
home: const HomeScreen(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
],
|
||||
child: Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return MaterialApp(
|
||||
title: 'Supplements Tracker',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
themeMode: settingsProvider.themeMode,
|
||||
home: const HomeScreen(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
64
lib/models/ingredient.dart
Normal file
64
lib/models/ingredient.dart
Normal 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;
|
||||
}
|
||||
}
|
@@ -1,9 +1,12 @@
|
||||
import 'ingredient.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
class Supplement {
|
||||
final int? id;
|
||||
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 String unit; // mg, g, ml, etc.
|
||||
final String unitType; // capsules, tablets, ml, etc.
|
||||
final int frequencyPerDay;
|
||||
final List<String> reminderTimes; // e.g., ['08:00', '20:00']
|
||||
@@ -14,9 +17,9 @@ class Supplement {
|
||||
Supplement({
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.dosageAmount,
|
||||
this.brand,
|
||||
this.ingredients = const [],
|
||||
required this.numberOfUnits,
|
||||
required this.unit,
|
||||
required this.unitType,
|
||||
required this.frequencyPerDay,
|
||||
required this.reminderTimes,
|
||||
@@ -25,16 +28,38 @@ class Supplement {
|
||||
this.isActive = true,
|
||||
});
|
||||
|
||||
// Helper getter for total dosage per intake
|
||||
double get totalDosagePerIntake => dosageAmount * numberOfUnits;
|
||||
// Helper getters
|
||||
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() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'dosageAmount': dosageAmount,
|
||||
'brand': brand,
|
||||
'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()),
|
||||
'numberOfUnits': numberOfUnits,
|
||||
'unit': unit,
|
||||
'unitType': unitType,
|
||||
'frequencyPerDay': frequencyPerDay,
|
||||
'reminderTimes': reminderTimes.join(','),
|
||||
@@ -45,13 +70,29 @@ class Supplement {
|
||||
}
|
||||
|
||||
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(
|
||||
id: map['id'],
|
||||
name: map['name'],
|
||||
dosageAmount: map['dosageAmount']?.toDouble() ?? map['dosage']?.toDouble() ?? 0.0, // Backwards compatibility
|
||||
numberOfUnits: map['numberOfUnits'] ?? 1, // Default to 1 for backwards compatibility
|
||||
unit: map['unit'],
|
||||
unitType: map['unitType'] ?? 'units', // Default unit type for backwards compatibility
|
||||
brand: map['brand'],
|
||||
ingredients: ingredients,
|
||||
numberOfUnits: map['numberOfUnits'] ?? 1,
|
||||
unitType: map['unitType'] ?? 'units',
|
||||
frequencyPerDay: map['frequencyPerDay'],
|
||||
reminderTimes: map['reminderTimes'].split(','),
|
||||
notes: map['notes'],
|
||||
@@ -63,9 +104,9 @@ class Supplement {
|
||||
Supplement copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
double? dosageAmount,
|
||||
String? brand,
|
||||
List<Ingredient>? ingredients,
|
||||
int? numberOfUnits,
|
||||
String? unit,
|
||||
String? unitType,
|
||||
int? frequencyPerDay,
|
||||
List<String>? reminderTimes,
|
||||
@@ -76,9 +117,9 @@ class Supplement {
|
||||
return Supplement(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
dosageAmount: dosageAmount ?? this.dosageAmount,
|
||||
brand: brand ?? this.brand,
|
||||
ingredients: ingredients ?? this.ingredients,
|
||||
numberOfUnits: numberOfUnits ?? this.numberOfUnits,
|
||||
unit: unit ?? this.unit,
|
||||
unitType: unitType ?? this.unitType,
|
||||
frequencyPerDay: frequencyPerDay ?? this.frequencyPerDay,
|
||||
reminderTimes: reminderTimes ?? this.reminderTimes,
|
||||
|
259
lib/providers/settings_provider.dart
Normal file
259
lib/providers/settings_provider.dart
Normal 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);
|
||||
}
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import '../models/supplement.dart';
|
||||
import '../models/supplement_intake.dart';
|
||||
import '../services/database_helper.dart';
|
||||
@@ -12,6 +14,7 @@ class SupplementProvider with ChangeNotifier {
|
||||
List<Map<String, dynamic>> _todayIntakes = [];
|
||||
List<Map<String, dynamic>> _monthlyIntakes = [];
|
||||
bool _isLoading = false;
|
||||
Timer? _persistentReminderTimer;
|
||||
|
||||
List<Supplement> get supplements => _supplements;
|
||||
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
|
||||
@@ -20,9 +23,115 @@ class SupplementProvider with ChangeNotifier {
|
||||
|
||||
Future<void> initialize() async {
|
||||
await _notificationService.initialize();
|
||||
await _notificationService.requestPermissions();
|
||||
|
||||
// Set up the callback for handling supplement intake from notifications
|
||||
print('📱 Setting up notification callback...');
|
||||
_notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) {
|
||||
print('📱 === NOTIFICATION CALLBACK TRIGGERED ===');
|
||||
print('📱 Supplement ID: $supplementId');
|
||||
print('📱 Supplement Name: $supplementName');
|
||||
print('📱 Units: $units');
|
||||
print('📱 Unit Type: $unitType');
|
||||
|
||||
// Record the intake when user taps "Take" on notification
|
||||
recordIntake(supplementId, 0.0, unitsTaken: units);
|
||||
print('📱 Intake recorded successfully');
|
||||
print('📱 === CALLBACK COMPLETE ===');
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📱 Recorded intake from notification: $supplementName ($units $unitType)');
|
||||
}
|
||||
});
|
||||
print('📱 Notification callback setup complete');
|
||||
|
||||
// Request permissions with error handling
|
||||
try {
|
||||
await _notificationService.requestPermissions();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error requesting notification permissions: $e');
|
||||
}
|
||||
// Continue without notifications rather than crashing
|
||||
}
|
||||
|
||||
await loadSupplements();
|
||||
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 {
|
||||
@@ -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 {
|
||||
final intake = SupplementIntake(
|
||||
supplementId: supplementId,
|
||||
takenAt: DateTime.now(),
|
||||
takenAt: takenAt ?? DateTime.now(),
|
||||
dosageTaken: dosage,
|
||||
unitsTaken: unitsTaken ?? 1.0,
|
||||
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}' : '';
|
||||
await _notificationService.showInstantNotification(
|
||||
'Supplement Taken',
|
||||
'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} ($dosage ${supplement.unit})',
|
||||
'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})',
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
@@ -167,10 +276,100 @@ class SupplementProvider with ChangeNotifier {
|
||||
try {
|
||||
await _databaseHelper.deleteIntake(intakeId);
|
||||
await loadTodayIntakes();
|
||||
// Also refresh monthly intakes if they're loaded
|
||||
if (_monthlyIntakes.isNotEmpty) {
|
||||
await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month);
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart';
|
||||
import '../models/supplement.dart';
|
||||
import '../models/ingredient.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 {
|
||||
final Supplement? supplement;
|
||||
|
||||
@@ -16,11 +43,13 @@ class AddSupplementScreen extends StatefulWidget {
|
||||
class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _dosageAmountController = TextEditingController();
|
||||
final _brandController = TextEditingController();
|
||||
final _numberOfUnitsController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
|
||||
String _selectedUnit = 'mg';
|
||||
// Multi-ingredient support with persistent controllers
|
||||
List<IngredientController> _ingredientControllers = [];
|
||||
|
||||
String _selectedUnitType = 'capsules';
|
||||
int _frequencyPerDay = 1;
|
||||
List<String> _reminderTimes = ['08:00'];
|
||||
@@ -28,26 +57,159 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
final List<String> _units = ['mg', 'g', 'μg', 'IU', 'ml'];
|
||||
final List<String> _unitTypes = ['capsules', 'tablets', 'softgels', 'drops', 'ml', 'scoops', 'gummies'];
|
||||
|
||||
@override
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.supplement != null) {
|
||||
_initializeWithExistingSupplement();
|
||||
} else {
|
||||
_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() {
|
||||
final supplement = widget.supplement!;
|
||||
_nameController.text = supplement.name;
|
||||
_dosageAmountController.text = supplement.dosageAmount.toString();
|
||||
_brandController.text = supplement.brand ?? '';
|
||||
_numberOfUnitsController.text = supplement.numberOfUnits.toString();
|
||||
_notesController.text = supplement.notes ?? '';
|
||||
_selectedUnit = supplement.unit;
|
||||
_selectedUnitType = supplement.unitType;
|
||||
_frequencyPerDay = supplement.frequencyPerDay;
|
||||
_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
|
||||
@@ -83,53 +245,36 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Dosage amount per unit
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextFormField(
|
||||
controller: _dosageAmountController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Amount per unit *',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '187',
|
||||
),
|
||||
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),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _selectedUnit,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Unit',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _units.map((unit) {
|
||||
return DropdownMenuItem(
|
||||
value: unit,
|
||||
child: Text(unit),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedUnit = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
// Brand field
|
||||
TextFormField(
|
||||
controller: _brandController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Brand (Optional)',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'e.g., Nature Made, NOW Foods',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Ingredients section
|
||||
Text(
|
||||
'Ingredients',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._ingredientControllers.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final controller = entry.value;
|
||||
return _buildIngredientRow(index, controller);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
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),
|
||||
|
||||
@@ -181,15 +326,41 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Total per intake: ${_dosageAmountController.text.isNotEmpty && _numberOfUnitsController.text.isNotEmpty ? (double.tryParse(_dosageAmountController.text) ?? 0) * (int.tryParse(_numberOfUnitsController.text) ?? 0) : 0} $_selectedUnit',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
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(
|
||||
'Per ${_numberOfUnitsController.text.isNotEmpty ? _numberOfUnitsController.text : "1"} $_selectedUnitType:',
|
||||
style: TextStyle(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Frequency per day
|
||||
@@ -232,30 +403,66 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _selectTime(index),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
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)
|
||||
IconButton(
|
||||
onPressed: () => _removeReminderTime(index),
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: IconButton(
|
||||
onPressed: () => _removeReminderTime(index),
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
color: Colors.red,
|
||||
tooltip: 'Remove reminder time',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (_reminderTimes.length < _frequencyPerDay)
|
||||
TextButton.icon(
|
||||
onPressed: _addReminderTime,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Reminder Time'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _addReminderTime,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Reminder Time'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -299,17 +506,35 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _selectTime(int index) {
|
||||
DatePicker.showTimePicker(
|
||||
context,
|
||||
showTitleActions: true,
|
||||
onConfirm: (time) {
|
||||
setState(() {
|
||||
_reminderTimes[index] = '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
});
|
||||
void _selectTime(int index) async {
|
||||
// Parse current time or use default
|
||||
TimeOfDay currentTime = TimeOfDay(hour: 8, minute: 0);
|
||||
if (index < _reminderTimes.length) {
|
||||
final timeParts = _reminderTimes[index].split(':');
|
||||
if (timeParts.length >= 2) {
|
||||
currentTime = TimeOfDay(
|
||||
hour: int.tryParse(timeParts[0]) ?? 8,
|
||||
minute: int.tryParse(timeParts[1]) ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: currentTime,
|
||||
builder: (context, child) {
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
currentTime: DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_reminderTimes[index] = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _addReminderTime() {
|
||||
@@ -330,12 +555,31 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
|
||||
void _saveSupplement() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Validate that we have at least one ingredient with name and amount
|
||||
final validIngredients = _ingredientControllers.where((controller) =>
|
||||
controller.nameController.text.trim().isNotEmpty &&
|
||||
(double.tryParse(controller.amountController.text) ?? 0) > 0
|
||||
).map((controller) => Ingredient(
|
||||
name: controller.nameController.text.trim(),
|
||||
amount: double.tryParse(controller.amountController.text) ?? 0,
|
||||
unit: controller.selectedUnit,
|
||||
)).toList();
|
||||
|
||||
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(
|
||||
id: widget.supplement?.id,
|
||||
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),
|
||||
unit: _selectedUnit,
|
||||
unitType: _selectedUnitType,
|
||||
frequencyPerDay: _frequencyPerDay,
|
||||
reminderTimes: _reminderTimes,
|
||||
@@ -380,9 +624,15 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_dosageAmountController.dispose();
|
||||
_brandController.dispose();
|
||||
_numberOfUnitsController.dispose();
|
||||
_notesController.dispose();
|
||||
|
||||
// Dispose all ingredient controllers
|
||||
for (final controller in _ingredientControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
385
lib/screens/archived_supplements_screen.dart
Normal file
385
lib/screens/archived_supplements_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -15,6 +15,7 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
int _selectedMonth = DateTime.now().month;
|
||||
int _selectedYear = DateTime.now().year;
|
||||
int _refreshKey = 0; // Add this to force FutureBuilder refresh
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -90,6 +91,7 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
key: ValueKey('daily_view_$_refreshKey'), // Use refresh key to force rebuild
|
||||
future: context.read<SupplementProvider>().getIntakesForDate(_selectedDate),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
@@ -137,12 +139,10 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
||||
final supplementIntakes = groupedIntakes[supplementName]!;
|
||||
|
||||
// Calculate totals
|
||||
double totalDosage = 0;
|
||||
double totalUnits = 0;
|
||||
final firstIntake = supplementIntakes.first;
|
||||
|
||||
for (final intake in supplementIntakes) {
|
||||
totalDosage += intake['dosageTaken'] as double;
|
||||
totalUnits += (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
|
||||
}
|
||||
|
||||
@@ -161,14 +161,14 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${totalDosage.toStringAsFixed(totalDosage % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnit']} total',
|
||||
'${totalUnits.toStringAsFixed(totalUnits % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnitType'] ?? 'units'} total',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
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(
|
||||
fontSize: 12,
|
||||
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;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 72, right: 16),
|
||||
contentPadding: const EdgeInsets.only(left: 72, right: 8),
|
||||
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),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
@@ -335,6 +348,7 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
||||
const SizedBox(height: 8),
|
||||
...dayIntakes.map((intake) {
|
||||
final takenAt = DateTime.parse(intake['takenAt']);
|
||||
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
@@ -347,10 +361,23 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
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),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<SupplementProvider>().deleteIntake(intakeId);
|
||||
onPressed: () async {
|
||||
await context.read<SupplementProvider>().deleteIntake(intakeId);
|
||||
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(
|
||||
const SnackBar(
|
||||
content: Text('Intake deleted'),
|
||||
SnackBar(
|
||||
content: Text('$supplementName intake deleted'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
setState(() {}); // Refresh the view
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('Delete'),
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/supplement_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import 'supplements_list_screen.dart';
|
||||
import 'history_screen.dart';
|
||||
import 'add_supplement_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -18,6 +20,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final List<Widget> _screens = [
|
||||
const SupplementsListScreen(),
|
||||
const HistoryScreen(),
|
||||
const SettingsScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -25,9 +28,42 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -39,6 +75,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
type: BottomNavigationBarType.fixed,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.medication),
|
||||
@@ -48,6 +85,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
icon: Icon(Icons.history),
|
||||
label: 'History',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _currentIndex == 0
|
||||
|
601
lib/screens/settings_screen.dart
Normal file
601
lib/screens/settings_screen.dart
Normal 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';
|
||||
}
|
||||
}
|
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/supplement_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../models/supplement.dart';
|
||||
import '../widgets/supplement_card.dart';
|
||||
import 'add_supplement_screen.dart';
|
||||
import 'archived_supplements_screen.dart';
|
||||
|
||||
class SupplementsListScreen extends StatelessWidget {
|
||||
const SupplementsListScreen({super.key});
|
||||
@@ -15,9 +16,22 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
appBar: AppBar(
|
||||
title: const Text('My Supplements'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.archive),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ArchivedSupplementsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Archived Supplements',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<SupplementProvider>(
|
||||
builder: (context, provider, child) {
|
||||
body: Consumer2<SupplementProvider, SettingsProvider>(
|
||||
builder: (context, provider, settingsProvider, child) {
|
||||
if (provider.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -56,86 +70,190 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
onRefresh: () async {
|
||||
await provider.loadSupplements();
|
||||
},
|
||||
child: Column(
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _buildGroupedSupplementsList(context, provider.supplements, settingsProvider),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
final unitsController = TextEditingController(text: supplement.numberOfUnits.toString());
|
||||
final notesController = TextEditingController();
|
||||
DateTime selectedDateTime = DateTime.now();
|
||||
bool useCustomTime = false;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final units = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble();
|
||||
final totalDosage = supplement.dosageAmount * units;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('Take ${supplement.name}'),
|
||||
content: Column(
|
||||
@@ -175,7 +293,7 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${totalDosage.toStringAsFixed(totalDosage % 1 == 0 ? 0 : 1)} ${supplement.unit}',
|
||||
supplement.ingredientsDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -186,6 +304,162 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Time selection section
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'When did you take it?',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RadioListTile<bool>(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Just now', style: TextStyle(fontSize: 12)),
|
||||
value: false,
|
||||
groupValue: useCustomTime,
|
||||
onChanged: (value) => setState(() => useCustomTime = value!),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RadioListTile<bool>(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Custom time', style: TextStyle(fontSize: 12)),
|
||||
value: true,
|
||||
groupValue: useCustomTime,
|
||||
onChanged: (value) => setState(() => useCustomTime = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (useCustomTime) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Date picker
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Date: ${selectedDateTime.day}/${selectedDateTime.month}/${selectedDateTime.year}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedDateTime,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 7)),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
selectedDateTime = DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
selectedDateTime.hour,
|
||||
selectedDateTime.minute,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('Change', style: TextStyle(fontSize: 10)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Time picker
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Time: ${selectedDateTime.hour.toString().padLeft(2, '0')}:${selectedDateTime.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(selectedDateTime),
|
||||
);
|
||||
if (time != null) {
|
||||
setState(() {
|
||||
selectedDateTime = DateTime(
|
||||
selectedDateTime.year,
|
||||
selectedDateTime.month,
|
||||
selectedDateTime.day,
|
||||
time.hour,
|
||||
time.minute,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('Change', style: TextStyle(fontSize: 10)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: notesController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -204,12 +478,15 @@ class SupplementsListScreen extends StatelessWidget {
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
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(
|
||||
supplement.id!,
|
||||
totalDosageTaken,
|
||||
unitsTaken: unitsTaken,
|
||||
notes: notesController.text.isNotEmpty ? notesController.text : null,
|
||||
takenAt: useCustomTime ? selectedDateTime : null,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -2,15 +2,17 @@ import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import '../models/supplement.dart';
|
||||
import '../models/supplement_intake.dart';
|
||||
|
||||
class DatabaseHelper {
|
||||
static const _databaseName = 'supplements.db';
|
||||
static const _databaseVersion = 2; // Increment version for schema changes
|
||||
static const _databaseVersion = 5; // Increment version for notification tracking
|
||||
|
||||
static const supplementsTable = 'supplements';
|
||||
static const intakesTable = 'supplement_intakes';
|
||||
static const notificationTrackingTable = 'notification_tracking';
|
||||
|
||||
DatabaseHelper._privateConstructor();
|
||||
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
|
||||
@@ -50,9 +52,9 @@ class DatabaseHelper {
|
||||
CREATE TABLE $supplementsTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
dosageAmount REAL NOT NULL,
|
||||
brand TEXT,
|
||||
ingredients TEXT NOT NULL DEFAULT '[]',
|
||||
numberOfUnits INTEGER NOT NULL DEFAULT 1,
|
||||
unit TEXT NOT NULL,
|
||||
unitType TEXT NOT NULL DEFAULT 'units',
|
||||
frequencyPerDay INTEGER NOT NULL,
|
||||
reminderTimes TEXT NOT NULL,
|
||||
@@ -73,6 +75,20 @@ class DatabaseHelper {
|
||||
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE $notificationTrackingTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
notificationId INTEGER NOT NULL UNIQUE,
|
||||
supplementId INTEGER NOT NULL,
|
||||
scheduledTime TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retryCount INTEGER NOT NULL DEFAULT 0,
|
||||
lastRetryTime TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
@@ -97,6 +113,7 @@ class DatabaseHelper {
|
||||
CREATE TABLE ${supplementsTable}_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
brand TEXT,
|
||||
dosageAmount REAL NOT NULL,
|
||||
numberOfUnits INTEGER NOT NULL DEFAULT 1,
|
||||
unit TEXT NOT NULL,
|
||||
@@ -112,8 +129,8 @@ class DatabaseHelper {
|
||||
// Copy data to new table
|
||||
await db.execute('''
|
||||
INSERT INTO ${supplementsTable}_new
|
||||
(id, name, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
|
||||
SELECT id, name, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
|
||||
(id, name, brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
|
||||
SELECT id, name, NULL as brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
|
||||
FROM $supplementsTable
|
||||
''');
|
||||
|
||||
@@ -121,6 +138,86 @@ class DatabaseHelper {
|
||||
await db.execute('DROP TABLE $supplementsTable');
|
||||
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
|
||||
}
|
||||
|
||||
if (oldVersion < 3) {
|
||||
// Add brand column for version 3
|
||||
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN brand TEXT');
|
||||
}
|
||||
|
||||
if (oldVersion < 4) {
|
||||
// Complete migration to new ingredient-based schema
|
||||
// Add ingredients column and migrate old data
|
||||
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN ingredients TEXT DEFAULT "[]"');
|
||||
|
||||
// Migrate existing supplements to use ingredients format
|
||||
final supplements = await db.query(supplementsTable);
|
||||
for (final supplement in supplements) {
|
||||
final dosageAmount = supplement['dosageAmount'] as double?;
|
||||
final unit = supplement['unit'] as String?;
|
||||
final name = supplement['name'] as String;
|
||||
|
||||
if (dosageAmount != null && unit != null && dosageAmount > 0) {
|
||||
// Create a single ingredient from the old dosage data
|
||||
final ingredient = {
|
||||
'name': name,
|
||||
'amount': dosageAmount,
|
||||
'unit': unit,
|
||||
};
|
||||
final ingredientsJson = jsonEncode([ingredient]);
|
||||
|
||||
await db.update(
|
||||
supplementsTable,
|
||||
{'ingredients': ingredientsJson},
|
||||
where: 'id = ?',
|
||||
whereArgs: [supplement['id']],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old columns
|
||||
await db.execute('''
|
||||
CREATE TABLE ${supplementsTable}_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
brand TEXT,
|
||||
ingredients TEXT NOT NULL DEFAULT '[]',
|
||||
numberOfUnits INTEGER NOT NULL DEFAULT 1,
|
||||
unitType TEXT NOT NULL DEFAULT 'units',
|
||||
frequencyPerDay INTEGER NOT NULL,
|
||||
reminderTimes TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
isActive INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
INSERT INTO ${supplementsTable}_new
|
||||
(id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
|
||||
SELECT id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
|
||||
FROM $supplementsTable
|
||||
''');
|
||||
|
||||
await db.execute('DROP TABLE $supplementsTable');
|
||||
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
|
||||
}
|
||||
|
||||
if (oldVersion < 5) {
|
||||
// Add notification tracking table
|
||||
await db.execute('''
|
||||
CREATE TABLE $notificationTrackingTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
notificationId INTEGER NOT NULL UNIQUE,
|
||||
supplementId INTEGER NOT NULL,
|
||||
scheduledTime TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retryCount INTEGER NOT NULL DEFAULT 0,
|
||||
lastRetryTime TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
|
||||
)
|
||||
''');
|
||||
}
|
||||
}
|
||||
|
||||
// Supplement CRUD operations
|
||||
@@ -140,6 +237,37 @@ class DatabaseHelper {
|
||||
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
|
||||
}
|
||||
|
||||
Future<List<Supplement>> getArchivedSupplements() async {
|
||||
Database db = await database;
|
||||
List<Map<String, dynamic>> maps = await db.query(
|
||||
supplementsTable,
|
||||
where: 'isActive = ?',
|
||||
whereArgs: [0],
|
||||
orderBy: 'name ASC',
|
||||
);
|
||||
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
|
||||
}
|
||||
|
||||
Future<void> archiveSupplement(int id) async {
|
||||
Database db = await database;
|
||||
await db.update(
|
||||
supplementsTable,
|
||||
{'isActive': 0},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> unarchiveSupplement(int id) async {
|
||||
Database db = await database;
|
||||
await db.update(
|
||||
supplementsTable,
|
||||
{'isActive': 1},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Supplement?> getSupplement(int id) async {
|
||||
Database db = await database;
|
||||
List<Map<String, dynamic>> maps = await db.query(
|
||||
@@ -213,7 +341,10 @@ class DatabaseHelper {
|
||||
String endDate = DateTime(date.year, date.month, date.day, 23, 59, 59).toIso8601String();
|
||||
|
||||
List<Map<String, dynamic>> result = await db.rawQuery('''
|
||||
SELECT i.*, s.name as supplementName, s.unit as supplementUnit, s.unitType as supplementUnitType
|
||||
SELECT i.*,
|
||||
i.supplementId as supplement_id,
|
||||
s.name as supplementName,
|
||||
s.unitType as supplementUnitType
|
||||
FROM $intakesTable i
|
||||
JOIN $supplementsTable s ON i.supplementId = s.id
|
||||
WHERE i.takenAt >= ? AND i.takenAt <= ?
|
||||
@@ -229,7 +360,10 @@ class DatabaseHelper {
|
||||
String endDate = DateTime(year, month + 1, 0, 23, 59, 59).toIso8601String();
|
||||
|
||||
List<Map<String, dynamic>> result = await db.rawQuery('''
|
||||
SELECT i.*, s.name as supplementName, s.unit as supplementUnit, s.unitType as supplementUnitType
|
||||
SELECT i.*,
|
||||
i.supplementId as supplement_id,
|
||||
s.name as supplementName,
|
||||
s.unitType as supplementUnitType
|
||||
FROM $intakesTable i
|
||||
JOIN $supplementsTable s ON i.supplementId = s.id
|
||||
WHERE i.takenAt >= ? AND i.takenAt <= ?
|
||||
@@ -247,4 +381,90 @@ class DatabaseHelper {
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
// Notification tracking methods
|
||||
Future<int> trackNotification({
|
||||
required int notificationId,
|
||||
required int supplementId,
|
||||
required DateTime scheduledTime,
|
||||
}) async {
|
||||
Database db = await database;
|
||||
|
||||
// Use INSERT OR REPLACE to handle both new and existing notifications
|
||||
await db.rawInsert('''
|
||||
INSERT OR REPLACE INTO $notificationTrackingTable
|
||||
(notificationId, supplementId, scheduledTime, status, retryCount, lastRetryTime, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', [
|
||||
notificationId,
|
||||
supplementId,
|
||||
scheduledTime.toIso8601String(),
|
||||
'pending',
|
||||
0,
|
||||
null,
|
||||
DateTime.now().toIso8601String(),
|
||||
]);
|
||||
|
||||
return notificationId;
|
||||
}
|
||||
|
||||
Future<void> markNotificationTaken(int notificationId) async {
|
||||
Database db = await database;
|
||||
await db.update(
|
||||
notificationTrackingTable,
|
||||
{'status': 'taken'},
|
||||
where: 'notificationId = ?',
|
||||
whereArgs: [notificationId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> incrementRetryCount(int notificationId) async {
|
||||
Database db = await database;
|
||||
await db.rawUpdate('''
|
||||
UPDATE $notificationTrackingTable
|
||||
SET retryCount = retryCount + 1,
|
||||
lastRetryTime = ?,
|
||||
status = 'retrying'
|
||||
WHERE notificationId = ?
|
||||
''', [DateTime.now().toIso8601String(), notificationId]);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getPendingNotifications() async {
|
||||
Database db = await database;
|
||||
return await db.query(
|
||||
notificationTrackingTable,
|
||||
where: 'status IN (?, ?)',
|
||||
whereArgs: ['pending', 'retrying'],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markNotificationExpired(int notificationId) async {
|
||||
Database db = await database;
|
||||
await db.update(
|
||||
notificationTrackingTable,
|
||||
{'status': 'expired'},
|
||||
where: 'notificationId = ?',
|
||||
whereArgs: [notificationId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cleanupOldNotificationTracking() async {
|
||||
Database db = await database;
|
||||
// Remove tracking records older than 7 days
|
||||
final cutoffDate = DateTime.now().subtract(const Duration(days: 7)).toIso8601String();
|
||||
await db.delete(
|
||||
notificationTrackingTable,
|
||||
where: 'createdAt < ?',
|
||||
whereArgs: [cutoffDate],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearNotificationTracking(int supplementId) async {
|
||||
Database db = await database;
|
||||
await db.delete(
|
||||
notificationTrackingTable,
|
||||
where: 'supplementId = ?',
|
||||
whereArgs: [supplementId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,24 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest.dart' as tz;
|
||||
import '../models/supplement.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
// Top-level function to handle notification responses when app is running
|
||||
@pragma('vm:entry-point')
|
||||
void notificationTapBackground(NotificationResponse notificationResponse) {
|
||||
print('📱 === BACKGROUND NOTIFICATION RESPONSE ===');
|
||||
print('📱 Action ID: ${notificationResponse.actionId}');
|
||||
print('📱 Payload: ${notificationResponse.payload}');
|
||||
print('📱 Notification ID: ${notificationResponse.id}');
|
||||
print('📱 ==========================================');
|
||||
|
||||
// For now, just log the action. The main app handler will process it.
|
||||
if (notificationResponse.actionId == 'take_supplement') {
|
||||
print('📱 BACKGROUND: Take action detected');
|
||||
} else if (notificationResponse.actionId == 'snooze_10') {
|
||||
print('📱 BACKGROUND: Snooze action detected');
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
@@ -9,15 +27,81 @@ class NotificationService {
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
static bool _engineInitialized = false;
|
||||
bool _permissionsRequested = false;
|
||||
|
||||
// Callback for handling supplement intake from notifications
|
||||
Function(int supplementId, String supplementName, double units, String unitType)? _onTakeSupplementCallback;
|
||||
|
||||
// Set callback for handling supplement intake from notifications
|
||||
void setTakeSupplementCallback(Function(int supplementId, String supplementName, double units, String unitType) callback) {
|
||||
_onTakeSupplementCallback = callback;
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
tz.initializeTimeZones();
|
||||
print('📱 Initializing NotificationService...');
|
||||
if (_isInitialized) {
|
||||
print('📱 Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
print('📱 Initializing timezones...');
|
||||
print('📱 Engine initialized flag: $_engineInitialized');
|
||||
|
||||
if (!_engineInitialized) {
|
||||
tz.initializeTimeZones();
|
||||
_engineInitialized = true;
|
||||
print('📱 Timezones initialized successfully');
|
||||
} else {
|
||||
print('📱 Timezones already initialized, skipping');
|
||||
}
|
||||
} catch (e) {
|
||||
print('📱 Warning: Timezone initialization issue (may already be initialized): $e');
|
||||
_engineInitialized = true; // Mark as initialized to prevent retry
|
||||
}
|
||||
|
||||
// Try to detect and set the local timezone more reliably
|
||||
try {
|
||||
// First try using the system timezone name
|
||||
final String timeZoneName = DateTime.now().timeZoneName;
|
||||
print('📱 System timezone name: $timeZoneName');
|
||||
|
||||
tz.Location? location;
|
||||
|
||||
// Try common timezone mappings for your region
|
||||
if (timeZoneName.contains('CET') || timeZoneName.contains('CEST')) {
|
||||
location = tz.getLocation('Europe/Amsterdam'); // Netherlands
|
||||
} else if (timeZoneName.contains('UTC') || timeZoneName.contains('GMT')) {
|
||||
location = tz.getLocation('UTC');
|
||||
} else {
|
||||
// Fallback: try to use the timezone name directly
|
||||
try {
|
||||
location = tz.getLocation(timeZoneName);
|
||||
} catch (e) {
|
||||
print('📱 Could not find timezone $timeZoneName, using Europe/Amsterdam as default');
|
||||
location = tz.getLocation('Europe/Amsterdam');
|
||||
}
|
||||
}
|
||||
|
||||
tz.setLocalLocation(location);
|
||||
print('📱 Timezone set to: ${location.name}');
|
||||
|
||||
} catch (e) {
|
||||
print('📱 Error setting timezone: $e, using default');
|
||||
// Fallback to a reasonable default for Netherlands
|
||||
tz.setLocalLocation(tz.getLocation('Europe/Amsterdam'));
|
||||
}
|
||||
|
||||
print('📱 Current local time: ${tz.TZDateTime.now(tz.local)}');
|
||||
print('📱 Current system time: ${DateTime.now()}');
|
||||
|
||||
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
requestAlertPermission: false, // We'll request these separately
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings(
|
||||
defaultActionName: 'Open notification',
|
||||
@@ -29,28 +113,347 @@ class NotificationService {
|
||||
linux: linuxSettings,
|
||||
);
|
||||
|
||||
await _notifications.initialize(initSettings);
|
||||
print('📱 Initializing flutter_local_notifications...');
|
||||
await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
|
||||
);
|
||||
|
||||
// Test if notification response callback is working
|
||||
print('📱 Callback function is set and ready');
|
||||
|
||||
_isInitialized = true;
|
||||
print('📱 NotificationService initialization complete');
|
||||
}
|
||||
|
||||
// Handle notification responses (when user taps on notification or action)
|
||||
void _onNotificationResponse(NotificationResponse response) {
|
||||
print('📱 === NOTIFICATION RESPONSE ===');
|
||||
print('📱 Action ID: ${response.actionId}');
|
||||
print('📱 Payload: ${response.payload}');
|
||||
print('📱 Notification ID: ${response.id}');
|
||||
print('📱 Input: ${response.input}');
|
||||
print('📱 ===============================');
|
||||
|
||||
if (response.actionId == 'take_supplement') {
|
||||
print('📱 Processing TAKE action...');
|
||||
_handleTakeAction(response.payload, response.id);
|
||||
} else if (response.actionId == 'snooze_10') {
|
||||
print('📱 Processing SNOOZE action...');
|
||||
_handleSnoozeAction(response.payload, 10, response.id);
|
||||
} else {
|
||||
print('📱 Default notification tap (no specific action)');
|
||||
// Default tap (no actionId) opens the app normally
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTakeAction(String? payload, int? notificationId) {
|
||||
print('📱 === HANDLING TAKE ACTION ===');
|
||||
print('📱 Payload received: $payload');
|
||||
|
||||
if (payload != null) {
|
||||
try {
|
||||
// Parse the payload to get supplement info
|
||||
final parts = payload.split('|');
|
||||
print('📱 Payload parts: $parts (length: ${parts.length})');
|
||||
|
||||
if (parts.length >= 4) {
|
||||
final supplementId = int.parse(parts[0]);
|
||||
final supplementName = parts[1];
|
||||
final units = double.parse(parts[2]);
|
||||
final unitType = parts[3];
|
||||
|
||||
print('📱 Parsed data:');
|
||||
print('📱 - ID: $supplementId');
|
||||
print('📱 - Name: $supplementName');
|
||||
print('📱 - Units: $units');
|
||||
print('📱 - Type: $unitType');
|
||||
|
||||
// Call the callback to record the intake
|
||||
if (_onTakeSupplementCallback != null) {
|
||||
print('📱 Calling supplement callback...');
|
||||
_onTakeSupplementCallback!(supplementId, supplementName, units, unitType);
|
||||
print('📱 Callback completed');
|
||||
} else {
|
||||
print('📱 ERROR: No callback registered!');
|
||||
}
|
||||
|
||||
// Mark notification as taken in database (this will cancel any pending retries)
|
||||
if (notificationId != null) {
|
||||
print('📱 Marking notification $notificationId as taken');
|
||||
DatabaseHelper.instance.markNotificationTaken(notificationId);
|
||||
|
||||
// Cancel any pending retry notifications for this notification
|
||||
_cancelRetryNotifications(notificationId);
|
||||
}
|
||||
|
||||
// Show a confirmation notification
|
||||
print('📱 Showing confirmation notification...');
|
||||
showInstantNotification(
|
||||
'Supplement Taken!',
|
||||
'$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}',
|
||||
);
|
||||
} else {
|
||||
print('📱 ERROR: Invalid payload format - not enough parts');
|
||||
}
|
||||
} catch (e) {
|
||||
print('📱 ERROR in _handleTakeAction: $e');
|
||||
}
|
||||
} else {
|
||||
print('📱 ERROR: Payload is null');
|
||||
}
|
||||
print('📱 === TAKE ACTION COMPLETE ===');
|
||||
}
|
||||
|
||||
void _cancelRetryNotifications(int notificationId) {
|
||||
// Retry notifications use ID range starting from 200000
|
||||
for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries
|
||||
int retryId = 200000 + (notificationId * 10) + i;
|
||||
_notifications.cancel(retryId);
|
||||
print('📱 Cancelled retry notification ID: $retryId');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSnoozeAction(String? payload, int minutes, int? notificationId) {
|
||||
print('📱 === HANDLING SNOOZE ACTION ===');
|
||||
print('📱 Payload: $payload, Minutes: $minutes');
|
||||
|
||||
if (payload != null) {
|
||||
try {
|
||||
final parts = payload.split('|');
|
||||
if (parts.length >= 2) {
|
||||
final supplementId = int.parse(parts[0]);
|
||||
final supplementName = parts[1];
|
||||
|
||||
print('📱 Snoozing supplement for $minutes minutes: $supplementName');
|
||||
|
||||
// Mark notification as snoozed in database (increment retry count)
|
||||
if (notificationId != null) {
|
||||
print('📱 Incrementing retry count for notification $notificationId');
|
||||
DatabaseHelper.instance.incrementRetryCount(notificationId);
|
||||
}
|
||||
|
||||
// Schedule a new notification for the snooze time
|
||||
final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes));
|
||||
print('📱 Snooze time: $snoozeTime');
|
||||
|
||||
_notifications.zonedSchedule(
|
||||
supplementId * 1000 + minutes, // Unique ID for snooze notifications
|
||||
'Reminder: $supplementName',
|
||||
'Snoozed reminder - Take your $supplementName now',
|
||||
snoozeTime,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'supplement_reminders',
|
||||
'Supplement Reminders',
|
||||
channelDescription: 'Notifications for supplement intake reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'take_supplement',
|
||||
'Take',
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'snooze_10',
|
||||
'Snooze 10min',
|
||||
),
|
||||
],
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
),
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
payload: payload,
|
||||
);
|
||||
|
||||
showInstantNotification(
|
||||
'Reminder Snoozed',
|
||||
'$supplementName reminder snoozed for $minutes minutes',
|
||||
);
|
||||
print('📱 Snooze scheduled successfully');
|
||||
}
|
||||
} catch (e) {
|
||||
print('📱 Error handling snooze action: $e');
|
||||
}
|
||||
}
|
||||
print('📱 === SNOOZE ACTION COMPLETE ===');
|
||||
}
|
||||
|
||||
/// Check for persistent reminders from app context with settings
|
||||
Future<void> checkPersistentReminders(
|
||||
bool persistentReminders,
|
||||
int reminderRetryInterval,
|
||||
int maxRetryAttempts,
|
||||
) async {
|
||||
await schedulePersistentReminders(
|
||||
persistentReminders: persistentReminders,
|
||||
reminderRetryInterval: reminderRetryInterval,
|
||||
maxRetryAttempts: maxRetryAttempts,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check for pending notifications that need retry and schedule them
|
||||
Future<void> schedulePersistentReminders({
|
||||
required bool persistentReminders,
|
||||
required int reminderRetryInterval,
|
||||
required int maxRetryAttempts,
|
||||
}) async {
|
||||
print('📱 Checking for pending notifications to retry...');
|
||||
|
||||
try {
|
||||
if (!persistentReminders) {
|
||||
print('📱 Persistent reminders disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
print('📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts');
|
||||
|
||||
// Get all pending notifications from database
|
||||
final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications();
|
||||
print('📱 Found ${pendingNotifications.length} pending notifications');
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
for (final notification in pendingNotifications) {
|
||||
final scheduledTime = DateTime.parse(notification['scheduledTime']);
|
||||
final retryCount = notification['retryCount'] as int;
|
||||
final lastRetryTime = notification['lastRetryTime'] != null
|
||||
? DateTime.parse(notification['lastRetryTime'])
|
||||
: null;
|
||||
|
||||
// Check if notification is overdue
|
||||
final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
|
||||
final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
|
||||
|
||||
// Check if we haven't exceeded max retry attempts
|
||||
if (retryCount >= maxRetryAttempts) {
|
||||
print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if enough time has passed since last retry
|
||||
if (lastRetryTime != null) {
|
||||
final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes;
|
||||
if (timeSinceLastRetry < reminderRetryInterval) {
|
||||
print('📱 Notification ${notification['notificationId']} not ready for retry yet');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRetry) {
|
||||
await _scheduleRetryNotification(notification, retryCount + 1);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('📱 Error scheduling persistent reminders: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scheduleRetryNotification(Map<String, dynamic> notification, int retryAttempt) async {
|
||||
try {
|
||||
final notificationId = notification['notificationId'] as int;
|
||||
final supplementId = notification['supplementId'] as int;
|
||||
|
||||
// Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt)
|
||||
final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt;
|
||||
|
||||
print('📱 Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)');
|
||||
|
||||
// Get supplement details from database
|
||||
final supplements = await DatabaseHelper.instance.getAllSupplements();
|
||||
final supplement = supplements.firstWhere((s) => s.id == supplementId && s.isActive, orElse: () => throw Exception('Supplement not found'));
|
||||
|
||||
// Schedule the retry notification immediately
|
||||
await _notifications.show(
|
||||
retryNotificationId,
|
||||
'Reminder: ${supplement.name}',
|
||||
'Don\'t forget to take your ${supplement.name}! (Retry #$retryAttempt)',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'supplement_reminders',
|
||||
'Supplement Reminders',
|
||||
channelDescription: 'Notifications for supplement intake reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'take_supplement',
|
||||
'Take',
|
||||
showsUserInterface: true,
|
||||
icon: DrawableResourceAndroidBitmap('@drawable/ic_check'),
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'snooze_10',
|
||||
'Snooze 10min',
|
||||
showsUserInterface: true,
|
||||
icon: DrawableResourceAndroidBitmap('@drawable/ic_snooze'),
|
||||
),
|
||||
],
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
),
|
||||
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}|$notificationId',
|
||||
);
|
||||
|
||||
// Update the retry count in database
|
||||
await DatabaseHelper.instance.incrementRetryCount(notificationId);
|
||||
|
||||
print('📱 Retry notification scheduled successfully');
|
||||
} catch (e) {
|
||||
print('📱 Error scheduling retry notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
if (androidPlugin != null) {
|
||||
await androidPlugin.requestNotificationsPermission();
|
||||
print('📱 Requesting notification permissions...');
|
||||
if (_permissionsRequested) {
|
||||
print('📱 Permissions already requested');
|
||||
return true;
|
||||
}
|
||||
|
||||
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
if (iosPlugin != null) {
|
||||
await iosPlugin.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
try {
|
||||
_permissionsRequested = true;
|
||||
|
||||
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
if (androidPlugin != null) {
|
||||
print('📱 Requesting Android permissions...');
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
print('📱 Android permissions granted: $granted');
|
||||
if (granted != true) {
|
||||
_permissionsRequested = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
if (iosPlugin != null) {
|
||||
print('📱 Requesting iOS permissions...');
|
||||
final granted = await iosPlugin.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
print('📱 iOS permissions granted: $granted');
|
||||
if (granted != true) {
|
||||
_permissionsRequested = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
print('📱 All permissions granted successfully');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_permissionsRequested = false;
|
||||
print('📱 Error requesting permissions: $e');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> scheduleSupplementReminders(Supplement supplement) async {
|
||||
print('📱 Scheduling reminders for ${supplement.name}');
|
||||
print('📱 Reminder times: ${supplement.reminderTimes}');
|
||||
|
||||
// Cancel existing notifications for this supplement
|
||||
await cancelSupplementReminders(supplement.id!);
|
||||
|
||||
@@ -61,25 +464,59 @@ class NotificationService {
|
||||
final minute = int.parse(timeParts[1]);
|
||||
|
||||
final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder
|
||||
final scheduledTime = _nextInstanceOfTime(hour, minute);
|
||||
|
||||
print('📱 Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}');
|
||||
|
||||
// Track this notification in the database
|
||||
await DatabaseHelper.instance.trackNotification(
|
||||
notificationId: notificationId,
|
||||
supplementId: supplement.id!,
|
||||
scheduledTime: scheduledTime.toLocal(),
|
||||
);
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
notificationId,
|
||||
'Time for ${supplement.name}',
|
||||
'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.totalDosagePerIntake} ${supplement.unit})',
|
||||
_nextInstanceOfTime(hour, minute),
|
||||
const NotificationDetails(
|
||||
'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.ingredientsPerUnit})',
|
||||
scheduledTime,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'supplement_reminders',
|
||||
'Supplement Reminders',
|
||||
channelDescription: 'Notifications for supplement intake reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'take_supplement',
|
||||
'Take',
|
||||
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'),
|
||||
showsUserInterface: true, // Changed to true to open app
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'snooze_10',
|
||||
'Snooze 10min',
|
||||
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'),
|
||||
showsUserInterface: true, // Changed to true to open app
|
||||
),
|
||||
],
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
),
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}',
|
||||
);
|
||||
|
||||
print('📱 Successfully scheduled notification ID $notificationId');
|
||||
}
|
||||
|
||||
// Get all pending notifications to verify
|
||||
final pendingNotifications = await _notifications.pendingNotificationRequests();
|
||||
print('📱 Total pending notifications: ${pendingNotifications.length}');
|
||||
for (final notification in pendingNotifications) {
|
||||
print('📱 Pending: ID=${notification.id}, Title=${notification.title}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +526,9 @@ class NotificationService {
|
||||
final notificationId = supplementId * 100 + i;
|
||||
await _notifications.cancel(notificationId);
|
||||
}
|
||||
|
||||
// Also clean up database tracking records for this supplement
|
||||
await DatabaseHelper.instance.clearNotificationTracking(supplementId);
|
||||
}
|
||||
|
||||
Future<void> cancelAllReminders() async {
|
||||
@@ -99,14 +539,22 @@ class NotificationService {
|
||||
final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
|
||||
tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
|
||||
|
||||
print('📱 Current time: $now (${now.timeZoneName})');
|
||||
print('📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}');
|
||||
print('📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||
|
||||
if (scheduledDate.isBefore(now)) {
|
||||
scheduledDate = scheduledDate.add(const Duration(days: 1));
|
||||
print('📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||
} else {
|
||||
print('📱 Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||
}
|
||||
|
||||
return scheduledDate;
|
||||
}
|
||||
|
||||
Future<void> showInstantNotification(String title, String body) async {
|
||||
print('📱 Showing instant notification: $title - $body');
|
||||
const NotificationDetails notificationDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'instant_notifications',
|
||||
@@ -124,5 +572,108 @@ class NotificationService {
|
||||
body,
|
||||
notificationDetails,
|
||||
);
|
||||
print('📱 Instant notification sent');
|
||||
}
|
||||
|
||||
// Debug function to test notifications
|
||||
Future<void> testNotification() async {
|
||||
print('📱 Testing notification system...');
|
||||
await showInstantNotification('Test Notification', 'This is a test notification to verify the system is working.');
|
||||
}
|
||||
|
||||
// Debug function to schedule a test notification 1 minute from now
|
||||
Future<void> testScheduledNotification() async {
|
||||
print('📱 Testing scheduled notification...');
|
||||
final now = tz.TZDateTime.now(tz.local);
|
||||
final testTime = now.add(const Duration(minutes: 1));
|
||||
|
||||
print('📱 Scheduling test notification for: $testTime');
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
99999, // Special ID for test notifications
|
||||
'Test Scheduled Notification',
|
||||
'This notification was scheduled 1 minute ago at ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}',
|
||||
testTime,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'test_notifications',
|
||||
'Test Notifications',
|
||||
channelDescription: 'Test notifications for debugging',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
),
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
|
||||
print('📱 Test notification scheduled successfully');
|
||||
}
|
||||
|
||||
// Debug function to get all pending notifications
|
||||
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||
return await _notifications.pendingNotificationRequests();
|
||||
}
|
||||
|
||||
// Debug function to test notification actions
|
||||
Future<void> testNotificationWithActions() async {
|
||||
print('📱 Creating test notification with actions...');
|
||||
|
||||
await _notifications.show(
|
||||
88888, // Special test ID
|
||||
'Test Action Notification',
|
||||
'Tap Take or Snooze to test notification actions',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'test_notifications',
|
||||
'Test Notifications',
|
||||
channelDescription: 'Test notifications for debugging actions',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'take_supplement',
|
||||
'Take',
|
||||
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'),
|
||||
showsUserInterface: true,
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'snooze_10',
|
||||
'Snooze 10min',
|
||||
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'),
|
||||
showsUserInterface: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
),
|
||||
payload: '999|Test Supplement|1.0|capsule',
|
||||
);
|
||||
|
||||
print('📱 Test notification with actions created');
|
||||
}
|
||||
|
||||
// Debug function to test basic notification tap response
|
||||
Future<void> testBasicNotification() async {
|
||||
print('📱 Creating basic test notification...');
|
||||
|
||||
await _notifications.show(
|
||||
77777, // Special test ID for basic notification
|
||||
'Basic Test Notification',
|
||||
'Tap this notification to test basic callback',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'test_notifications',
|
||||
'Test Notifications',
|
||||
channelDescription: 'Test notifications for debugging',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
),
|
||||
payload: 'basic_test',
|
||||
);
|
||||
|
||||
print('📱 Basic test notification created');
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/supplement.dart';
|
||||
import '../providers/supplement_provider.dart';
|
||||
|
||||
class SupplementCard extends StatelessWidget {
|
||||
class SupplementCard extends StatefulWidget {
|
||||
final Supplement supplement;
|
||||
final VoidCallback onTake;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onArchive;
|
||||
|
||||
const SupplementCard({
|
||||
super.key,
|
||||
@@ -13,51 +16,204 @@ class SupplementCard extends StatelessWidget {
|
||||
required this.onTake,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
required this.onArchive,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SupplementCard> createState() => _SupplementCardState();
|
||||
}
|
||||
|
||||
class _SupplementCardState extends State<SupplementCard> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
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(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
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(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: isCompletelyTaken
|
||||
? Colors.green.shade800
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
PopupMenuButton(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.supplement.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 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) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
onEdit();
|
||||
widget.onEdit();
|
||||
break;
|
||||
case 'archive':
|
||||
widget.onArchive();
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete();
|
||||
widget.onDelete();
|
||||
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(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
@@ -84,115 +250,269 @@ class SupplementCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.schedule, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${supplement.frequencyPerDay}x daily',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
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: 8),
|
||||
Text(
|
||||
supplement.notes!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Take supplement section
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Take Supplement',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
// Today's intake times (if any) - only show in expanded view
|
||||
if (todayIntakes.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isCompletelyTaken
|
||||
? Colors.green.shade700.withOpacity(0.8)
|
||||
: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isCompletelyTaken
|
||||
? Colors.green.shade500
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 16,
|
||||
color: isCompletelyTaken
|
||||
? Colors.green.shade200
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Taken today:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isCompletelyTaken
|
||||
? Colors.green.shade200
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: todayIntakes.map((intake) {
|
||||
final units = intake['units'] as double;
|
||||
final unitsText = units == 1.0
|
||||
? '${widget.supplement.unitType}'
|
||||
: '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${widget.supplement.unitType}';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isCompletelyTaken
|
||||
? Colors.green.shade600
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'${intake['time']} • $unitsText',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isCompletelyTaken
|
||||
? Colors.white
|
||||
: Theme.of(context).colorScheme.onSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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(
|
||||
'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: 8),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Schedule and dosage info
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _InfoChip(
|
||||
icon: Icons.schedule,
|
||||
label: '${widget.supplement.frequencyPerDay}x daily',
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onTake,
|
||||
icon: const Icon(Icons.medication, size: 16),
|
||||
label: const Text('Take', style: TextStyle(fontSize: 12)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
minimumSize: const Size(80, 32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _InfoChip(
|
||||
icon: Icons.medication,
|
||||
label: '${widget.supplement.numberOfUnits} ${widget.supplement.unitType}',
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (widget.supplement.reminderTimes.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_InfoChip(
|
||||
icon: Icons.notifications,
|
||||
label: 'Reminders: ${widget.supplement.reminderTimes.join(', ')}',
|
||||
context: context,
|
||||
fullWidth: true,
|
||||
),
|
||||
],
|
||||
|
||||
if (widget.supplement.notes != null && widget.supplement.notes!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
widget.supplement.notes!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Take button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: isCompletelyTaken ? null : widget.onTake,
|
||||
icon: Icon(
|
||||
isCompletelyTaken ? Icons.check_circle : Icons.medication,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
isCompletelyTaken
|
||||
? 'All doses taken today'
|
||||
: isTakenToday
|
||||
? 'Take next dose'
|
||||
: 'Take supplement',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isCompletelyTaken
|
||||
? Colors.green.shade500
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: isCompletelyTaken ? 0 : 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -6,9 +6,11 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_local_notifications
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
}
|
||||
|
103
pubspec.lock
103
pubspec.lock
@@ -81,19 +81,19 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -139,6 +139,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -235,6 +240,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -267,6 +296,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -442,4 +527,4 @@ packages:
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
flutter: ">=3.27.0"
|
||||
|
@@ -43,15 +43,15 @@ dependencies:
|
||||
# State management
|
||||
provider: ^6.1.1
|
||||
|
||||
# Settings persistence
|
||||
shared_preferences: ^2.2.2
|
||||
|
||||
# Local notifications
|
||||
flutter_local_notifications: ^19.4.1
|
||||
timezone: ^0.10.1
|
||||
|
||||
# Date time handling
|
||||
intl: ^0.20.2
|
||||
|
||||
# UI components
|
||||
flutter_datetime_picker_plus: ^2.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Reference in New Issue
Block a user