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
This commit is contained in:
2025-08-31 20:00:32 +02:00
parent 666008f05d
commit 7c63eb473b
8 changed files with 750 additions and 397 deletions

View File

@@ -98,11 +98,11 @@ class MyApp extends StatelessWidget {
title: 'Supplements Tracker', title: 'Supplements Tracker',
theme: ShadThemeData( theme: ShadThemeData(
brightness: Brightness.light, brightness: Brightness.light,
colorScheme: const ShadBlueColorScheme.light(), colorScheme: const ShadZincColorScheme.light(),
), ),
darkTheme: ShadThemeData( darkTheme: ShadThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
colorScheme: const ShadBlueColorScheme.dark(), colorScheme: const ShadZincColorScheme.dark(),
), ),
themeMode: settingsProvider.themeMode, themeMode: settingsProvider.themeMode,
home: const HomeScreen(), home: const HomeScreen(),

View File

@@ -367,6 +367,24 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length; return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length;
} }
/// Get the most recent intake for a supplement today
Map<String, dynamic>? getMostRecentIntake(int supplementId) {
final supplementIntakes = _todayIntakes
.where((intake) => intake['supplement_id'] == supplementId)
.toList();
if (supplementIntakes.isEmpty) return null;
// Sort by takenAt time (most recent first)
supplementIntakes.sort((a, b) {
final aTime = DateTime.parse(a['takenAt']);
final bTime = DateTime.parse(b['takenAt']);
return bTime.compareTo(aTime); // Descending order
});
return supplementIntakes.first;
}
Map<String, double> get dailyIngredientIntake { Map<String, double> get dailyIngredientIntake {
final Map<String, double> ingredientIntake = {}; final Map<String, double> ingredientIntake = {};

View File

@@ -6,6 +6,7 @@ import 'add_supplement_screen.dart';
import 'history_screen.dart'; import 'history_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'supplements_list_screen.dart'; import 'supplements_list_screen.dart';
import 'today_schedule_screen.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -18,6 +19,7 @@ class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0; int _currentIndex = 0;
final List<Widget> _screens = [ final List<Widget> _screens = [
const TodayScheduleScreen(),
const SupplementsListScreen(), const SupplementsListScreen(),
const HistoryScreen(), const HistoryScreen(),
const SettingsScreen(), const SettingsScreen(),
@@ -45,11 +47,18 @@ class _HomeScreenState extends State<HomeScreen> {
}); });
}, },
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
backgroundColor: Theme.of(context).colorScheme.surface,
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.medication), icon: Icon(Icons.medication_liquid_sharp),
label: 'Supplements', label: 'Supplements',
), ),
BottomNavigationBarItem(
icon: Icon(Icons.edit_calendar_rounded),
label: 'Edit',
),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.history), icon: Icon(Icons.history),
label: 'History', label: 'History',
@@ -60,7 +69,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
], ],
), ),
floatingActionButton: _currentIndex == 0 floatingActionButton: _currentIndex == 1
? FloatingActionButton( ? FloatingActionButton(
onPressed: () async { onPressed: () async {
await Navigator.of(context).push( await Navigator.of(context).push(

View File

@@ -18,7 +18,7 @@ class SupplementsListScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('My Supplements'), title: const Text('Edit Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary, backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [ actions: [
Consumer<SimpleSyncProvider>( Consumer<SimpleSyncProvider>(
@@ -100,18 +100,18 @@ class SupplementsListScreen extends StatelessWidget {
await provider.loadSupplements(); await provider.loadSupplements();
await provider.refreshDailyStatus(); await provider.refreshDailyStatus();
}, },
child: _buildGroupedSupplementsList(context, provider.supplements, settingsProvider), child: _buildGroupedSupplementsList(context, provider.supplements, settingsProvider, provider),
); );
}, },
), ),
); );
} }
Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider) { Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider, SupplementProvider provider) {
final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider); final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider);
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
children: [ children: [
if (groupedSupplements['morning']!.isNotEmpty) ...[ if (groupedSupplements['morning']!.isNotEmpty) ...[
_buildSectionHeader('Morning (${settingsProvider.morningRange})', Icons.wb_sunny, Colors.orange, groupedSupplements['morning']!.length), _buildSectionHeader('Morning (${settingsProvider.morningRange})', Icons.wb_sunny, Colors.orange, groupedSupplements['morning']!.length),
@@ -123,6 +123,7 @@ class SupplementsListScreen extends StatelessWidget {
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!), onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -138,6 +139,7 @@ class SupplementsListScreen extends StatelessWidget {
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!), onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -153,6 +155,7 @@ class SupplementsListScreen extends StatelessWidget {
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!), onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -168,6 +171,7 @@ class SupplementsListScreen extends StatelessWidget {
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!), onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -183,9 +187,11 @@ class SupplementsListScreen extends StatelessWidget {
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!), onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
), ),
), ),
], ],
], ],
); );
} }
@@ -278,7 +284,7 @@ class SupplementsListScreen extends StatelessWidget {
return grouped; return grouped;
} }
void _editSupplement(BuildContext context, Supplement supplement) { void _editSupplement(BuildContext context, Supplement supplement) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(

View File

@@ -0,0 +1,456 @@
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;
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:popover/popover.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
@@ -11,6 +12,8 @@ class SupplementCard extends StatefulWidget {
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback onArchive; final VoidCallback onArchive;
final VoidCallback onDuplicate; final VoidCallback onDuplicate;
final VoidCallback? onUndoLastTaken;
final bool showCompletionStatus;
const SupplementCard({ const SupplementCard({
super.key, super.key,
@@ -20,6 +23,8 @@ class SupplementCard extends StatefulWidget {
required this.onDelete, required this.onDelete,
required this.onArchive, required this.onArchive,
required this.onDuplicate, required this.onDuplicate,
this.onUndoLastTaken,
this.showCompletionStatus = true,
}); });
@override @override
@@ -29,16 +34,48 @@ class SupplementCard extends StatefulWidget {
class _SupplementCardState extends State<SupplementCard> { class _SupplementCardState extends State<SupplementCard> {
bool _isExpanded = false; bool _isExpanded = false;
Widget _buildPopoverItem({
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,
),
),
],
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<SupplementProvider>( return Consumer<SupplementProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
final bool isTakenToday = provider.hasBeenTakenToday(widget.supplement.id!); final bool isTakenToday = widget.showCompletionStatus ? provider.hasBeenTakenToday(widget.supplement.id!) : false;
final int todayIntakeCount = provider.getTodayIntakeCount(widget.supplement.id!); final int todayIntakeCount = widget.showCompletionStatus ? provider.getTodayIntakeCount(widget.supplement.id!) : 0;
final bool isCompletelyTaken = todayIntakeCount >= widget.supplement.frequencyPerDay; final bool isCompletelyTaken = widget.showCompletionStatus ? todayIntakeCount >= widget.supplement.frequencyPerDay : false;
// Get today's intake times for this supplement // Get today's intake times for this supplement (only if showing completion status)
final todayIntakes = provider.todayIntakes final todayIntakes = widget.showCompletionStatus ? provider.todayIntakes
.where((intake) => intake['supplement_id'] == widget.supplement.id) .where((intake) => intake['supplement_id'] == widget.supplement.id)
.map((intake) { .map((intake) {
final takenAt = DateTime.parse(intake['takenAt']); final takenAt = DateTime.parse(intake['takenAt']);
@@ -47,410 +84,227 @@ class _SupplementCardState extends State<SupplementCard> {
'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}', 'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}',
'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double, 'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double,
}; };
}).toList(); }).toList() : [];
return Card( return Card(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 8),
elevation: 3, elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Container( child: InkWell(
decoration: BoxDecoration( onTap: () {
borderRadius: BorderRadius.circular(16), showPopover(
color: isCompletelyTaken context: context,
? Theme.of(context).colorScheme.surface bodyBuilder: (context) => Container(
: isTakenToday constraints: const BoxConstraints(maxWidth: 200),
? 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.withValues(alpha: 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( decoration: BoxDecoration(
color: isCompletelyTaken color: Theme.of(context).colorScheme.surface,
? Colors.green.shade500 borderRadius: BorderRadius.circular(12),
: isTakenToday border: Border.all(
? Theme.of(context).colorScheme.secondary color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), width: 1,
shape: BoxShape.circle,
),
child: Icon(
isCompletelyTaken
? Icons.check_circle
: isTakenToday
? Icons.schedule
: Icons.medication,
color: isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: isTakenToday
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.primary,
size: 20,
),
),
title: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.supplement.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: 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 boxShadow: [
if (!_isExpanded) ...[ BoxShadow(
if (isCompletelyTaken) color: Theme.of(context).colorScheme.shadow.withValues(alpha: 0.1),
Container( blurRadius: 8,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), offset: const Offset(0, 2),
decoration: BoxDecoration( ),
color: Colors.green.shade500,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Complete',
style: TextStyle(
color: Theme.of(context).colorScheme.inverseSurface,
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,
),
),
),
],
], ],
],
),
trailing: PopupMenuButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert,
color: isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
onSelected: (value) { child: Column(
switch (value) { mainAxisSize: MainAxisSize.min,
case 'edit': children: [
widget.onEdit(); if (widget.onUndoLastTaken != null && isTakenToday)
break; _buildPopoverItem(
case 'duplicate': icon: Icons.undo,
widget.onDuplicate(); label: 'Undo Last Taken',
break; onTap: () {
case 'archive': Navigator.of(context).pop();
widget.onArchive(); widget.onUndoLastTaken!();
break; },
case 'delete': color: Colors.orange,
widget.onDelete();
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'take',
onTap: widget.onTake,
child: Row(
children: [
Icon(isTakenToday ? Icons.add_circle_outline : Icons.medication),
const SizedBox(width: 8),
Text(isTakenToday ? 'Take Again' : 'Take'),
],
),
),
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit),
SizedBox(width: 8),
Text('Edit'),
],
),
),
const PopupMenuItem(
value: 'duplicate',
child: Row(
children: [
Icon(Icons.copy),
SizedBox(width: 8),
Text('Duplicate'),
],
),
),
const PopupMenuItem(
value: 'archive',
child: Row(
children: [
Icon(Icons.archive, color: Colors.orange),
SizedBox(width: 8),
Text('Archive'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Delete', style: TextStyle(color: Colors.red)),
],
),
),
],
),
children: [
// Today's intake times (if any) - only show in expanded view
if (todayIntakes.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isCompletelyTaken
? Colors.green.shade700.withValues(alpha: 0.8)
: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCompletelyTaken
? Colors.green.shade500
: Theme.of(context).colorScheme.secondary,
), ),
_buildPopoverItem(
icon: Icons.edit,
label: 'Edit',
onTap: () {
Navigator.of(context).pop();
widget.onEdit();
},
), ),
child: Column( _buildPopoverItem(
crossAxisAlignment: CrossAxisAlignment.start, icon: Icons.copy,
children: [ label: 'Duplicate',
Row( onTap: () {
children: [ Navigator.of(context).pop();
Icon( widget.onDuplicate();
Icons.check_circle_outline, },
size: 16, ),
color: isCompletelyTaken _buildPopoverItem(
? Colors.green.shade200 icon: Icons.archive,
: Theme.of(context).colorScheme.onSecondaryContainer, label: 'Archive',
), color: Colors.orange,
const SizedBox(width: 6), onTap: () {
Text( Navigator.of(context).pop();
'Taken today:', widget.onArchive();
},
),
_buildPopoverItem(
icon: Icons.delete,
label: 'Delete',
color: Colors.red,
onTap: () {
Navigator.of(context).pop();
widget.onDelete();
},
),
],
),
),
direction: PopoverDirection.bottom,
width: 180,
height: null,
arrowHeight: 0,
arrowWidth: 0,
backgroundColor: Colors.transparent,
shadow: const [],
);
},
borderRadius: BorderRadius.circular(12),
splashColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.4),
highlightColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: widget.showCompletionStatus ? (isCompletelyTaken
? Theme.of(context).colorScheme.surface
: isTakenToday
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.surface) : Theme.of(context).colorScheme.surface,
border: Border.all(
color: widget.showCompletionStatus ? (isCompletelyTaken
? Colors.green.shade600
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)) : Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: widget.showCompletionStatus ? (isCompletelyTaken
? Colors.green.shade500
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1)) : Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
widget.showCompletionStatus ? (isCompletelyTaken
? Icons.check_circle
: isTakenToday
? Icons.schedule
: Icons.medication) : Icons.medication,
color: widget.showCompletionStatus ? (isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: isTakenToday
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.primary) : Theme.of(context).colorScheme.primary,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
widget.supplement.name,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.bold,
color: isCompletelyTaken color: widget.showCompletionStatus ? (isCompletelyTaken
? Colors.green.shade200 ? Theme.of(context).colorScheme.inverseSurface
: Theme.of(context).colorScheme.onSecondaryContainer, : isTakenToday
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onSurface) : Theme.of(context).colorScheme.onSurface,
),
),
),
if (widget.showCompletionStatus && isCompletelyTaken) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.shade500,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Complete',
style: TextStyle(
color: Theme.of(context).colorScheme.inverseSurface,
fontSize: 10,
fontWeight: FontWeight.bold,
),
), ),
), ),
], ],
],
),
if (widget.supplement.brand != null && widget.supplement.brand!.isNotEmpty)
Text(
widget.supplement.brand!,
style: TextStyle(
fontSize: 11,
color: widget.showCompletionStatus ? (isCompletelyTaken
? Colors.green.shade200
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primary) : Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
Wrap( Row(
spacing: 8, children: [
runSpacing: 4, Text(
children: todayIntakes.map((intake) { '${widget.supplement.frequencyPerDay}x daily • ${widget.supplement.numberOfUnits} ${widget.supplement.unitType}',
final units = intake['units'] as double; style: TextStyle(
final unitsText = units == 1.0 fontSize: 11,
? widget.supplement.unitType color: ShadTheme.of(context).colorScheme.foreground.withValues(alpha: 0.7),
: '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${widget.supplement.unitType}'; ),
),
return Container( if (widget.showCompletionStatus && isTakenToday && !isCompletelyTaken) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isCompletelyTaken color: Theme.of(context).colorScheme.secondary,
? Colors.green.shade600 borderRadius: BorderRadius.circular(8),
: Theme.of(context).colorScheme.secondary,
borderRadius: BorderRadius.circular(6),
), ),
child: Text( child: Text(
'${intake['time']}$unitsText', '$todayIntakeCount/${widget.supplement.frequencyPerDay}',
style: TextStyle( style: TextStyle(
fontSize: 10, color: Theme.of(context).colorScheme.onSecondary,
fontWeight: FontWeight.w500, fontSize: 9,
color: isCompletelyTaken fontWeight: FontWeight.bold,
? Colors.white
: Theme.of(context).colorScheme.onSecondary,
), ),
), ),
); ),
}).toList(), ],
), ],
], ),
), ],
),
const SizedBox(height: 16),
],
// Ingredients section
ShadCard(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ingredients per ${widget.supplement.unitType}:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: ShadTheme.of(context).colorScheme.foreground,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: widget.supplement.ingredients.map((ingredient) {
return ShadBadge(
child: Text(
'${ingredient.name} ${ingredient.amount}${ingredient.unit}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
),
],
),
), ),
), ),
const SizedBox(height: 12),
// Schedule and dosage info
Row(
children: [
Expanded(
child: ShadBadge(
child: Row(
children: [
Icon(
Icons.schedule,
size: 14,
color: ShadTheme.of(context).colorScheme.foreground,
),
const SizedBox(width: 4),
Text('${widget.supplement.frequencyPerDay}x daily'),
],
),
),
),
const SizedBox(width: 8),
Expanded(
child: ShadBadge(
child: Row(
children: [
Icon(
Icons.medication,
size: 14,
color: ShadTheme.of(context).colorScheme.foreground,
),
const SizedBox(width: 4),
Text('${widget.supplement.numberOfUnits} ${widget.supplement.unitType}'),
],
),
),
),
],
),
if (widget.supplement.reminderTimes.isNotEmpty) ...[
const SizedBox(height: 8),
ShadBadge(
child: Row(
children: [
Icon(
Icons.notifications,
size: 14,
color: ShadTheme.of(context).colorScheme.foreground,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'Reminders: ${widget.supplement.reminderTimes.join(', ')}',
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
if (widget.supplement.notes != null && widget.supplement.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
ShadCard(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
widget.supplement.notes!,
style: TextStyle(
fontSize: 12,
color: ShadTheme.of(context).colorScheme.foreground,
fontStyle: FontStyle.italic,
),
),
),
),
],
], ],
), ),
), ),
@@ -508,3 +362,4 @@ class _InfoChip extends StatelessWidget {
); );
} }
} }

View File

@@ -558,6 +558,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
popover:
dependency: "direct main"
description:
name: popover
sha256: "0606f3e10f92fc0459f5c52fd917738c29e7552323b28694d50c2d3312d0e1a2"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -31,6 +31,7 @@ dependencies:
# UI components # UI components
shadcn_ui: ^0.29.2 shadcn_ui: ^0.29.2
popover: ^0.3.0
# WebDAV sync functionality # WebDAV sync functionality
webdav_client: ^1.2.2 webdav_client: ^1.2.2