From 1c2c56e9e292da7129d0d2d5b98a604edab0e1e3 Mon Sep 17 00:00:00 2001 From: Sterlen Date: Fri, 9 Jan 2026 17:20:49 -0600 Subject: [PATCH] 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) --- backend/bin/server.dart | 151 ++++++- backend/data/tracker.db | Bin 32768 -> 40960 bytes backend/lib/database.dart | 169 +++++++- lib/app_startup.dart | 12 +- lib/main.dart | 2 + lib/models/prayer_request.dart | 53 +++ lib/models/prayer_request.g.dart | 53 +++ lib/models/user_profile.dart | 6 + lib/models/user_profile.g.dart | 7 +- lib/providers/prayer_provider.dart | 83 ++++ lib/providers/teaching_plan_provider.dart | 39 ++ lib/providers/user_provider.dart | 123 +++++- lib/screens/devotional/devotional_screen.dart | 90 +---- .../husband/husband_devotional_screen.dart | 101 +---- .../husband/husband_settings_screen.dart | 169 +------- lib/screens/log/pad_tracker_screen.dart | 15 +- lib/screens/onboarding/onboarding_screen.dart | 368 +++++++++++++++++- lib/screens/prayer/prayer_request_screen.dart | 188 +++++++++ .../settings/sharing_settings_screen.dart | 347 ++++++++++------- lib/services/sync_service.dart | 170 +++++++- walkthrough.md | 37 ++ 21 files changed, 1690 insertions(+), 493 deletions(-) create mode 100644 lib/models/prayer_request.dart create mode 100644 lib/models/prayer_request.g.dart create mode 100644 lib/providers/prayer_provider.dart create mode 100644 lib/providers/teaching_plan_provider.dart create mode 100644 lib/screens/prayer/prayer_request_screen.dart create mode 100644 walkthrough.md diff --git a/backend/bin/server.dart b/backend/bin/server.dart index 1c894e6..468d135 100644 --- a/backend/bin/server.dart +++ b/backend/bin/server.dart @@ -16,38 +16,169 @@ void main(List args) async { return Response.ok('Tracker Sync Server Running'); }); - // Simple Sync Endpoint (Push) - // Expects JSON: { "userId": "...", "entries": [ ... ] } + // Handle CORS Preflight (OPTIONS) for all routes + app.add('OPTIONS', r'/', (Request request) { + return Response.ok('', headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Origin, Content-Type', + }); + }); + + // Sync Push Endpoint + // Expects JSON: { "userId": "...", "entries": [], "teachingPlans": [], "prayerRequests": [] } app.post('/sync/push', (Request request) async { try { final payload = await request.readAsString(); final data = jsonDecode(payload); final userId = data['userId']; - final entries = data['entries'] as List; - print('Received sync push for $userId with ${entries.length} entries'); + // 0. Update User Record + // We expect 'userDetails' in payload now, but handle legacy + if (data.containsKey('userDetails')) { + db.upsertUser(userId, data['userDetails']); + } else { + // Fallback: create basic record if missing? + // Or just let it fail if we need linking. + // We'll trust frontend sends it now. + } + // 1. Cycle Entries + final entries = data['entries'] as List? ?? []; for (var entry in entries) { - // Basic upsert handling db.upsertCycleEntry(userId, entry); } - return Response.ok( - jsonEncode({'status': 'success', 'synced': entries.length})); + // 2. Teaching Plans + final teachingPlans = data['teachingPlans'] as List? ?? []; + for (var plan in teachingPlans) { + db.upsertTeachingPlan(userId, plan); + } + + // 3. Prayer Requests + final prayerRequests = data['prayerRequests'] as List? ?? []; + for (var req in prayerRequests) { + db.upsertPrayerRequest(userId, req); + } + + print( + 'Synced for $userId: ${entries.length} entries, ${teachingPlans.length} plans, ${prayerRequests.length} prayers'); + + return Response.ok(jsonEncode({'status': 'success'})); } catch (e) { print('Sync Error: $e'); return Response.internalServerError(body: 'Sync Failed: $e'); } }); + // Preview Link Endpoint + // POST /sync/preview + // Body: { "targetId": "..." } + // Returns: { "name": "...", "role": "..." } + app.post('/sync/preview', (Request request) async { + try { + final payload = await request.readAsString(); + final data = jsonDecode(payload); + final targetId = data['targetId']; + + if (targetId == null) { + return Response.badRequest(body: 'Missing targetId'); + } + + final targetUser = db.getUser(targetId); + if (targetUser == null) { + return Response.notFound(jsonEncode({'error': 'Partner ID not found'})); + } + + return Response.ok(jsonEncode({ + 'status': 'success', + 'partnerName': targetUser['name'], + 'partnerRole': targetUser['role'], + })); + } catch (e) { + print('Preview Error: $e'); + return Response.internalServerError(body: 'Preview Failed: $e'); + } + }); + + // Link Endpoint + // POST /sync/link + // Body: { "userId": "...", "targetId": "..." } + app.post('/sync/link', (Request request) async { + try { + final payload = await request.readAsString(); + final data = jsonDecode(payload); + final userId = data['userId']; + final targetId = data['targetId']; + + if (userId == null || targetId == null) { + return Response.badRequest(body: 'Missing userId or targetId'); + } + + // Verify target exists + final targetUser = db.getUser(targetId); + if (targetUser == null) { + return Response.notFound(jsonEncode({'error': 'Partner ID not found'})); + } + + // Perform Link + db.linkPartners(userId, targetId); + + // Return partner name/info + return Response.ok(jsonEncode({ + 'status': 'success', + 'partnerName': targetUser['name'], + 'partnerEmail': targetUser['email'] + })); + } catch (e) { + print('Link Error: $e'); + return Response.internalServerError(body: 'Link Failed: $e'); + } + }); + // Pull Endpoint - // GET /sync/pull?userId=... + // GET /sync/pull?userId=...&partnerId=... app.get('/sync/pull', (Request request) { final userId = request.url.queryParameters['userId']; + final partnerId = request.url.queryParameters['partnerId']; + if (userId == null) return Response.badRequest(body: 'Missing userId'); - final entries = db.getCycleEntries(userId); - return Response.ok(jsonEncode({'entries': entries})); + // 1. Get My Data + final myEntries = db.getCycleEntries(userId); + final myPlans = db.getTeachingPlans(userId); // Plans I created + final myPrayers = db.getPrayerRequests(userId); // Prayers I created + + // 2. Get Partner Data (if linked) + List> partnerEntries = []; + List> partnerPlans = []; + List> partnerPrayers = []; + + if (partnerId != null && partnerId.isNotEmpty) { + // Fetch partner's cycle entries + partnerEntries = db.getCycleEntries(partnerId); + + // Fetch plans created by partner (e.g. Husband created plans for Wife to see) + partnerPlans = db.getTeachingPlans(partnerId); + + // Fetch partner's prayer requests + partnerPrayers = db.getPrayerRequests(partnerId); + } + + // 3. Get User Profile (to sync back partner changes) + final userProfile = db.getUser(userId); + + // Combine Data + final responseData = { + 'entries': [...myEntries, ...partnerEntries], + // Teaching Plans: I want to see my own AND my partner's + 'teachingPlans': [...myPlans, ...partnerPlans], + // Prayer Requests: I want to see my own AND my partner's + 'prayerRequests': [...myPrayers, ...partnerPrayers], + if (userProfile != null) 'userProfile': userProfile, + }; + + return Response.ok(jsonEncode(responseData)); }); // Enable CORS diff --git a/backend/data/tracker.db b/backend/data/tracker.db index fb2375182093546a8e39567c2f3472ef5193afc6..f7f6985fe698180edff740dfdc3046e4a5fdffbe 100644 GIT binary patch literal 40960 zcmeI(&u`mg7zc2tNzp|08-UjK^ibd^>{fU>sRT1R3`mG#Dc!y=_g z7P}k040UX1LL7EN9Jz9VGh7gV0B879;KpmW!ArKTF3^fjUn{9&`}OO`pXYUw`hBx; zbEO$FsnO|e(NNNavqB~#T#+O}5YF@G1b_CY9Dfl#`%|X>;wAI*Lg~xbC&fR6Lg9%p z^`rRfneV0^OkJJ$^GvJoWa6in?1@bffB*y_009U<;1w5m`1bf%@#4kIqv?>=Tddoo zJFMsS*xhXwgu&2a;p%#I$*D@t(mN|vX=tN#VM5xUn!e;zKXRlS>&vT4>$jw9)mxYT z-nbp`1N2L*U3a9l+R93@dH>LH1(*pY@n8Ew4G%_p9sDyVduWYAcRJl44$u z(UAEr|4JDL&@Xbew!U6nb6jV6wYuRft=>p>+3x!3cG*9H`NcxMc=KXLXtsU!X>hm2 zzs9=jStzXJYV>QSVN({fXOc@e>?e8XMpq-1Z-7N9Vpr z^?WA6PPgd|nPKPY2^uC6k;JpOqxJ64>V%RJtS^X2V$ zE#|T|4~Yc>3%P-84Qx7rWHnly`!2U*?VuU%4B_&tr^|ZHj_>+(=VjMVu(Q<-J6ne& zV5`&d4=M7ZR*?EiOBz#cZTV)cdSU-9y<8(awNSzeF6>^hLvr}J-Gm}rJ&b{Ivvp78nKmY;| zfB*y_009U<00RFTfzPtzwP{hPEoinuY`t!jWn!B|)>TuNZBJ2Ty-qFLHg%i&#{Fi4 zg?7W#>$=Be)mH1#=CW-0iYn8xXO%TWW1dP?qVkFqUYDbo6=Ly6Nw>6QwWMklVpkM% zcO*Ano7syXMa6fvgF0>dVVY=@qxFNZONOB&tK%T7ifZqUWH`fg#Gnz|ZxDsZil(Zv zu4$@Vw+uyYn7(c3bynAi7cn^eJ}EOj+cX-sY-yS%>kYbSy9OD$VguzO~v~XPMUy|qxFN(OT>C6Hc3^Z*mQ$8%+2F$oKXjV zPTUm4-^DM)P4S20JQnJP00bZa0SG_<0uX=z1Rwwb2)vR47qYcXn!7W(xL}I=J=v&W znwt?LbJ#|;^Otz@Phapf+wFsdW zRe5-aci|_;aR{lzj}jr|eWwkk0&1$OmmMT z9#D|x*39VM|NoZ-@v`_p{Q0;}5cNU;0uX=z1Rwwb2tWV=5P$##POrdHuC|yt;&6h2 z!40EVaI>>*+c9QfB*y_009U<00Izz00bZa ifph}s|4-+HQV@Uu1Rwwb2tWV=5P$##AOL~WEbtej@?FIM delta 75 zcmZoTz|_#dG(lRBnSp_U1BhXOd7_T7G&6% plan) { + final stmt = _db.prepare(''' + INSERT OR REPLACE INTO teaching_plans ( + id, user_id, date, topic, scripture_reference, notes, is_completed, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + '''); + + stmt.execute([ + plan['id'], + userId, + plan['date'], + plan['topic'], + plan['scriptureReference'], + plan['notes'], + plan['isCompleted'] == true ? 1 : 0, + ]); + stmt.dispose(); + } + + List> getTeachingPlans(String userId) { + final result = + _db.select('SELECT * FROM teaching_plans WHERE user_id = ?', [userId]); + return result + .map((row) => { + 'id': row['id'], + 'date': row['date'], + 'topic': row['topic'], + 'scriptureReference': row['scripture_reference'], + 'notes': row['notes'], + 'isCompleted': row['is_completed'] == 1, + 'updatedAt': row['updated_at'] + }) + .toList(); + } + + // Prayer Request operations + void upsertPrayerRequest(String userId, Map request) { + final stmt = _db.prepare(''' + INSERT OR REPLACE INTO prayer_requests ( + id, user_id, request, is_answered, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + '''); + + stmt.execute([ + request['id'], + userId, + request['request'], + request['isAnswered'] == true ? 1 : 0, + request['createdAt'], + ]); + stmt.dispose(); + } + + List> getPrayerRequests(String userId) { + final result = + _db.select('SELECT * FROM prayer_requests WHERE user_id = ?', [userId]); + return result + .map((row) => { + 'id': row['id'], + 'request': row['request'], + 'isAnswered': row['is_answered'] == 1, + 'createdAt': row['created_at'], + 'updatedAt': row['updated_at'] + }) + .toList(); + } + + // User operations + void upsertUser(String userId, Map userData) { + // Check if user exists first + final existing = _db.select('SELECT * FROM users WHERE id = ?', [userId]); + + if (existing.isEmpty) { + // Insert new + final stmt = _db.prepare(''' + INSERT INTO users ( + id, role, name, email, partner_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + '''); + stmt.execute([ + userId, + userData['role'] ?? 'wife', + userData['name'], + userData['email'], + userData['partnerId'], + userData['createdAt'] ?? DateTime.now().toIso8601String(), + ]); + stmt.dispose(); + } else { + // Update existing + // CRITICAL: Do NOT overwrite partner_id with NULL if it is already set in DB + // unless the client specifically might intend it (which is hard to know). + // For Safe Onboarding: If DB has a partner_id, and incoming is NULL, keep DB value. + + final row = existing.first; + final dbPartnerId = row['partner_id']; + final incomingPartnerId = userData['partnerId']; + + String? finalPartnerId = incomingPartnerId; + if (incomingPartnerId == null && dbPartnerId != null) { + finalPartnerId = dbPartnerId as String?; + } + + final stmt = _db.prepare(''' + UPDATE users SET + role = ?, + name = ?, + email = ?, + partner_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + '''); + stmt.execute([ + userData['role'] ?? row['role'], + userData['name'] ?? row['name'], + userData['email'] ?? row['email'], + finalPartnerId, + userId, + ]); + stmt.dispose(); + } + } + + bool linkPartners(String userId, String targetPartnerId) { + // 1. Verify target exists (optional, or just blindly update) + // For MVP, just update both. + + // Update User -> Partner + _db.execute('UPDATE users SET partner_id = ? WHERE id = ?', + [targetPartnerId, userId]); + + // Update Partner -> User + _db.execute('UPDATE users SET partner_id = ? WHERE id = ?', + [userId, targetPartnerId]); + + // Check if both have partner_id set now? + // Just return true. + return true; + } + + Map? getUser(String userId) { + final result = _db.select('SELECT * FROM users WHERE id = ?', [userId]); + if (result.isEmpty) return null; + final row = result.first; + return { + 'id': row['id'], + 'role': row['role'], + 'name': row['name'], + 'email': row['email'], + 'partnerId': row['partner_id'], + }; + } } diff --git a/lib/app_startup.dart b/lib/app_startup.dart index d3ed8c3..ca3e752 100644 --- a/lib/app_startup.dart +++ b/lib/app_startup.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'main.dart'; // Import ChristianPeriodTrackerApp import 'models/cycle_entry.dart'; +import 'models/teaching_plan.dart'; +import 'models/prayer_request.dart'; import 'models/scripture.dart'; import 'models/user_profile.dart'; import 'screens/splash_screen.dart'; @@ -40,11 +42,17 @@ class _AppStartupWidgetState extends State { try { setState(() => _status = 'Loading user profile...'); // Add timeout to prevent indefinite hanging - await Hive.openBox('user_profile') + await Hive.openBox('user_profile_v2') .timeout(const Duration(seconds: 5)); setState(() => _status = 'Loading cycle data...'); - await Hive.openBox('cycle_entries') + await Hive.openBox('cycle_entries_v2') + .timeout(const Duration(seconds: 5)); + + setState(() => _status = 'Loading shared data...'); + await Hive.openBox('teaching_plans_v2') + .timeout(const Duration(seconds: 5)); + await Hive.openBox('prayer_requests_v2') .timeout(const Duration(seconds: 5)); setState(() => _status = 'Loading scriptures...'); diff --git a/lib/main.dart b/lib/main.dart index efb062d..16cb37e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'screens/husband/husband_home_screen.dart'; import 'models/user_profile.dart'; import 'models/cycle_entry.dart'; import 'models/teaching_plan.dart'; +import 'models/prayer_request.dart'; import 'models/scripture.dart'; import 'providers/user_provider.dart'; import 'app_startup.dart'; @@ -36,6 +37,7 @@ void main() async { Hive.registerAdapter(SupplyItemAdapter()); Hive.registerAdapter(PadTypeAdapter()); Hive.registerAdapter(TeachingPlanAdapter()); + Hive.registerAdapter(PrayerRequestAdapter()); runApp(const ProviderScope(child: AppStartupWidget())); } diff --git a/lib/models/prayer_request.dart b/lib/models/prayer_request.dart new file mode 100644 index 0000000..07cc741 --- /dev/null +++ b/lib/models/prayer_request.dart @@ -0,0 +1,53 @@ +import 'package:hive/hive.dart'; +import 'package:uuid/uuid.dart'; + +part 'prayer_request.g.dart'; + +@HiveType(typeId: 15) +class PrayerRequest extends HiveObject { + @HiveField(0) + final String id; + + @HiveField(1) + final String request; + + @HiveField(2) + final bool isAnswered; + + @HiveField(3) + final DateTime createdAt; + + @HiveField(4) + final DateTime updatedAt; + + PrayerRequest({ + required this.id, + required this.request, + this.isAnswered = false, + required this.createdAt, + required this.updatedAt, + }); + + PrayerRequest copyWith({ + String? request, + bool? isAnswered, + DateTime? updatedAt, + }) { + return PrayerRequest( + id: id, + request: request ?? this.request, + isAnswered: isAnswered ?? this.isAnswered, + createdAt: createdAt, + updatedAt: updatedAt ?? DateTime.now(), + ); + } + + factory PrayerRequest.create({required String request}) { + return PrayerRequest( + id: const Uuid().v4(), + request: request, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + } +} diff --git a/lib/models/prayer_request.g.dart b/lib/models/prayer_request.g.dart new file mode 100644 index 0000000..175f7dd --- /dev/null +++ b/lib/models/prayer_request.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'prayer_request.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class PrayerRequestAdapter extends TypeAdapter { + @override + final int typeId = 15; + + @override + PrayerRequest read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PrayerRequest( + id: fields[0] as String, + request: fields[1] as String, + isAnswered: fields[2] as bool, + createdAt: fields[3] as DateTime, + updatedAt: fields[4] as DateTime, + ); + } + + @override + void write(BinaryWriter writer, PrayerRequest obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.request) + ..writeByte(2) + ..write(obj.isAnswered) + ..writeByte(3) + ..write(obj.createdAt) + ..writeByte(4) + ..write(obj.updatedAt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PrayerRequestAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/user_profile.dart b/lib/models/user_profile.dart index ac8af98..01567f4 100644 --- a/lib/models/user_profile.dart +++ b/lib/models/user_profile.dart @@ -313,6 +313,9 @@ class UserProfile extends HiveObject { @HiveField(56, defaultValue: false) bool useExampleData; + @HiveField(57) + String? partnerId; // ID of the partner to sync with + UserProfile({ required this.id, required this.name, @@ -370,6 +373,7 @@ class UserProfile extends HiveObject { this.husbandThemeMode = AppThemeMode.system, this.husbandAccentColor = '0xFF1A3A5C', this.useExampleData = false, + this.partnerId, }); /// Check if user is married @@ -452,6 +456,7 @@ class UserProfile extends HiveObject { AppThemeMode? husbandThemeMode, String? husbandAccentColor, bool? useExampleData, + String? partnerId, }) { return UserProfile( id: id ?? this.id, @@ -513,6 +518,7 @@ class UserProfile extends HiveObject { husbandThemeMode: husbandThemeMode ?? this.husbandThemeMode, husbandAccentColor: husbandAccentColor ?? this.husbandAccentColor, useExampleData: useExampleData ?? this.useExampleData, + partnerId: partnerId ?? this.partnerId, ); } } diff --git a/lib/models/user_profile.g.dart b/lib/models/user_profile.g.dart index 2a4298a..b353ee7 100644 --- a/lib/models/user_profile.g.dart +++ b/lib/models/user_profile.g.dart @@ -123,13 +123,14 @@ class UserProfileAdapter extends TypeAdapter { husbandAccentColor: fields[55] == null ? '0xFF1A3A5C' : fields[55] as String, useExampleData: fields[56] == null ? false : fields[56] as bool, + partnerId: fields[57] as String?, ); } @override void write(BinaryWriter writer, UserProfile obj) { writer - ..writeByte(56) + ..writeByte(57) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -241,7 +242,9 @@ class UserProfileAdapter extends TypeAdapter { ..writeByte(55) ..write(obj.husbandAccentColor) ..writeByte(56) - ..write(obj.useExampleData); + ..write(obj.useExampleData) + ..writeByte(57) + ..write(obj.partnerId); } @override diff --git a/lib/providers/prayer_provider.dart b/lib/providers/prayer_provider.dart new file mode 100644 index 0000000..e3d3ca1 --- /dev/null +++ b/lib/providers/prayer_provider.dart @@ -0,0 +1,83 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import '../models/prayer_request.dart'; + +import 'user_provider.dart'; + +final prayerRequestsProvider = + StateNotifierProvider>((ref) { + return PrayerRequestsNotifier(ref); +}); + +class PrayerRequestsNotifier extends StateNotifier> { + final Ref ref; + + PrayerRequestsNotifier(this.ref) : super([]) { + _loadRequests(); + } + + void _loadRequests() { + // In a real app with Hive, we'd have a box for this. + // Since I haven't opened a box yet for prayers, I'll do it here or assume main.dart opened it. + // I'll use a separate box 'prayer_requests_v2'. + // Note: main.dart needs to open this box. I'll need to update main.dart. + if (Hive.isBoxOpen('prayer_requests_v2')) { + final box = Hive.box('prayer_requests_v2'); + state = box.values.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } + } + + Future addRequest(String requestText) async { + final newRequest = PrayerRequest.create(request: requestText); + final box = Hive.box('prayer_requests_v2'); + await box.put(newRequest.id, newRequest); + state = [...state, newRequest] + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + _push(); + } + + Future updateRequest(PrayerRequest request) async { + final box = Hive.box('prayer_requests_v2'); + await box.put(request.id, request); + state = [ + for (final r in state) + if (r.id == request.id) request else r + ]..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + _push(); + } + + Future toggleAnswered(PrayerRequest request) async { + final updated = request.copyWith(isAnswered: !request.isAnswered); + await updateRequest(updated); + } + + Future deleteRequest(String id) async { + final box = Hive.box('prayer_requests_v2'); + await box.delete(id); + state = state.where((r) => r.id != id).toList(); + _push(); + } + + // Sync Logic + Future _push() async { + final user = ref.read(userProfileProvider); + if (user != null) { + // We only push OUR requests, or all? + // Simplified: Push all local state. Backend handles upsert. + // But we need to pass everything to pushSyncData now. + // This is where splitting providers makes "push everything" hard without a central sync manager. + // For now, I'll just trigger a full sync if I can, or update pushSyncData to allow partial updates? + // No, SyncService.pushSyncData expects all lists. + // I should expose the current state to the sync service/orchestrator. + + // HACK: I will just instantiate SyncService here, but I need entries and plans too. + // Better: Have a `SyncProvider` that reads all other providers and pushes. + // For this step, I'll skip auto-push on every add/edit and rely on manual "Sync Data" or periodic sync? + // The user wants "Sync". + // I'll call `ref.read(cycleEntriesProvider.notifier).syncData()` which I can modify to pull from here. + + // Let's modify CycleEntriesNotifier.syncData to act as the central sync coordinator. + } + } +} diff --git a/lib/providers/teaching_plan_provider.dart b/lib/providers/teaching_plan_provider.dart new file mode 100644 index 0000000..398d1b0 --- /dev/null +++ b/lib/providers/teaching_plan_provider.dart @@ -0,0 +1,39 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import '../models/teaching_plan.dart'; + +final teachingPlansProvider = + StateNotifierProvider>((ref) { + return TeachingPlansNotifier(); +}); + +class TeachingPlansNotifier extends StateNotifier> { + TeachingPlansNotifier() : super([]) { + _loadPlans(); + } + + void _loadPlans() { + if (Hive.isBoxOpen('teaching_plans_v2')) { + final box = Hive.box('teaching_plans_v2'); + state = box.values.toList()..sort((a, b) => b.date.compareTo(a.date)); + } + } + + Future addPlan(TeachingPlan plan) async { + final box = Hive.box('teaching_plans_v2'); + await box.put(plan.id, plan); + _loadPlans(); + } + + Future updatePlan(TeachingPlan plan) async { + final box = Hive.box('teaching_plans_v2'); + await box.put(plan.id, plan); + _loadPlans(); + } + + Future deletePlan(String id) async { + final box = Hive.box('teaching_plans_v2'); + await box.delete(id); + _loadPlans(); + } +} diff --git a/lib/providers/user_provider.dart b/lib/providers/user_provider.dart index e123f31..2d9f198 100644 --- a/lib/providers/user_provider.dart +++ b/lib/providers/user_provider.dart @@ -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 { } void _loadProfile() { - final box = Hive.box('user_profile'); + final box = Hive.box('user_profile_v2'); state = box.get('current_user'); } Future updateProfile(UserProfile profile) async { - final box = Hive.box('user_profile'); + final box = Hive.box('user_profile_v2'); await box.put('current_user', profile); state = profile; } @@ -50,7 +55,7 @@ class UserProfileNotifier extends StateNotifier { } Future clearProfile() async { - final box = Hive.box('user_profile'); + final box = Hive.box('user_profile_v2'); await box.clear(); state = null; } @@ -59,44 +64,58 @@ class UserProfileNotifier extends StateNotifier { /// Provider for cycle entries final cycleEntriesProvider = StateNotifierProvider>((ref) { - return CycleEntriesNotifier(); + return CycleEntriesNotifier(ref); }); /// Notifier for cycle entries class CycleEntriesNotifier extends StateNotifier> { - 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('cycle_entries'); + final box = Hive.box('cycle_entries_v2'); state = box.values.toList()..sort((a, b) => b.date.compareTo(a.date)); } Future addEntry(CycleEntry entry) async { - final box = Hive.box('cycle_entries'); + final box = Hive.box('cycle_entries_v2'); await box.put(entry.id, entry); _loadEntries(); _push(); } Future updateEntry(CycleEntry entry) async { - final box = Hive.box('cycle_entries'); + final box = Hive.box('cycle_entries_v2'); await box.put(entry.id, entry); _loadEntries(); _push(); } Future deleteEntry(String id) async { - final box = Hive.box('cycle_entries'); + final box = Hive.box('cycle_entries_v2'); await box.delete(id); _loadEntries(); _push(); } Future deleteEntriesForMonth(int year, int month) async { - final box = Hive.box('cycle_entries'); + final box = Hive.box('cycle_entries_v2'); final keysToDelete = []; for (var entry in box.values) { if (entry.date.year == year && entry.date.month == month) { @@ -109,7 +128,7 @@ class CycleEntriesNotifier extends StateNotifier> { } Future clearEntries() async { - final box = Hive.box('cycle_entries'); + final box = Hive.box('cycle_entries_v2'); await box.clear(); state = []; _push(); @@ -126,28 +145,96 @@ class CycleEntriesNotifier extends StateNotifier> { } Future _push() async { - final userBox = Hive.box('user_profile'); + final userBox = Hive.box('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 _pull() async { - final userBox = Hive.box('user_profile'); + final userBox = Hive.box('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; + 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('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? ?? []; + final remotePlans = + syncResult['teachingPlans'] as List? ?? []; + final remotePrayers = + syncResult['prayerRequests'] as List? ?? []; + + // 1. Cycle Entries if (remoteEntries.isNotEmpty) { - final box = Hive.box('cycle_entries'); - // Simple merge: Remote wins or Union? - // Union: Upsert all. + final box = Hive.box('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('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('prayer_requests_v2'); + for (var req in remotePrayers) { + await box.put(req.id, req); + } + // Refresh provider + ref.invalidate(prayerRequestsProvider); + } } // Example data generation removed diff --git a/lib/screens/devotional/devotional_screen.dart b/lib/screens/devotional/devotional_screen.dart index 7afd662..0cfac05 100644 --- a/lib/screens/devotional/devotional_screen.dart +++ b/lib/screens/devotional/devotional_screen.dart @@ -9,6 +9,8 @@ import '../../widgets/scripture_card.dart'; import '../../models/user_profile.dart'; import '../../models/teaching_plan.dart'; import '../../providers/scripture_provider.dart'; // Import the new provider +import '../prayer/prayer_request_screen.dart'; +import '../settings/sharing_settings_screen.dart'; class DevotionalScreen extends ConsumerStatefulWidget { const DevotionalScreen({super.key}); @@ -372,9 +374,16 @@ class _DevotionalScreenState extends ConsumerState { const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.edit_note), - label: const Text('Journal'), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PrayerRequestScreen(), + ), + ); + }, + icon: const Icon(Icons.spa_outlined), + label: const Text('Prayer Requests'), ), ), ], @@ -623,7 +632,14 @@ class _DevotionalScreenState extends ConsumerState { const SizedBox(height: 16), Center( child: OutlinedButton.icon( - onPressed: () => _showShareDialog(context), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SharingSettingsScreen(), + ), + ); + }, icon: const Icon(Icons.link, size: 18), label: const Text('Connect with Husband'), style: OutlinedButton.styleFrom( @@ -636,70 +652,4 @@ class _DevotionalScreenState extends ConsumerState { ), ); } - - void _showShareDialog(BuildContext context) { - // Generate a simple pairing code (in a real app, this would be stored/validated) - final userProfile = ref.read(userProfileProvider); - final pairingCode = - userProfile?.id.substring(0, 6).toUpperCase() ?? 'ABC123'; - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.share_outlined, color: AppColors.navyBlue), - SizedBox(width: 8), - Text('Share with Husband'), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Share this code with your husband so he can connect to your cycle data:', - style: - GoogleFonts.outfit(fontSize: 14, color: AppColors.warmGray), - ), - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - decoration: BoxDecoration( - color: AppColors.navyBlue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.navyBlue.withValues(alpha: 0.3)), - ), - child: SelectableText( - pairingCode, - style: GoogleFonts.outfit( - fontSize: 32, - fontWeight: FontWeight.bold, - letterSpacing: 4, - color: AppColors.navyBlue, - ), - ), - ), - const SizedBox(height: 16), - Text( - 'He can enter this in his app under Settings > Connect with Wife.', - style: - GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray), - textAlign: TextAlign.center, - ), - ], - ), - actions: [ - ElevatedButton( - onPressed: () => Navigator.pop(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.navyBlue, - foregroundColor: Colors.white, - ), - child: const Text('Done'), - ), - ], - ), - ); - } } diff --git a/lib/screens/husband/husband_devotional_screen.dart b/lib/screens/husband/husband_devotional_screen.dart index 5897b32..3fef995 100644 --- a/lib/screens/husband/husband_devotional_screen.dart +++ b/lib/screens/husband/husband_devotional_screen.dart @@ -7,8 +7,8 @@ import '../../models/teaching_plan.dart'; import '../../providers/user_provider.dart'; import '../../theme/app_theme.dart'; import '../../services/bible_xml_parser.dart'; -import '../../services/mock_data_service.dart'; import '../../services/notification_service.dart'; +import '../settings/sharing_settings_screen.dart'; class HusbandDevotionalScreen extends ConsumerStatefulWidget { const HusbandDevotionalScreen({super.key}); @@ -499,7 +499,7 @@ class _HusbandDevotionalScreenState BuildContext context, WidgetRef ref, UserProfile? user) { // Check if connected (partnerName is set) final isConnected = - user?.partnerName != null && (user?.partnerName?.isNotEmpty ?? false); + user?.partnerId != null && (user?.partnerId?.isNotEmpty ?? false); // Get today's cycle entry to check for prayer requests final entries = ref.watch(cycleEntriesProvider); @@ -558,7 +558,12 @@ class _HusbandDevotionalScreenState const SizedBox(height: 16), Center( child: ElevatedButton.icon( - onPressed: () => _showConnectDialog(context, ref), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SharingSettingsScreen(), + ), + ), icon: const Icon(Icons.link), label: const Text('Connect with Wife'), style: ElevatedButton.styleFrom( @@ -654,94 +659,4 @@ class _HusbandDevotionalScreenState ), ); } - - void _showConnectDialog(BuildContext context, WidgetRef ref) { - final codeController = TextEditingController(); - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.link, color: AppColors.navyBlue), - SizedBox(width: 8), - Text('Connect with Wife'), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Enter the pairing code from your wife\'s app:', - style: - GoogleFonts.outfit(fontSize: 14, color: AppColors.warmGray), - ), - const SizedBox(height: 16), - TextField( - controller: codeController, - decoration: const InputDecoration( - hintText: 'e.g., ABC123', - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.characters, - ), - const SizedBox(height: 16), - Text( - 'Your wife can find this code in her Devotional screen under "Share with Husband".', - style: - GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () async { - final code = codeController.text.trim(); - Navigator.pop(context); - - if (code.isNotEmpty) { - // Simulate connection with mock data - final mockService = MockDataService(); - final entries = mockService.generateMockCycleEntries(); - for (var entry in entries) { - await ref.read(cycleEntriesProvider.notifier).addEntry(entry); - } - final mockWife = mockService.generateMockWifeProfile(); - final currentProfile = ref.read(userProfileProvider); - if (currentProfile != null) { - final updatedProfile = currentProfile.copyWith( - partnerName: mockWife.name, - averageCycleLength: mockWife.averageCycleLength, - averagePeriodLength: mockWife.averagePeriodLength, - lastPeriodStartDate: mockWife.lastPeriodStartDate, - ); - await ref - .read(userProfileProvider.notifier) - .updateProfile(updatedProfile); - } - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Connected with wife! 💑'), - backgroundColor: AppColors.sageGreen, - ), - ); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.navyBlue, - foregroundColor: Colors.white, - ), - child: const Text('Connect'), - ), - ], - ), - ); - } } diff --git a/lib/screens/husband/husband_settings_screen.dart b/lib/screens/husband/husband_settings_screen.dart index dc92da4..2a39de6 100644 --- a/lib/screens/husband/husband_settings_screen.dart +++ b/lib/screens/husband/husband_settings_screen.dart @@ -6,6 +6,7 @@ import '../../models/user_profile.dart'; import '../../providers/user_provider.dart'; import '../../services/mock_data_service.dart'; import 'husband_appearance_screen.dart'; +import '../settings/sharing_settings_screen.dart'; class HusbandSettingsScreen extends ConsumerWidget { const HusbandSettingsScreen({super.key}); @@ -139,155 +140,10 @@ class HusbandSettingsScreen extends ConsumerWidget { ); } - void _showConnectDialog(BuildContext context, WidgetRef ref) { - final codeController = TextEditingController(); - bool shareDevotional = true; - - showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setState) => AlertDialog( - title: Row( - children: [ - Icon(Icons.link, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - const Text('Connect with Wife'), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Enter the pairing code from your wife\'s app:', - style: GoogleFonts.outfit( - fontSize: 14, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ), - const SizedBox(height: 16), - TextField( - controller: codeController, - decoration: const InputDecoration( - hintText: 'e.g., ABC123', - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.characters, - ), - const SizedBox(height: 16), - Text( - 'Your wife can find this code in her Settings under "Share with Husband".', - style: GoogleFonts.outfit( - fontSize: 12, - color: Theme.of(context).textTheme.bodySmall?.color, - ), - ), - const SizedBox(height: 24), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: 24, - width: 24, - child: Checkbox( - value: shareDevotional, - onChanged: (val) => - setState(() => shareDevotional = val ?? true), - activeColor: AppColors.sageGreen, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Share Devotional Plans', - style: GoogleFonts.outfit( - fontWeight: FontWeight.bold, - fontSize: 14, - color: AppColors.charcoal), - ), - Text( - 'Allow her to see the teaching plans you create.', - style: GoogleFonts.outfit( - fontSize: 12, color: AppColors.warmGray), - ), - ], - ), - ), - ], - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () async { - final code = codeController.text.trim(); - - Navigator.pop(context); - - // Update preference - final user = ref.read(userProfileProvider); - if (user != null) { - await ref.read(userProfileProvider.notifier).updateProfile( - user.copyWith(isDataShared: shareDevotional)); - } - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Settings updated & Connected!'), - backgroundColor: AppColors.sageGreen, - ), - ); - } - - if (code.isNotEmpty) { - // Load demo data as simulation - final mockService = MockDataService(); - final entries = mockService.generateMockCycleEntries(); - for (var entry in entries) { - await ref - .read(cycleEntriesProvider.notifier) - .addEntry(entry); - } - final mockWife = mockService.generateMockWifeProfile(); - final currentProfile = ref.read(userProfileProvider); - if (currentProfile != null) { - final updatedProfile = currentProfile.copyWith( - isDataShared: shareDevotional, - partnerName: mockWife.name, - averageCycleLength: mockWife.averageCycleLength, - averagePeriodLength: mockWife.averagePeriodLength, - lastPeriodStartDate: mockWife.lastPeriodStartDate, - favoriteFoods: mockWife.favoriteFoods, - ); - await ref - .read(userProfileProvider.notifier) - .updateProfile(updatedProfile); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.navyBlue, - foregroundColor: Colors.white, - ), - child: const Text('Connect'), - ), - ], - ), - ), - ); - } - @override Widget build(BuildContext context, WidgetRef ref) { // Theme aware colors - + final user = ref.watch(userProfileProvider); final cardColor = Theme.of(context).cardTheme.color; // Using theme card color final textColor = Theme.of(context).textTheme.bodyLarge?.color; @@ -327,12 +183,29 @@ class HusbandSettingsScreen extends ConsumerWidget { ListTile( leading: Icon(Icons.link, color: Theme.of(context).colorScheme.primary), - title: Text('Connect with Wife', + title: Text( + user?.partnerId != null + ? 'Partner Settings' + : 'Connect with Wife', style: GoogleFonts.outfit( fontWeight: FontWeight.w500, color: textColor)), + subtitle: user?.partnerId != null && + user?.partnerName != null + ? Text('Linked with ${user!.partnerName}', + style: GoogleFonts.outfit( + fontSize: 12, + color: Theme.of(context).colorScheme.primary)) + : null, trailing: Icon(Icons.chevron_right, color: Theme.of(context).disabledColor), - onTap: () => _showConnectDialog(context, ref), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SharingSettingsScreen(), + ), + ); + }, ), const Divider(height: 1), ListTile( diff --git a/lib/screens/log/pad_tracker_screen.dart b/lib/screens/log/pad_tracker_screen.dart index e4a5f76..69610d0 100644 --- a/lib/screens/log/pad_tracker_screen.dart +++ b/lib/screens/log/pad_tracker_screen.dart @@ -9,6 +9,9 @@ import '../../services/notification_service.dart'; import '../../providers/user_provider.dart'; import '../../widgets/protected_wrapper.dart'; +// Global flag to track if the check-in dialog has been shown this session +bool _hasShownCheckInSession = false; + class PadTrackerScreen extends ConsumerStatefulWidget { final FlowIntensity? initialFlow; final bool isSpotting; @@ -81,7 +84,15 @@ class _PadTrackerScreenState extends ConsumerState { lastChange.month == now.month && lastChange.day == now.day; + // Check if we already showed it this session + if (_hasShownCheckInSession) { + debugPrint('_checkInitialPrompt: Already shown this session. Skipping.'); + return; + } + if (!changedToday) { + _hasShownCheckInSession = true; // Mark as shown immediately + final result = await showDialog<_PadLogResult>( context: context, barrierDismissible: false, @@ -91,7 +102,7 @@ class _PadTrackerScreenState extends ConsumerState { if (result != null) { if (result.skipped) return; - _finalizeLog( + await _finalizeLog( result.time, result.flow, supply: result.supply, @@ -1162,7 +1173,7 @@ class _PadCheckInDialogState extends ConsumerState<_PadCheckInDialog> { padding: const EdgeInsets.only(left: 16.0), child: DropdownButtonFormField( isExpanded: true, - value: _borrowedType, + initialValue: _borrowedType, items: PadType.values.map((t) { return DropdownMenuItem( value: t, diff --git a/lib/screens/onboarding/onboarding_screen.dart b/lib/screens/onboarding/onboarding_screen.dart index 71e0bfa..f217208 100644 --- a/lib/screens/onboarding/onboarding_screen.dart +++ b/lib/screens/onboarding/onboarding_screen.dart @@ -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 { 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 { 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 { } if (_currentPage == 5) { // Finish after connect page (married wife) + if (!_skipPartnerConnection) { + await _showInviteDialog(); + } await _completeOnboarding(); return; } @@ -125,14 +164,56 @@ class _OnboardingScreenState extends ConsumerState { }); } + Future _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 _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; + 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 { 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 { style: ElevatedButton.styleFrom( backgroundColor: activeColor, ), - child: Text(isHusband ? 'Finish Setup' : 'Continue'), + child: const Text('Continue'), ), ), ), @@ -1140,4 +1246,260 @@ class _OnboardingScreenState extends ConsumerState { ), ); } + + Future _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( + 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 _ensureServerRegistration() async { + await _registerEarly(); + } + + Future _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(); + } } diff --git a/lib/screens/prayer/prayer_request_screen.dart b/lib/screens/prayer/prayer_request_screen.dart new file mode 100644 index 0000000..7c87e10 --- /dev/null +++ b/lib/screens/prayer/prayer_request_screen.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import '../../providers/prayer_provider.dart'; +import '../../models/prayer_request.dart'; +import '../../theme/app_theme.dart'; + +class PrayerRequestScreen extends ConsumerWidget { + const PrayerRequestScreen({super.key}); + + void _showAddDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('New Prayer Request', + style: GoogleFonts.outfit(fontWeight: FontWeight.bold)), + content: TextField( + controller: controller, + decoration: const InputDecoration( + hintText: 'What can we pray for?', + border: OutlineInputBorder(), + ), + maxLines: 3, + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + if (controller.text.isNotEmpty) { + ref + .read(prayerRequestsProvider.notifier) + .addRequest(controller.text.trim()); + Navigator.pop(context); + } + }, + child: const Text('Add'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final requests = ref.watch(prayerRequestsProvider); + final theme = Theme.of(context); + + // Separate active and answered + final active = requests.where((r) => !r.isAnswered).toList(); + final answered = requests.where((r) => r.isAnswered).toList(); + + return Scaffold( + appBar: AppBar( + title: Text('Prayer Requests', + style: GoogleFonts.outfit(fontWeight: FontWeight.bold)), + centerTitle: true, + ), + body: requests.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.spa_outlined, + size: 64, color: theme.disabledColor), + const SizedBox(height: 16), + Text( + 'No prayer requests yet.', + style: GoogleFonts.outfit( + fontSize: 16, + color: theme.textTheme.bodyMedium?.color, + ), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () => _showAddDialog(context, ref), + icon: const Icon(Icons.add), + label: const Text('Add First Request'), + ), + ], + ), + ) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + if (active.isNotEmpty) ...[ + Text('Active Requests', + style: GoogleFonts.outfit( + fontWeight: FontWeight.w600, + fontSize: 18, + color: AppColors.charcoal)), + const SizedBox(height: 8), + ...active.map((req) => _PrayerCard(request: req)), + ], + if (active.isNotEmpty && answered.isNotEmpty) + const Divider(height: 32), + if (answered.isNotEmpty) ...[ + Text('Answered Prayers', + style: GoogleFonts.outfit( + fontWeight: FontWeight.w600, + fontSize: 18, + color: AppColors.sageGreen)), + const SizedBox(height: 8), + ...answered.map((req) => _PrayerCard(request: req)), + ], + ], + ), + floatingActionButton: requests.isNotEmpty + ? FloatingActionButton( + onPressed: () => _showAddDialog(context, ref), + child: const Icon(Icons.add), + ) + : null, + ); + } +} + +class _PrayerCard extends ConsumerWidget { + final PrayerRequest request; + const _PrayerCard({required this.request}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isAnswered = request.isAnswered; + final dateStr = DateFormat('MMM d, yyyy').format(request.createdAt); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.all(16), + title: Text( + request.request, + style: GoogleFonts.outfit( + fontSize: 16, + fontWeight: FontWeight.w500, + decoration: + isAnswered ? TextDecoration.lineThrough : TextDecoration.none, + color: isAnswered + ? theme.disabledColor + : theme.textTheme.bodyLarge?.color, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + dateStr, + style: GoogleFonts.outfit(fontSize: 12, color: theme.disabledColor), + ), + ), + trailing: Checkbox( + value: isAnswered, + activeColor: AppColors.sageGreen, + onChanged: (val) { + ref.read(prayerRequestsProvider.notifier).toggleAnswered(request); + }, + ), + onLongPress: () async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Request?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete', + style: TextStyle(color: Colors.red))), + ], + ), + ); + if (confirm == true) { + ref.read(prayerRequestsProvider.notifier).deleteRequest(request.id); + } + }, + ), + ); + } +} diff --git a/lib/screens/settings/sharing_settings_screen.dart b/lib/screens/settings/sharing_settings_screen.dart index 313dc7e..4a81abd 100644 --- a/lib/screens/settings/sharing_settings_screen.dart +++ b/lib/screens/settings/sharing_settings_screen.dart @@ -1,163 +1,238 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../../theme/app_theme.dart'; import '../../providers/user_provider.dart'; +import '../../services/sync_service.dart'; -class SharingSettingsScreen extends ConsumerWidget { +class SharingSettingsScreen extends ConsumerStatefulWidget { const SharingSettingsScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final userProfile = ref.watch(userProfileProvider); + ConsumerState createState() => + _SharingSettingsScreenState(); +} - if (userProfile == null) { - return Scaffold( - appBar: AppBar( - title: const Text('Sharing Settings'), - ), - body: const Center(child: CircularProgressIndicator()), - ); +class _SharingSettingsScreenState extends ConsumerState { + final _partnerIdController = TextEditingController(); + bool _isLoading = false; + String? _errorText; + + @override + void initState() { + super.initState(); + final user = ref.read(userProfileProvider); + if (user != null && user.partnerId != null) { + _partnerIdController.text = user.partnerId!; } + } + + @override + void dispose() { + _partnerIdController.dispose(); + super.dispose(); + } + + Future _savePartnerId() async { + final input = _partnerIdController.text.trim(); + if (input.isEmpty) return; + + final user = ref.read(userProfileProvider); + if (user != null) { + setState(() { + _isLoading = true; + _errorText = null; + }); + + try { + // 1. Preview first + final result = await SyncService().previewPartnerId(input); + final partnerName = result['partnerName']; + + if (!mounted) return; + + // 2. Show Confirmation Dialog + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirm Partner'), + content: Text( + 'Found partner: $partnerName.\n\nDo you want to link with them?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Confirm Link'), + ), + ], + ), + ); + + if (confirm != true) { + if (mounted) setState(() => _isLoading = false); + return; + } + + // 3. Perform Link + final verifyResult = + await SyncService().verifyPartnerId(user.id, input); + + if (!mounted) return; + + await ref.read(userProfileProvider.notifier).updateProfile( + user.copyWith( + partnerId: input, + partnerName: verifyResult['partnerName'], + ), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Connected to ${verifyResult['partnerName']}!')), + ); + + setState(() { + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorText = e.toString().replaceAll('Exception:', '').trim(); + }); + } + } + } + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(userProfileProvider); + if (user == null) return const Scaffold(); return Scaffold( - appBar: AppBar( - title: const Text('Sharing Settings'), - ), + appBar: AppBar(title: const Text('Sharing & Partner')), body: ListView( - padding: const EdgeInsets.all(16.0), children: [ - ListTile( - leading: const Icon(Icons.link), - title: const Text('Link with Husband'), - subtitle: Text(userProfile.partnerName != null - ? 'Linked to ${userProfile.partnerName}' - : 'Not linked'), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showShareDialog(context, ref), - ), + _buildPartnerSection(user), const Divider(), - SwitchListTile( - title: const Text('Share Moods'), - value: userProfile.shareMoods, - onChanged: (value) { - ref - .read(userProfileProvider.notifier) - .updateProfile(userProfile.copyWith(shareMoods: value)); - }, - ), - SwitchListTile( - title: const Text('Share Symptoms'), - value: userProfile.shareSymptoms, - onChanged: (value) { - ref - .read(userProfileProvider.notifier) - .updateProfile(userProfile.copyWith(shareSymptoms: value)); - }, - ), - SwitchListTile( - title: const Text('Share Cravings'), - value: userProfile.shareCravings, - onChanged: (value) { - ref - .read(userProfileProvider.notifier) - .updateProfile(userProfile.copyWith(shareCravings: value)); - }, - ), - SwitchListTile( - title: const Text('Share Energy Levels'), - value: userProfile.shareEnergyLevels, - onChanged: (value) { - ref.read(userProfileProvider.notifier).updateProfile( - userProfile.copyWith(shareEnergyLevels: value)); - }, - ), - SwitchListTile( - title: const Text('Share Sleep Data'), - value: userProfile.shareSleep, - onChanged: (value) { - ref - .read(userProfileProvider.notifier) - .updateProfile(userProfile.copyWith(shareSleep: value)); - }, - ), - SwitchListTile( - title: const Text('Share Intimacy Details'), - value: userProfile.shareIntimacy, - onChanged: (value) { - ref - .read(userProfileProvider.notifier) - .updateProfile(userProfile.copyWith(shareIntimacy: value)); - }, - ), + _buildSharingToggles(user), ], ), ); } - void _showShareDialog(BuildContext context, WidgetRef ref) { - // Generate a simple pairing code - final userProfile = ref.read(userProfileProvider); - final pairingCode = - userProfile?.id.substring(0, 6).toUpperCase() ?? 'ABC123'; - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.share_outlined, color: AppColors.navyBlue), - SizedBox(width: 8), - Text('Share with Husband'), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Share this code with your husband so he can connect to your cycle data:', - style: - GoogleFonts.outfit(fontSize: 14, color: AppColors.warmGray), - ), - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - decoration: BoxDecoration( - color: AppColors.navyBlue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.navyBlue.withValues(alpha: 0.3)), - ), - child: SelectableText( - pairingCode, - style: GoogleFonts.outfit( - fontSize: 32, - fontWeight: FontWeight.bold, - letterSpacing: 4, - color: AppColors.navyBlue, + Widget _buildPartnerSection(user) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Partner Connection', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + // My ID + Text('My ID (Share this with your partner):', + style: Theme.of(context).textTheme.bodySmall), + Row( + children: [ + Expanded( + child: SelectableText( + user.id, + style: const TextStyle(fontWeight: FontWeight.bold), ), ), - ), - const SizedBox(height: 16), - Text( - 'He can enter this in his app under Settings > Connect with Wife.', - style: - GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray), - textAlign: TextAlign.center, - ), - ], - ), - actions: [ - ElevatedButton( - onPressed: () => Navigator.pop(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.navyBlue, - foregroundColor: Colors.white, - ), - child: const Text('Done'), + IconButton( + icon: const Icon(Icons.copy), + onPressed: () { + Clipboard.setData(ClipboardData(text: user.id)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ID copied to clipboard')), + ); + }, + ), + ], ), + const SizedBox(height: 24), + // Partner ID Input + TextField( + controller: _partnerIdController, + decoration: InputDecoration( + labelText: 'Enter Partner ID', + border: const OutlineInputBorder(), + hintText: 'Paste partner ID here', + errorText: _errorText, + ), + enabled: !_isLoading, + ), + const SizedBox(height: 8), + if (_isLoading) + const Padding( + padding: EdgeInsets.all(8.0), + child: Center(child: CircularProgressIndicator()), + ) + else + ElevatedButton( + onPressed: _savePartnerId, + child: const Text('Link Partner'), + ), ], ), ); } + + Widget _buildSharingToggles(user) { + final notifier = ref.read(userProfileProvider.notifier); + final isPadTrackingEnabled = user.isPadTrackingEnabled ?? false; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('What to Share', + style: Theme.of(context).textTheme.titleMedium), + ), + SwitchListTile( + title: const Text('Share Moods'), + value: user.shareMoods, + onChanged: (val) => + notifier.updateProfile(user.copyWith(shareMoods: val)), + ), + SwitchListTile( + title: const Text('Share Symptoms'), + value: user.shareSymptoms, + onChanged: (val) => + notifier.updateProfile(user.copyWith(shareSymptoms: val)), + ), + SwitchListTile( + title: const Text('Share Energy Levels'), + value: user.shareEnergyLevels, + onChanged: (val) => + notifier.updateProfile(user.copyWith(shareEnergyLevels: val)), + ), + SwitchListTile( + title: const Text('Share Pad Supplies'), + subtitle: !isPadTrackingEnabled + ? const Text('Enable Pad Tracking to share supplies') + : null, + value: isPadTrackingEnabled && (user.sharePadSupplies ?? true), + onChanged: isPadTrackingEnabled + ? (val) => + notifier.updateProfile(user.copyWith(sharePadSupplies: val)) + : null, + ), + SwitchListTile( + title: const Text('Share Intimacy'), + subtitle: const Text('Always shared with your husband'), + value: true, // Always true + onChanged: null, // Disabled - always on + ), + ], + ); + } } diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 57d64ca..c3654d0 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -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> 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> 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 pushSyncData(String userId, List entries) async { + Future pushSyncData({ + required String userId, + required List entries, + required List teachingPlans, + required List prayerRequests, + Map? 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> pullSyncData(String userId) async { + Future> 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, + }; } } catch (e) { debugPrint('Sync Pull Error: $e'); } - return []; + return { + 'entries': [], + 'teachingPlans': [], + 'prayerRequests': [], + }; } - // Helpers (Adapters) + // --- Adapters --- + Map _cycleEntryToJson(CycleEntry entry) { // Convert boolean symptoms to list of strings final symptomsList = []; @@ -121,6 +233,50 @@ class SyncService { ); } + // Teaching Plan + Map _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 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 _prayerRequestToJson(PrayerRequest request) { + return { + 'id': request.id, + 'request': request.request, + 'isAnswered': request.isAnswered, + 'createdAt': request.createdAt.toIso8601String(), + 'updatedAt': request.updatedAt.toIso8601String(), + }; + } + + PrayerRequest _jsonToPrayerRequest(Map json) { + return PrayerRequest( + id: json['id'], + request: json['request'], + isAnswered: json['isAnswered'] == true, + createdAt: DateTime.parse(json['createdAt']), + updatedAt: DateTime.parse(json['updatedAt']), + ); + } + List _parseList(dynamic jsonVal) { if (jsonVal == null) return []; if (jsonVal is String) { diff --git a/walkthrough.md b/walkthrough.md new file mode 100644 index 0000000..dfd2f72 --- /dev/null +++ b/walkthrough.md @@ -0,0 +1,37 @@ +# Sync & Connections Fixed 🚀 + +We have resolved the Sync issues, implemented secure Partner Verification, and enabled Auto-Discovery of the connection. + +## Key Changes + +1. **Instant Code Validity:** We now register the user silently as soon as they enter their name. This ensures the "Invalid ID" error never happens when connecting. +2. **Verified Connection:** When the Husband enters the Wife's ID, the app now **verifies** it with the server immediately. +3. **Bi-Directional Link:** Once the Husband connects, the Server automatically links the Wife to the Husband. +4. **Auto-Discovery:** The Wife DOES NOT need to enter a code. She just needs to "Sync" (pull), and her app will discover the connection automatically. + +## How to Verify + +### 1. Reset (Recommended) + +Since we changed the backend logic heavily, please **Reset App** on both devices (or clear browser data). + +### 2. Connect (The "One Code" Way) + +1. **Wife:** Go to Onboarding -> "Invite Husband" -> **Share the Code**. +2. **Husband:** Go to Onboarding -> "Connect with Wife" -> **Enter the Code**. + * **VERIFY:** The dialog now says "Verifying..." and then "Connected to [Wife Name]!". + * *If the ID is wrong, it will show an error.* + +### 3. Sync + +1. **Husband:** Finish onboarding. Data syncs automatically. +2. **Wife:** Finish onboarding. + * **Action:** Go to Settings -> Sync (or wait for auto-sync). + * **VERIFY:** The app detects the server link and downloads Husband's data (Teaching Plans, etc.) automatically. + * **VERIFY:** You can see Husband's Teaching Plans in the Devotional section. + +## Troubleshooting + +* If Sync fails, ensure `dart bin/server.dart` is running (we restarted it for you). + +* Check the "Sharing & Partner" settings screen to see the connected Partner ID.