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.
646 lines
21 KiB
Dart
646 lines
21 KiB
Dart
import 'package:christian_period_tracker/models/scripture.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:table_calendar/table_calendar.dart';
|
|
import '../../models/user_profile.dart';
|
|
import '../../models/cycle_entry.dart';
|
|
import '../../providers/user_provider.dart';
|
|
import '../../services/cycle_service.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../log/log_screen.dart';
|
|
|
|
class CalendarScreen extends ConsumerStatefulWidget {
|
|
final bool readOnly;
|
|
|
|
const CalendarScreen({
|
|
super.key,
|
|
this.readOnly = false,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<CalendarScreen> createState() => _CalendarScreenState();
|
|
}
|
|
|
|
class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
|
DateTime _focusedDay = DateTime.now();
|
|
DateTime? _selectedDay;
|
|
CalendarFormat _calendarFormat = CalendarFormat.month;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final entries = ref.watch(cycleEntriesProvider);
|
|
final user = ref.watch(userProfileProvider);
|
|
final cycleLength = user?.averageCycleLength ?? 28;
|
|
final lastPeriodStart = user?.lastPeriodStartDate;
|
|
|
|
return SafeArea(
|
|
child: Column(
|
|
children: [
|
|
// Header
|
|
Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'Calendar',
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.charcoal,
|
|
),
|
|
),
|
|
),
|
|
_buildLegendButton(),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Calendar
|
|
Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.charcoal.withOpacity(0.05),
|
|
blurRadius: 15,
|
|
offset: const Offset(0, 5),
|
|
),
|
|
],
|
|
),
|
|
child: TableCalendar(
|
|
firstDay: DateTime.now().subtract(const Duration(days: 365)),
|
|
lastDay: DateTime.now().add(const Duration(days: 365)),
|
|
focusedDay: _focusedDay,
|
|
calendarFormat: _calendarFormat,
|
|
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
|
onDaySelected: (selectedDay, focusedDay) {
|
|
setState(() {
|
|
_selectedDay = selectedDay;
|
|
_focusedDay = focusedDay;
|
|
});
|
|
},
|
|
onFormatChanged: (format) {
|
|
setState(() => _calendarFormat = format);
|
|
},
|
|
onPageChanged: (focusedDay) {
|
|
_focusedDay = focusedDay;
|
|
},
|
|
calendarStyle: CalendarStyle(
|
|
outsideDaysVisible: false,
|
|
defaultTextStyle: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
color: AppColors.charcoal,
|
|
),
|
|
weekendTextStyle: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
color: AppColors.charcoal,
|
|
),
|
|
todayDecoration: BoxDecoration(
|
|
color: AppColors.sageGreen.withOpacity(0.3),
|
|
shape: BoxShape.circle,
|
|
),
|
|
todayTextStyle: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.sageGreen,
|
|
),
|
|
selectedDecoration: const BoxDecoration(
|
|
color: AppColors.sageGreen,
|
|
shape: BoxShape.circle,
|
|
),
|
|
selectedTextStyle: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
headerStyle: HeaderStyle(
|
|
formatButtonVisible: false,
|
|
titleCentered: true,
|
|
titleTextStyle: GoogleFonts.outfit(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.charcoal,
|
|
),
|
|
leftChevronIcon: Icon(
|
|
Icons.chevron_left,
|
|
color: AppColors.warmGray,
|
|
),
|
|
rightChevronIcon: Icon(
|
|
Icons.chevron_right,
|
|
color: AppColors.warmGray,
|
|
),
|
|
),
|
|
daysOfWeekStyle: DaysOfWeekStyle(
|
|
weekdayStyle: GoogleFonts.outfit(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.warmGray,
|
|
),
|
|
weekendStyle: GoogleFonts.outfit(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.warmGray,
|
|
),
|
|
),
|
|
calendarBuilders: CalendarBuilders(
|
|
markerBuilder: (context, date, events) {
|
|
final entry = _getEntryForDate(date, entries);
|
|
|
|
if (entry == null) {
|
|
final phase =
|
|
_getPhaseForDate(date, lastPeriodStart, cycleLength);
|
|
if (phase != null) {
|
|
return Positioned(
|
|
bottom: 1,
|
|
child: Container(
|
|
width: 4,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: _getPhaseColor(phase).withOpacity(0.3),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// If we have an entry, show icons/markers
|
|
return Positioned(
|
|
bottom: 1,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (entry.isPeriodDay)
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
margin: const EdgeInsets.symmetric(horizontal: 1),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.menstrualPhase,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
if (entry.mood != null ||
|
|
entry.energyLevel != 3 ||
|
|
entry.hasSymptoms)
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
margin: const EdgeInsets.symmetric(horizontal: 1),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.softGold,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
// Selected Day Info
|
|
if (_selectedDay != null)
|
|
Expanded(
|
|
child: _buildDayInfo(
|
|
_selectedDay!, lastPeriodStart, cycleLength, entries),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendButton() {
|
|
return GestureDetector(
|
|
onTap: () => _showLegend(),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.blushPink.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.info_outline, size: 16, color: AppColors.rose),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'Legend',
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.rose,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showLegend() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (context) => Container(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Legend',
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.charcoal,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
_buildLegendItem(AppColors.menstrualPhase, 'Period'),
|
|
_buildLegendItem(AppColors.follicularPhase, 'Follicular Phase'),
|
|
_buildLegendItem(AppColors.ovulationPhase, 'Ovulation Window'),
|
|
_buildLegendItem(AppColors.lutealPhase, 'Luteal Phase'),
|
|
const SizedBox(height: 20),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendItem(Color color, String label) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
label,
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
color: AppColors.charcoal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDayInfo(DateTime date, DateTime? lastPeriodStart, int cycleLength,
|
|
List<CycleEntry> entries) {
|
|
final phase = _getPhaseForDate(date, lastPeriodStart, cycleLength);
|
|
final entry = _getEntryForDate(date, entries);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).cardTheme.color,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'${_getMonthName(date.month)} ${date.day}, ${date.year}',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (entry != null)
|
|
const Icon(Icons.check_circle,
|
|
color: AppColors.sageGreen, size: 20),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (phase != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: _getPhaseColor(phase).withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(phase.emoji),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
phase.label,
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: _getPhaseColor(phase),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (entry == null)
|
|
Text(
|
|
phase?.description ?? 'No data for this date',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodyMedium
|
|
?.copyWith(color: AppColors.warmGray),
|
|
)
|
|
else ...[
|
|
// Period Detail
|
|
if (entry.isPeriodDay)
|
|
_buildDetailRow(Icons.water_drop, 'Period Day',
|
|
AppColors.menstrualPhase,
|
|
value: entry.flowIntensity?.label),
|
|
|
|
// Mood Detail
|
|
if (entry.mood != null)
|
|
_buildDetailRow(
|
|
Icons.emoji_emotions_outlined, 'Mood', AppColors.softGold,
|
|
value: '${entry.mood!.emoji} ${entry.mood!.label}'),
|
|
|
|
// Energy Detail
|
|
_buildDetailRow(
|
|
Icons.flash_on, 'Energy Level', AppColors.follicularPhase,
|
|
value: _getEnergyLabel(entry.energyLevel)),
|
|
|
|
// Symptoms
|
|
if (entry.hasSymptoms)
|
|
_buildDetailRow(
|
|
Icons.healing_outlined, 'Symptoms', AppColors.lavender,
|
|
value: _getSymptomsString(entry)),
|
|
|
|
// Contextual Recommendation
|
|
_buildRecommendation(entry),
|
|
|
|
// Notes
|
|
if (entry.notes?.isNotEmpty == true)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Notes',
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.warmGray)),
|
|
const SizedBox(height: 4),
|
|
Text(entry.notes!,
|
|
style: GoogleFonts.outfit(fontSize: 14)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
|
|
// Action Buttons
|
|
if (!widget.readOnly)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(
|
|
'Log for ${_getMonthName(date.month)} ${date.day}'),
|
|
),
|
|
body: LogScreen(initialDate: date),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
icon: Icon(entry != null
|
|
? Icons.edit_note
|
|
: Icons.add_circle_outline),
|
|
label: Text(entry != null ? 'Edit Log' : 'Add Log'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.sageGreen,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRecommendation(CycleEntry entry) {
|
|
final scripture = ScriptureDatabase().getRecommendedScripture(entry);
|
|
if (scripture == null) return const SizedBox.shrink();
|
|
|
|
final user = ref.read(userProfileProvider);
|
|
final translation = user?.bibleTranslation ?? BibleTranslation.esv;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(top: 16),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.softGold.withOpacity(isDark ? 0.15 : 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColors.softGold.withOpacity(0.3)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.auto_awesome,
|
|
color: AppColors.softGold, size: 18),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Daily Encouragement',
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.softGold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'"${scripture.getVerse(translation)}"',
|
|
style: GoogleFonts.lora(
|
|
fontSize: 14,
|
|
fontStyle: FontStyle.italic,
|
|
color: isDark ? Colors.white : AppColors.charcoal,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'— ${scripture.reference}',
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.warmGray,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailRow(IconData icon, String label, Color color,
|
|
{String? value}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, color: color, size: 18),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 12,
|
|
color: AppColors.warmGray,
|
|
),
|
|
),
|
|
if (value != null)
|
|
Text(
|
|
value,
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _getSymptomsString(CycleEntry entry) {
|
|
List<String> s = [];
|
|
if (entry.crampIntensity != null && entry.crampIntensity! > 0)
|
|
s.add('Cramps (${entry.crampIntensity}/5)');
|
|
if (entry.hasHeadache) s.add('Headache');
|
|
if (entry.hasBloating) s.add('Bloating');
|
|
if (entry.hasBreastTenderness) s.add('Breast Tenderness');
|
|
if (entry.hasFatigue) s.add('Fatigue');
|
|
if (entry.hasAcne) s.add('Acne');
|
|
return s.join(', ');
|
|
}
|
|
|
|
String _getEnergyLabel(int? energyLevel) {
|
|
if (energyLevel == null) return 'Not logged';
|
|
if (energyLevel <= 1) return 'Very Low';
|
|
if (energyLevel == 2) return 'Low';
|
|
if (energyLevel == 3) return 'Neutral';
|
|
if (energyLevel == 4) return 'High';
|
|
return 'Very High';
|
|
}
|
|
|
|
CyclePhase? _getPhaseForDate(
|
|
DateTime date, DateTime? lastPeriodStart, int cycleLength) {
|
|
if (lastPeriodStart == null) return null;
|
|
|
|
final daysSinceLastPeriod = date.difference(lastPeriodStart).inDays;
|
|
if (daysSinceLastPeriod < 0) return null;
|
|
|
|
final dayOfCycle = (daysSinceLastPeriod % cycleLength) + 1;
|
|
|
|
if (dayOfCycle <= 5) return CyclePhase.menstrual;
|
|
if (dayOfCycle <= 13) return CyclePhase.follicular;
|
|
if (dayOfCycle <= 16) return CyclePhase.ovulation;
|
|
return CyclePhase.luteal;
|
|
}
|
|
|
|
Color _getPhaseColor(CyclePhase phase) {
|
|
switch (phase) {
|
|
case CyclePhase.menstrual:
|
|
return AppColors.menstrualPhase;
|
|
case CyclePhase.follicular:
|
|
return AppColors.follicularPhase;
|
|
case CyclePhase.ovulation:
|
|
return AppColors.ovulationPhase;
|
|
case CyclePhase.luteal:
|
|
return AppColors.lutealPhase;
|
|
}
|
|
}
|
|
|
|
String _getMonthName(int month) {
|
|
const months = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December'
|
|
];
|
|
return months[month - 1];
|
|
}
|
|
|
|
bool _isLoggedPeriodDay(DateTime date, List<CycleEntry> entries) {
|
|
final entry = _getEntryForDate(date, entries);
|
|
return entry?.isPeriodDay ?? false;
|
|
}
|
|
|
|
CycleEntry? _getEntryForDate(DateTime date, List<CycleEntry> entries) {
|
|
try {
|
|
return entries.firstWhere(
|
|
(entry) => isSameDay(entry.date, date),
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|