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)
This commit is contained in:
2026-01-09 17:20:49 -06:00
parent d28898cb81
commit 1c2c56e9e2
21 changed files with 1690 additions and 493 deletions

View File

@@ -1,4 +1,7 @@
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';
@@ -8,6 +11,7 @@ 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});
@@ -34,9 +38,17 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
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();
@@ -52,10 +64,34 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
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;
}
@@ -74,6 +110,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
}
if (_currentPage == 5) {
// Finish after connect page (married wife)
if (!_skipPartnerConnection) {
await _showInviteDialog();
}
await _completeOnboarding();
return;
}
@@ -125,14 +164,56 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
});
}
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: const Uuid().v4(),
id: _userId,
name: _name,
role: _role,
relationshipStatus: _role == UserRole.husband
? RelationshipStatus.married
: _relationshipStatus,
partnerId: _partnerId,
fertilityGoal: (_role == UserRole.wife &&
_relationshipStatus == RelationshipStatus.married)
? _fertilityGoal
@@ -141,14 +222,39 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
lastPeriodStartDate: _lastPeriodStart,
isIrregularCycle: _isIrregularCycle,
hasCompletedOnboarding: true,
// useExampleData: Removed
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) {
@@ -456,7 +562,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
style: ElevatedButton.styleFrom(
backgroundColor: activeColor,
),
child: Text(isHusband ? 'Finish Setup' : 'Continue'),
child: const Text('Continue'),
),
),
),
@@ -1140,4 +1246,260 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
),
);
}
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();
}
}