Files
Tracker/lib/screens/log/log_screen.dart
Sterlen b4b2bfe749 feat: Implement husband features and fix iOS Safari web startup
Implement initial features for husband's companion app, including mock data
service and husband notes screen. Refactor scripture and cycle services
for improved stability and testability. Address iOS Safari web app
startup issue by removing deprecated initialization.

- Implemented MockDataService and HusbandNotesScreen.
- Converted _DashboardTab and DevotionalScreen to StatefulWidgets for robust
  scripture provider initialization.
- Refactored CycleService to use immutable CycleInfo class, reducing UI rebuilds.
- Removed deprecated window.flutterConfiguration from index.html, resolving
  Flutter web app startup failure on iOS Safari.
- Updated and fixed related tests.
2025-12-26 22:40:52 -06:00

837 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../models/cycle_entry.dart';
import '../../providers/navigation_provider.dart';
import '../../providers/user_provider.dart';
import '../../theme/app_theme.dart';
import 'package:uuid/uuid.dart';
class LogScreen extends ConsumerStatefulWidget {
final DateTime? initialDate;
const LogScreen({super.key, this.initialDate});
@override
ConsumerState<LogScreen> createState() => _LogScreenState();
}
class _LogScreenState extends ConsumerState<LogScreen> {
late DateTime _selectedDate;
String? _existingEntryId;
bool _isPeriodDay = false;
FlowIntensity? _flowIntensity;
MoodLevel? _mood;
int? _energyLevel;
int _crampIntensity = 0;
bool _hasHeadache = false;
bool _hasBloating = false;
bool _hasBreastTenderness = false;
bool _hasFatigue = false;
bool _hasAcne = false;
bool _hasLowerBackPain = false;
bool _hasConstipation = false;
bool _hasDiarrhea = false;
bool _hasInsomnia = false;
int? _stressLevel;
final TextEditingController _notesController = TextEditingController();
final TextEditingController _cravingsController = TextEditingController();
// Intimacy tracking
bool _hadIntimacy = false;
bool? _intimacyProtected; // null = no selection, true = protected, false = unprotected
// Hidden field to preserve husband's notes
String? _husbandNotes;
@override
void initState() {
super.initState();
_selectedDate = widget.initialDate ?? DateTime.now();
// Defer data loading to avoid build-time ref.read
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadExistingData();
});
}
void _loadExistingData() {
final entries = ref.read(cycleEntriesProvider);
try {
final entry = entries.firstWhere(
(e) => DateUtils.isSameDay(e.date, _selectedDate),
);
setState(() {
_existingEntryId = entry.id;
_isPeriodDay = entry.isPeriodDay;
_flowIntensity = entry.flowIntensity;
_mood = entry.mood;
_energyLevel = entry.energyLevel;
_crampIntensity = entry.crampIntensity ?? 0;
_hasHeadache = entry.hasHeadache;
_hasBloating = entry.hasBloating;
_hasBreastTenderness = entry.hasBreastTenderness;
_hasFatigue = entry.hasFatigue;
_hasAcne = entry.hasAcne;
_hasLowerBackPain = entry.hasLowerBackPain;
_hasConstipation = entry.hasConstipation;
_hasDiarrhea = entry.hasDiarrhea;
_hasInsomnia = entry.hasInsomnia;
_stressLevel = entry.stressLevel;
_notesController.text = entry.notes ?? '';
_cravingsController.text = entry.cravings?.join(', ') ?? '';
_husbandNotes = entry.husbandNotes;
_hadIntimacy = entry.hadIntimacy;
_intimacyProtected = entry.intimacyProtected;
});
} catch (_) {
// No existing entry for this day
}
}
@override
void dispose() {
_notesController.dispose();
_cravingsController.dispose();
super.dispose();
}
Future<void> _saveEntry() async {
List<String>? cravings;
if (_cravingsController.text.isNotEmpty) {
cravings = _cravingsController.text
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
final entry = CycleEntry(
id: _existingEntryId ?? const Uuid().v4(),
date: _selectedDate,
isPeriodDay: _isPeriodDay,
flowIntensity: _isPeriodDay ? _flowIntensity : null,
mood: _mood,
energyLevel: _energyLevel,
crampIntensity: _crampIntensity > 0 ? _crampIntensity : null,
hasHeadache: _hasHeadache,
hasBloating: _hasBloating,
hasBreastTenderness: _hasBreastTenderness,
hasFatigue: _hasFatigue,
hasAcne: _hasAcne,
hasLowerBackPain: _hasLowerBackPain,
hasConstipation: _hasConstipation,
hasDiarrhea: _hasDiarrhea,
hasInsomnia: _hasInsomnia,
stressLevel: _stressLevel,
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
cravings: cravings,
husbandNotes: _husbandNotes,
hadIntimacy: _hadIntimacy,
intimacyProtected: _hadIntimacy ? _intimacyProtected : null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
if (_existingEntryId != null) {
await ref.read(cycleEntriesProvider.notifier).updateEntry(entry);
} else {
await ref.read(cycleEntriesProvider.notifier).addEntry(entry);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Entry saved!', style: GoogleFonts.outfit()),
backgroundColor: AppColors.success,
behavior: SnackBarBehavior.floating,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
if (widget.initialDate != null) {
Navigator.pop(context);
} else {
_resetForm();
}
}
}
void _resetForm() {
setState(() {
_existingEntryId = null;
_isPeriodDay = false;
_flowIntensity = null;
_mood = null;
_energyLevel = 3;
_crampIntensity = 0;
_hasHeadache = false;
_hasBloating = false;
_hasBreastTenderness = false;
_hasFatigue = false;
_hasAcne = false;
_hasLowerBackPain = false;
_hasConstipation = false;
_hasDiarrhea = false;
_hasInsomnia = false;
_stressLevel = 1;
_notesController.clear();
_cravingsController.clear();
_husbandNotes = null;
_hadIntimacy = false;
_intimacyProtected = null;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'How are you feeling?',
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
Text(
_formatDate(_selectedDate),
style: GoogleFonts.outfit(
fontSize: 14,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
if (widget.initialDate == null)
IconButton(
onPressed: () =>
ref.read(navigationProvider.notifier).setIndex(0),
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.surfaceVariant.withOpacity(0.5),
),
),
],
),
const SizedBox(height: 24),
// Period Toggle
_buildSectionCard(
context,
title: 'Period',
child: Row(
children: [
Expanded(
child: Text(
'Is today a period day?',
style: GoogleFonts.outfit(
fontSize: 16,
color: theme.colorScheme.onSurface,
),
),
),
Switch(
value: _isPeriodDay,
onChanged: (value) => setState(() => _isPeriodDay = value),
activeColor: AppColors.menstrualPhase,
),
],
),
),
// Flow Intensity (only if period day)
if (_isPeriodDay) ...[
const SizedBox(height: 16),
_buildSectionCard(
context,
title: 'Flow Intensity',
child: Row(
children: FlowIntensity.values.map((flow) {
final isSelected = _flowIntensity == flow;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _flowIntensity = flow),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected
? AppColors.menstrualPhase
.withOpacity(isDark ? 0.3 : 0.2)
: theme.colorScheme.surfaceVariant
.withOpacity(0.3),
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(color: AppColors.menstrualPhase)
: Border.all(color: Colors.transparent),
),
child: Column(
children: [
Icon(
Icons.water_drop,
color: isSelected
? AppColors.menstrualPhase
: theme.colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(height: 4),
Text(
flow.label,
style: GoogleFonts.outfit(
fontSize: 11,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
color: isSelected
? AppColors.menstrualPhase
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}).toList(),
),
),
],
const SizedBox(height: 16),
// Mood
_buildSectionCard(
context,
title: 'Mood',
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: MoodLevel.values.map((mood) {
final isSelected = _mood == mood;
return GestureDetector(
onTap: () => setState(() => _mood = mood),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected
? AppColors.softGold
.withOpacity(isDark ? 0.3 : 0.2)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: isSelected
? Border.all(color: AppColors.softGold)
: Border.all(color: Colors.transparent),
),
child: Column(
children: [
Text(
mood.emoji,
style: TextStyle(
fontSize: isSelected ? 32 : 28,
),
),
const SizedBox(height: 4),
Text(
mood.label,
style: GoogleFonts.outfit(
fontSize: 10,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
color: isSelected
? AppColors.softGold
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}).toList(),
),
),
const SizedBox(height: 16),
// Energy & Stress Levels
_buildSectionCard(
context,
title: 'Daily Levels',
child: Column(
children: [
// Energy Level
Row(
children: [
SizedBox(
width: 80,
child: Text(
'Energy',
style: GoogleFonts.outfit(
fontSize: 14,
color: theme.colorScheme.onSurface,
),
),
),
Expanded(
child: Slider(
value: (_energyLevel ?? 3).toDouble(),
min: 1,
max: 5,
divisions: 4,
activeColor: AppColors.sageGreen,
onChanged: (value) {
setState(() => _energyLevel = value.round());
},
),
),
SizedBox(
width: 50,
child: Text(
_getEnergyLabel(_energyLevel),
textAlign: TextAlign.end,
style: GoogleFonts.outfit(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 12),
// Stress Level
Row(
children: [
SizedBox(
width: 80,
child: Text(
'Stress',
style: GoogleFonts.outfit(
fontSize: 14,
color: theme.colorScheme.onSurface,
),
),
),
Expanded(
child: Slider(
value: (_stressLevel ?? 1).toDouble(),
min: 1,
max: 5,
divisions: 4,
activeColor: AppColors.ovulationPhase,
onChanged: (value) {
setState(() => _stressLevel = value.round());
},
),
),
SizedBox(
width: 50,
child: Text(
'${_stressLevel ?? 1}/5',
textAlign: TextAlign.end,
style: GoogleFonts.outfit(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Symptoms
_buildSectionCard(
context,
title: 'Symptoms',
child: Column(
children: [
// Cramps Slider
Row(
children: [
SizedBox(
width: 80,
child: Text(
'Cramps',
style: GoogleFonts.outfit(
fontSize: 14,
color: theme.colorScheme.onSurface,
),
),
),
Expanded(
child: Slider(
value: _crampIntensity.toDouble(),
min: 0,
max: 5,
divisions: 5,
activeColor: AppColors.rose,
onChanged: (value) {
setState(() => _crampIntensity = value.round());
},
),
),
SizedBox(
width: 50,
child: Text(
_crampIntensity == 0
? 'None'
: '$_crampIntensity/5',
textAlign: TextAlign.end,
style: GoogleFonts.outfit(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 12),
// Symptom Toggles
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildSymptomChip(context, 'Headache', _hasHeadache,
(v) => setState(() => _hasHeadache = v)),
_buildSymptomChip(context, 'Bloating', _hasBloating,
(v) => setState(() => _hasBloating = v)),
_buildSymptomChip(context, 'Breast Tenderness',
_hasBreastTenderness,
(v) => setState(() => _hasBreastTenderness = v)),
_buildSymptomChip(context, 'Fatigue', _hasFatigue,
(v) => setState(() => _hasFatigue = v)),
_buildSymptomChip(context, 'Acne', _hasAcne,
(v) => setState(() => _hasAcne = v)),
_buildSymptomChip(context, 'Back Pain',
_hasLowerBackPain,
(v) => setState(() => _hasLowerBackPain = v)),
_buildSymptomChip(
context,
'Constipation',
_hasConstipation,
(v) => setState(() => _hasConstipation = v)),
_buildSymptomChip(context, 'Diarrhea', _hasDiarrhea,
(v) => setState(() => _hasDiarrhea = v)),
_buildSymptomChip(context, 'Insomnia', _hasInsomnia,
(v) => setState(() => _hasInsomnia = v)),
],
),
],
),
),
const SizedBox(height: 16),
// Cravings
_buildSectionCard(
context,
title: 'Cravings',
child: TextField(
controller: _cravingsController,
decoration: InputDecoration(
hintText: 'e.g., Chocolate, salty chips (comma separated)',
filled: true,
fillColor: isDark
? theme.colorScheme.surface
: theme.colorScheme.surfaceVariant.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
style: GoogleFonts.outfit(
fontSize: 14,
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(height: 16),
// Intimacy Tracking (for married users)
_buildSectionCard(
context,
title: 'Intimacy',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: Text('Had Intimacy Today', style: GoogleFonts.outfit(fontSize: 14)),
value: _hadIntimacy,
onChanged: (val) => setState(() {
_hadIntimacy = val;
if (!val) _intimacyProtected = null;
}),
activeColor: AppColors.sageGreen,
contentPadding: EdgeInsets.zero,
),
if (_hadIntimacy) ...[
const SizedBox(height: 8),
Text('Protection:', style: GoogleFonts.outfit(fontSize: 13, color: AppColors.warmGray)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _intimacyProtected = true),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _intimacyProtected == true
? AppColors.sageGreen.withOpacity(0.2)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _intimacyProtected == true
? AppColors.sageGreen
: Colors.grey.withOpacity(0.3),
),
),
child: Center(
child: Text(
'Protected',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: _intimacyProtected == true
? AppColors.sageGreen
: AppColors.warmGray,
),
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () => setState(() => _intimacyProtected = false),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _intimacyProtected == false
? AppColors.rose.withOpacity(0.15)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _intimacyProtected == false
? AppColors.rose
: Colors.grey.withOpacity(0.3),
),
),
child: Center(
child: Text(
'Unprotected',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: _intimacyProtected == false
? AppColors.rose
: AppColors.warmGray,
),
),
),
),
),
),
],
),
],
],
),
),
const SizedBox(height: 16),
// Notes
_buildSectionCard(
context,
title: 'Notes',
child: TextField(
controller: _notesController,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Add any notes about how you\'re feeling...',
filled: true,
fillColor: isDark
? theme.colorScheme.surface
: theme.colorScheme.surfaceVariant.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
style: GoogleFonts.outfit(
fontSize: 14,
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(height: 24),
// Save Button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton(
onPressed: _saveEntry,
child: const Text('Save Entry'),
),
),
const SizedBox(height: 40),
],
),
),
);
}
Widget _buildSectionCard(BuildContext context,
{required String title, required Widget child}) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.cardTheme.color,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.05)),
boxShadow: isDark
? null
: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 12),
child,
],
),
);
}
Widget _buildSymptomChip(BuildContext context, String label, bool isSelected,
ValueChanged<bool> onChanged) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => onChanged(!isSelected),
borderRadius: BorderRadius.circular(20),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.tertiary.withOpacity(isDark ? 0.3 : 0.2)
: theme.colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(20),
border: isSelected
? Border.all(color: theme.colorScheme.tertiary)
: Border.all(color: Colors.transparent),
),
child: Text(
label,
style: GoogleFonts.outfit(
fontSize: 13,
color: isSelected
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
if (DateUtils.isSameDay(date, now)) {
return 'Today, ${_getMonth(date.month)} ${date.day}';
}
const days = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday'
];
return '${days[date.weekday - 1]}, ${_getMonth(date.month)} ${date.day}';
}
String _getMonth(int month) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
return months[month - 1];
}
String _getEnergyLabel(int? level) {
if (level == null) return 'Not logged';
switch (level) {
case 1:
return 'Very Low';
case 2:
return 'Low';
case 3:
return 'Normal';
case 4:
return 'Good';
case 5:
return 'Excellent';
default:
return 'Normal';
}
}
}