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:
@@ -16,38 +16,169 @@ void main(List<String> 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'/<ignored|.*>', (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<Map<String, dynamic>> partnerEntries = [];
|
||||
List<Map<String, dynamic>> partnerPlans = [];
|
||||
List<Map<String, dynamic>> 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
|
||||
|
||||
Binary file not shown.
@@ -64,8 +64,19 @@ class TrackerDatabase {
|
||||
topic TEXT,
|
||||
scripture_reference TEXT,
|
||||
notes TEXT,
|
||||
application_question TEXT,
|
||||
prayer_points TEXT, -- JSON string
|
||||
is_completed INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// Prayer Requests table
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS prayer_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
request TEXT,
|
||||
is_answered INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
@@ -114,4 +125,158 @@ class TrackerDatabase {
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Teaching Plan operations
|
||||
void upsertTeachingPlan(String userId, Map<String, dynamic> 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<Map<String, dynamic>> 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<String, dynamic> 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<Map<String, dynamic>> 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<String, dynamic> 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<String, dynamic>? 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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user