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
|
<application
|
||||||
android:label="supplements"
|
android:label="supplements"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -46,6 +47,10 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
<!-- Notification action receiver for handling action button clicks -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'providers/supplement_provider.dart';
|
import 'providers/supplement_provider.dart';
|
||||||
|
import 'providers/settings_provider.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -12,27 +13,38 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ChangeNotifierProvider(
|
return MultiProvider(
|
||||||
create: (context) => SupplementProvider()..initialize(),
|
providers: [
|
||||||
child: MaterialApp(
|
ChangeNotifierProvider(
|
||||||
title: 'Supplements Tracker',
|
create: (context) => SupplementProvider()..initialize(),
|
||||||
theme: ThemeData(
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: Colors.blue,
|
|
||||||
brightness: Brightness.light,
|
|
||||||
),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
ChangeNotifierProvider(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
create: (context) => SettingsProvider()..initialize(),
|
||||||
seedColor: Colors.blue,
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
),
|
||||||
themeMode: ThemeMode.system, // Follows system theme
|
],
|
||||||
home: const HomeScreen(),
|
child: Consumer<SettingsProvider>(
|
||||||
debugShowCheckedModeBanner: false,
|
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 {
|
class Supplement {
|
||||||
final int? id;
|
final int? id;
|
||||||
final String name;
|
final String name;
|
||||||
final double dosageAmount; // Amount per unit (e.g., 187mg)
|
final String? brand;
|
||||||
|
final List<Ingredient> ingredients;
|
||||||
final int numberOfUnits; // Number of units to take (e.g., 2 capsules)
|
final int numberOfUnits; // Number of units to take (e.g., 2 capsules)
|
||||||
final String unit; // mg, g, ml, etc.
|
|
||||||
final String unitType; // capsules, tablets, ml, etc.
|
final String unitType; // capsules, tablets, ml, etc.
|
||||||
final int frequencyPerDay;
|
final int frequencyPerDay;
|
||||||
final List<String> reminderTimes; // e.g., ['08:00', '20:00']
|
final List<String> reminderTimes; // e.g., ['08:00', '20:00']
|
||||||
@@ -14,9 +17,9 @@ class Supplement {
|
|||||||
Supplement({
|
Supplement({
|
||||||
this.id,
|
this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.dosageAmount,
|
this.brand,
|
||||||
|
this.ingredients = const [],
|
||||||
required this.numberOfUnits,
|
required this.numberOfUnits,
|
||||||
required this.unit,
|
|
||||||
required this.unitType,
|
required this.unitType,
|
||||||
required this.frequencyPerDay,
|
required this.frequencyPerDay,
|
||||||
required this.reminderTimes,
|
required this.reminderTimes,
|
||||||
@@ -25,16 +28,38 @@ class Supplement {
|
|||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper getter for total dosage per intake
|
// Helper getters
|
||||||
double get totalDosagePerIntake => dosageAmount * numberOfUnits;
|
double get totalDosagePerIntake {
|
||||||
|
// This concept doesn't apply well to multi-ingredient supplements
|
||||||
|
// Return 0 as it should be handled per ingredient
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get formatted ingredients string for display
|
||||||
|
String get ingredientsDisplay {
|
||||||
|
if (ingredients.isEmpty) {
|
||||||
|
return 'No ingredients specified';
|
||||||
|
}
|
||||||
|
return ingredients.map((ingredient) =>
|
||||||
|
'${ingredient.amount * numberOfUnits}${ingredient.unit} ${ingredient.name}'
|
||||||
|
).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ingredients per single unit
|
||||||
|
String get ingredientsPerUnit {
|
||||||
|
if (ingredients.isEmpty) {
|
||||||
|
return 'No ingredients specified';
|
||||||
|
}
|
||||||
|
return ingredients.map((ingredient) => ingredient.toString()).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
'name': name,
|
'name': name,
|
||||||
'dosageAmount': dosageAmount,
|
'brand': brand,
|
||||||
|
'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()),
|
||||||
'numberOfUnits': numberOfUnits,
|
'numberOfUnits': numberOfUnits,
|
||||||
'unit': unit,
|
|
||||||
'unitType': unitType,
|
'unitType': unitType,
|
||||||
'frequencyPerDay': frequencyPerDay,
|
'frequencyPerDay': frequencyPerDay,
|
||||||
'reminderTimes': reminderTimes.join(','),
|
'reminderTimes': reminderTimes.join(','),
|
||||||
@@ -45,13 +70,29 @@ class Supplement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory Supplement.fromMap(Map<String, dynamic> map) {
|
factory Supplement.fromMap(Map<String, dynamic> map) {
|
||||||
|
List<Ingredient> ingredients = [];
|
||||||
|
|
||||||
|
// Try to parse ingredients if they exist
|
||||||
|
if (map['ingredients'] != null && map['ingredients'].isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final ingredientsJson = map['ingredients'] as String;
|
||||||
|
final ingredientsList = jsonDecode(ingredientsJson) as List;
|
||||||
|
ingredients = ingredientsList
|
||||||
|
.map((ingredient) => Ingredient.fromMap(ingredient as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, fall back to empty list
|
||||||
|
ingredients = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Supplement(
|
return Supplement(
|
||||||
id: map['id'],
|
id: map['id'],
|
||||||
name: map['name'],
|
name: map['name'],
|
||||||
dosageAmount: map['dosageAmount']?.toDouble() ?? map['dosage']?.toDouble() ?? 0.0, // Backwards compatibility
|
brand: map['brand'],
|
||||||
numberOfUnits: map['numberOfUnits'] ?? 1, // Default to 1 for backwards compatibility
|
ingredients: ingredients,
|
||||||
unit: map['unit'],
|
numberOfUnits: map['numberOfUnits'] ?? 1,
|
||||||
unitType: map['unitType'] ?? 'units', // Default unit type for backwards compatibility
|
unitType: map['unitType'] ?? 'units',
|
||||||
frequencyPerDay: map['frequencyPerDay'],
|
frequencyPerDay: map['frequencyPerDay'],
|
||||||
reminderTimes: map['reminderTimes'].split(','),
|
reminderTimes: map['reminderTimes'].split(','),
|
||||||
notes: map['notes'],
|
notes: map['notes'],
|
||||||
@@ -63,9 +104,9 @@ class Supplement {
|
|||||||
Supplement copyWith({
|
Supplement copyWith({
|
||||||
int? id,
|
int? id,
|
||||||
String? name,
|
String? name,
|
||||||
double? dosageAmount,
|
String? brand,
|
||||||
|
List<Ingredient>? ingredients,
|
||||||
int? numberOfUnits,
|
int? numberOfUnits,
|
||||||
String? unit,
|
|
||||||
String? unitType,
|
String? unitType,
|
||||||
int? frequencyPerDay,
|
int? frequencyPerDay,
|
||||||
List<String>? reminderTimes,
|
List<String>? reminderTimes,
|
||||||
@@ -76,9 +117,9 @@ class Supplement {
|
|||||||
return Supplement(
|
return Supplement(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
dosageAmount: dosageAmount ?? this.dosageAmount,
|
brand: brand ?? this.brand,
|
||||||
|
ingredients: ingredients ?? this.ingredients,
|
||||||
numberOfUnits: numberOfUnits ?? this.numberOfUnits,
|
numberOfUnits: numberOfUnits ?? this.numberOfUnits,
|
||||||
unit: unit ?? this.unit,
|
|
||||||
unitType: unitType ?? this.unitType,
|
unitType: unitType ?? this.unitType,
|
||||||
frequencyPerDay: frequencyPerDay ?? this.frequencyPerDay,
|
frequencyPerDay: frequencyPerDay ?? this.frequencyPerDay,
|
||||||
reminderTimes: reminderTimes ?? this.reminderTimes,
|
reminderTimes: reminderTimes ?? this.reminderTimes,
|
||||||
|
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/foundation.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
import '../models/supplement_intake.dart';
|
import '../models/supplement_intake.dart';
|
||||||
import '../services/database_helper.dart';
|
import '../services/database_helper.dart';
|
||||||
@@ -12,6 +14,7 @@ class SupplementProvider with ChangeNotifier {
|
|||||||
List<Map<String, dynamic>> _todayIntakes = [];
|
List<Map<String, dynamic>> _todayIntakes = [];
|
||||||
List<Map<String, dynamic>> _monthlyIntakes = [];
|
List<Map<String, dynamic>> _monthlyIntakes = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
Timer? _persistentReminderTimer;
|
||||||
|
|
||||||
List<Supplement> get supplements => _supplements;
|
List<Supplement> get supplements => _supplements;
|
||||||
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
|
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
|
||||||
@@ -20,9 +23,115 @@ class SupplementProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
await _notificationService.initialize();
|
await _notificationService.initialize();
|
||||||
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 loadSupplements();
|
||||||
await loadTodayIntakes();
|
await loadTodayIntakes();
|
||||||
|
|
||||||
|
// Reschedule notifications for all active supplements to ensure persistence
|
||||||
|
await _rescheduleAllNotifications();
|
||||||
|
|
||||||
|
// Start periodic checking for persistent reminders (every 5 minutes)
|
||||||
|
_startPersistentReminderCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPersistentReminderCheck() {
|
||||||
|
// Cancel any existing timer
|
||||||
|
_persistentReminderTimer?.cancel();
|
||||||
|
|
||||||
|
// Check every 5 minutes for persistent reminders
|
||||||
|
_persistentReminderTimer = Timer.periodic(const Duration(minutes: 5), (timer) async {
|
||||||
|
try {
|
||||||
|
// This will be called from settings provider context, so we need to import it
|
||||||
|
await _checkPersistentReminders();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error checking persistent reminders: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also check immediately
|
||||||
|
_checkPersistentReminders();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkPersistentReminders() async {
|
||||||
|
// This method will be enhanced to accept settings from the UI layer
|
||||||
|
// For now, we'll check with default settings
|
||||||
|
// In practice, the UI should call checkPersistentRemindersWithSettings
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('📱 Checking persistent reminders with default settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to be called from UI with actual settings
|
||||||
|
Future<void> checkPersistentRemindersWithSettings({
|
||||||
|
required bool persistentReminders,
|
||||||
|
required int reminderRetryInterval,
|
||||||
|
required int maxRetryAttempts,
|
||||||
|
}) async {
|
||||||
|
await _notificationService.checkPersistentReminders(
|
||||||
|
persistentReminders,
|
||||||
|
reminderRetryInterval,
|
||||||
|
maxRetryAttempts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_persistentReminderTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _rescheduleAllNotifications() async {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('📱 Rescheduling notifications for all active supplements...');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final supplement in _supplements) {
|
||||||
|
if (supplement.reminderTimes.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
await _notificationService.scheduleSupplementReminders(supplement);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('📱 Error rescheduling notifications for ${supplement.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('📱 Finished rescheduling notifications');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadSupplements() async {
|
Future<void> loadSupplements() async {
|
||||||
@@ -103,11 +212,11 @@ class SupplementProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> recordIntake(int supplementId, double dosage, {double? unitsTaken, String? notes}) async {
|
Future<void> recordIntake(int supplementId, double dosage, {double? unitsTaken, String? notes, DateTime? takenAt}) async {
|
||||||
try {
|
try {
|
||||||
final intake = SupplementIntake(
|
final intake = SupplementIntake(
|
||||||
supplementId: supplementId,
|
supplementId: supplementId,
|
||||||
takenAt: DateTime.now(),
|
takenAt: takenAt ?? DateTime.now(),
|
||||||
dosageTaken: dosage,
|
dosageTaken: dosage,
|
||||||
unitsTaken: unitsTaken ?? 1.0,
|
unitsTaken: unitsTaken ?? 1.0,
|
||||||
notes: notes,
|
notes: notes,
|
||||||
@@ -121,7 +230,7 @@ class SupplementProvider with ChangeNotifier {
|
|||||||
final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : '';
|
final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : '';
|
||||||
await _notificationService.showInstantNotification(
|
await _notificationService.showInstantNotification(
|
||||||
'Supplement Taken',
|
'Supplement Taken',
|
||||||
'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} ($dosage ${supplement.unit})',
|
'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
@@ -167,10 +276,100 @@ class SupplementProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await _databaseHelper.deleteIntake(intakeId);
|
await _databaseHelper.deleteIntake(intakeId);
|
||||||
await loadTodayIntakes();
|
await loadTodayIntakes();
|
||||||
|
// Also refresh monthly intakes if they're loaded
|
||||||
|
if (_monthlyIntakes.isNotEmpty) {
|
||||||
|
await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('Error deleting intake: $e');
|
print('Error deleting intake: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasBeenTakenToday(int supplementId) {
|
||||||
|
return _todayIntakes.any((intake) => intake['supplement_id'] == supplementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
int getTodayIntakeCount(int supplementId) {
|
||||||
|
return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive functionality
|
||||||
|
List<Supplement> _archivedSupplements = [];
|
||||||
|
List<Supplement> get archivedSupplements => _archivedSupplements;
|
||||||
|
|
||||||
|
Future<void> loadArchivedSupplements() async {
|
||||||
|
try {
|
||||||
|
_archivedSupplements = await _databaseHelper.getArchivedSupplements();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error loading archived supplements: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> archiveSupplement(int supplementId) async {
|
||||||
|
try {
|
||||||
|
await _databaseHelper.archiveSupplement(supplementId);
|
||||||
|
await loadSupplements(); // Refresh active supplements
|
||||||
|
await loadArchivedSupplements(); // Refresh archived supplements
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error archiving supplement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unarchiveSupplement(int supplementId) async {
|
||||||
|
try {
|
||||||
|
await _databaseHelper.unarchiveSupplement(supplementId);
|
||||||
|
await loadSupplements(); // Refresh active supplements
|
||||||
|
await loadArchivedSupplements(); // Refresh archived supplements
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error unarchiving supplement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteArchivedSupplement(int supplementId) async {
|
||||||
|
try {
|
||||||
|
await _databaseHelper.deleteSupplement(supplementId);
|
||||||
|
await loadArchivedSupplements(); // Refresh archived supplements
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Error deleting archived supplement: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug methods for notification testing
|
||||||
|
Future<void> testNotifications() async {
|
||||||
|
await _notificationService.testNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> testScheduledNotification() async {
|
||||||
|
await _notificationService.testScheduledNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> testNotificationActions() async {
|
||||||
|
await _notificationService.testNotificationWithActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||||
|
return await _notificationService.getPendingNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug method to test notification persistence
|
||||||
|
Future<void> rescheduleAllNotifications() async {
|
||||||
|
await _rescheduleAllNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug method to cancel all notifications
|
||||||
|
Future<void> cancelAllNotifications() async {
|
||||||
|
await _notificationService.cancelAllReminders();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart';
|
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
|
import '../models/ingredient.dart';
|
||||||
import '../providers/supplement_provider.dart';
|
import '../providers/supplement_provider.dart';
|
||||||
|
|
||||||
|
// Helper class to manage ingredient text controllers
|
||||||
|
class IngredientController {
|
||||||
|
final TextEditingController nameController;
|
||||||
|
final TextEditingController amountController;
|
||||||
|
String selectedUnit;
|
||||||
|
|
||||||
|
IngredientController({
|
||||||
|
String name = '',
|
||||||
|
double amount = 0.0,
|
||||||
|
this.selectedUnit = 'mg',
|
||||||
|
}) : nameController = TextEditingController(text: name),
|
||||||
|
amountController = TextEditingController(text: amount > 0 ? amount.toString() : '');
|
||||||
|
|
||||||
|
Ingredient toIngredient() {
|
||||||
|
return Ingredient(
|
||||||
|
name: nameController.text.trim(),
|
||||||
|
amount: double.tryParse(amountController.text) ?? 0.0,
|
||||||
|
unit: selectedUnit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
nameController.dispose();
|
||||||
|
amountController.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AddSupplementScreen extends StatefulWidget {
|
class AddSupplementScreen extends StatefulWidget {
|
||||||
final Supplement? supplement;
|
final Supplement? supplement;
|
||||||
|
|
||||||
@@ -16,11 +43,13 @@ class AddSupplementScreen extends StatefulWidget {
|
|||||||
class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _nameController = TextEditingController();
|
final _nameController = TextEditingController();
|
||||||
final _dosageAmountController = TextEditingController();
|
final _brandController = TextEditingController();
|
||||||
final _numberOfUnitsController = TextEditingController();
|
final _numberOfUnitsController = TextEditingController();
|
||||||
final _notesController = TextEditingController();
|
final _notesController = TextEditingController();
|
||||||
|
|
||||||
String _selectedUnit = 'mg';
|
// Multi-ingredient support with persistent controllers
|
||||||
|
List<IngredientController> _ingredientControllers = [];
|
||||||
|
|
||||||
String _selectedUnitType = 'capsules';
|
String _selectedUnitType = 'capsules';
|
||||||
int _frequencyPerDay = 1;
|
int _frequencyPerDay = 1;
|
||||||
List<String> _reminderTimes = ['08:00'];
|
List<String> _reminderTimes = ['08:00'];
|
||||||
@@ -28,26 +57,159 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
final List<String> _units = ['mg', 'g', 'μg', 'IU', 'ml'];
|
final List<String> _units = ['mg', 'g', 'μg', 'IU', 'ml'];
|
||||||
final List<String> _unitTypes = ['capsules', 'tablets', 'softgels', 'drops', 'ml', 'scoops', 'gummies'];
|
final List<String> _unitTypes = ['capsules', 'tablets', 'softgels', 'drops', 'ml', 'scoops', 'gummies'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.supplement != null) {
|
if (widget.supplement != null) {
|
||||||
_initializeWithExistingSupplement();
|
_initializeWithExistingSupplement();
|
||||||
} else {
|
} else {
|
||||||
_numberOfUnitsController.text = '1'; // Default to 1 unit
|
_numberOfUnitsController.text = '1'; // Default to 1 unit
|
||||||
|
// Start with one empty ingredient
|
||||||
|
_ingredientControllers.add(IngredientController());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _addIngredient() {
|
||||||
|
setState(() {
|
||||||
|
_ingredientControllers.add(IngredientController());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeIngredient(int index) {
|
||||||
|
if (_ingredientControllers.length > 1) {
|
||||||
|
setState(() {
|
||||||
|
_ingredientControllers[index].dispose();
|
||||||
|
_ingredientControllers.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateIngredient(int index, String field, dynamic value) {
|
||||||
|
if (index < _ingredientControllers.length) {
|
||||||
|
setState(() {
|
||||||
|
if (field == 'unit') {
|
||||||
|
_ingredientControllers[index].selectedUnit = value as String;
|
||||||
|
}
|
||||||
|
// Note: name and amount are handled by the TextEditingControllers directly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIngredientRow(int index, IngredientController controller) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Ingredient ${index + 1}',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (_ingredientControllers.length > 1)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _removeIngredient(index),
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
color: Colors.red,
|
||||||
|
tooltip: 'Remove ingredient',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller.nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ingredient Name *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: 'e.g., Vitamin D3, Magnesium',
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Please enter ingredient name';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller.amountController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Amount *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: '100',
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Enter amount';
|
||||||
|
}
|
||||||
|
if (double.tryParse(value) == null || double.parse(value) <= 0) {
|
||||||
|
return 'Enter valid amount';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: controller.selectedUnit,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Unit',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: _units.map((unit) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: unit,
|
||||||
|
child: Text(unit),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
_updateIngredient(index, 'unit', value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _initializeWithExistingSupplement() {
|
void _initializeWithExistingSupplement() {
|
||||||
final supplement = widget.supplement!;
|
final supplement = widget.supplement!;
|
||||||
_nameController.text = supplement.name;
|
_nameController.text = supplement.name;
|
||||||
_dosageAmountController.text = supplement.dosageAmount.toString();
|
_brandController.text = supplement.brand ?? '';
|
||||||
_numberOfUnitsController.text = supplement.numberOfUnits.toString();
|
_numberOfUnitsController.text = supplement.numberOfUnits.toString();
|
||||||
_notesController.text = supplement.notes ?? '';
|
_notesController.text = supplement.notes ?? '';
|
||||||
_selectedUnit = supplement.unit;
|
|
||||||
_selectedUnitType = supplement.unitType;
|
_selectedUnitType = supplement.unitType;
|
||||||
_frequencyPerDay = supplement.frequencyPerDay;
|
_frequencyPerDay = supplement.frequencyPerDay;
|
||||||
_reminderTimes = List.from(supplement.reminderTimes);
|
_reminderTimes = List.from(supplement.reminderTimes);
|
||||||
|
|
||||||
|
// Initialize ingredient controllers from existing ingredients
|
||||||
|
_ingredientControllers.clear();
|
||||||
|
if (supplement.ingredients.isEmpty) {
|
||||||
|
// If no ingredients, start with one empty controller
|
||||||
|
_ingredientControllers.add(IngredientController());
|
||||||
|
} else {
|
||||||
|
for (final ingredient in supplement.ingredients) {
|
||||||
|
_ingredientControllers.add(IngredientController(
|
||||||
|
name: ingredient.name,
|
||||||
|
amount: ingredient.amount,
|
||||||
|
selectedUnit: ingredient.unit,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -83,53 +245,36 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Dosage amount per unit
|
// Brand field
|
||||||
Row(
|
TextFormField(
|
||||||
children: [
|
controller: _brandController,
|
||||||
Expanded(
|
decoration: const InputDecoration(
|
||||||
flex: 2,
|
labelText: 'Brand (Optional)',
|
||||||
child: TextFormField(
|
border: OutlineInputBorder(),
|
||||||
controller: _dosageAmountController,
|
hintText: 'e.g., Nature Made, NOW Foods',
|
||||||
keyboardType: TextInputType.number,
|
),
|
||||||
decoration: const InputDecoration(
|
),
|
||||||
labelText: 'Amount per unit *',
|
const SizedBox(height: 16),
|
||||||
border: OutlineInputBorder(),
|
|
||||||
hintText: '187',
|
// Ingredients section
|
||||||
),
|
Text(
|
||||||
validator: (value) {
|
'Ingredients',
|
||||||
if (value == null || value.trim().isEmpty) {
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
return 'Please enter amount per unit';
|
),
|
||||||
}
|
const SizedBox(height: 8),
|
||||||
if (double.tryParse(value) == null) {
|
..._ingredientControllers.asMap().entries.map((entry) {
|
||||||
return 'Please enter a valid number';
|
final index = entry.key;
|
||||||
}
|
final controller = entry.value;
|
||||||
return null;
|
return _buildIngredientRow(index, controller);
|
||||||
},
|
}),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
),
|
OutlinedButton.icon(
|
||||||
const SizedBox(width: 12),
|
onPressed: _addIngredient,
|
||||||
Expanded(
|
icon: const Icon(Icons.add),
|
||||||
flex: 1,
|
label: const Text('Add Ingredient'),
|
||||||
child: DropdownButtonFormField<String>(
|
style: OutlinedButton.styleFrom(
|
||||||
value: _selectedUnit,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: const InputDecoration(
|
),
|
||||||
labelText: 'Unit',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: _units.map((unit) {
|
|
||||||
return DropdownMenuItem(
|
|
||||||
value: unit,
|
|
||||||
child: Text(unit),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedUnit = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -181,15 +326,41 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
Text(
|
|
||||||
'Total per intake: ${_dosageAmountController.text.isNotEmpty && _numberOfUnitsController.text.isNotEmpty ? (double.tryParse(_dosageAmountController.text) ?? 0) * (int.tryParse(_numberOfUnitsController.text) ?? 0) : 0} $_selectedUnit',
|
// Show ingredients summary
|
||||||
style: TextStyle(
|
if (_ingredientControllers.isNotEmpty && _ingredientControllers.any((c) => c.nameController.text.isNotEmpty && (double.tryParse(c.amountController.text) ?? 0) > 0))
|
||||||
fontSize: 12,
|
Card(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||||
fontStyle: FontStyle.italic,
|
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),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Frequency per day
|
// Frequency per day
|
||||||
@@ -232,30 +403,66 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _selectTime(index),
|
onTap: () => _selectTime(index),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 20,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(
|
||||||
|
Icons.edit,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Text(time),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_reminderTimes.length > 1)
|
if (_reminderTimes.length > 1)
|
||||||
IconButton(
|
Padding(
|
||||||
onPressed: () => _removeReminderTime(index),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
icon: const Icon(Icons.remove_circle_outline),
|
child: IconButton(
|
||||||
|
onPressed: () => _removeReminderTime(index),
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
color: Colors.red,
|
||||||
|
tooltip: 'Remove reminder time',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
if (_reminderTimes.length < _frequencyPerDay)
|
if (_reminderTimes.length < _frequencyPerDay)
|
||||||
TextButton.icon(
|
Padding(
|
||||||
onPressed: _addReminderTime,
|
padding: const EdgeInsets.only(top: 8),
|
||||||
icon: const Icon(Icons.add),
|
child: OutlinedButton.icon(
|
||||||
label: const Text('Add Reminder Time'),
|
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),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -299,17 +506,35 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _selectTime(int index) {
|
void _selectTime(int index) async {
|
||||||
DatePicker.showTimePicker(
|
// Parse current time or use default
|
||||||
context,
|
TimeOfDay currentTime = TimeOfDay(hour: 8, minute: 0);
|
||||||
showTitleActions: true,
|
if (index < _reminderTimes.length) {
|
||||||
onConfirm: (time) {
|
final timeParts = _reminderTimes[index].split(':');
|
||||||
setState(() {
|
if (timeParts.length >= 2) {
|
||||||
_reminderTimes[index] = '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
currentTime = TimeOfDay(
|
||||||
});
|
hour: int.tryParse(timeParts[0]) ?? 8,
|
||||||
|
minute: int.tryParse(timeParts[1]) ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
void _addReminderTime() {
|
||||||
@@ -330,12 +555,31 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
|
|
||||||
void _saveSupplement() async {
|
void _saveSupplement() async {
|
||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
|
// Validate that we have at least one ingredient with name and amount
|
||||||
|
final validIngredients = _ingredientControllers.where((controller) =>
|
||||||
|
controller.nameController.text.trim().isNotEmpty &&
|
||||||
|
(double.tryParse(controller.amountController.text) ?? 0) > 0
|
||||||
|
).map((controller) => Ingredient(
|
||||||
|
name: controller.nameController.text.trim(),
|
||||||
|
amount: double.tryParse(controller.amountController.text) ?? 0,
|
||||||
|
unit: controller.selectedUnit,
|
||||||
|
)).toList();
|
||||||
|
|
||||||
|
if (validIngredients.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Please add at least one ingredient with name and amount'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final supplement = Supplement(
|
final supplement = Supplement(
|
||||||
id: widget.supplement?.id,
|
id: widget.supplement?.id,
|
||||||
name: _nameController.text.trim(),
|
name: _nameController.text.trim(),
|
||||||
dosageAmount: double.parse(_dosageAmountController.text),
|
brand: _brandController.text.trim().isNotEmpty ? _brandController.text.trim() : null,
|
||||||
|
ingredients: validIngredients,
|
||||||
numberOfUnits: int.parse(_numberOfUnitsController.text),
|
numberOfUnits: int.parse(_numberOfUnitsController.text),
|
||||||
unit: _selectedUnit,
|
|
||||||
unitType: _selectedUnitType,
|
unitType: _selectedUnitType,
|
||||||
frequencyPerDay: _frequencyPerDay,
|
frequencyPerDay: _frequencyPerDay,
|
||||||
reminderTimes: _reminderTimes,
|
reminderTimes: _reminderTimes,
|
||||||
@@ -380,9 +624,15 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_dosageAmountController.dispose();
|
_brandController.dispose();
|
||||||
_numberOfUnitsController.dispose();
|
_numberOfUnitsController.dispose();
|
||||||
_notesController.dispose();
|
_notesController.dispose();
|
||||||
|
|
||||||
|
// Dispose all ingredient controllers
|
||||||
|
for (final controller in _ingredientControllers) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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();
|
DateTime _selectedDate = DateTime.now();
|
||||||
int _selectedMonth = DateTime.now().month;
|
int _selectedMonth = DateTime.now().month;
|
||||||
int _selectedYear = DateTime.now().year;
|
int _selectedYear = DateTime.now().year;
|
||||||
|
int _refreshKey = 0; // Add this to force FutureBuilder refresh
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -90,6 +91,7 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<Map<String, dynamic>>>(
|
child: FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
|
key: ValueKey('daily_view_$_refreshKey'), // Use refresh key to force rebuild
|
||||||
future: context.read<SupplementProvider>().getIntakesForDate(_selectedDate),
|
future: context.read<SupplementProvider>().getIntakesForDate(_selectedDate),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
@@ -137,12 +139,10 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
|||||||
final supplementIntakes = groupedIntakes[supplementName]!;
|
final supplementIntakes = groupedIntakes[supplementName]!;
|
||||||
|
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
double totalDosage = 0;
|
|
||||||
double totalUnits = 0;
|
double totalUnits = 0;
|
||||||
final firstIntake = supplementIntakes.first;
|
final firstIntake = supplementIntakes.first;
|
||||||
|
|
||||||
for (final intake in supplementIntakes) {
|
for (final intake in supplementIntakes) {
|
||||||
totalDosage += intake['dosageTaken'] as double;
|
|
||||||
totalUnits += (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
|
totalUnits += (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,14 +161,14 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${totalDosage.toStringAsFixed(totalDosage % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnit']} total',
|
'${totalUnits.toStringAsFixed(totalUnits % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnitType'] ?? 'units'} total',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${totalUnits.toStringAsFixed(totalUnits % 1 == 0 ? 0 : 1)} ${firstIntake['supplementUnitType'] ?? 'units'} • ${supplementIntakes.length} intake${supplementIntakes.length > 1 ? 's' : ''}',
|
'${supplementIntakes.length} intake${supplementIntakes.length > 1 ? 's' : ''}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
@@ -181,9 +181,9 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
|||||||
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
|
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.only(left: 72, right: 16),
|
contentPadding: const EdgeInsets.only(left: 72, right: 8),
|
||||||
title: Text(
|
title: Text(
|
||||||
'${(intake['dosageTaken'] as double).toStringAsFixed((intake['dosageTaken'] as double) % 1 == 0 ? 0 : 1)} ${intake['supplementUnit']}',
|
'${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'}',
|
||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
@@ -210,6 +210,19 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.red.shade400,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
@@ -335,6 +348,7 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...dayIntakes.map((intake) {
|
...dayIntakes.map((intake) {
|
||||||
final takenAt = DateTime.parse(intake['takenAt']);
|
final takenAt = DateTime.parse(intake['takenAt']);
|
||||||
|
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -347,10 +361,23 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'${intake['supplementName']} - ${intake['dosageTaken']} ${intake['supplementUnit']} at ${DateFormat('HH:mm').format(takenAt)}',
|
'${intake['supplementName']} - ${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'} at ${DateFormat('HH:mm').format(takenAt)}',
|
||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.red.shade400,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 24,
|
||||||
|
minHeight: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -403,16 +430,27 @@ class _HistoryScreenState extends State<HistoryScreen> with SingleTickerProvider
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
context.read<SupplementProvider>().deleteIntake(intakeId);
|
await context.read<SupplementProvider>().deleteIntake(intakeId);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
// Force refresh of the UI
|
||||||
|
setState(() {
|
||||||
|
_refreshKey++; // This will force FutureBuilder to rebuild
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force refresh of the current view data
|
||||||
|
if (_tabController.index == 1) {
|
||||||
|
// Monthly view - refresh monthly intakes
|
||||||
|
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
|
||||||
|
}
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Intake deleted'),
|
content: Text('$supplementName intake deleted'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setState(() {}); // Refresh the view
|
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
child: const Text('Delete'),
|
child: const Text('Delete'),
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/supplement_provider.dart';
|
import '../providers/supplement_provider.dart';
|
||||||
|
import '../providers/settings_provider.dart';
|
||||||
import 'supplements_list_screen.dart';
|
import 'supplements_list_screen.dart';
|
||||||
import 'history_screen.dart';
|
import 'history_screen.dart';
|
||||||
import 'add_supplement_screen.dart';
|
import 'add_supplement_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -18,6 +20,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
final List<Widget> _screens = [
|
final List<Widget> _screens = [
|
||||||
const SupplementsListScreen(),
|
const SupplementsListScreen(),
|
||||||
const HistoryScreen(),
|
const HistoryScreen(),
|
||||||
|
const SettingsScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -25,9 +28,42 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<SupplementProvider>().initialize();
|
context.read<SupplementProvider>().initialize();
|
||||||
|
_startPersistentReminderCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startPersistentReminderCheck() {
|
||||||
|
// Check immediately and then every 10 minutes
|
||||||
|
_checkPersistentReminders();
|
||||||
|
|
||||||
|
// Set up periodic checking
|
||||||
|
Future.doWhile(() async {
|
||||||
|
await Future.delayed(const Duration(minutes: 10));
|
||||||
|
if (mounted) {
|
||||||
|
await _checkPersistentReminders();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkPersistentReminders() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final supplementProvider = context.read<SupplementProvider>();
|
||||||
|
final settingsProvider = context.read<SettingsProvider>();
|
||||||
|
|
||||||
|
await supplementProvider.checkPersistentRemindersWithSettings(
|
||||||
|
persistentReminders: settingsProvider.persistentReminders,
|
||||||
|
reminderRetryInterval: settingsProvider.reminderRetryInterval,
|
||||||
|
maxRetryAttempts: settingsProvider.maxRetryAttempts,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error checking persistent reminders: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -39,6 +75,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
items: const [
|
items: const [
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.medication),
|
icon: Icon(Icons.medication),
|
||||||
@@ -48,6 +85,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
icon: Icon(Icons.history),
|
icon: Icon(Icons.history),
|
||||||
label: 'History',
|
label: 'History',
|
||||||
),
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
label: 'Settings',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: _currentIndex == 0
|
floatingActionButton: _currentIndex == 0
|
||||||
|
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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import '../providers/supplement_provider.dart';
|
import '../providers/supplement_provider.dart';
|
||||||
|
import '../providers/settings_provider.dart';
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
import '../widgets/supplement_card.dart';
|
import '../widgets/supplement_card.dart';
|
||||||
import 'add_supplement_screen.dart';
|
import 'add_supplement_screen.dart';
|
||||||
|
import 'archived_supplements_screen.dart';
|
||||||
|
|
||||||
class SupplementsListScreen extends StatelessWidget {
|
class SupplementsListScreen extends StatelessWidget {
|
||||||
const SupplementsListScreen({super.key});
|
const SupplementsListScreen({super.key});
|
||||||
@@ -15,9 +16,22 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('My Supplements'),
|
title: const Text('My Supplements'),
|
||||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.archive),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const ArchivedSupplementsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tooltip: 'Archived Supplements',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Consumer<SupplementProvider>(
|
body: Consumer2<SupplementProvider, SettingsProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, settingsProvider, child) {
|
||||||
if (provider.isLoading) {
|
if (provider.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -56,86 +70,190 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await provider.loadSupplements();
|
await provider.loadSupplements();
|
||||||
},
|
},
|
||||||
child: Column(
|
child: _buildGroupedSupplementsList(context, provider.supplements, settingsProvider),
|
||||||
children: [
|
|
||||||
// Today's Intakes Section
|
|
||||||
if (provider.todayIntakes.isNotEmpty) ...[
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.check_circle, color: Theme.of(context).colorScheme.primary),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Today\'s Intakes',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
...provider.todayIntakes.map((intake) {
|
|
||||||
final takenAt = DateTime.parse(intake['takenAt']);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
|
||||||
child: Text(
|
|
||||||
'${intake['supplementName']} - ${intake['dosageTaken']} ${intake['supplementUnit']} at ${DateFormat('HH:mm').format(takenAt)}',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Supplements List
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
itemCount: provider.supplements.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final supplement = provider.supplements[index];
|
|
||||||
return SupplementCard(
|
|
||||||
supplement: supplement,
|
|
||||||
onTake: () => _showTakeDialog(context, supplement),
|
|
||||||
onEdit: () => _editSupplement(context, supplement),
|
|
||||||
onDelete: () => _deleteSupplement(context, supplement),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider) {
|
||||||
|
final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider);
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if (groupedSupplements['morning']!.isNotEmpty) ...[
|
||||||
|
_buildSectionHeader('Morning (${settingsProvider.morningRange})', Icons.wb_sunny, Colors.orange, groupedSupplements['morning']!.length),
|
||||||
|
...groupedSupplements['morning']!.map((supplement) =>
|
||||||
|
SupplementCard(
|
||||||
|
supplement: supplement,
|
||||||
|
onTake: () => _showTakeDialog(context, supplement),
|
||||||
|
onEdit: () => _editSupplement(context, supplement),
|
||||||
|
onDelete: () => _deleteSupplement(context, supplement),
|
||||||
|
onArchive: () => _archiveSupplement(context, supplement),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (groupedSupplements['afternoon']!.isNotEmpty) ...[
|
||||||
|
_buildSectionHeader('Afternoon (${settingsProvider.afternoonRange})', Icons.light_mode, Colors.blue, groupedSupplements['afternoon']!.length),
|
||||||
|
...groupedSupplements['afternoon']!.map((supplement) =>
|
||||||
|
SupplementCard(
|
||||||
|
supplement: supplement,
|
||||||
|
onTake: () => _showTakeDialog(context, supplement),
|
||||||
|
onEdit: () => _editSupplement(context, supplement),
|
||||||
|
onDelete: () => _deleteSupplement(context, supplement),
|
||||||
|
onArchive: () => _archiveSupplement(context, supplement),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (groupedSupplements['evening']!.isNotEmpty) ...[
|
||||||
|
_buildSectionHeader('Evening (${settingsProvider.eveningRange})', Icons.nightlight_round, Colors.indigo, groupedSupplements['evening']!.length),
|
||||||
|
...groupedSupplements['evening']!.map((supplement) =>
|
||||||
|
SupplementCard(
|
||||||
|
supplement: supplement,
|
||||||
|
onTake: () => _showTakeDialog(context, supplement),
|
||||||
|
onEdit: () => _editSupplement(context, supplement),
|
||||||
|
onDelete: () => _deleteSupplement(context, supplement),
|
||||||
|
onArchive: () => _archiveSupplement(context, supplement),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (groupedSupplements['night']!.isNotEmpty) ...[
|
||||||
|
_buildSectionHeader('Night (${settingsProvider.nightRange})', Icons.bedtime, Colors.purple, groupedSupplements['night']!.length),
|
||||||
|
...groupedSupplements['night']!.map((supplement) =>
|
||||||
|
SupplementCard(
|
||||||
|
supplement: supplement,
|
||||||
|
onTake: () => _showTakeDialog(context, supplement),
|
||||||
|
onEdit: () => _editSupplement(context, supplement),
|
||||||
|
onDelete: () => _deleteSupplement(context, supplement),
|
||||||
|
onArchive: () => _archiveSupplement(context, supplement),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (groupedSupplements['anytime']!.isNotEmpty) ...[
|
||||||
|
_buildSectionHeader('Anytime', Icons.schedule, Colors.grey, groupedSupplements['anytime']!.length),
|
||||||
|
...groupedSupplements['anytime']!.map((supplement) =>
|
||||||
|
SupplementCard(
|
||||||
|
supplement: supplement,
|
||||||
|
onTake: () => _showTakeDialog(context, supplement),
|
||||||
|
onEdit: () => _editSupplement(context, supplement),
|
||||||
|
onDelete: () => _deleteSupplement(context, supplement),
|
||||||
|
onArchive: () => _archiveSupplement(context, supplement),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(String title, IconData icon, Color color, int count) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: color.withOpacity(0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title.contains('(') ? title.split('(')[0].trim() : title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (title.contains('(')) ...[
|
||||||
|
Text(
|
||||||
|
'(${title.split('(')[1]}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: color.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
count.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<Supplement>> _groupSupplementsByTimeOfDay(List<Supplement> supplements, SettingsProvider settingsProvider) {
|
||||||
|
final Map<String, List<Supplement>> grouped = {
|
||||||
|
'morning': <Supplement>[],
|
||||||
|
'afternoon': <Supplement>[],
|
||||||
|
'evening': <Supplement>[],
|
||||||
|
'night': <Supplement>[],
|
||||||
|
'anytime': <Supplement>[],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final supplement in supplements) {
|
||||||
|
final category = settingsProvider.determineTimeCategory(supplement.reminderTimes);
|
||||||
|
grouped[category]!.add(supplement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
void _showTakeDialog(BuildContext context, Supplement supplement) {
|
void _showTakeDialog(BuildContext context, Supplement supplement) {
|
||||||
final unitsController = TextEditingController(text: supplement.numberOfUnits.toString());
|
final unitsController = TextEditingController(text: supplement.numberOfUnits.toString());
|
||||||
final notesController = TextEditingController();
|
final notesController = TextEditingController();
|
||||||
|
DateTime selectedDateTime = DateTime.now();
|
||||||
|
bool useCustomTime = false;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => StatefulBuilder(
|
builder: (context) => StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
final units = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble();
|
|
||||||
final totalDosage = supplement.dosageAmount * units;
|
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text('Take ${supplement.name}'),
|
title: Text('Take ${supplement.name}'),
|
||||||
content: Column(
|
content: Column(
|
||||||
@@ -175,7 +293,7 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${totalDosage.toStringAsFixed(totalDosage % 1 == 0 ? 0 : 1)} ${supplement.unit}',
|
supplement.ingredientsDisplay,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -186,6 +304,162 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Time selection section
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'When did you take it?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<bool>(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Just now', style: TextStyle(fontSize: 12)),
|
||||||
|
value: false,
|
||||||
|
groupValue: useCustomTime,
|
||||||
|
onChanged: (value) => setState(() => useCustomTime = value!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<bool>(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Custom time', style: TextStyle(fontSize: 12)),
|
||||||
|
value: true,
|
||||||
|
groupValue: useCustomTime,
|
||||||
|
onChanged: (value) => setState(() => useCustomTime = value!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (useCustomTime) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Date picker
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
size: 14,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Date: ${selectedDateTime.day}/${selectedDateTime.month}/${selectedDateTime.year}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedDateTime,
|
||||||
|
firstDate: DateTime.now().subtract(const Duration(days: 7)),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (date != null) {
|
||||||
|
setState(() {
|
||||||
|
selectedDateTime = DateTime(
|
||||||
|
date.year,
|
||||||
|
date.month,
|
||||||
|
date.day,
|
||||||
|
selectedDateTime.hour,
|
||||||
|
selectedDateTime.minute,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Change', style: TextStyle(fontSize: 10)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Time picker
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 14,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Time: ${selectedDateTime.hour.toString().padLeft(2, '0')}:${selectedDateTime.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final time = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: TimeOfDay.fromDateTime(selectedDateTime),
|
||||||
|
);
|
||||||
|
if (time != null) {
|
||||||
|
setState(() {
|
||||||
|
selectedDateTime = DateTime(
|
||||||
|
selectedDateTime.year,
|
||||||
|
selectedDateTime.month,
|
||||||
|
selectedDateTime.day,
|
||||||
|
time.hour,
|
||||||
|
time.minute,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Change', style: TextStyle(fontSize: 10)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: notesController,
|
controller: notesController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -204,12 +478,15 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final unitsTaken = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble();
|
final unitsTaken = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble();
|
||||||
final totalDosageTaken = supplement.dosageAmount * unitsTaken;
|
// For now, we'll record 0 as total dosage since we're transitioning to ingredients
|
||||||
|
// This will be properly implemented when we add the full ingredient tracking
|
||||||
|
final totalDosageTaken = 0.0;
|
||||||
context.read<SupplementProvider>().recordIntake(
|
context.read<SupplementProvider>().recordIntake(
|
||||||
supplement.id!,
|
supplement.id!,
|
||||||
totalDosageTaken,
|
totalDosageTaken,
|
||||||
unitsTaken: unitsTaken,
|
unitsTaken: unitsTaken,
|
||||||
notes: notesController.text.isNotEmpty ? notesController.text : null,
|
notes: notesController.text.isNotEmpty ? notesController.text : null,
|
||||||
|
takenAt: useCustomTime ? selectedDateTime : null,
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -219,7 +496,7 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Take'),
|
child: const Text('Record'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -265,4 +542,34 @@ class SupplementsListScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _archiveSupplement(BuildContext context, Supplement supplement) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Archive Supplement'),
|
||||||
|
content: Text('Are you sure you want to archive ${supplement.name}? You can unarchive it later from the archived supplements list.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.read<SupplementProvider>().archiveSupplement(supplement.id!);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('${supplement.name} archived'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
|
||||||
|
child: const Text('Archive'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,15 +2,17 @@ import 'package:sqflite/sqflite.dart';
|
|||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
import '../models/supplement_intake.dart';
|
import '../models/supplement_intake.dart';
|
||||||
|
|
||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
static const _databaseName = 'supplements.db';
|
static const _databaseName = 'supplements.db';
|
||||||
static const _databaseVersion = 2; // Increment version for schema changes
|
static const _databaseVersion = 5; // Increment version for notification tracking
|
||||||
|
|
||||||
static const supplementsTable = 'supplements';
|
static const supplementsTable = 'supplements';
|
||||||
static const intakesTable = 'supplement_intakes';
|
static const intakesTable = 'supplement_intakes';
|
||||||
|
static const notificationTrackingTable = 'notification_tracking';
|
||||||
|
|
||||||
DatabaseHelper._privateConstructor();
|
DatabaseHelper._privateConstructor();
|
||||||
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
|
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
|
||||||
@@ -50,9 +52,9 @@ class DatabaseHelper {
|
|||||||
CREATE TABLE $supplementsTable (
|
CREATE TABLE $supplementsTable (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
dosageAmount REAL NOT NULL,
|
brand TEXT,
|
||||||
|
ingredients TEXT NOT NULL DEFAULT '[]',
|
||||||
numberOfUnits INTEGER NOT NULL DEFAULT 1,
|
numberOfUnits INTEGER NOT NULL DEFAULT 1,
|
||||||
unit TEXT NOT NULL,
|
|
||||||
unitType TEXT NOT NULL DEFAULT 'units',
|
unitType TEXT NOT NULL DEFAULT 'units',
|
||||||
frequencyPerDay INTEGER NOT NULL,
|
frequencyPerDay INTEGER NOT NULL,
|
||||||
reminderTimes TEXT NOT NULL,
|
reminderTimes TEXT NOT NULL,
|
||||||
@@ -73,6 +75,20 @@ class DatabaseHelper {
|
|||||||
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
|
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $notificationTrackingTable (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
notificationId INTEGER NOT NULL UNIQUE,
|
||||||
|
supplementId INTEGER NOT NULL,
|
||||||
|
scheduledTime TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
retryCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lastRetryTime TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
|
||||||
|
)
|
||||||
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
@@ -97,6 +113,7 @@ class DatabaseHelper {
|
|||||||
CREATE TABLE ${supplementsTable}_new (
|
CREATE TABLE ${supplementsTable}_new (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
brand TEXT,
|
||||||
dosageAmount REAL NOT NULL,
|
dosageAmount REAL NOT NULL,
|
||||||
numberOfUnits INTEGER NOT NULL DEFAULT 1,
|
numberOfUnits INTEGER NOT NULL DEFAULT 1,
|
||||||
unit TEXT NOT NULL,
|
unit TEXT NOT NULL,
|
||||||
@@ -112,8 +129,8 @@ class DatabaseHelper {
|
|||||||
// Copy data to new table
|
// Copy data to new table
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
INSERT INTO ${supplementsTable}_new
|
INSERT INTO ${supplementsTable}_new
|
||||||
(id, name, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
|
(id, name, brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
|
||||||
SELECT id, name, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
|
SELECT id, name, NULL as brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
|
||||||
FROM $supplementsTable
|
FROM $supplementsTable
|
||||||
''');
|
''');
|
||||||
|
|
||||||
@@ -121,6 +138,86 @@ class DatabaseHelper {
|
|||||||
await db.execute('DROP TABLE $supplementsTable');
|
await db.execute('DROP TABLE $supplementsTable');
|
||||||
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
|
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
// Add brand column for version 3
|
||||||
|
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN brand TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
// Complete migration to new ingredient-based schema
|
||||||
|
// Add ingredients column and migrate old data
|
||||||
|
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN ingredients TEXT DEFAULT "[]"');
|
||||||
|
|
||||||
|
// Migrate existing supplements to use ingredients format
|
||||||
|
final supplements = await db.query(supplementsTable);
|
||||||
|
for (final supplement in supplements) {
|
||||||
|
final dosageAmount = supplement['dosageAmount'] as double?;
|
||||||
|
final unit = supplement['unit'] as String?;
|
||||||
|
final name = supplement['name'] as String;
|
||||||
|
|
||||||
|
if (dosageAmount != null && unit != null && dosageAmount > 0) {
|
||||||
|
// Create a single ingredient from the old dosage data
|
||||||
|
final ingredient = {
|
||||||
|
'name': name,
|
||||||
|
'amount': dosageAmount,
|
||||||
|
'unit': unit,
|
||||||
|
};
|
||||||
|
final ingredientsJson = jsonEncode([ingredient]);
|
||||||
|
|
||||||
|
await db.update(
|
||||||
|
supplementsTable,
|
||||||
|
{'ingredients': ingredientsJson},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [supplement['id']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old columns
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ${supplementsTable}_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
brand TEXT,
|
||||||
|
ingredients TEXT NOT NULL DEFAULT '[]',
|
||||||
|
numberOfUnits INTEGER NOT NULL DEFAULT 1,
|
||||||
|
unitType TEXT NOT NULL DEFAULT 'units',
|
||||||
|
frequencyPerDay INTEGER NOT NULL,
|
||||||
|
reminderTimes TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
isActive INTEGER NOT NULL DEFAULT 1
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
INSERT INTO ${supplementsTable}_new
|
||||||
|
(id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
|
||||||
|
SELECT id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
|
||||||
|
FROM $supplementsTable
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('DROP TABLE $supplementsTable');
|
||||||
|
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 5) {
|
||||||
|
// Add notification tracking table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $notificationTrackingTable (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
notificationId INTEGER NOT NULL UNIQUE,
|
||||||
|
supplementId INTEGER NOT NULL,
|
||||||
|
scheduledTime TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
retryCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lastRetryTime TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supplement CRUD operations
|
// Supplement CRUD operations
|
||||||
@@ -140,6 +237,37 @@ class DatabaseHelper {
|
|||||||
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
|
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Supplement>> getArchivedSupplements() async {
|
||||||
|
Database db = await database;
|
||||||
|
List<Map<String, dynamic>> maps = await db.query(
|
||||||
|
supplementsTable,
|
||||||
|
where: 'isActive = ?',
|
||||||
|
whereArgs: [0],
|
||||||
|
orderBy: 'name ASC',
|
||||||
|
);
|
||||||
|
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> archiveSupplement(int id) async {
|
||||||
|
Database db = await database;
|
||||||
|
await db.update(
|
||||||
|
supplementsTable,
|
||||||
|
{'isActive': 0},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unarchiveSupplement(int id) async {
|
||||||
|
Database db = await database;
|
||||||
|
await db.update(
|
||||||
|
supplementsTable,
|
||||||
|
{'isActive': 1},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Supplement?> getSupplement(int id) async {
|
Future<Supplement?> getSupplement(int id) async {
|
||||||
Database db = await database;
|
Database db = await database;
|
||||||
List<Map<String, dynamic>> maps = await db.query(
|
List<Map<String, dynamic>> maps = await db.query(
|
||||||
@@ -213,7 +341,10 @@ class DatabaseHelper {
|
|||||||
String endDate = DateTime(date.year, date.month, date.day, 23, 59, 59).toIso8601String();
|
String endDate = DateTime(date.year, date.month, date.day, 23, 59, 59).toIso8601String();
|
||||||
|
|
||||||
List<Map<String, dynamic>> result = await db.rawQuery('''
|
List<Map<String, dynamic>> result = await db.rawQuery('''
|
||||||
SELECT i.*, s.name as supplementName, s.unit as supplementUnit, s.unitType as supplementUnitType
|
SELECT i.*,
|
||||||
|
i.supplementId as supplement_id,
|
||||||
|
s.name as supplementName,
|
||||||
|
s.unitType as supplementUnitType
|
||||||
FROM $intakesTable i
|
FROM $intakesTable i
|
||||||
JOIN $supplementsTable s ON i.supplementId = s.id
|
JOIN $supplementsTable s ON i.supplementId = s.id
|
||||||
WHERE i.takenAt >= ? AND i.takenAt <= ?
|
WHERE i.takenAt >= ? AND i.takenAt <= ?
|
||||||
@@ -229,7 +360,10 @@ class DatabaseHelper {
|
|||||||
String endDate = DateTime(year, month + 1, 0, 23, 59, 59).toIso8601String();
|
String endDate = DateTime(year, month + 1, 0, 23, 59, 59).toIso8601String();
|
||||||
|
|
||||||
List<Map<String, dynamic>> result = await db.rawQuery('''
|
List<Map<String, dynamic>> result = await db.rawQuery('''
|
||||||
SELECT i.*, s.name as supplementName, s.unit as supplementUnit, s.unitType as supplementUnitType
|
SELECT i.*,
|
||||||
|
i.supplementId as supplement_id,
|
||||||
|
s.name as supplementName,
|
||||||
|
s.unitType as supplementUnitType
|
||||||
FROM $intakesTable i
|
FROM $intakesTable i
|
||||||
JOIN $supplementsTable s ON i.supplementId = s.id
|
JOIN $supplementsTable s ON i.supplementId = s.id
|
||||||
WHERE i.takenAt >= ? AND i.takenAt <= ?
|
WHERE i.takenAt >= ? AND i.takenAt <= ?
|
||||||
@@ -247,4 +381,90 @@ class DatabaseHelper {
|
|||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification tracking methods
|
||||||
|
Future<int> trackNotification({
|
||||||
|
required int notificationId,
|
||||||
|
required int supplementId,
|
||||||
|
required DateTime scheduledTime,
|
||||||
|
}) async {
|
||||||
|
Database db = await database;
|
||||||
|
|
||||||
|
// Use INSERT OR REPLACE to handle both new and existing notifications
|
||||||
|
await db.rawInsert('''
|
||||||
|
INSERT OR REPLACE INTO $notificationTrackingTable
|
||||||
|
(notificationId, supplementId, scheduledTime, status, retryCount, lastRetryTime, createdAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', [
|
||||||
|
notificationId,
|
||||||
|
supplementId,
|
||||||
|
scheduledTime.toIso8601String(),
|
||||||
|
'pending',
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
DateTime.now().toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return notificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markNotificationTaken(int notificationId) async {
|
||||||
|
Database db = await database;
|
||||||
|
await db.update(
|
||||||
|
notificationTrackingTable,
|
||||||
|
{'status': 'taken'},
|
||||||
|
where: 'notificationId = ?',
|
||||||
|
whereArgs: [notificationId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> incrementRetryCount(int notificationId) async {
|
||||||
|
Database db = await database;
|
||||||
|
await db.rawUpdate('''
|
||||||
|
UPDATE $notificationTrackingTable
|
||||||
|
SET retryCount = retryCount + 1,
|
||||||
|
lastRetryTime = ?,
|
||||||
|
status = 'retrying'
|
||||||
|
WHERE notificationId = ?
|
||||||
|
''', [DateTime.now().toIso8601String(), notificationId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getPendingNotifications() async {
|
||||||
|
Database db = await database;
|
||||||
|
return await db.query(
|
||||||
|
notificationTrackingTable,
|
||||||
|
where: 'status IN (?, ?)',
|
||||||
|
whereArgs: ['pending', 'retrying'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markNotificationExpired(int notificationId) async {
|
||||||
|
Database db = await database;
|
||||||
|
await db.update(
|
||||||
|
notificationTrackingTable,
|
||||||
|
{'status': 'expired'},
|
||||||
|
where: 'notificationId = ?',
|
||||||
|
whereArgs: [notificationId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cleanupOldNotificationTracking() async {
|
||||||
|
Database db = await database;
|
||||||
|
// Remove tracking records older than 7 days
|
||||||
|
final cutoffDate = DateTime.now().subtract(const Duration(days: 7)).toIso8601String();
|
||||||
|
await db.delete(
|
||||||
|
notificationTrackingTable,
|
||||||
|
where: 'createdAt < ?',
|
||||||
|
whereArgs: [cutoffDate],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearNotificationTracking(int supplementId) async {
|
||||||
|
Database db = await database;
|
||||||
|
await db.delete(
|
||||||
|
notificationTrackingTable,
|
||||||
|
where: 'supplementId = ?',
|
||||||
|
whereArgs: [supplementId],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,24 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|||||||
import 'package:timezone/timezone.dart' as tz;
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
import 'package:timezone/data/latest.dart' as tz;
|
import 'package:timezone/data/latest.dart' as tz;
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
|
import 'database_helper.dart';
|
||||||
|
|
||||||
|
// Top-level function to handle notification responses when app is running
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
void notificationTapBackground(NotificationResponse notificationResponse) {
|
||||||
|
print('📱 === BACKGROUND NOTIFICATION RESPONSE ===');
|
||||||
|
print('📱 Action ID: ${notificationResponse.actionId}');
|
||||||
|
print('📱 Payload: ${notificationResponse.payload}');
|
||||||
|
print('📱 Notification ID: ${notificationResponse.id}');
|
||||||
|
print('📱 ==========================================');
|
||||||
|
|
||||||
|
// For now, just log the action. The main app handler will process it.
|
||||||
|
if (notificationResponse.actionId == 'take_supplement') {
|
||||||
|
print('📱 BACKGROUND: Take action detected');
|
||||||
|
} else if (notificationResponse.actionId == 'snooze_10') {
|
||||||
|
print('📱 BACKGROUND: Snooze action detected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class NotificationService {
|
class NotificationService {
|
||||||
static final NotificationService _instance = NotificationService._internal();
|
static final NotificationService _instance = NotificationService._internal();
|
||||||
@@ -9,15 +27,81 @@ class NotificationService {
|
|||||||
NotificationService._internal();
|
NotificationService._internal();
|
||||||
|
|
||||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||||
|
bool _isInitialized = false;
|
||||||
|
static bool _engineInitialized = false;
|
||||||
|
bool _permissionsRequested = false;
|
||||||
|
|
||||||
|
// Callback for handling supplement intake from notifications
|
||||||
|
Function(int supplementId, String supplementName, double units, String unitType)? _onTakeSupplementCallback;
|
||||||
|
|
||||||
|
// Set callback for handling supplement intake from notifications
|
||||||
|
void setTakeSupplementCallback(Function(int supplementId, String supplementName, double units, String unitType) callback) {
|
||||||
|
_onTakeSupplementCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
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 AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
|
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
|
||||||
requestAlertPermission: true,
|
requestAlertPermission: false, // We'll request these separately
|
||||||
requestBadgePermission: true,
|
requestBadgePermission: false,
|
||||||
requestSoundPermission: true,
|
requestSoundPermission: false,
|
||||||
);
|
);
|
||||||
const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings(
|
const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings(
|
||||||
defaultActionName: 'Open notification',
|
defaultActionName: 'Open notification',
|
||||||
@@ -29,28 +113,347 @@ class NotificationService {
|
|||||||
linux: linuxSettings,
|
linux: linuxSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notifications.initialize(initSettings);
|
print('📱 Initializing flutter_local_notifications...');
|
||||||
|
await _notifications.initialize(
|
||||||
|
initSettings,
|
||||||
|
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||||
|
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test if notification response callback is working
|
||||||
|
print('📱 Callback function is set and ready');
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
print('📱 NotificationService initialization complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle notification responses (when user taps on notification or action)
|
||||||
|
void _onNotificationResponse(NotificationResponse response) {
|
||||||
|
print('📱 === NOTIFICATION RESPONSE ===');
|
||||||
|
print('📱 Action ID: ${response.actionId}');
|
||||||
|
print('📱 Payload: ${response.payload}');
|
||||||
|
print('📱 Notification ID: ${response.id}');
|
||||||
|
print('📱 Input: ${response.input}');
|
||||||
|
print('📱 ===============================');
|
||||||
|
|
||||||
|
if (response.actionId == 'take_supplement') {
|
||||||
|
print('📱 Processing TAKE action...');
|
||||||
|
_handleTakeAction(response.payload, response.id);
|
||||||
|
} else if (response.actionId == 'snooze_10') {
|
||||||
|
print('📱 Processing SNOOZE action...');
|
||||||
|
_handleSnoozeAction(response.payload, 10, response.id);
|
||||||
|
} else {
|
||||||
|
print('📱 Default notification tap (no specific action)');
|
||||||
|
// Default tap (no actionId) opens the app normally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTakeAction(String? payload, int? notificationId) {
|
||||||
|
print('📱 === HANDLING TAKE ACTION ===');
|
||||||
|
print('📱 Payload received: $payload');
|
||||||
|
|
||||||
|
if (payload != null) {
|
||||||
|
try {
|
||||||
|
// Parse the payload to get supplement info
|
||||||
|
final parts = payload.split('|');
|
||||||
|
print('📱 Payload parts: $parts (length: ${parts.length})');
|
||||||
|
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
final supplementId = int.parse(parts[0]);
|
||||||
|
final supplementName = parts[1];
|
||||||
|
final units = double.parse(parts[2]);
|
||||||
|
final unitType = parts[3];
|
||||||
|
|
||||||
|
print('📱 Parsed data:');
|
||||||
|
print('📱 - ID: $supplementId');
|
||||||
|
print('📱 - Name: $supplementName');
|
||||||
|
print('📱 - Units: $units');
|
||||||
|
print('📱 - Type: $unitType');
|
||||||
|
|
||||||
|
// Call the callback to record the intake
|
||||||
|
if (_onTakeSupplementCallback != null) {
|
||||||
|
print('📱 Calling supplement callback...');
|
||||||
|
_onTakeSupplementCallback!(supplementId, supplementName, units, unitType);
|
||||||
|
print('📱 Callback completed');
|
||||||
|
} else {
|
||||||
|
print('📱 ERROR: No callback registered!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark notification as taken in database (this will cancel any pending retries)
|
||||||
|
if (notificationId != null) {
|
||||||
|
print('📱 Marking notification $notificationId as taken');
|
||||||
|
DatabaseHelper.instance.markNotificationTaken(notificationId);
|
||||||
|
|
||||||
|
// Cancel any pending retry notifications for this notification
|
||||||
|
_cancelRetryNotifications(notificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a confirmation notification
|
||||||
|
print('📱 Showing confirmation notification...');
|
||||||
|
showInstantNotification(
|
||||||
|
'Supplement Taken!',
|
||||||
|
'$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
print('📱 ERROR: Invalid payload format - not enough parts');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('📱 ERROR in _handleTakeAction: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('📱 ERROR: Payload is null');
|
||||||
|
}
|
||||||
|
print('📱 === TAKE ACTION COMPLETE ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelRetryNotifications(int notificationId) {
|
||||||
|
// Retry notifications use ID range starting from 200000
|
||||||
|
for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries
|
||||||
|
int retryId = 200000 + (notificationId * 10) + i;
|
||||||
|
_notifications.cancel(retryId);
|
||||||
|
print('📱 Cancelled retry notification ID: $retryId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSnoozeAction(String? payload, int minutes, int? notificationId) {
|
||||||
|
print('📱 === HANDLING SNOOZE ACTION ===');
|
||||||
|
print('📱 Payload: $payload, Minutes: $minutes');
|
||||||
|
|
||||||
|
if (payload != null) {
|
||||||
|
try {
|
||||||
|
final parts = payload.split('|');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
final supplementId = int.parse(parts[0]);
|
||||||
|
final supplementName = parts[1];
|
||||||
|
|
||||||
|
print('📱 Snoozing supplement for $minutes minutes: $supplementName');
|
||||||
|
|
||||||
|
// Mark notification as snoozed in database (increment retry count)
|
||||||
|
if (notificationId != null) {
|
||||||
|
print('📱 Incrementing retry count for notification $notificationId');
|
||||||
|
DatabaseHelper.instance.incrementRetryCount(notificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a new notification for the snooze time
|
||||||
|
final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes));
|
||||||
|
print('📱 Snooze time: $snoozeTime');
|
||||||
|
|
||||||
|
_notifications.zonedSchedule(
|
||||||
|
supplementId * 1000 + minutes, // Unique ID for snooze notifications
|
||||||
|
'Reminder: $supplementName',
|
||||||
|
'Snoozed reminder - Take your $supplementName now',
|
||||||
|
snoozeTime,
|
||||||
|
NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'supplement_reminders',
|
||||||
|
'Supplement Reminders',
|
||||||
|
channelDescription: 'Notifications for supplement intake reminders',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
actions: [
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'take_supplement',
|
||||||
|
'Take',
|
||||||
|
),
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_10',
|
||||||
|
'Snooze 10min',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(),
|
||||||
|
),
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
showInstantNotification(
|
||||||
|
'Reminder Snoozed',
|
||||||
|
'$supplementName reminder snoozed for $minutes minutes',
|
||||||
|
);
|
||||||
|
print('📱 Snooze scheduled successfully');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('📱 Error handling snooze action: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print('📱 === SNOOZE ACTION COMPLETE ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for persistent reminders from app context with settings
|
||||||
|
Future<void> checkPersistentReminders(
|
||||||
|
bool persistentReminders,
|
||||||
|
int reminderRetryInterval,
|
||||||
|
int maxRetryAttempts,
|
||||||
|
) async {
|
||||||
|
await schedulePersistentReminders(
|
||||||
|
persistentReminders: persistentReminders,
|
||||||
|
reminderRetryInterval: reminderRetryInterval,
|
||||||
|
maxRetryAttempts: maxRetryAttempts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for pending notifications that need retry and schedule them
|
||||||
|
Future<void> schedulePersistentReminders({
|
||||||
|
required bool persistentReminders,
|
||||||
|
required int reminderRetryInterval,
|
||||||
|
required int maxRetryAttempts,
|
||||||
|
}) async {
|
||||||
|
print('📱 Checking for pending notifications to retry...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!persistentReminders) {
|
||||||
|
print('📱 Persistent reminders disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print('📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts');
|
||||||
|
|
||||||
|
// Get all pending notifications from database
|
||||||
|
final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications();
|
||||||
|
print('📱 Found ${pendingNotifications.length} pending notifications');
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
for (final notification in pendingNotifications) {
|
||||||
|
final scheduledTime = DateTime.parse(notification['scheduledTime']);
|
||||||
|
final retryCount = notification['retryCount'] as int;
|
||||||
|
final lastRetryTime = notification['lastRetryTime'] != null
|
||||||
|
? DateTime.parse(notification['lastRetryTime'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Check if notification is overdue
|
||||||
|
final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
|
||||||
|
final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
|
||||||
|
|
||||||
|
// Check if we haven't exceeded max retry attempts
|
||||||
|
if (retryCount >= maxRetryAttempts) {
|
||||||
|
print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough time has passed since last retry
|
||||||
|
if (lastRetryTime != null) {
|
||||||
|
final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes;
|
||||||
|
if (timeSinceLastRetry < reminderRetryInterval) {
|
||||||
|
print('📱 Notification ${notification['notificationId']} not ready for retry yet');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRetry) {
|
||||||
|
await _scheduleRetryNotification(notification, retryCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('📱 Error scheduling persistent reminders: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _scheduleRetryNotification(Map<String, dynamic> notification, int retryAttempt) async {
|
||||||
|
try {
|
||||||
|
final notificationId = notification['notificationId'] as int;
|
||||||
|
final supplementId = notification['supplementId'] as int;
|
||||||
|
|
||||||
|
// Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt)
|
||||||
|
final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt;
|
||||||
|
|
||||||
|
print('📱 Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)');
|
||||||
|
|
||||||
|
// Get supplement details from database
|
||||||
|
final supplements = await DatabaseHelper.instance.getAllSupplements();
|
||||||
|
final supplement = supplements.firstWhere((s) => s.id == supplementId && s.isActive, orElse: () => throw Exception('Supplement not found'));
|
||||||
|
|
||||||
|
// Schedule the retry notification immediately
|
||||||
|
await _notifications.show(
|
||||||
|
retryNotificationId,
|
||||||
|
'Reminder: ${supplement.name}',
|
||||||
|
'Don\'t forget to take your ${supplement.name}! (Retry #$retryAttempt)',
|
||||||
|
NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'supplement_reminders',
|
||||||
|
'Supplement Reminders',
|
||||||
|
channelDescription: 'Notifications for supplement intake reminders',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
actions: [
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'take_supplement',
|
||||||
|
'Take',
|
||||||
|
showsUserInterface: true,
|
||||||
|
icon: DrawableResourceAndroidBitmap('@drawable/ic_check'),
|
||||||
|
),
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_10',
|
||||||
|
'Snooze 10min',
|
||||||
|
showsUserInterface: true,
|
||||||
|
icon: DrawableResourceAndroidBitmap('@drawable/ic_snooze'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(),
|
||||||
|
),
|
||||||
|
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}|$notificationId',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the retry count in database
|
||||||
|
await DatabaseHelper.instance.incrementRetryCount(notificationId);
|
||||||
|
|
||||||
|
print('📱 Retry notification scheduled successfully');
|
||||||
|
} catch (e) {
|
||||||
|
print('📱 Error scheduling retry notification: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> requestPermissions() async {
|
Future<bool> requestPermissions() async {
|
||||||
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
print('📱 Requesting notification permissions...');
|
||||||
if (androidPlugin != null) {
|
if (_permissionsRequested) {
|
||||||
await androidPlugin.requestNotificationsPermission();
|
print('📱 Permissions already requested');
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
try {
|
||||||
if (iosPlugin != null) {
|
_permissionsRequested = true;
|
||||||
await iosPlugin.requestPermissions(
|
|
||||||
alert: true,
|
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||||
badge: true,
|
if (androidPlugin != null) {
|
||||||
sound: true,
|
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 {
|
Future<void> scheduleSupplementReminders(Supplement supplement) async {
|
||||||
|
print('📱 Scheduling reminders for ${supplement.name}');
|
||||||
|
print('📱 Reminder times: ${supplement.reminderTimes}');
|
||||||
|
|
||||||
// Cancel existing notifications for this supplement
|
// Cancel existing notifications for this supplement
|
||||||
await cancelSupplementReminders(supplement.id!);
|
await cancelSupplementReminders(supplement.id!);
|
||||||
|
|
||||||
@@ -61,25 +464,59 @@ class NotificationService {
|
|||||||
final minute = int.parse(timeParts[1]);
|
final minute = int.parse(timeParts[1]);
|
||||||
|
|
||||||
final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder
|
final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder
|
||||||
|
final scheduledTime = _nextInstanceOfTime(hour, minute);
|
||||||
|
|
||||||
|
print('📱 Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}');
|
||||||
|
|
||||||
|
// Track this notification in the database
|
||||||
|
await DatabaseHelper.instance.trackNotification(
|
||||||
|
notificationId: notificationId,
|
||||||
|
supplementId: supplement.id!,
|
||||||
|
scheduledTime: scheduledTime.toLocal(),
|
||||||
|
);
|
||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
notificationId,
|
notificationId,
|
||||||
'Time for ${supplement.name}',
|
'Time for ${supplement.name}',
|
||||||
'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.totalDosagePerIntake} ${supplement.unit})',
|
'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.ingredientsPerUnit})',
|
||||||
_nextInstanceOfTime(hour, minute),
|
scheduledTime,
|
||||||
const NotificationDetails(
|
NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'supplement_reminders',
|
'supplement_reminders',
|
||||||
'Supplement Reminders',
|
'Supplement Reminders',
|
||||||
channelDescription: 'Notifications for supplement intake reminders',
|
channelDescription: 'Notifications for supplement intake reminders',
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
|
actions: [
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'take_supplement',
|
||||||
|
'Take',
|
||||||
|
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'),
|
||||||
|
showsUserInterface: true, // Changed to true to open app
|
||||||
|
),
|
||||||
|
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,
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
matchDateTimeComponents: DateTimeComponents.time,
|
matchDateTimeComponents: DateTimeComponents.time,
|
||||||
|
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
print('📱 Successfully scheduled notification ID $notificationId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all pending notifications to verify
|
||||||
|
final pendingNotifications = await _notifications.pendingNotificationRequests();
|
||||||
|
print('📱 Total pending notifications: ${pendingNotifications.length}');
|
||||||
|
for (final notification in pendingNotifications) {
|
||||||
|
print('📱 Pending: ID=${notification.id}, Title=${notification.title}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +526,9 @@ class NotificationService {
|
|||||||
final notificationId = supplementId * 100 + i;
|
final notificationId = supplementId * 100 + i;
|
||||||
await _notifications.cancel(notificationId);
|
await _notifications.cancel(notificationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also clean up database tracking records for this supplement
|
||||||
|
await DatabaseHelper.instance.clearNotificationTracking(supplementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelAllReminders() async {
|
Future<void> cancelAllReminders() async {
|
||||||
@@ -99,14 +539,22 @@ class NotificationService {
|
|||||||
final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
|
final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
|
||||||
tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
|
tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
|
||||||
|
|
||||||
|
print('📱 Current time: $now (${now.timeZoneName})');
|
||||||
|
print('📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}');
|
||||||
|
print('📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||||
|
|
||||||
if (scheduledDate.isBefore(now)) {
|
if (scheduledDate.isBefore(now)) {
|
||||||
scheduledDate = scheduledDate.add(const Duration(days: 1));
|
scheduledDate = scheduledDate.add(const Duration(days: 1));
|
||||||
|
print('📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||||
|
} else {
|
||||||
|
print('📱 Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||||
}
|
}
|
||||||
|
|
||||||
return scheduledDate;
|
return scheduledDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> showInstantNotification(String title, String body) async {
|
Future<void> showInstantNotification(String title, String body) async {
|
||||||
|
print('📱 Showing instant notification: $title - $body');
|
||||||
const NotificationDetails notificationDetails = NotificationDetails(
|
const NotificationDetails notificationDetails = NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'instant_notifications',
|
'instant_notifications',
|
||||||
@@ -124,5 +572,108 @@ class NotificationService {
|
|||||||
body,
|
body,
|
||||||
notificationDetails,
|
notificationDetails,
|
||||||
);
|
);
|
||||||
|
print('📱 Instant notification sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug function to test notifications
|
||||||
|
Future<void> testNotification() async {
|
||||||
|
print('📱 Testing notification system...');
|
||||||
|
await showInstantNotification('Test Notification', 'This is a test notification to verify the system is working.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug function to schedule a test notification 1 minute from now
|
||||||
|
Future<void> testScheduledNotification() async {
|
||||||
|
print('📱 Testing scheduled notification...');
|
||||||
|
final now = tz.TZDateTime.now(tz.local);
|
||||||
|
final testTime = now.add(const Duration(minutes: 1));
|
||||||
|
|
||||||
|
print('📱 Scheduling test notification for: $testTime');
|
||||||
|
|
||||||
|
await _notifications.zonedSchedule(
|
||||||
|
99999, // Special ID for test notifications
|
||||||
|
'Test Scheduled Notification',
|
||||||
|
'This notification was scheduled 1 minute ago at ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}',
|
||||||
|
testTime,
|
||||||
|
const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'test_notifications',
|
||||||
|
'Test Notifications',
|
||||||
|
channelDescription: 'Test notifications for debugging',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
iOS: DarwinNotificationDetails(),
|
||||||
|
),
|
||||||
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||||
|
);
|
||||||
|
|
||||||
|
print('📱 Test notification scheduled successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug function to get all pending notifications
|
||||||
|
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||||
|
return await _notifications.pendingNotificationRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug function to test notification actions
|
||||||
|
Future<void> testNotificationWithActions() async {
|
||||||
|
print('📱 Creating test notification with actions...');
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
88888, // Special test ID
|
||||||
|
'Test Action Notification',
|
||||||
|
'Tap Take or Snooze to test notification actions',
|
||||||
|
NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'test_notifications',
|
||||||
|
'Test Notifications',
|
||||||
|
channelDescription: 'Test notifications for debugging actions',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
actions: [
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'take_supplement',
|
||||||
|
'Take',
|
||||||
|
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'),
|
||||||
|
showsUserInterface: true,
|
||||||
|
),
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_10',
|
||||||
|
'Snooze 10min',
|
||||||
|
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'),
|
||||||
|
showsUserInterface: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(),
|
||||||
|
),
|
||||||
|
payload: '999|Test Supplement|1.0|capsule',
|
||||||
|
);
|
||||||
|
|
||||||
|
print('📱 Test notification with actions created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug function to test basic notification tap response
|
||||||
|
Future<void> testBasicNotification() async {
|
||||||
|
print('📱 Creating basic test notification...');
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
77777, // Special test ID for basic notification
|
||||||
|
'Basic Test Notification',
|
||||||
|
'Tap this notification to test basic callback',
|
||||||
|
NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'test_notifications',
|
||||||
|
'Test Notifications',
|
||||||
|
channelDescription: 'Test notifications for debugging',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(),
|
||||||
|
),
|
||||||
|
payload: 'basic_test',
|
||||||
|
);
|
||||||
|
|
||||||
|
print('📱 Basic test notification created');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
|
import '../providers/supplement_provider.dart';
|
||||||
|
|
||||||
class SupplementCard extends StatelessWidget {
|
class SupplementCard extends StatefulWidget {
|
||||||
final Supplement supplement;
|
final Supplement supplement;
|
||||||
final VoidCallback onTake;
|
final VoidCallback onTake;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
final VoidCallback onArchive;
|
||||||
|
|
||||||
const SupplementCard({
|
const SupplementCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -13,51 +16,204 @@ class SupplementCard extends StatelessWidget {
|
|||||||
required this.onTake,
|
required this.onTake,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
required this.onArchive,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SupplementCard> createState() => _SupplementCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SupplementCardState extends State<SupplementCard> {
|
||||||
|
bool _isExpanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Consumer<SupplementProvider>(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
builder: (context, provider, child) {
|
||||||
elevation: 2,
|
final bool isTakenToday = provider.hasBeenTakenToday(widget.supplement.id!);
|
||||||
child: Padding(
|
final int todayIntakeCount = provider.getTodayIntakeCount(widget.supplement.id!);
|
||||||
padding: const EdgeInsets.all(16),
|
final bool isCompletelyTaken = todayIntakeCount >= widget.supplement.frequencyPerDay;
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// Get today's intake times for this supplement
|
||||||
children: [
|
final todayIntakes = provider.todayIntakes
|
||||||
Row(
|
.where((intake) => intake['supplement_id'] == widget.supplement.id)
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
.map((intake) {
|
||||||
children: [
|
final takenAt = DateTime.parse(intake['takenAt']);
|
||||||
Expanded(
|
final unitsTaken = intake['unitsTaken'] ?? 1.0;
|
||||||
child: Column(
|
return {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}',
|
||||||
children: [
|
'units': unitsTaken,
|
||||||
Text(
|
};
|
||||||
supplement.name,
|
}).toList();
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
return Card(
|
||||||
fontWeight: FontWeight.bold,
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
),
|
elevation: 3,
|
||||||
),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
const SizedBox(height: 4),
|
child: Container(
|
||||||
Text(
|
decoration: BoxDecoration(
|
||||||
'${supplement.numberOfUnits} ${supplement.unitType} (${supplement.dosageAmount} ${supplement.unit} each)',
|
borderRadius: BorderRadius.circular(16),
|
||||||
style: TextStyle(
|
color: isCompletelyTaken
|
||||||
fontSize: 14,
|
? Colors.green.shade800
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
: 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) {
|
onSelected: (value) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'edit':
|
case 'edit':
|
||||||
onEdit();
|
widget.onEdit();
|
||||||
|
break;
|
||||||
|
case 'archive':
|
||||||
|
widget.onArchive();
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
onDelete();
|
widget.onDelete();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -72,6 +228,16 @@ class SupplementCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'archive',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.archive, color: Colors.orange),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Archive'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -84,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: [
|
children: [
|
||||||
Text(
|
// Today's intake times (if any) - only show in expanded view
|
||||||
'Take Supplement',
|
if (todayIntakes.isNotEmpty) ...[
|
||||||
style: TextStyle(
|
Container(
|
||||||
fontSize: 14,
|
padding: const EdgeInsets.all(12),
|
||||||
fontWeight: FontWeight.w600,
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: _InfoChip(
|
||||||
children: [
|
icon: Icons.schedule,
|
||||||
Text(
|
label: '${widget.supplement.frequencyPerDay}x daily',
|
||||||
'Amount: ',
|
context: context,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${supplement.numberOfUnits} ${supplement.unitType}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
' (${supplement.totalDosagePerIntake} ${supplement.unit})',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
const SizedBox(width: 8),
|
||||||
onPressed: onTake,
|
Expanded(
|
||||||
icon: const Icon(Icons.medication, size: 16),
|
child: _InfoChip(
|
||||||
label: const Text('Take', style: TextStyle(fontSize: 12)),
|
icon: Icons.medication,
|
||||||
style: ElevatedButton.styleFrom(
|
label: '${widget.supplement.numberOfUnits} ${widget.supplement.unitType}',
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
context: context,
|
||||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
|
||||||
minimumSize: const Size(80, 32),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
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 Foundation
|
||||||
|
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
}
|
}
|
||||||
|
103
pubspec.lock
103
pubspec.lock
@@ -81,19 +81,19 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_datetime_picker_plus:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_datetime_picker_plus
|
|
||||||
sha256: "7d82da02c4e070bb28a9107de119ad195e2319b45c786fecc13482a9ffcc51da"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -139,6 +139,11 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -235,6 +240,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -267,6 +296,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.3"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.11"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.4"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -442,4 +527,4 @@ packages:
|
|||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.0 <4.0.0"
|
dart: ">=3.9.0 <4.0.0"
|
||||||
flutter: ">=3.24.0"
|
flutter: ">=3.27.0"
|
||||||
|
@@ -43,15 +43,15 @@ dependencies:
|
|||||||
# State management
|
# State management
|
||||||
provider: ^6.1.1
|
provider: ^6.1.1
|
||||||
|
|
||||||
|
# Settings persistence
|
||||||
|
shared_preferences: ^2.2.2
|
||||||
|
|
||||||
# Local notifications
|
# Local notifications
|
||||||
flutter_local_notifications: ^19.4.1
|
flutter_local_notifications: ^19.4.1
|
||||||
timezone: ^0.10.1
|
timezone: ^0.10.1
|
||||||
|
|
||||||
# Date time handling
|
# Date time handling
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
|
||||||
# UI components
|
|
||||||
flutter_datetime_picker_plus: ^2.1.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Reference in New Issue
Block a user