- Add 10-second periodic auto-sync to CycleEntriesNotifier - Fix husband_devotional_screen: use partnerId for isConnected check, navigate to SharingSettingsScreen instead of legacy mock dialog - Remove obsolete _showConnectDialog method and mock data import - Update husband_settings_screen: show 'Partner Settings' with linked partner name when connected - Add SharingSettingsScreen: Pad Supplies toggle (disabled when pad tracking off), Intimacy always enabled - Add CORS OPTIONS handler to backend server - Add _ensureServerRegistration for reliable partner linking - Add copy button to Invite Partner dialog - Dynamic base URL for web (uses window.location.hostname)
1275 lines
43 KiB
Dart
1275 lines
43 KiB
Dart
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';
|
|
import '../../widgets/protected_wrapper.dart';
|
|
|
|
// Global flag to track if the check-in dialog has been shown this session
|
|
bool _hasShownCheckInSession = false;
|
|
|
|
class PadTrackerScreen extends ConsumerStatefulWidget {
|
|
final FlowIntensity? initialFlow;
|
|
final bool isSpotting;
|
|
const PadTrackerScreen({
|
|
super.key,
|
|
this.initialFlow,
|
|
this.isSpotting = false,
|
|
});
|
|
|
|
@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;
|
|
SupplyItem? _manualSupply;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedFlow = widget.isSpotting
|
|
? FlowIntensity.spotting
|
|
: widget.initialFlow ?? FlowIntensity.medium;
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_checkInitialPrompt();
|
|
});
|
|
_startTimer();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startTimer() {
|
|
_timer = Timer.periodic(const Duration(seconds: 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;
|
|
|
|
// Check if we already showed it this session
|
|
if (_hasShownCheckInSession) {
|
|
debugPrint('_checkInitialPrompt: Already shown this session. Skipping.');
|
|
return;
|
|
}
|
|
|
|
if (!changedToday) {
|
|
_hasShownCheckInSession = true; // Mark as shown immediately
|
|
|
|
final result = await showDialog<_PadLogResult>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => const _PadCheckInDialog(),
|
|
);
|
|
|
|
if (result != null) {
|
|
if (result.skipped) return;
|
|
|
|
await _finalizeLog(
|
|
result.time,
|
|
result.flow,
|
|
supply: result.supply,
|
|
supplyIndex: result.supplyIndex,
|
|
deductInventory: result.deductInventory,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _finalizeLog(DateTime time, FlowIntensity flow,
|
|
{required SupplyItem supply,
|
|
required int? supplyIndex,
|
|
required bool deductInventory}) async {
|
|
final user = ref.read(userProfileProvider);
|
|
if (user != null) {
|
|
UserProfile updatedProfile = user;
|
|
|
|
// Deduct inventory if needed
|
|
if (deductInventory &&
|
|
user.isAutoInventoryEnabled &&
|
|
supplyIndex != null) {
|
|
// Clone the supplies list
|
|
List<SupplyItem> newSupplies = List.from(user.padSupplies ?? []);
|
|
if (supplyIndex < newSupplies.length) {
|
|
final oldItem = newSupplies[supplyIndex];
|
|
if (oldItem.count > 0) {
|
|
newSupplies[supplyIndex] =
|
|
oldItem.copyWith(count: oldItem.count - 1);
|
|
}
|
|
}
|
|
updatedProfile = updatedProfile.copyWith(padSupplies: newSupplies);
|
|
}
|
|
|
|
// Update Last Change Time
|
|
updatedProfile = updatedProfile.copyWith(
|
|
lastPadChangeTime: time,
|
|
lastInventoryUpdate:
|
|
deductInventory ? DateTime.now() : user.lastInventoryUpdate,
|
|
);
|
|
|
|
await ref
|
|
.read(userProfileProvider.notifier)
|
|
.updateProfile(updatedProfile);
|
|
|
|
setState(() {
|
|
_activeSupplyIndex = supplyIndex;
|
|
if (supplyIndex == null) {
|
|
_manualSupply = supply;
|
|
} else {
|
|
_manualSupply = null;
|
|
}
|
|
});
|
|
|
|
_updateTimeSinceChange();
|
|
_scheduleReminders(time);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Logged! Timer started.')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _scheduleReminders(DateTime lastChangeTime) async {
|
|
final user = ref.read(userProfileProvider);
|
|
if (user == null || !user.isPadTrackingEnabled) return;
|
|
|
|
final service = NotificationService();
|
|
// Cancel previous
|
|
await service.cancelNotification(200);
|
|
await service.cancelNotification(201);
|
|
await service.cancelNotification(202);
|
|
await service.cancelNotification(203);
|
|
|
|
// Calculate target
|
|
final hours = _recommendedHours;
|
|
final changeTime = lastChangeTime.add(Duration(hours: hours));
|
|
final now = DateTime.now();
|
|
|
|
// 2 Hours Before
|
|
if (user.notifyPad2Hours) {
|
|
final notifyTime = changeTime.subtract(const Duration(hours: 2));
|
|
if (notifyTime.isAfter(now)) {
|
|
await service.scheduleNotification(
|
|
id: 200,
|
|
title: 'Upcoming Pad Change',
|
|
body: 'Recommended change in 2 hours.',
|
|
scheduledDate: notifyTime);
|
|
}
|
|
}
|
|
|
|
// 1 Hour Before
|
|
if (user.notifyPad1Hour) {
|
|
final notifyTime = changeTime.subtract(const Duration(hours: 1));
|
|
if (notifyTime.isAfter(now)) {
|
|
await service.scheduleNotification(
|
|
id: 201,
|
|
title: 'Upcoming Pad Change',
|
|
body: 'Recommended change in 1 hour.',
|
|
scheduledDate: notifyTime);
|
|
}
|
|
}
|
|
|
|
// 30 Mins Before
|
|
if (user.notifyPad30Mins) {
|
|
final notifyTime = changeTime.subtract(const Duration(minutes: 30));
|
|
if (notifyTime.isAfter(now)) {
|
|
await service.scheduleNotification(
|
|
id: 202,
|
|
title: 'Upcoming Pad Change',
|
|
body: 'Recommended change in 30 minutes.',
|
|
scheduledDate: notifyTime);
|
|
}
|
|
}
|
|
|
|
// Change Now
|
|
if (user.notifyPadNow) {
|
|
if (changeTime.isAfter(now)) {
|
|
await service.scheduleNotification(
|
|
id: 203,
|
|
title: 'Time to Change!',
|
|
body: 'It has been $hours hours since your last change.',
|
|
scheduledDate: changeTime);
|
|
}
|
|
}
|
|
}
|
|
|
|
SupplyItem? get _activeSupply {
|
|
if (_manualSupply != null) return _manualSupply;
|
|
|
|
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;
|
|
|
|
// No flow is fine with any protection
|
|
if (_selectedFlow == FlowIntensity.none) return false;
|
|
// Spotting is fine with any protection
|
|
if (_selectedFlow == FlowIntensity.spotting) return false;
|
|
|
|
int flowValue = 1;
|
|
switch (_selectedFlow) {
|
|
case FlowIntensity.light:
|
|
flowValue = 2;
|
|
break;
|
|
case FlowIntensity.medium:
|
|
flowValue = 3;
|
|
break;
|
|
case FlowIntensity.heavy:
|
|
flowValue = 5;
|
|
break;
|
|
default:
|
|
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.superPad ||
|
|
type == PadType.overnight ||
|
|
type == PadType.tamponSuper)
|
|
? 4
|
|
: 3;
|
|
break;
|
|
case FlowIntensity.medium:
|
|
baseHours = 6;
|
|
break;
|
|
case FlowIntensity.light:
|
|
baseHours = 8;
|
|
break;
|
|
case FlowIntensity.spotting:
|
|
baseHours = 10; // More generous for spotting
|
|
break;
|
|
case FlowIntensity.none:
|
|
baseHours = 8; // Health guideline for precautionary wear
|
|
break;
|
|
}
|
|
|
|
int flowValue = 1;
|
|
switch (_selectedFlow) {
|
|
case FlowIntensity.none:
|
|
flowValue = 0;
|
|
break; // Health-only, no absorbency needed
|
|
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;
|
|
|
|
// Avoid division by zero for precautionary (no flow) case
|
|
if (flowValue == 0) {
|
|
return baseHours;
|
|
}
|
|
|
|
final ratio = absorbency / flowValue;
|
|
|
|
double adjusted = baseHours * ratio;
|
|
|
|
int maxHours =
|
|
(type == PadType.tamponRegular || type == PadType.tamponSuper) ? 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 ProtectedContentWrapper(
|
|
title: 'Pad Tracker',
|
|
isProtected: user?.isSuppliesProtected ?? false,
|
|
userProfile: user,
|
|
child: 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.withValues(alpha: 0.3)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
AppColors.menstrualPhase.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 0.15)
|
|
: AppColors.sageGreen.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: isOverdue
|
|
? AppColors.rose.withValues(alpha: 0.3)
|
|
: AppColors.sageGreen.withValues(alpha: 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(
|
|
_formatRemainingTime(
|
|
Duration(hours: _recommendedHours) -
|
|
_timeSinceLastChange,
|
|
user!),
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
color: isOverdue
|
|
? AppColors.rose
|
|
: AppColors.navyBlue,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
Text(
|
|
'Last changed: ${_formatDuration(_timeSinceLastChange, user)} 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.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: AppColors.rose.withValues(alpha: 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 (context.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.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
));
|
|
}
|
|
|
|
String _formatDuration(Duration d, UserProfile user) {
|
|
final hours = d.inHours;
|
|
final minutes = d.inMinutes % 60;
|
|
final seconds = d.inSeconds % 60;
|
|
|
|
final bool showMins = user.showPadTimerMinutes;
|
|
final bool showSecs = user.showPadTimerSeconds &&
|
|
showMins; // Enforce minutes must be shown to show seconds
|
|
|
|
List<String> parts = [];
|
|
if (hours > 0) parts.add('${hours}h');
|
|
if (showMins) parts.add('${minutes}m');
|
|
if (showSecs) parts.add('${seconds}s');
|
|
|
|
if (parts.isEmpty) {
|
|
if (hours == 0 && minutes == 0 && seconds == 0) return 'Just now';
|
|
return '${d.inMinutes}m'; // Fallback
|
|
}
|
|
return parts.join(' ');
|
|
}
|
|
|
|
String _formatRemainingTime(Duration remaining, UserProfile user) {
|
|
final isOverdue = remaining.isNegative;
|
|
final absRemaining = remaining.abs();
|
|
|
|
final hours = absRemaining.inHours;
|
|
final minutes = absRemaining.inMinutes % 60;
|
|
final seconds = absRemaining.inSeconds % 60;
|
|
|
|
final bool showMins = user.showPadTimerMinutes;
|
|
final bool showSecs = user.showPadTimerSeconds &&
|
|
showMins; // Enforce minutes must be shown to show seconds
|
|
|
|
List<String> parts = [];
|
|
if (hours > 0) parts.add('${hours}h');
|
|
if (showMins) {
|
|
parts.add('${minutes}m');
|
|
}
|
|
if (showSecs) {
|
|
parts.add('${seconds}s');
|
|
}
|
|
|
|
if (parts.isEmpty) {
|
|
return isOverdue ? 'Overdue' : 'Change Now';
|
|
}
|
|
|
|
String timeStr = parts.join(' ');
|
|
return isOverdue ? '$timeStr overdue' : timeStr;
|
|
}
|
|
|
|
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.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: AppColors.warmGray.withValues(alpha: 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.withValues(alpha: 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>(
|
|
initialValue: _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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PadLogResult {
|
|
final DateTime time;
|
|
final FlowIntensity flow;
|
|
final SupplyItem supply;
|
|
final int? supplyIndex;
|
|
final bool deductInventory;
|
|
final bool skipped;
|
|
|
|
_PadLogResult({
|
|
required this.time,
|
|
required this.flow,
|
|
required this.supply,
|
|
required this.supplyIndex,
|
|
required this.deductInventory,
|
|
this.skipped = false,
|
|
});
|
|
|
|
factory _PadLogResult.skipped() {
|
|
return _PadLogResult(
|
|
time: DateTime.now(),
|
|
flow: FlowIntensity.medium,
|
|
supply:
|
|
SupplyItem(brand: '', type: PadType.regular, absorbency: 0, count: 0),
|
|
supplyIndex: null,
|
|
deductInventory: false,
|
|
skipped: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PadCheckInDialog extends ConsumerStatefulWidget {
|
|
const _PadCheckInDialog();
|
|
|
|
@override
|
|
ConsumerState<_PadCheckInDialog> createState() => _PadCheckInDialogState();
|
|
}
|
|
|
|
class _PadCheckInDialogState extends ConsumerState<_PadCheckInDialog> {
|
|
DateTime _selectedTime = DateTime.now(); // "Just Now" by default
|
|
FlowIntensity _selectedFlow = FlowIntensity.medium;
|
|
int? _selectedSupplyIndex;
|
|
SupplyItem? _selectedSupply;
|
|
bool _useBorrowed = false;
|
|
PadType _borrowedType = PadType.regular;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Default to first available supply if exists
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final user = ref.read(userProfileProvider);
|
|
if (user != null &&
|
|
user.padSupplies != null &&
|
|
user.padSupplies!.isNotEmpty) {
|
|
setState(() {
|
|
_selectedSupplyIndex = 0;
|
|
_selectedSupply = user.padSupplies![0];
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_useBorrowed = true; // Fallback if no inventory
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _onTimePressed() async {
|
|
final TimeOfDay? picked = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.fromDateTime(_selectedTime),
|
|
);
|
|
if (picked != null) {
|
|
final now = DateTime.now();
|
|
setState(() {
|
|
_selectedTime = DateTime(
|
|
now.year,
|
|
now.month,
|
|
now.day,
|
|
picked.hour,
|
|
picked.minute,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
String _formatTimeDisplay() {
|
|
final diff = DateTime.now().difference(_selectedTime).abs();
|
|
if (diff.inMinutes < 1) return 'Just Now';
|
|
// Format H:mm AM/PM would be better, but keeping simple 24h or simple format
|
|
// Just using H:mm for now as in code snippet
|
|
final hour = _selectedTime.hour > 12
|
|
? _selectedTime.hour - 12
|
|
: (_selectedTime.hour == 0 ? 12 : _selectedTime.hour);
|
|
final period = _selectedTime.hour >= 12 ? 'PM' : 'AM';
|
|
return '$hour:${_selectedTime.minute.toString().padLeft(2, '0')} $period';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final user = ref.watch(userProfileProvider);
|
|
final supplies = user?.padSupplies ?? [];
|
|
|
|
return AlertDialog(
|
|
title: Text('Track Your Change',
|
|
style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 1. Time Selection
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.access_time,
|
|
size: 20, color: AppColors.warmGray),
|
|
const SizedBox(width: 8),
|
|
Text('Time: ',
|
|
style: GoogleFonts.outfit(fontWeight: FontWeight.w600)),
|
|
Text(_formatTimeDisplay(), style: GoogleFonts.outfit()),
|
|
const Spacer(),
|
|
TextButton(
|
|
onPressed: _onTimePressed,
|
|
child: const Text('Edit'),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
|
|
// 2. Flow Intensity
|
|
Text('Flow Intensity:',
|
|
style: GoogleFonts.outfit(fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: FlowIntensity.values.map((f) {
|
|
return ChoiceChip(
|
|
label: Text(f.label),
|
|
selected: _selectedFlow == f,
|
|
onSelected: (selected) {
|
|
if (selected) setState(() => _selectedFlow = f);
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// 3. Supply Selection
|
|
Text('Supply:',
|
|
style: GoogleFonts.outfit(fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
|
|
if (supplies.isNotEmpty) ...[
|
|
// Inventory Dropdown
|
|
RadioListTile<bool>(
|
|
title: const Text('Use Inventory'),
|
|
value: false, // _useBorrowed = false
|
|
groupValue: _useBorrowed,
|
|
onChanged: (val) => setState(() => _useBorrowed = false),
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
if (!_useBorrowed)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16.0, bottom: 8),
|
|
child: DropdownButtonFormField<int>(
|
|
isExpanded: true,
|
|
initialValue: _selectedSupplyIndex,
|
|
items: supplies.asMap().entries.map((entry) {
|
|
final s = entry.value;
|
|
return DropdownMenuItem(
|
|
value: entry.key,
|
|
child: Text('${s.brand} ${s.type.label} (x${s.count})',
|
|
overflow: TextOverflow.ellipsis),
|
|
);
|
|
}).toList(),
|
|
onChanged: (val) {
|
|
setState(() {
|
|
_selectedSupplyIndex = val;
|
|
if (val != null) _selectedSupply = supplies[val];
|
|
});
|
|
},
|
|
decoration: InputDecoration(
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
|
|
// Borrowed / Other
|
|
RadioListTile<bool>(
|
|
title: const Text('Borrowed / Other'),
|
|
value: true, // _useBorrowed = true
|
|
groupValue: _useBorrowed,
|
|
onChanged: (val) => setState(() => _useBorrowed = true),
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
|
|
if (_useBorrowed)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16.0),
|
|
child: DropdownButtonFormField<PadType>(
|
|
isExpanded: true,
|
|
initialValue: _borrowedType,
|
|
items: PadType.values.map((t) {
|
|
return DropdownMenuItem(
|
|
value: t,
|
|
child: Text(t.label),
|
|
);
|
|
}).toList(),
|
|
onChanged: (val) {
|
|
if (val != null) setState(() => _borrowedType = val);
|
|
},
|
|
decoration: InputDecoration(
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context, _PadLogResult.skipped());
|
|
},
|
|
child: const Text('Skip'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
SupplyItem supplyToUse;
|
|
bool deduct = false;
|
|
int? index;
|
|
|
|
if (_useBorrowed) {
|
|
// Create temp supply
|
|
int absorbency = 3;
|
|
switch (_borrowedType) {
|
|
case PadType.pantyLiner:
|
|
absorbency = 1;
|
|
break;
|
|
case PadType.regular:
|
|
case PadType.tamponRegular:
|
|
absorbency = 3;
|
|
break;
|
|
case PadType.superPad:
|
|
case PadType.tamponSuper:
|
|
case PadType.overnight:
|
|
absorbency = 5;
|
|
break;
|
|
default:
|
|
absorbency = 4;
|
|
}
|
|
supplyToUse = SupplyItem(
|
|
brand: 'Borrowed',
|
|
type: _borrowedType,
|
|
absorbency: absorbency,
|
|
count: 0);
|
|
deduct = false;
|
|
index = null;
|
|
} else {
|
|
if (_selectedSupply == null) {
|
|
// Auto-select first if available as fallback
|
|
if (supplies.isNotEmpty) {
|
|
supplyToUse = supplies.first;
|
|
index = 0;
|
|
deduct = true;
|
|
} else {
|
|
supplyToUse = SupplyItem(
|
|
brand: 'Generic',
|
|
type: PadType.regular,
|
|
absorbency: 3,
|
|
count: 0);
|
|
index = null;
|
|
deduct = false;
|
|
}
|
|
} else {
|
|
supplyToUse = _selectedSupply!;
|
|
deduct = true;
|
|
index = _selectedSupplyIndex;
|
|
}
|
|
}
|
|
|
|
Navigator.pop(
|
|
context,
|
|
_PadLogResult(
|
|
time: _selectedTime,
|
|
flow: _selectedFlow,
|
|
supply: supplyToUse,
|
|
supplyIndex: index,
|
|
deductInventory: deduct,
|
|
));
|
|
},
|
|
child: const Text('Log Change'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|