- 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)
292 lines
8.9 KiB
Dart
292 lines
8.9 KiB
Dart
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 {
|
|
// 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({
|
|
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(
|
|
url,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode(payload),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
debugPrint('Sync Push Successful');
|
|
} else {
|
|
debugPrint('Sync Push Failed: ${response.body}');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Sync Push Error: $e');
|
|
}
|
|
}
|
|
|
|
// Pull data from backend
|
|
Future<Map<String, dynamic>> pullSyncData(String userId,
|
|
{String? partnerId}) async {
|
|
try {
|
|
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'] ?? [];
|
|
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 {
|
|
'entries': <CycleEntry>[],
|
|
'teachingPlans': <TeachingPlan>[],
|
|
'prayerRequests': <PrayerRequest>[],
|
|
};
|
|
}
|
|
|
|
// --- Adapters ---
|
|
|
|
Map<String, dynamic> _cycleEntryToJson(CycleEntry entry) {
|
|
// Convert boolean symptoms to list of strings
|
|
final symptomsList = <String>[];
|
|
if (entry.hasHeadache) symptomsList.add('headache');
|
|
if (entry.hasBloating) symptomsList.add('bloating');
|
|
if (entry.hasBreastTenderness) symptomsList.add('breastTenderness');
|
|
if (entry.hasFatigue) symptomsList.add('fatigue');
|
|
if (entry.hasAcne) symptomsList.add('acne');
|
|
if (entry.hasLowerBackPain) symptomsList.add('lowerBackPain');
|
|
if (entry.hasConstipation) symptomsList.add('constipation');
|
|
if (entry.hasDiarrhea) symptomsList.add('diarrhea');
|
|
if (entry.hasInsomnia) symptomsList.add('insomnia');
|
|
|
|
return {
|
|
'id': entry.id,
|
|
'date': entry.date.toIso8601String(),
|
|
'flowIntensity': entry.flowIntensity?.name,
|
|
'isPeriodDay': entry.isPeriodDay,
|
|
'symptoms': jsonEncode(symptomsList),
|
|
'moods': jsonEncode(entry.mood != null ? [entry.mood!.name] : []),
|
|
'notes': entry.notes,
|
|
'createdAt': entry.createdAt.toIso8601String(),
|
|
'updatedAt': entry.updatedAt.toIso8601String(),
|
|
};
|
|
}
|
|
|
|
CycleEntry _jsonToCycleEntry(Map<String, dynamic> json) {
|
|
// FlowIntensity enum parsing
|
|
FlowIntensity? flow;
|
|
if (json['flowIntensity'] != null) {
|
|
flow = FlowIntensity.values.firstWhere(
|
|
(e) => e.name == json['flowIntensity'],
|
|
orElse: () => FlowIntensity.medium,
|
|
);
|
|
}
|
|
|
|
// Mood parsing
|
|
MoodLevel? mood;
|
|
final moodsList = _parseList(json['moods']);
|
|
if (moodsList.isNotEmpty) {
|
|
try {
|
|
mood = MoodLevel.values.firstWhere((e) => e.name == moodsList.first);
|
|
} catch (_) {}
|
|
}
|
|
|
|
final symptoms = _parseList(json['symptoms']);
|
|
|
|
return CycleEntry(
|
|
id: json['id'],
|
|
date: DateTime.parse(json['date']),
|
|
flowIntensity: flow,
|
|
isPeriodDay: json['isPeriodDay'] == true,
|
|
mood: mood,
|
|
hasHeadache: symptoms.contains('headache'),
|
|
hasBloating: symptoms.contains('bloating'),
|
|
hasBreastTenderness: symptoms.contains('breastTenderness'),
|
|
hasFatigue: symptoms.contains('fatigue'),
|
|
hasAcne: symptoms.contains('acne'),
|
|
hasLowerBackPain: symptoms.contains('lowerBackPain'),
|
|
hasConstipation: symptoms.contains('constipation'),
|
|
hasDiarrhea: symptoms.contains('diarrhea'),
|
|
hasInsomnia: symptoms.contains('insomnia'),
|
|
notes: json['notes'],
|
|
createdAt: json['createdAt'] != null
|
|
? DateTime.parse(json['createdAt'])
|
|
: DateTime.now(),
|
|
updatedAt: json['updatedAt'] != null
|
|
? DateTime.parse(json['updatedAt'])
|
|
: DateTime.now(),
|
|
);
|
|
}
|
|
|
|
// 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) {
|
|
try {
|
|
return List<String>.from(jsonDecode(jsonVal));
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
return List<String>.from(jsonVal);
|
|
}
|
|
}
|