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',
theme: ShadThemeData(
brightness: Brightness.light,
colorScheme: const ShadBlueColorScheme.light(),
colorScheme: const ShadZincColorScheme.light(),
),
darkTheme: ShadThemeData(
brightness: Brightness.dark,
colorScheme: const ShadBlueColorScheme.dark(),
colorScheme: const ShadZincColorScheme.dark(),
),
themeMode: settingsProvider.themeMode,
home: const HomeScreen(),

View File

@@ -367,6 +367,24 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
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 {
final Map<String, double> ingredientIntake = {};

View File

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

View File

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

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:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:popover/popover.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
@@ -11,6 +12,8 @@ class SupplementCard extends StatefulWidget {
final VoidCallback onDelete;
final VoidCallback onArchive;
final VoidCallback onDuplicate;
final VoidCallback? onUndoLastTaken;
final bool showCompletionStatus;
const SupplementCard({
super.key,
@@ -20,6 +23,8 @@ class SupplementCard extends StatefulWidget {
required this.onDelete,
required this.onArchive,
required this.onDuplicate,
this.onUndoLastTaken,
this.showCompletionStatus = true,
});
@override
@@ -29,16 +34,48 @@ class SupplementCard extends StatefulWidget {
class _SupplementCardState extends State<SupplementCard> {
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
Widget build(BuildContext context) {
return Consumer<SupplementProvider>(
builder: (context, provider, child) {
final bool isTakenToday = provider.hasBeenTakenToday(widget.supplement.id!);
final int todayIntakeCount = provider.getTodayIntakeCount(widget.supplement.id!);
final bool isCompletelyTaken = todayIntakeCount >= widget.supplement.frequencyPerDay;
final bool isTakenToday = widget.showCompletionStatus ? provider.hasBeenTakenToday(widget.supplement.id!) : false;
final int todayIntakeCount = widget.showCompletionStatus ? provider.getTodayIntakeCount(widget.supplement.id!) : 0;
final bool isCompletelyTaken = widget.showCompletionStatus ? todayIntakeCount >= widget.supplement.frequencyPerDay : false;
// Get today's intake times for this supplement
final todayIntakes = provider.todayIntakes
// Get today's intake times for this supplement (only if showing completion status)
final todayIntakes = widget.showCompletionStatus ? provider.todayIntakes
.where((intake) => intake['supplement_id'] == widget.supplement.id)
.map((intake) {
final takenAt = DateTime.parse(intake['takenAt']);
@@ -47,103 +84,163 @@ class _SupplementCardState extends State<SupplementCard> {
'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}',
'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double,
};
}).toList();
}).toList() : [];
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
showPopover(
context: context,
bodyBuilder: (context) => Container(
constraints: const BoxConstraints(maxWidth: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: isCompletelyTaken
? Theme.of(context).colorScheme.surface
: isTakenToday
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.surface,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
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,
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: 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.withValues(alpha: 0.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,
mainAxisSize: MainAxisSize.min,
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.onUndoLastTaken != null && isTakenToday)
_buildPopoverItem(
icon: Icons.undo,
label: 'Undo Last Taken',
onTap: () {
Navigator.of(context).pop();
widget.onUndoLastTaken!();
},
color: Colors.orange,
),
_buildPopoverItem(
icon: Icons.edit,
label: 'Edit',
onTap: () {
Navigator.of(context).pop();
widget.onEdit();
},
),
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,
_buildPopoverItem(
icon: Icons.copy,
label: 'Duplicate',
onTap: () {
Navigator.of(context).pop();
widget.onDuplicate();
},
),
_buildPopoverItem(
icon: Icons.archive,
label: 'Archive',
color: Colors.orange,
onTap: () {
Navigator.of(context).pop();
widget.onArchive();
},
),
_buildPopoverItem(
icon: Icons.delete,
label: 'Delete',
color: Colors.red,
onTap: () {
Navigator.of(context).pop();
widget.onDelete();
},
),
],
),
),
// Status badge and take button in collapsed view
if (!_isExpanded) ...[
if (isCompletelyTaken)
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(
fontSize: 14,
fontWeight: FontWeight.bold,
color: widget.showCompletionStatus ? (isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: 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(
@@ -158,299 +255,56 @@ class _SupplementCardState extends State<SupplementCard> {
fontWeight: FontWeight.bold,
),
),
)
else ...[
if (isTakenToday)
),
],
],
),
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: 4),
Row(
children: [
Text(
'${widget.supplement.frequencyPerDay}x daily • ${widget.supplement.numberOfUnits} ${widget.supplement.unitType}',
style: TextStyle(
fontSize: 11,
color: ShadTheme.of(context).colorScheme.foreground.withValues(alpha: 0.7),
),
),
if (widget.showCompletionStatus && isTakenToday && !isCompletelyTaken) ...[
const SizedBox(width: 8),
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),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$todayIntakeCount/${widget.supplement.frequencyPerDay}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
fontSize: 10,
fontSize: 9,
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) {
switch (value) {
case 'edit':
widget.onEdit();
break;
case 'duplicate':
widget.onDuplicate();
break;
case 'archive':
widget.onArchive();
break;
case 'delete':
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,
),
),
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
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"
source: hosted
version: "2.1.8"
popover:
dependency: "direct main"
description:
name: popover
sha256: "0606f3e10f92fc0459f5c52fd917738c29e7552323b28694d50c2d3312d0e1a2"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
provider:
dependency: "direct main"
description:

View File

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