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,3 +1,4 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/user_profile.dart';
@@ -5,6 +6,10 @@ import '../models/cycle_entry.dart';
import '../services/cycle_service.dart';
import '../services/sync_service.dart';
import 'prayer_provider.dart';
import 'teaching_plan_provider.dart';
import '../models/prayer_request.dart';
import '../models/teaching_plan.dart';
/// Provider for the user profile
final userProfileProvider =
@@ -19,12 +24,12 @@ class UserProfileNotifier extends StateNotifier<UserProfile?> {
}
void _loadProfile() {
final box = Hive.box<UserProfile>('user_profile');
final box = Hive.box<UserProfile>('user_profile_v2');
state = box.get('current_user');
}
Future<void> updateProfile(UserProfile profile) async {
final box = Hive.box<UserProfile>('user_profile');
final box = Hive.box<UserProfile>('user_profile_v2');
await box.put('current_user', profile);
state = profile;
}
@@ -50,7 +55,7 @@ class UserProfileNotifier extends StateNotifier<UserProfile?> {
}
Future<void> clearProfile() async {
final box = Hive.box<UserProfile>('user_profile');
final box = Hive.box<UserProfile>('user_profile_v2');
await box.clear();
state = null;
}
@@ -59,44 +64,58 @@ class UserProfileNotifier extends StateNotifier<UserProfile?> {
/// Provider for cycle entries
final cycleEntriesProvider =
StateNotifierProvider<CycleEntriesNotifier, List<CycleEntry>>((ref) {
return CycleEntriesNotifier();
return CycleEntriesNotifier(ref);
});
/// Notifier for cycle entries
class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
CycleEntriesNotifier() : super([]) {
final Ref ref;
Timer? _syncTimer;
CycleEntriesNotifier(this.ref) : super([]) {
_loadEntries();
syncData(); // Auto-sync on load
// Start periodic sync every 10 seconds
_syncTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
_pull();
});
}
@override
void dispose() {
_syncTimer?.cancel();
super.dispose();
}
void _loadEntries() {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
state = box.values.toList()..sort((a, b) => b.date.compareTo(a.date));
}
Future<void> addEntry(CycleEntry entry) async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
await box.put(entry.id, entry);
_loadEntries();
_push();
}
Future<void> updateEntry(CycleEntry entry) async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
await box.put(entry.id, entry);
_loadEntries();
_push();
}
Future<void> deleteEntry(String id) async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
await box.delete(id);
_loadEntries();
_push();
}
Future<void> deleteEntriesForMonth(int year, int month) async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
final keysToDelete = <dynamic>[];
for (var entry in box.values) {
if (entry.date.year == year && entry.date.month == month) {
@@ -109,7 +128,7 @@ class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
}
Future<void> clearEntries() async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
await box.clear();
state = [];
_push();
@@ -126,28 +145,96 @@ class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
}
Future<void> _push() async {
final userBox = Hive.box<UserProfile>('user_profile');
final userBox = Hive.box<UserProfile>('user_profile_v2');
final user = userBox.get('current_user');
// Read current state from other providers
// Note: This relies on the providers being initialized.
final plans = ref.read(teachingPlansProvider);
final prayers = ref.read(prayerRequestsProvider);
if (user != null) {
await SyncService().pushSyncData(user.id, state);
final userDetails = {
'name': user.name,
'role': user.role.name,
'partnerId': user.partnerId,
'createdAt': user.createdAt.toIso8601String(),
// Add other relevant fields if needed for display on partner's side
};
await SyncService().pushSyncData(
userId: user.id,
entries: state,
teachingPlans: plans,
prayerRequests: prayers,
userDetails: userDetails,
);
}
}
Future<void> _pull() async {
final userBox = Hive.box<UserProfile>('user_profile');
final userBox = Hive.box<UserProfile>('user_profile_v2');
final user = userBox.get('current_user');
if (user == null) return;
final remoteEntries = await SyncService().pullSyncData(user.id);
final syncResult = await SyncService().pullSyncData(
user.id,
partnerId: user.partnerId,
);
// 0. Check for Server-Side Profile Updates (Auto-Link)
if (syncResult.containsKey('userProfile')) {
final serverProfile = syncResult['userProfile'] as Map<String, dynamic>;
final serverPartnerId = serverProfile['partnerId'];
// If server has a partner ID and we don't (or it's different), update!
if (serverPartnerId != null && serverPartnerId != user.partnerId) {
// Update local profile
final updatedProfile = user.copyWith(partnerId: serverPartnerId);
await Hive.box<UserProfile>('user_profile_v2')
.put('current_user', updatedProfile);
// Refresh provider state if needed, but important is to RE-SYNC with new ID
// so we get the partner's data immediately.
return _pull(); // Recursive call will now use new partnerId
}
}
final remoteEntries = syncResult['entries'] as List<CycleEntry>? ?? [];
final remotePlans =
syncResult['teachingPlans'] as List<TeachingPlan>? ?? [];
final remotePrayers =
syncResult['prayerRequests'] as List<PrayerRequest>? ?? [];
// 1. Cycle Entries
if (remoteEntries.isNotEmpty) {
final box = Hive.box<CycleEntry>('cycle_entries');
// Simple merge: Remote wins or Union?
// Union: Upsert all.
final box = Hive.box<CycleEntry>('cycle_entries_v2');
// Simple merge: remote wins for id collisions
for (var entry in remoteEntries) {
await box.put(entry.id, entry);
}
_loadEntries();
}
// 2. Teaching Plans
if (remotePlans.isNotEmpty) {
final box = Hive.box<TeachingPlan>('teaching_plans_v2');
for (var plan in remotePlans) {
await box.put(plan.id, plan);
}
// Refresh provider
ref.invalidate(teachingPlansProvider);
}
// 3. Prayer Requests
if (remotePrayers.isNotEmpty) {
final box = Hive.box<PrayerRequest>('prayer_requests_v2');
for (var req in remotePrayers) {
await box.put(req.id, req);
}
// Refresh provider
ref.invalidate(prayerRequestsProvider);
}
}
// Example data generation removed