mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-12-07 21:52:35 +00:00
547 lines
23 KiB
Dart
547 lines
23 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../models/supplement.dart';
|
|
import '../providers/supplement_provider.dart';
|
|
|
|
class SupplementCard extends StatefulWidget {
|
|
final Supplement supplement;
|
|
final VoidCallback onTake;
|
|
final VoidCallback onEdit;
|
|
final VoidCallback onDelete;
|
|
final VoidCallback onArchive;
|
|
final VoidCallback onDuplicate;
|
|
|
|
const SupplementCard({
|
|
super.key,
|
|
required this.supplement,
|
|
required this.onTake,
|
|
required this.onEdit,
|
|
required this.onDelete,
|
|
required this.onArchive,
|
|
required this.onDuplicate,
|
|
});
|
|
|
|
@override
|
|
State<SupplementCard> createState() => _SupplementCardState();
|
|
}
|
|
|
|
class _SupplementCardState extends State<SupplementCard> {
|
|
bool _isExpanded = false;
|
|
|
|
@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;
|
|
|
|
// Get today's intake times for this supplement
|
|
final todayIntakes = provider.todayIntakes
|
|
.where((intake) => intake['supplement_id'] == widget.supplement.id)
|
|
.map((intake) {
|
|
final takenAt = DateTime.parse(intake['takenAt']);
|
|
final unitsTaken = intake['unitsTaken'] ?? 1.0;
|
|
return {
|
|
'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}',
|
|
'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double,
|
|
};
|
|
}).toList();
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
elevation: 3,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: isCompletelyTaken
|
|
? Theme.of(context).colorScheme.surface
|
|
: isTakenToday
|
|
? Theme.of(context).colorScheme.secondaryContainer
|
|
: Theme.of(context).colorScheme.surface,
|
|
border: Border.all(
|
|
color: isCompletelyTaken
|
|
? Colors.green.shade600
|
|
: isTakenToday
|
|
? Theme.of(context).colorScheme.secondary
|
|
: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
|
width: 1.5,
|
|
),
|
|
),
|
|
child: Theme(
|
|
data: Theme.of(context).copyWith(
|
|
dividerColor: Colors.transparent,
|
|
),
|
|
child: ExpansionTile(
|
|
initiallyExpanded: _isExpanded,
|
|
onExpansionChanged: (expanded) {
|
|
setState(() {
|
|
_isExpanded = expanded;
|
|
});
|
|
},
|
|
tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
|
childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: isCompletelyTaken
|
|
? Colors.green.shade500
|
|
: isTakenToday
|
|
? Theme.of(context).colorScheme.secondary
|
|
: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
isCompletelyTaken
|
|
? Icons.check_circle
|
|
: isTakenToday
|
|
? Icons.schedule
|
|
: Icons.medication,
|
|
color: isCompletelyTaken
|
|
? 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
|
|
if (!_isExpanded) ...[
|
|
if (isCompletelyTaken)
|
|
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,
|
|
),
|
|
),
|
|
)
|
|
else ...[
|
|
if (isTakenToday)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
margin: const EdgeInsets.only(right: 8),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.secondary,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'$todayIntakeCount/${widget.supplement.frequencyPerDay}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSecondary,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: widget.onTake,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: isCompletelyTaken
|
|
? Colors.green.shade500
|
|
: Theme.of(context).colorScheme.primary,
|
|
foregroundColor: Theme.of(context).colorScheme.inverseSurface,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
minimumSize: const Size(60, 32),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
child: Text(
|
|
isCompletelyTaken ? '✓' : 'Take',
|
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
trailing: PopupMenuButton(
|
|
padding: EdgeInsets.zero,
|
|
icon: Icon(
|
|
Icons.more_vert,
|
|
color: isCompletelyTaken
|
|
? 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) => [
|
|
if (isTakenToday)
|
|
PopupMenuItem(
|
|
value: 'take',
|
|
onTap: widget.onTake,
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.add_circle_outline),
|
|
SizedBox(width: 8),
|
|
Text('Take Again'),
|
|
],
|
|
),
|
|
),
|
|
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.withOpacity(0.8)
|
|
: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.7),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isCompletelyTaken
|
|
? Colors.green.shade500
|
|
: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.check_circle_outline,
|
|
size: 16,
|
|
color: isCompletelyTaken
|
|
? Colors.green.shade200
|
|
: Theme.of(context).colorScheme.onSecondaryContainer,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Taken today:',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: isCompletelyTaken
|
|
? Colors.green.shade200
|
|
: Theme.of(context).colorScheme.onSecondaryContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children: todayIntakes.map((intake) {
|
|
final units = intake['units'] as double;
|
|
final unitsText = units == 1.0
|
|
? '${widget.supplement.unitType}'
|
|
: '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${widget.supplement.unitType}';
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: isCompletelyTaken
|
|
? Colors.green.shade600
|
|
: Theme.of(context).colorScheme.secondary,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
'${intake['time']} • $unitsText',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w500,
|
|
color: isCompletelyTaken
|
|
? Colors.white
|
|
: Theme.of(context).colorScheme.onSecondary,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
// Ingredients section
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Ingredients per ${widget.supplement.unitType}:',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children: widget.supplement.ingredients.map((ingredient) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
|
),
|
|
),
|
|
child: Text(
|
|
'${ingredient.name} ${ingredient.amount}${ingredient.unit}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// Schedule and dosage info
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _InfoChip(
|
|
icon: Icons.schedule,
|
|
label: '${widget.supplement.frequencyPerDay}x daily',
|
|
context: context,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _InfoChip(
|
|
icon: Icons.medication,
|
|
label: '${widget.supplement.numberOfUnits} ${widget.supplement.unitType}',
|
|
context: context,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
if (widget.supplement.reminderTimes.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
_InfoChip(
|
|
icon: Icons.notifications,
|
|
label: 'Reminders: ${widget.supplement.reminderTimes.join(', ')}',
|
|
context: context,
|
|
fullWidth: true,
|
|
),
|
|
],
|
|
|
|
if (widget.supplement.notes != null && widget.supplement.notes!.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
widget.supplement.notes!,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Take button
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: widget.onTake,
|
|
icon: Icon(
|
|
isCompletelyTaken ? Icons.check_circle : Icons.medication,
|
|
size: 18,
|
|
),
|
|
label: Text(
|
|
isCompletelyTaken
|
|
? 'All doses taken today'
|
|
: isTakenToday
|
|
? 'Take next dose'
|
|
: 'Take supplement',
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: isCompletelyTaken
|
|
? Colors.green.shade500
|
|
: Theme.of(context).colorScheme.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
elevation: isCompletelyTaken ? 0 : 2,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoChip extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final BuildContext context;
|
|
final bool fullWidth;
|
|
|
|
const _InfoChip({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.context,
|
|
this.fullWidth = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: fullWidth ? double.infinity : null,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.4),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 14,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|