Files
Tracker/lib/screens/onboarding/onboarding_screen.dart
Sterlen 1c2c56e9e2 feat: Add auto-sync, fix partner linking UI, update sharing settings
- 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)
2026-01-09 17:20:49 -06:00

1506 lines
50 KiB
Dart

import 'dart:async'; // Add this import for Timer
// import 'dart:convert'; // For encoding/decoding // Removed unused import to fix lint
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // For Clipboard
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import 'package:uuid/uuid.dart';
import '../../theme/app_theme.dart';
import '../../models/user_profile.dart';
import '../home/home_screen.dart';
import '../husband/husband_home_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/user_provider.dart';
import '../../services/notification_service.dart';
import '../../services/sync_service.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;
int _minCycleLength = 25;
int _maxCycleLength = 35;
bool _isPadTrackingEnabled = false;
// Connection options
late String _userId;
String? _partnerId;
bool _useExampleData = false;
bool _skipPartnerConnection = false;
@override
void initState() {
super.initState();
_userId = const Uuid().v4();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _nextPage() async {
if (_isNavigating) return;
_isNavigating = true;
// Husband Flow: Role (0) -> Name (1) -> Connect (2) -> Finish
// Wife Flow: Role (0) -> Name (1) -> Relationship (2) -> [Fertility (3)] -> Cycle (4) -> [Connect (5) if married]
int nextPage = _currentPage + 1;
// Early Server Registration (After Name/Role selection)
if (_currentPage == 1) {
// Don't await this, let it happen in background to keep UI snappy?
// Actually, await it to ensure ID is valid before they reach "Connect"?
// "Connect" is Page 2 for Husband.
// So yes, we should probably await or just fire and hope response is fast.
// But _nextPage is async.
// Let's fire and forget, but maybe add a small delay or ensure it happens.
// Since it's local network often, it should be fast.
_registerEarly();
}
// Logic for skipping pages
// Logic for skipping pages
if (_role == UserRole.husband) {
if (_currentPage == 2) {
// Finish after connect page
if (!_useExampleData) {
final id = await _showConnectDialog();
if (id != null && id.isNotEmpty) {
setState(() => _partnerId = id);
} else if (id == null) {
// Cancelled
if (mounted) setState(() => _isNavigating = false);
return;
}
}
await _completeOnboarding();
return;
}
} else {
// Wife flow
if (_currentPage == 2 &&
_relationshipStatus != RelationshipStatus.married) {
// Skip fertility goal (page 3) if not married
nextPage = 4;
}
if (_currentPage == 4 &&
_relationshipStatus != RelationshipStatus.married) {
// Skip connect page (page 5) if not married - finish now
await _completeOnboarding();
return;
}
if (_currentPage == 5) {
// Finish after connect page (married wife)
if (!_skipPartnerConnection) {
await _showInviteDialog();
}
await _completeOnboarding();
return;
}
}
final maxPages = _role == UserRole.husband ? 2 : 5;
if (nextPage <= maxPages) {
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> _registerEarly() async {
// Register the user on the server early so partner can link to them
// immediately.
try {
final userDetails = {
'name': _name,
'role': _role.name,
'partnerId': null, // No partner yet
'createdAt': DateTime.now().toIso8601String(),
};
await SyncService().pushSyncData(
userId: _userId,
entries: [],
teachingPlans: [],
prayerRequests: [],
userDetails: userDetails,
);
debugPrint('Early registration successful for $_name');
} catch (e) {
debugPrint('Early registration failed: $e');
}
}
Future<void> _completeOnboarding() async {
// 1. Check for Server-Linked Partner (Auto-Discovery)
// If the husband linked to us while we were finishing the form,
// the server will have the partnerId.
try {
final syncData = await SyncService().pullSyncData(_userId);
if (syncData.containsKey('userProfile')) {
final serverProfile = syncData['userProfile'] as Map<String, dynamic>;
if (serverProfile['partnerId'] != null) {
_partnerId = serverProfile['partnerId'];
debugPrint('Auto-discovered partner: $_partnerId');
}
}
} catch (e) {
debugPrint('Error checking for partner link: $e');
}
// 2. Create User Profile
final userProfile = UserProfile(
id: _userId,
name: _name,
role: _role,
relationshipStatus: _role == UserRole.husband
? RelationshipStatus.married
: _relationshipStatus,
partnerId: _partnerId,
fertilityGoal: (_role == UserRole.wife &&
_relationshipStatus == RelationshipStatus.married)
? _fertilityGoal
: null,
averageCycleLength: _averageCycleLength,
lastPeriodStartDate: _lastPeriodStart,
isIrregularCycle: _isIrregularCycle,
hasCompletedOnboarding: true,
isPadTrackingEnabled: _isPadTrackingEnabled,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
// 3. Save Profile (triggers local save)
await ref.read(userProfileProvider.notifier).updateProfile(userProfile);
// 4. Force Final Sync (Push everything including completed status)
// Note: CycleEntriesNotifier handles data sync, but we want to ensure
// profile is consistent. The Provider doesn't push profile changes automatically yet,
// so we do it manually or rely on the next data change.
// For safety, let's just push one last time or let the Home Screen handle it.
// But since we just updated the profile, we should sync it.
try {
final userDetails = {
'name': userProfile.name,
'role': userProfile.role.name,
'partnerId': userProfile.partnerId,
'createdAt': userProfile.createdAt.toIso8601String(),
};
await SyncService().pushSyncData(
userId: _userId,
entries: [],
teachingPlans: [],
prayerRequests: [],
userDetails: userDetails,
);
} catch (e) {
debugPrint('Final onboarding sync failed: $e');
}
// Generate example data if requested - REMOVED
/*
if (_useExampleData) {
await ref
.read(cycleEntriesProvider.notifier)
.generateExampleData(userProfile.id);
}
*/
// Trigger partner connection notification if applicable
if (!_skipPartnerConnection) {
await NotificationService().showPartnerUpdateNotification(
title: 'Connection Successful!',
body: 'You are now connected with your partner. Tap to start sharing.',
);
}
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; - unused
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
? 3
: (_relationshipStatus == RelationshipStatus.married
? 6
: 5),
effect: WormEffect(
dotHeight: 8,
dotWidth: 8,
spacing: 12,
activeDotColor:
isHusband ? AppColors.navyBlue : AppColors.sageGreen,
dotColor: theme.colorScheme.outline.withValues(alpha: 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
if (_role == UserRole.husband)
_buildHusbandConnectPage() // Page 2 (Husband only)
else ...[
_buildRelationshipPage(), // Page 2 (Wife only)
_buildFertilityGoalPage(), // Page 3 (Wife married only)
_buildCyclePage(), // Page 4 (Wife only)
if (_relationshipStatus == RelationshipStatus.married)
_buildWifeConnectPage(), // Page 5 (Wife married 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 0.1),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected
? activeColor
: theme.colorScheme.surfaceContainerHighest,
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: const Text('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: !_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.withValues(alpha: isDark ? 0.3 : 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.sageGreen
: theme.colorScheme.outline.withValues(alpha: 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)
const 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.withValues(alpha: isDark ? 0.3 : 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.sageGreen
: theme.colorScheme.outline.withValues(alpha: 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)
const Icon(Icons.check_circle, color: AppColors.sageGreen),
],
),
),
);
}
Widget _buildCyclePage() {
final theme = Theme.of(context);
// final isDark = theme.brightness == Brightness.dark; - unused
// 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,
),
if (_isIrregularCycle) ...[
const SizedBox(height: 8),
Text('Cycle range (shortest to longest)',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface)),
Row(
children: [
Expanded(
child: RangeSlider(
values: RangeValues(
_minCycleLength.toDouble(), _maxCycleLength.toDouble()),
min: 21,
max: 45,
divisions: 24,
activeColor: AppColors.sageGreen,
labels: RangeLabels(
'$_minCycleLength days', '$_maxCycleLength days'),
onChanged: (values) {
setState(() {
_minCycleLength = values.start.round();
_maxCycleLength = values.end.round();
});
},
),
),
],
),
Center(
child: Text('$_minCycleLength - $_maxCycleLength days',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, color: AppColors.sageGreen)),
),
],
// Enable Supply Tracking Checkbox
CheckboxListTile(
title: Text('Enable supply tracking',
style: theme.textTheme.bodyLarge
?.copyWith(color: theme.colorScheme.onSurface)),
value: _isPadTrackingEnabled,
onChanged: (val) =>
setState(() => _isPadTrackingEnabled = 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.withValues(alpha: 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'),
),
),
),
],
),
],
),
);
}
/// Husband Connect Page - Choose to connect with wife or use example data
Widget _buildHusbandConnectPage() {
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),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.navyBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.link, size: 32, color: AppColors.navyBlue),
),
const SizedBox(height: 24),
Text(
'Connect with your wife',
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'See her cycle info and prayer requests to support her better.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
// Option 1: Connect with Wife (placeholder for now)
_buildConnectOption(
icon: Icons.qr_code_scanner,
title: 'Connect with Wife',
subtitle: 'Enter connection code from her app',
isSelected: !_useExampleData,
onTap: () => setState(() => _useExampleData = false),
color: AppColors.navyBlue,
isDark: isDark,
),
const SizedBox(height: 16),
// Option 2: Use Example Data
_buildConnectOption(
icon: Icons.auto_awesome,
title: 'Use Example Data',
subtitle: 'Explore the app with sample data',
isSelected: _useExampleData,
onTap: () => setState(() => _useExampleData = true),
color: AppColors.navyBlue,
isDark: isDark,
),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 54,
child: OutlinedButton(
onPressed: _previousPage,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.navyBlue,
side: const BorderSide(color: AppColors.navyBlue),
),
child: const Text('Back'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: !_isNavigating ? _nextPage : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.navyBlue,
),
child: const Text('Finish Setup'),
),
),
),
],
),
],
),
);
}
/// Wife Connect Page - Invite husband or skip
Widget _buildWifeConnectPage() {
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),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.sageGreen.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.favorite,
size: 32, color: AppColors.sageGreen),
),
const SizedBox(height: 24),
Text(
'Invite your husband',
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'Share your cycle info and prayer requests so he can support you.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
// Option 1: Invite Husband
_buildConnectOption(
icon: Icons.share,
title: 'Invite Husband',
subtitle: 'Generate a connection code to share',
isSelected: !_skipPartnerConnection,
onTap: () => setState(() => _skipPartnerConnection = false),
color: AppColors.sageGreen,
isDark: isDark,
),
const SizedBox(height: 16),
// Option 2: Skip for Now
_buildConnectOption(
icon: Icons.schedule,
title: 'Skip for Now',
subtitle: 'You can invite him later in settings',
isSelected: _skipPartnerConnection,
onTap: () => setState(() => _skipPartnerConnection = true),
color: AppColors.sageGreen,
isDark: isDark,
),
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: !_isNavigating ? _nextPage : null,
child: const Text('Get Started'),
),
),
),
],
),
],
),
);
}
/// Helper for connection option cards
Widget _buildConnectOption({
required IconData icon,
required String title,
required String subtitle,
required bool isSelected,
required VoidCallback onTap,
required Color color,
required bool isDark,
}) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? color.withValues(alpha: isDark ? 0.3 : 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? color
: theme.colorScheme.outline.withValues(alpha: 0.1),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected
? color
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected
? Colors.white
: theme.colorScheme.onSurfaceVariant,
size: 20,
),
),
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: color),
],
),
),
);
}
Future<String?> _showConnectDialog() async {
// Ensure we exist before connecting
await _ensureServerRegistration();
final controller = TextEditingController();
String? error;
bool isLoading = false;
// State for the dialog: 'input', 'confirm'
String step = 'input';
String? partnerName;
String? partnerRole;
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
if (step == 'confirm') {
return AlertDialog(
title: const Text('Confirm Connection'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Found Partner: $partnerName'),
if (partnerRole != null) Text('Role: $partnerRole'),
const SizedBox(height: 16),
const Text('Do you want to connect with this user?'),
if (isLoading) ...[
const SizedBox(height: 16),
const CircularProgressIndicator(),
],
],
),
actions: [
if (!isLoading)
TextButton(
onPressed: () {
setState(() {
step = 'input';
error = null;
});
},
child: const Text('Back'),
),
ElevatedButton(
onPressed: isLoading
? null
: () async {
setState(() => isLoading = true);
try {
// Final Link
final input = controller.text.trim();
await SyncService().verifyPartnerId(_userId, input);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Connected to $partnerName!')),
);
Navigator.pop(context, input);
}
} catch (e) {
if (context.mounted) {
setState(() {
isLoading = false;
error = 'Connection Request Failed';
step = 'input';
});
}
}
},
child: const Text('Confirm & Link'),
),
],
);
}
return AlertDialog(
title: const Text('Connect with Partner'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Enter your partner\'s User ID:'),
const SizedBox(height: 16),
TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: 'Paste ID here',
errorText: error,
),
enabled: !isLoading,
),
if (isLoading) ...[
const SizedBox(height: 16),
const CircularProgressIndicator(),
const SizedBox(height: 8),
const Text('Searching...'),
],
],
),
actions: [
if (!isLoading)
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: isLoading
? null
: () async {
final input = controller.text.trim();
if (input.isEmpty) return;
setState(() {
isLoading = true;
error = null;
});
try {
// Preview First
final result =
await SyncService().previewPartnerId(input);
if (context.mounted) {
setState(() {
isLoading = false;
partnerName = result['partnerName'];
partnerRole = result['partnerRole'];
step = 'confirm';
});
}
} catch (e) {
if (context.mounted) {
setState(() {
isLoading = false;
// Show actual error for debugging
error = e
.toString()
.replaceAll('Exception:', '')
.trim();
});
}
}
},
child: const Text('Find Partner'),
),
],
);
},
),
);
}
Future<void> _ensureServerRegistration() async {
await _registerEarly();
}
Future<void> _showInviteDialog() async {
// 1. Ensure we are actually registered so they can find us
await _ensureServerRegistration();
Timer? pollTimer;
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
// Poll for connection
if (pollTimer == null) {
pollTimer =
Timer.periodic(const Duration(seconds: 3), (timer) async {
if (!mounted) {
timer.cancel();
return;
}
// Check if we are connected yet
try {
final result = await SyncService().pullSyncData(_userId);
if (result.containsKey('userProfile')) {
final profile = result['userProfile'];
final partnerId = profile['partnerId'];
if (partnerId != null) {
// SUCCESS!
timer.cancel();
if (context.mounted) {
// We could also fetch partner name here if needed,
// but for now we just know we are linked.
// Or pull again to get teaching plans etc if they synced.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Husband Connected Successfully!')),
);
Navigator.pop(context); // Close dialog
}
}
}
} catch (e) {
debugPrint('Poll error: $e');
}
});
}
return AlertDialog(
title: const Text('Invite Partner'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Share this code with your partner:'),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SelectableText(
_userId,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18),
),
IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: _userId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied to clipboard!')),
);
},
),
],
),
const SizedBox(height: 16),
const Text('Waiting for him to connect...'),
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
),
actions: [
TextButton(
onPressed: () {
pollTimer?.cancel();
Navigator.pop(context);
},
child: const Text('Cancel / Done'),
),
],
);
},
),
);
pollTimer?.cancel();
}
}