Implement Notifications and Pad Tracking Enhancements
This commit is contained in:
@@ -17,6 +17,9 @@ class PadTrackerCard extends ConsumerStatefulWidget {
|
||||
class _PadTrackerCardState extends ConsumerState<PadTrackerCard> {
|
||||
Timer? _timer;
|
||||
String _timeDisplay = '';
|
||||
double _progress = 0.0;
|
||||
Color _statusColor = AppColors.menstrualPhase;
|
||||
bool _isCountDown = true; // Toggle state
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -40,35 +43,92 @@ class _PadTrackerCardState extends ConsumerState<PadTrackerCard> {
|
||||
void _updateTime() {
|
||||
final user = ref.read(userProfileProvider);
|
||||
if (user?.lastPadChangeTime == null) {
|
||||
if (mounted) setState(() => _timeDisplay = 'Tap to start');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_timeDisplay = 'Tap to start';
|
||||
_progress = 0;
|
||||
_statusColor = AppColors.menstrualPhase;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(user!.lastPadChangeTime!);
|
||||
|
||||
// We want to show time SINCE change (duration worn)
|
||||
final hours = difference.inHours;
|
||||
final minutes = difference.inMinutes.remainder(60);
|
||||
final seconds = difference.inSeconds.remainder(60);
|
||||
|
||||
// Estimate max duration based on flow
|
||||
// None/Precautionary: 8h, Spotting: 8h, Light: 6h, Medium: 4h, Heavy: 3h
|
||||
final flowIntensity = user.typicalFlowIntensity ?? 2; // Default to light
|
||||
Duration maxDuration;
|
||||
switch (flowIntensity) {
|
||||
case 0: // No Flow / Precautionary (health guideline: 8h max)
|
||||
maxDuration = const Duration(hours: 8);
|
||||
break;
|
||||
case 1: // Spotting
|
||||
maxDuration = const Duration(hours: 8);
|
||||
break;
|
||||
case 2: // Light
|
||||
maxDuration = const Duration(hours: 6);
|
||||
break;
|
||||
case 3: // Medium
|
||||
maxDuration = const Duration(hours: 4);
|
||||
break;
|
||||
case 4: // Heavy
|
||||
maxDuration = const Duration(hours: 3);
|
||||
break;
|
||||
case 5: // Very Heavy
|
||||
maxDuration = const Duration(hours: 2);
|
||||
break;
|
||||
default:
|
||||
maxDuration = const Duration(hours: 4);
|
||||
}
|
||||
|
||||
final totalSeconds = maxDuration.inSeconds;
|
||||
final elapsedSeconds = difference.inSeconds;
|
||||
double progress = elapsedSeconds / totalSeconds;
|
||||
progress = progress.clamp(0.0, 1.0);
|
||||
|
||||
// Determine Status Color
|
||||
Color newColor = AppColors.menstrualPhase;
|
||||
if (progress > 0.9) {
|
||||
newColor = Colors.red;
|
||||
} else if (progress > 0.75) {
|
||||
newColor = Colors.orange;
|
||||
} else {
|
||||
newColor = AppColors.menstrualPhase; // Greenish/Theme color
|
||||
}
|
||||
// Override if we want to visually show "fresh" vs "old"
|
||||
|
||||
String text = '';
|
||||
|
||||
if (user.showPadTimerMinutes) {
|
||||
if (hours > 0) text += '$hours hr ';
|
||||
text += '$minutes min';
|
||||
}
|
||||
|
||||
if (user.showPadTimerSeconds) {
|
||||
if (text.isNotEmpty) text += ' ';
|
||||
text += '$seconds sec';
|
||||
}
|
||||
|
||||
if (text.isEmpty) text = 'Active'; // Fallback
|
||||
if (_isCountDown) {
|
||||
final remaining = maxDuration - difference;
|
||||
final isOverdue = remaining.isNegative;
|
||||
final absRemaining = remaining.abs();
|
||||
final hours = absRemaining.inHours;
|
||||
final mins = absRemaining.inMinutes % 60;
|
||||
|
||||
if (isOverdue) {
|
||||
text = 'Overdue by ${hours}h ${mins}m';
|
||||
newColor = Colors.red; // Force red if overdue
|
||||
} else {
|
||||
text = '${hours}h ${mins}m left';
|
||||
}
|
||||
_progress =
|
||||
isOverdue ? 1.0 : (1.0 - progress); // Depleting if not overdue
|
||||
} else {
|
||||
// Count Up
|
||||
final hours = difference.inHours;
|
||||
final minutes = difference.inMinutes % 60;
|
||||
text = '${hours}h ${minutes}m worn';
|
||||
_progress = progress; // Filling
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_timeDisplay = text;
|
||||
_progress = _isCountDown && text.contains('Overdue') ? 1.0 : _progress;
|
||||
_statusColor = newColor;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -76,10 +136,8 @@ class _PadTrackerCardState extends ConsumerState<PadTrackerCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(userProfileProvider);
|
||||
if (user == null || !user.isPadTrackingEnabled) return const SizedBox.shrink();
|
||||
|
||||
// Re-check time on rebuilds in case settings changed
|
||||
// _updateTime(); // Actually let the timer handle it, or use a key to rebuild on setting changes
|
||||
if (user == null || !user.isPadTrackingEnabled)
|
||||
return const SizedBox.shrink();
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
@@ -91,53 +149,86 @@ class _PadTrackerCardState extends ConsumerState<PadTrackerCard> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.menstrualPhase.withOpacity(0.1),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.menstrualPhase.withOpacity(0.3)),
|
||||
border: Border.all(color: _statusColor.withOpacity(0.3)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.menstrualPhase.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
BoxShadow(
|
||||
color: _statusColor.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.menstrualPhase.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.timer_outlined, color: AppColors.menstrualPhase, size: 24),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusColor.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child:
|
||||
Icon(Icons.timer_outlined, color: _statusColor, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Pad Tracker',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.charcoal,
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isCountDown = !_isCountDown;
|
||||
_updateTime();
|
||||
});
|
||||
},
|
||||
child: Icon(
|
||||
_isCountDown
|
||||
? Icons.arrow_downward
|
||||
: Icons.arrow_upward,
|
||||
size: 16,
|
||||
color: AppColors.warmGray),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_timeDisplay.isNotEmpty ? _timeDisplay : 'Tap to track',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _statusColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: AppColors.lightGray),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Pad Tracker',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.charcoal,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_timeDisplay.isNotEmpty ? _timeDisplay : 'Tap to track',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.menstrualPhase
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
backgroundColor: _statusColor.withOpacity(0.1),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(_statusColor),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: AppColors.menstrualPhase),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import '../providers/user_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../models/user_profile.dart';
|
||||
import 'quick_log_dialog.dart';
|
||||
|
||||
class QuickLogButtons extends ConsumerWidget {
|
||||
@@ -63,6 +64,13 @@ class QuickLogButtons extends ConsumerWidget {
|
||||
color: AppColors.lutealPhase,
|
||||
onTap: () => _showQuickLogDialog(context, 'pads'),
|
||||
),
|
||||
_buildQuickButton(
|
||||
context,
|
||||
icon: Icons.church_outlined,
|
||||
label: 'Prayer',
|
||||
color: AppColors.softGold, // Or a suitable color
|
||||
onTap: () => _showQuickLogDialog(context, 'prayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -96,8 +104,7 @@ class QuickLogButtons extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(isDark ? 0.2 : 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border:
|
||||
isDark ? Border.all(color: color.withOpacity(0.3)) : null,
|
||||
border: isDark ? Border.all(color: color.withOpacity(0.3)) : null,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -23,7 +23,7 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
|
||||
FlowIntensity? _flowIntensity;
|
||||
MoodLevel? _mood;
|
||||
int? _energyLevel;
|
||||
|
||||
|
||||
// Symptoms & Cravings
|
||||
final Map<String, bool> _symptoms = {
|
||||
'Headache': false,
|
||||
@@ -37,7 +37,7 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
|
||||
'Insomnia': false,
|
||||
'Cramps': false,
|
||||
};
|
||||
|
||||
|
||||
final TextEditingController _cravingController = TextEditingController();
|
||||
List<String> _cravings = [];
|
||||
List<String> _recentCravings = [];
|
||||
@@ -102,6 +102,8 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
|
||||
return _buildSymptomsLog();
|
||||
case 'cravings':
|
||||
return _buildCravingsLog();
|
||||
case 'prayer':
|
||||
return _buildPrayerLog();
|
||||
default:
|
||||
return const Text('Invalid log type.');
|
||||
}
|
||||
@@ -137,7 +139,7 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
|
||||
}
|
||||
|
||||
Widget _buildCravingsLog() {
|
||||
return Container(
|
||||
return Container(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -152,37 +154,44 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
setState(() {
|
||||
_cravings.add(value.trim());
|
||||
_cravingController.clear();
|
||||
});
|
||||
setState(() {
|
||||
_cravings.add(value.trim());
|
||||
_cravingController.clear();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: _cravings.map((c) => Chip(
|
||||
label: Text(c),
|
||||
onDeleted: () {
|
||||
setState(() => _cravings.remove(c));
|
||||
},
|
||||
)).toList(),
|
||||
children: _cravings
|
||||
.map((c) => Chip(
|
||||
label: Text(c),
|
||||
onDeleted: () {
|
||||
setState(() => _cravings.remove(c));
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_recentCravings.isNotEmpty) ...[
|
||||
Text('Recent Cravings:', style: GoogleFonts.outfit(fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
Text('Recent Cravings:',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: _recentCravings.take(5).map((c) => ActionChip(
|
||||
label: Text(c),
|
||||
onPressed: () {
|
||||
if (!_cravings.contains(c)) {
|
||||
setState(() => _cravings.add(c));
|
||||
}
|
||||
},
|
||||
)).toList(),
|
||||
children: _recentCravings
|
||||
.take(5)
|
||||
.map((c) => ActionChip(
|
||||
label: Text(c),
|
||||
onPressed: () {
|
||||
if (!_cravings.contains(c)) {
|
||||
setState(() => _cravings.add(c));
|
||||
}
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
]
|
||||
],
|
||||
@@ -277,10 +286,30 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrayerLog() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Enter your prayer request or gratitude:'),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller:
|
||||
_cravingController, // Reusing controller for simplicity, or create _prayerController
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'I am thankful for...',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveLog() async {
|
||||
// Handle text input for cravings if user didn't hit enter
|
||||
if (widget.logType == 'cravings' && _cravingController.text.isNotEmpty) {
|
||||
_cravings.add(_cravingController.text.trim());
|
||||
_cravings.add(_cravingController.text.trim());
|
||||
}
|
||||
|
||||
final cycleNotifier = ref.read(cycleEntriesProvider.notifier);
|
||||
@@ -288,7 +317,11 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
|
||||
final entries = ref.read(cycleEntriesProvider);
|
||||
final entry = entries.firstWhere(
|
||||
(e) => DateUtils.isSameDay(e.date, today),
|
||||
orElse: () => CycleEntry(id: const Uuid().v4(), date: today, createdAt: today, updatedAt: today),
|
||||
orElse: () => CycleEntry(
|
||||
id: const Uuid().v4(),
|
||||
date: today,
|
||||
createdAt: today,
|
||||
updatedAt: today),
|
||||
);
|
||||
|
||||
CycleEntry updatedEntry = entry;
|
||||
@@ -308,28 +341,61 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
|
||||
break;
|
||||
case 'symptoms':
|
||||
updatedEntry = entry.copyWith(
|
||||
hasHeadache: _symptoms['Headache'],
|
||||
hasBloating: _symptoms['Bloating'],
|
||||
hasBreastTenderness: _symptoms['Breast Tenderness'],
|
||||
hasFatigue: _symptoms['Fatigue'],
|
||||
hasAcne: _symptoms['Acne'],
|
||||
hasLowerBackPain: _symptoms['Back Pain'],
|
||||
hasConstipation: _symptoms['Constipation'],
|
||||
hasDiarrhea: _symptoms['Diarrhea'],
|
||||
hasInsomnia: _symptoms['Insomnia'],
|
||||
crampIntensity: _symptoms['Cramps'] == true ? 2 : 0, // Default to mild cramps if just toggled
|
||||
hasHeadache: _symptoms['Headache'],
|
||||
hasBloating: _symptoms['Bloating'],
|
||||
hasBreastTenderness: _symptoms['Breast Tenderness'],
|
||||
hasFatigue: _symptoms['Fatigue'],
|
||||
hasAcne: _symptoms['Acne'],
|
||||
hasLowerBackPain: _symptoms['Back Pain'],
|
||||
hasConstipation: _symptoms['Constipation'],
|
||||
hasDiarrhea: _symptoms['Diarrhea'],
|
||||
hasInsomnia: _symptoms['Insomnia'],
|
||||
crampIntensity: _symptoms['Cramps'] == true
|
||||
? 2
|
||||
: 0, // Default to mild cramps if just toggled
|
||||
);
|
||||
// Trigger notification if any symptom is selected
|
||||
final user = ref.read(userProfileProvider);
|
||||
if (_symptoms.values.any((selected) => selected == true)) {
|
||||
final selectedSymptom = _symptoms.entries
|
||||
.firstWhere((element) => element.value == true)
|
||||
.key;
|
||||
NotificationService().showSymptomNotification(
|
||||
senderName: user?.name ?? 'Wife',
|
||||
symptom: selectedSymptom,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'cravings':
|
||||
final currentCravings = entry.cravings ?? [];
|
||||
final newCravings = {...currentCravings, ..._cravings}.toList();
|
||||
updatedEntry = entry.copyWith(cravings: newCravings);
|
||||
|
||||
|
||||
// Update History
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final history = prefs.getStringList('recent_cravings') ?? [];
|
||||
final updatedHistory = {..._cravings, ...history}.take(20).toList();
|
||||
await prefs.setStringList('recent_cravings', updatedHistory);
|
||||
await prefs.setStringList('recent_cravings', updatedHistory);
|
||||
break;
|
||||
case 'prayer':
|
||||
final currentPrayer = entry.prayerRequest ?? '';
|
||||
final newPrayer =
|
||||
_cravingController.text.trim(); // Using reused controller
|
||||
if (newPrayer.isNotEmpty) {
|
||||
updatedEntry = entry.copyWith(
|
||||
prayerRequest: currentPrayer.isEmpty
|
||||
? newPrayer
|
||||
: '$currentPrayer\n$newPrayer');
|
||||
|
||||
// Trigger notification
|
||||
final user = ref.read(userProfileProvider);
|
||||
NotificationService().showPrayerRequestNotification(
|
||||
senderName: user?.name ?? 'Wife',
|
||||
);
|
||||
} else {
|
||||
return; // Don't save empty prayer
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// pads handled separately
|
||||
|
||||
Reference in New Issue
Block a user