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,18 +1,98 @@
import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../models/cycle_entry.dart';
import '../models/teaching_plan.dart';
import '../models/prayer_request.dart';
class SyncService {
static const String _baseUrl = 'http://localhost:8090';
// Use 192.168.1.81 for the user's specific setup as they are testing across devices.
// In a real app we'd allow this to be configured.
// static const String _baseUrl = 'http://localhost:8090';
// Dynamic Base URL
static String get _baseUrl {
if (kIsWeb) {
// On web, use the current window's hostname (whether localhost or IP)
// and target port 8090.
final host = Uri.base.host;
// If host is empty (some web views), fallback.
if (host.isNotEmpty) {
return 'http://$host:8090';
}
}
// Mobile / Desktop App Fallback
// Use the specific local IP for this user's physical device testing.
return 'http://192.168.1.81:8090';
}
// Preview Partner Link (Check without linking)
Future<Map<String, dynamic>> previewPartnerId(String targetId) async {
try {
final url = Uri.parse('$_baseUrl/sync/preview');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'targetId': targetId,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to preview: ${response.body}');
}
} catch (e) {
debugPrint('Preview Error: $e');
rethrow;
}
}
// Link Partner
Future<Map<String, dynamic>> verifyPartnerId(
String userId, String targetId) async {
try {
final url = Uri.parse('$_baseUrl/sync/link');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'userId': userId,
'targetId': targetId,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to link: ${response.body}');
}
} catch (e) {
debugPrint('Link Error: $e');
rethrow;
}
}
// Push data to backend
Future<void> pushSyncData(String userId, List<CycleEntry> entries) async {
Future<void> pushSyncData({
required String userId,
required List<CycleEntry> entries,
required List<TeachingPlan> teachingPlans,
required List<PrayerRequest> prayerRequests,
Map<String, dynamic>? userDetails,
}) async {
try {
final url = Uri.parse('$_baseUrl/sync/push');
final payload = {
'userId': userId,
'entries': entries.map((e) => _cycleEntryToJson(e)).toList(),
'teachingPlans':
teachingPlans.map((p) => _teachingPlanToJson(p)).toList(),
'prayerRequests':
prayerRequests.map((r) => _prayerRequestToJson(r)).toList(),
if (userDetails != null) 'userDetails': userDetails,
};
final response = await http.post(
@@ -32,23 +112,55 @@ class SyncService {
}
// Pull data from backend
Future<List<CycleEntry>> pullSyncData(String userId) async {
Future<Map<String, dynamic>> pullSyncData(String userId,
{String? partnerId}) async {
try {
final url = Uri.parse('$_baseUrl/sync/pull?userId=$userId');
var urlStr = '$_baseUrl/sync/pull?userId=$userId';
if (partnerId != null && partnerId.isNotEmpty) {
urlStr += '&partnerId=$partnerId';
}
final url = Uri.parse(urlStr);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// entries
final List entriesJson = data['entries'] ?? [];
return entriesJson.map((json) => _jsonToCycleEntry(json)).toList();
final entries =
entriesJson.map((json) => _jsonToCycleEntry(json)).toList();
// teaching plans
final List plansJson = data['teachingPlans'] ?? [];
final plans =
plansJson.map((json) => _jsonToTeachingPlan(json)).toList();
// prayer requests
final List prayersJson = data['prayerRequests'] ?? [];
final prayers =
prayersJson.map((json) => _jsonToPrayerRequest(json)).toList();
return {
'entries': entries,
'teachingPlans': plans,
'prayerRequests': prayers,
if (data['userProfile'] != null)
'userProfile': data['userProfile'] as Map<String, dynamic>,
};
}
} catch (e) {
debugPrint('Sync Pull Error: $e');
}
return [];
return {
'entries': <CycleEntry>[],
'teachingPlans': <TeachingPlan>[],
'prayerRequests': <PrayerRequest>[],
};
}
// Helpers (Adapters)
// --- Adapters ---
Map<String, dynamic> _cycleEntryToJson(CycleEntry entry) {
// Convert boolean symptoms to list of strings
final symptomsList = <String>[];
@@ -121,6 +233,50 @@ class SyncService {
);
}
// Teaching Plan
Map<String, dynamic> _teachingPlanToJson(TeachingPlan plan) {
return {
'id': plan.id,
'date': plan.date.toIso8601String(),
'topic': plan.topic,
'scriptureReference': plan.scriptureReference,
'notes': plan.notes,
'isCompleted': plan.isCompleted,
};
}
TeachingPlan _jsonToTeachingPlan(Map<String, dynamic> json) {
return TeachingPlan(
id: json['id'],
date: DateTime.parse(json['date']),
topic: json['topic'],
scriptureReference: json['scriptureReference'],
notes: json['notes'],
isCompleted: json['isCompleted'] == true,
);
}
// Prayer Request
Map<String, dynamic> _prayerRequestToJson(PrayerRequest request) {
return {
'id': request.id,
'request': request.request,
'isAnswered': request.isAnswered,
'createdAt': request.createdAt.toIso8601String(),
'updatedAt': request.updatedAt.toIso8601String(),
};
}
PrayerRequest _jsonToPrayerRequest(Map<String, dynamic> json) {
return PrayerRequest(
id: json['id'],
request: json['request'],
isAnswered: json['isAnswered'] == true,
createdAt: DateTime.parse(json['createdAt']),
updatedAt: DateTime.parse(json['updatedAt']),
);
}
List<String> _parseList(dynamic jsonVal) {
if (jsonVal == null) return [];
if (jsonVal is String) {