Files
Tracker/lib/screens/onboarding/onboarding_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

791 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
import '../../theme/app_theme.dart';
import 'package:christian_period_tracker/models/user_profile.dart';
import 'package:christian_period_tracker/models/cycle_entry.dart';
import '../home/home_screen.dart';
import '../husband/husband_home_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/user_provider.dart';
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
bool _isNavigating = false; // Debounce flag
// Form data
UserRole _role = UserRole.wife;
String _name = '';
RelationshipStatus _relationshipStatus = RelationshipStatus.single;
FertilityGoal? _fertilityGoal;
int _averageCycleLength = 28;
DateTime? _lastPeriodStart;
bool _isIrregularCycle = false;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _nextPage() async {
if (_isNavigating) return;
_isNavigating = true;
// Husband Flow: Role (0) -> Name (1) -> Finish
// Wife Flow: Role (0) -> Name (1) -> Relationship (2) -> [Fertility (3)] -> Cycle (4)
int nextPage = _currentPage + 1;
// Logic for skipping pages
if (_role == UserRole.husband) {
if (_currentPage == 1) {
await _completeOnboarding();
return;
}
} else {
// Wife flow
if (_currentPage == 2 &&
_relationshipStatus != RelationshipStatus.married) {
// Skip fertility goal (page 3) if not married
nextPage = 4;
}
}
if (nextPage <= 4) {
// Max pages
await _pageController.animateToPage(
nextPage,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
} else {
await _completeOnboarding();
}
// Reset debounce after animation
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) setState(() => _isNavigating = false);
});
}
void _previousPage() async {
if (_isNavigating) return;
_isNavigating = true;
int prevPage = _currentPage - 1;
// Logic for reverse skipping
if (_role == UserRole.wife) {
if (_currentPage == 4 &&
_relationshipStatus != RelationshipStatus.married) {
// Skip back over fertility goal (page 3)
prevPage = 2;
}
}
if (prevPage >= 0) {
await _pageController.animateToPage(
prevPage,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
}
// Reset debounce after animation
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) setState(() => _isNavigating = false);
});
}
Future<void> _completeOnboarding() async {
final userProfile = UserProfile(
id: const Uuid().v4(),
name: _name,
role: _role,
relationshipStatus: _role == UserRole.husband
? RelationshipStatus.married
: _relationshipStatus,
fertilityGoal: (_role == UserRole.wife &&
_relationshipStatus == RelationshipStatus.married)
? _fertilityGoal
: null,
averageCycleLength: _averageCycleLength,
lastPeriodStartDate: _lastPeriodStart,
isIrregularCycle: _isIrregularCycle,
hasCompletedOnboarding: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await ref.read(userProfileProvider.notifier).updateProfile(userProfile);
if (mounted) {
// Navigate to appropriate home screen
if (_role == UserRole.husband) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const HusbandHomeScreen(),
),
);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// Different background color for husband flow
final isHusband = _role == UserRole.husband;
final bgColor = isHusband
? (isDark ? const Color(0xFF1A1C1E) : AppColors.warmCream)
: theme.scaffoldBackgroundColor;
return Scaffold(
backgroundColor: bgColor,
body: SafeArea(
child: Column(
children: [
// Progress indicator (hide on role page 0)
if (_currentPage > 0)
Padding(
padding: const EdgeInsets.all(24),
child: SmoothPageIndicator(
controller: _pageController,
count: isHusband ? 2 : 5,
effect: WormEffect(
dotHeight: 8,
dotWidth: 8,
spacing: 12,
activeDotColor:
isHusband ? AppColors.navyBlue : AppColors.sageGreen,
dotColor: theme.colorScheme.outline.withOpacity(0.2),
),
),
),
// Pages
Expanded(
child: PageView(
controller: _pageController,
physics:
const NeverScrollableScrollPhysics(), // Disable swipe
onPageChanged: (index) {
setState(() => _currentPage = index);
},
children: [
_buildRolePage(), // Page 0
_buildNamePage(), // Page 1
_buildRelationshipPage(), // Page 2 (Wife only)
_buildFertilityGoalPage(), // Page 3 (Wife married only)
_buildCyclePage(), // Page 4 (Wife only)
],
),
),
],
),
),
);
}
Widget _buildRolePage() {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.blushPink,
AppColors.rose.withOpacity(0.7)
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.favorite_rounded,
size: 40,
color: Colors.white,
),
),
const SizedBox(height: 32),
Text(
'Who is this app for?',
textAlign: TextAlign.center,
style: theme.textTheme.displayMedium?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 48),
_buildRoleOption(UserRole.wife, 'For Her',
'Track cycle, health, and faith', Icons.female),
const SizedBox(height: 16),
_buildRoleOption(UserRole.husband, 'For Him',
'Support your wife and grow together', Icons.male),
const Spacer(),
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton(
onPressed: _nextPage,
style: ElevatedButton.styleFrom(
backgroundColor: _role == UserRole.husband
? AppColors.navyBlue
: AppColors.sageGreen,
),
child: const Text('Continue'),
),
),
],
),
);
}
Widget _buildRoleOption(
UserRole role, String title, String subtitle, IconData icon) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isSelected = _role == role;
// Dynamic colors based on role selection
final activeColor =
role == UserRole.wife ? AppColors.sageGreen : AppColors.navyBlue;
final activeBg = activeColor.withOpacity(isDark ? 0.3 : 0.1);
return GestureDetector(
onTap: () => setState(() => _role = role),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isSelected ? activeBg : theme.cardTheme.color,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? activeColor
: theme.colorScheme.outline.withOpacity(0.1),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected
? activeColor
: theme.colorScheme.surfaceVariant,
shape: BoxShape.circle,
),
child: Icon(
icon,
color: isSelected
? Colors.white
: theme.colorScheme.onSurfaceVariant,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isSelected)
Icon(Icons.check_circle, color: activeColor),
],
),
),
);
}
Widget _buildNamePage() {
final theme = Theme.of(context);
final isHusband = _role == UserRole.husband;
final activeColor =
isHusband ? AppColors.navyBlue : AppColors.sageGreen;
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
Text(
isHusband ? 'What\'s your name, sir?' : 'What\'s your name?',
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'We\'ll use this to personalize the app.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
TextField(
onChanged: (value) => setState(() => _name = value),
decoration: InputDecoration(
hintText: 'Enter your name',
prefixIcon: Icon(
Icons.person_outline,
color: theme.colorScheme.onSurfaceVariant,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: activeColor, width: 2),
),
),
style: theme.textTheme.bodyLarge,
textCapitalization: TextCapitalization.words,
),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 54,
child: OutlinedButton(
onPressed: _previousPage,
style: OutlinedButton.styleFrom(
foregroundColor: activeColor,
side: BorderSide(color: activeColor),
),
child: const Text('Back'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: (_name.isNotEmpty && !_isNavigating)
? _nextPage
: null,
style: ElevatedButton.styleFrom(
backgroundColor: activeColor,
),
child: Text(isHusband ? 'Finish Setup' : 'Continue'),
),
),
),
],
),
],
),
);
}
Widget _buildRelationshipPage() {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
Text(
'Tell us about yourself',
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 32),
_buildRelationshipOption(RelationshipStatus.single, 'Single',
'Wellness focus', Icons.person_outline),
const SizedBox(height: 12),
_buildRelationshipOption(RelationshipStatus.engaged, 'Engaged',
'Prepare for marriage', Icons.favorite_border),
const SizedBox(height: 12),
_buildRelationshipOption(RelationshipStatus.married, 'Married',
'Fertility & intimacy', Icons.favorite),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 54,
child: OutlinedButton(
onPressed: _previousPage,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.sageGreen,
side: const BorderSide(color: AppColors.sageGreen)),
child: const Text('Back'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: (_relationshipStatus != null && !_isNavigating)
? _nextPage
: null,
child: const Text('Continue'),
),
),
),
],
),
],
),
);
}
Widget _buildRelationshipOption(
RelationshipStatus status, String title, String subtitle, IconData icon) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isSelected = _relationshipStatus == status;
return GestureDetector(
onTap: () => setState(() => _relationshipStatus = status),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? AppColors.sageGreen.withOpacity(isDark ? 0.3 : 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.sageGreen
: theme.colorScheme.outline.withOpacity(0.1),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Icon(icon,
color: isSelected
? AppColors.sageGreen
: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface)),
Text(subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant)),
],
),
),
if (isSelected)
Icon(Icons.check_circle, color: AppColors.sageGreen),
],
),
),
);
}
Widget _buildFertilityGoalPage() {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
Text('What\'s your goal?',
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface)),
const SizedBox(height: 32),
_buildGoalOption(
FertilityGoal.tryingToConceive,
'Trying to Conceive',
'Track fertile days',
Icons.child_care_outlined),
const SizedBox(height: 12),
_buildGoalOption(
FertilityGoal.tryingToAvoid,
'Natural Family Planning',
'Track fertility signs',
Icons.calendar_today_outlined),
const SizedBox(height: 12),
_buildGoalOption(FertilityGoal.justTracking, 'Just Tracking',
'Monitor cycle health', Icons.insights_outlined),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 54,
child: OutlinedButton(
onPressed: _previousPage,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.sageGreen,
side: const BorderSide(color: AppColors.sageGreen)),
child: const Text('Back'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: (_fertilityGoal != null && !_isNavigating)
? _nextPage
: null,
child: const Text('Continue'),
),
),
),
],
),
],
),
);
}
Widget _buildGoalOption(
FertilityGoal goal, String title, String subtitle, IconData icon) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isSelected = _fertilityGoal == goal;
return GestureDetector(
onTap: () => setState(() => _fertilityGoal = goal),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? AppColors.sageGreen.withOpacity(isDark ? 0.3 : 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.sageGreen
: theme.colorScheme.outline.withOpacity(0.1),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Icon(icon,
color: isSelected
? AppColors.sageGreen
: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface)),
Text(subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant)),
],
),
),
if (isSelected)
Icon(Icons.check_circle, color: AppColors.sageGreen),
],
),
),
);
}
Widget _buildCyclePage() {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
Text('About your cycle',
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface)),
const SizedBox(height: 32),
Text('Average cycle length',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface)),
Row(
children: [
Expanded(
child: Slider(
value: _averageCycleLength.toDouble(),
min: 21,
max: 40,
divisions: 19,
activeColor: AppColors.sageGreen,
onChanged: (value) =>
setState(() => _averageCycleLength = value.round()),
),
),
Text('$_averageCycleLength days',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.sageGreen)),
],
),
// Irregular Cycle Checkbox
CheckboxListTile(
title: Text('My cycles are irregular',
style: theme.textTheme.bodyLarge
?.copyWith(color: theme.colorScheme.onSurface)),
value: _isIrregularCycle,
onChanged: (val) =>
setState(() => _isIrregularCycle = val ?? false),
activeColor: AppColors.sageGreen,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 24),
Text('Last period start date',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface)),
const SizedBox(height: 8),
GestureDetector(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _lastPeriodStart ?? DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 60)),
lastDate: DateTime.now(),
builder: (context, child) {
return Theme(
data: theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
primary: AppColors.sageGreen,
onPrimary: Colors.white,
),
),
child: child!,
);
},
);
if (date != null) setState(() => _lastPeriodStart = date);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withOpacity(0.1))),
child: Row(
children: [
Icon(Icons.calendar_today,
color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 12),
Text(
_lastPeriodStart != null
? "${_lastPeriodStart!.month}/${_lastPeriodStart!.day}/${_lastPeriodStart!.year}"
: "Select Date",
style: theme.textTheme.bodyLarge
?.copyWith(color: theme.colorScheme.onSurface)),
],
),
),
),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 54,
child: OutlinedButton(
onPressed: _previousPage,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.sageGreen,
side: const BorderSide(color: AppColors.sageGreen)),
child: const Text('Back'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: (_lastPeriodStart != null && !_isNavigating)
? _nextPage
: null,
child: const Text('Get Started'),
),
),
),
],
),
],
),
);
}
}