Refactor: Implement multi-item inventory for Pad Tracker and dynamic navigation

This commit is contained in:
2026-01-02 18:10:50 -06:00
parent 56683f5407
commit 8772b56f36
44 changed files with 3515 additions and 781 deletions

View File

@@ -0,0 +1,690 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../theme/app_theme.dart';
import '../../models/cycle_entry.dart';
import '../../models/user_profile.dart';
import '../../services/notification_service.dart';
import '../../providers/user_provider.dart';
class PadTrackerScreen extends ConsumerStatefulWidget {
const PadTrackerScreen({super.key});
@override
ConsumerState<PadTrackerScreen> createState() => _PadTrackerScreenState();
}
class _PadTrackerScreenState extends ConsumerState<PadTrackerScreen> {
FlowIntensity _selectedFlow = FlowIntensity.medium;
bool _notificationScheduled = false;
Timer? _timer;
Duration _timeSinceLastChange = Duration.zero;
int? _activeSupplyIndex;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkInitialPrompt();
});
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(minutes: 1), (timer) {
if (mounted) {
setState(() {
_updateTimeSinceChange();
});
}
});
_updateTimeSinceChange();
}
void _updateTimeSinceChange() {
final user = ref.read(userProfileProvider);
if (user?.lastPadChangeTime != null) {
_timeSinceLastChange = DateTime.now().difference(user!.lastPadChangeTime!);
} else {
_timeSinceLastChange = Duration.zero;
}
}
Future<void> _checkInitialPrompt() async {
final user = ref.read(userProfileProvider);
if (user == null) return;
final lastChange = user.lastPadChangeTime;
final now = DateTime.now();
final bool changedToday = lastChange != null &&
lastChange.year == now.year &&
lastChange.month == now.month &&
lastChange.day == now.day;
if (!changedToday) {
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text('Track Your Change', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
content: Text(
'When did you last change your pad/tampon?',
style: GoogleFonts.outfit(),
),
actions: [
TextButton(
onPressed: () {
_updateLastChangeTime(DateTime.now());
Navigator.pop(context);
},
child: const Text('Just Now'),
),
TextButton(
onPressed: () async {
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (time != null && mounted) {
final now = DateTime.now();
final selectedDate = DateTime(now.year, now.month, now.day, time.hour, time.minute);
if (selectedDate.isAfter(now)) {
_updateLastChangeTime(now);
} else {
_updateLastChangeTime(selectedDate);
}
Navigator.pop(context);
}
},
child: const Text('Pick Time'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Skip'),
),
],
),
);
}
}
Future<void> _updateLastChangeTime(DateTime time) async {
final user = ref.read(userProfileProvider);
if (user != null) {
final updatedProfile = user.copyWith(
lastPadChangeTime: time,
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
_updateTimeSinceChange();
}
}
SupplyItem? get _activeSupply {
final user = ref.watch(userProfileProvider);
if (user == null || user.padSupplies == null || user.padSupplies!.isEmpty) return null;
if (_activeSupplyIndex == null || _activeSupplyIndex! >= user.padSupplies!.length) {
return user.padSupplies!.first;
}
return user.padSupplies![_activeSupplyIndex!];
}
bool get _shouldShowMismatchWarning {
final supply = _activeSupply;
if (supply == null) return false;
int flowValue = 1;
switch (_selectedFlow) {
case FlowIntensity.spotting: flowValue = 1; break;
case FlowIntensity.light: flowValue = 2; break;
case FlowIntensity.medium: flowValue = 3; break;
case FlowIntensity.heavy: flowValue = 5; break;
}
return flowValue > supply.absorbency;
}
int get _recommendedHours {
final supply = _activeSupply;
if (supply == null) return 6; // Default
final type = supply.type;
if (type == PadType.menstrualCup ||
type == PadType.menstrualDisc ||
type == PadType.periodUnderwear) {
return 12;
}
int baseHours;
switch (_selectedFlow) {
case FlowIntensity.heavy:
baseHours = (type == PadType.super_pad || type == PadType.overnight || type == PadType.tampon_super)
? 4
: 3;
break;
case FlowIntensity.medium:
baseHours = 6;
break;
case FlowIntensity.light:
baseHours = 8;
break;
case FlowIntensity.spotting:
baseHours = 8;
break;
}
int flowValue = 1;
switch (_selectedFlow) {
case FlowIntensity.spotting: flowValue = 1; break;
case FlowIntensity.light: flowValue = 2; break;
case FlowIntensity.medium: flowValue = 3; break;
case FlowIntensity.heavy: flowValue = 5; break;
}
final absorbency = supply.absorbency;
final ratio = absorbency / flowValue;
double adjusted = baseHours * ratio;
int maxHours = (type == PadType.tampon_regular || type == PadType.tampon_super)
? 8
: 12;
if (adjusted < 1) adjusted = 1;
if (adjusted > maxHours) adjusted = maxHours.toDouble();
return adjusted.round();
}
void _showSupplyPicker() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const _SupplyManagementPopup(),
);
}
@override
Widget build(BuildContext context) {
final remainingHours = _recommendedHours - _timeSinceLastChange.inHours;
final isOverdue = remainingHours < 0;
final supply = _activeSupply;
final user = ref.watch(userProfileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Pad Tracker'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Supply Selection at the top as requested
_buildSectionHeader('Current Protection'),
const SizedBox(height: 12),
GestureDetector(
onTap: _showSupplyPicker,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.menstrualPhase.withOpacity(0.3)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.menstrualPhase.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.inventory_2_outlined, color: AppColors.menstrualPhase),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
supply != null ? '${supply.brand} ${supply.type.label}' : 'No Supply Selected',
style: GoogleFonts.outfit(fontWeight: FontWeight.bold, fontSize: 16),
),
if (supply != null)
Text(
'Absorbency: ${supply.absorbency}/5 • Stock: ${supply.count}',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
)
else
Text(
'Tap to manage your supplies',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
),
],
),
),
const Icon(Icons.edit_outlined, size: 20, color: AppColors.warmGray),
],
),
),
),
const SizedBox(height: 32),
_buildSectionHeader('Current Flow Intensity'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: FlowIntensity.values.map((flow) {
return ChoiceChip(
label: Text(flow.label),
selected: _selectedFlow == flow,
onSelected: (selected) {
if (selected) setState(() => _selectedFlow = flow);
},
selectedColor: AppColors.menstrualPhase.withOpacity(0.3),
labelStyle: GoogleFonts.outfit(
color: _selectedFlow == flow ? AppColors.navyBlue : AppColors.charcoal,
fontWeight: _selectedFlow == flow ? FontWeight.w600 : FontWeight.w400,
),
);
}).toList(),
),
const SizedBox(height: 48),
// Recommendation Card / Timer
Center(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isOverdue ? AppColors.rose.withOpacity(0.15) : AppColors.sageGreen.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isOverdue ? AppColors.rose.withOpacity(0.3) : AppColors.sageGreen.withOpacity(0.3)
),
),
child: Column(
children: [
Icon(
isOverdue ? Icons.warning_amber_rounded : Icons.timer_outlined,
size: 48,
color: isOverdue ? AppColors.rose : AppColors.sageGreen
),
const SizedBox(height: 16),
Text(
isOverdue ? 'Change Overdue!' : 'Next Change In:',
style: GoogleFonts.outfit(
fontSize: 16,
color: AppColors.warmGray,
),
),
const SizedBox(height: 8),
if (_timeSinceLastChange != Duration.zero) ...[
Text(
isOverdue
? '${(-remainingHours).toString()}h overdue'
: '${remainingHours}h ${((_recommendedHours * 60) - _timeSinceLastChange.inMinutes) % 60}m',
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
color: isOverdue ? AppColors.rose : AppColors.navyBlue,
),
),
Text(
'Last changed: ${_formatDuration(_timeSinceLastChange)} ago',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
),
] else ...[
Text(
'~$_recommendedHours Hours',
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppColors.navyBlue,
),
),
],
],
),
),
),
const SizedBox(height: 32),
if (_shouldShowMismatchWarning)
Container(
margin: const EdgeInsets.only(bottom: 24),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.rose.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.rose.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: AppColors.rose),
const SizedBox(width: 12),
Expanded(
child: Text(
'Your flow is heavier than your protection capacity. Change sooner to avoid leaks!',
style: GoogleFonts.outfit(
fontSize: 14,
color: AppColors.charcoal,
fontWeight: FontWeight.w500
),
),
),
],
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: supply == null ? null : () async {
final hours = _recommendedHours;
// 1. Auto-deduct inventory
if (user != null &&
user.isAutoInventoryEnabled) {
// Deduct from the active supply
final List<SupplyItem> updatedSupplies = user.padSupplies!.map((s) {
if (s == supply && s.count > 0) {
return s.copyWith(count: s.count - 1);
}
return s;
}).toList();
final updatedProfile = user.copyWith(
padSupplies: updatedSupplies,
lastInventoryUpdate: DateTime.now(),
lastPadChangeTime: DateTime.now(),
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
} else if (user != null) {
final updatedProfile = user.copyWith(
lastPadChangeTime: DateTime.now(),
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
}
await NotificationService().scheduleNotification(
id: 100,
title: 'Time to change!',
body: 'It\'s been $hours hours since you logged your protection.',
scheduledDate: DateTime.now().add(Duration(hours: hours)),
);
setState(() {
_notificationScheduled = true;
_updateTimeSinceChange();
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logged! Timer reset & Inventory updated.')),
);
}
},
icon: Icon(_notificationScheduled ? Icons.check : Icons.restart_alt),
label: Text(
'Changed / Remind Me',
style: GoogleFonts.outfit(fontSize: 18, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.menstrualPhase,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.warmGray.withOpacity(0.2),
),
),
),
],
),
),
);
}
String _formatDuration(Duration d) {
if (d.inHours > 0) return '${d.inHours}h ${d.inMinutes % 60}m';
return '${d.inMinutes}m';
}
Widget _buildSectionHeader(String title) {
return Text(
title,
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.navyBlue,
),
);
}
}
class _SupplyManagementPopup extends ConsumerStatefulWidget {
const _SupplyManagementPopup();
@override
ConsumerState<_SupplyManagementPopup> createState() => _SupplyManagementPopupState();
}
class _SupplyManagementPopupState extends ConsumerState<_SupplyManagementPopup> {
final _brandController = TextEditingController();
PadType _selectedType = PadType.regular;
int _absorbency = 3;
int _count = 20;
@override
void dispose() {
_brandController.dispose();
super.dispose();
}
void _addSupply() async {
final brand = _brandController.text.trim();
if (brand.isEmpty) return;
final user = ref.read(userProfileProvider);
if (user == null) return;
final newSupply = SupplyItem(
brand: brand,
type: _selectedType,
absorbency: _absorbency,
count: _count,
);
final List<SupplyItem> updatedSupplies = <SupplyItem>[...(user.padSupplies ?? []), newSupply];
final updatedProfile = user.copyWith(padSupplies: updatedSupplies);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
_brandController.clear();
setState(() {
_count = 20;
_absorbency = 3;
});
}
@override
Widget build(BuildContext context) {
final user = ref.watch(userProfileProvider);
final supplies = user?.padSupplies ?? [];
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
top: 20,
left: 20,
right: 20,
),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Manage Supplies',
style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const Divider(),
const SizedBox(height: 16),
if (supplies.isNotEmpty) ...[
Text('Current Stock', style: GoogleFonts.outfit(fontWeight: FontWeight.w600, color: AppColors.navyBlue)),
const SizedBox(height: 12),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: supplies.length,
itemBuilder: (context, index) {
final item = supplies[index];
return Container(
width: 160,
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.warmCream.withOpacity(0.3),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.warmGray.withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(item.brand, style: GoogleFonts.outfit(fontWeight: FontWeight.bold, fontSize: 13), overflow: TextOverflow.ellipsis),
),
GestureDetector(
onTap: () async {
final updatedSupplies = List<SupplyItem>.from(supplies)..removeAt(index);
await ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(padSupplies: updatedSupplies));
},
child: const Icon(Icons.delete_outline, size: 16, color: Colors.red),
),
],
),
Text(item.type.label, style: GoogleFonts.outfit(fontSize: 11, color: AppColors.warmGray)),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Qty: ${item.count}', style: GoogleFonts.outfit(fontSize: 12, fontWeight: FontWeight.w600)),
Text('Abs: ${item.absorbency}', style: GoogleFonts.outfit(fontSize: 11, color: AppColors.menstrualPhase)),
],
),
],
),
);
},
),
),
const SizedBox(height: 24),
],
Text('Add New Pack', style: GoogleFonts.outfit(fontWeight: FontWeight.w600, color: AppColors.navyBlue)),
const SizedBox(height: 12),
TextField(
controller: _brandController,
decoration: InputDecoration(
hintText: 'Brand Name (e.g. Always)',
filled: true,
fillColor: AppColors.warmCream.withOpacity(0.2),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: DropdownButtonFormField<PadType>(
value: _selectedType,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
items: PadType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.label, style: const TextStyle(fontSize: 13)))).toList(),
onChanged: (val) => setState(() => _selectedType = val!),
),
),
const SizedBox(width: 12),
Container(
width: 100,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
IconButton(icon: const Icon(Icons.remove, size: 16), onPressed: () => setState(() => _count = (_count > 0 ? _count - 1 : 0))),
Text('$_count', style: const TextStyle(fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(Icons.add, size: 16), onPressed: () => setState(() => _count++)),
],
),
),
],
),
const SizedBox(height: 16),
Text('Absorbency: $_absorbency/5', style: GoogleFonts.outfit(fontSize: 13, color: AppColors.warmGray)),
Slider(
value: _absorbency.toDouble(),
min: 1, max: 5, divisions: 4,
activeColor: AppColors.menstrualPhase,
onChanged: (val) => setState(() => _absorbency = val.round()),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _addSupply,
style: ElevatedButton.styleFrom(backgroundColor: AppColors.navyBlue, foregroundColor: Colors.white),
child: const Text('Add to Inventory'),
),
),
const SizedBox(height: 24),
],
),
);
}
}