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