Files
supplements/lib/screens/today_schedule_screen.dart
Menno van Leeuwen 7c63eb473b feat: Update theme colors and add Today Schedule screen
- Changed color scheme from ShadBlueColorScheme to ShadZincColorScheme in main.dart
- Added getMostRecentIntake method in SupplementProvider to retrieve the latest intake for a supplement
- Integrated TodayScheduleScreen into HomeScreen with a new BottomNavigationBar item
- Updated SupplementsListScreen title and adjusted layout for better UX
- Enhanced SupplementCard to support undoing last taken action and improved popover menu options
- Added popover package for better UI interactions
2025-08-31 20:00:32 +02:00

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;
}
}