Implement data sync and cleanup
This commit is contained in:
67
backend/bin/server.dart
Normal file
67
backend/bin/server.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf/shelf_io.dart' as io;
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import 'package:tracker_backend/database.dart';
|
||||||
|
|
||||||
|
void main(List<String> args) async {
|
||||||
|
// Use port 8090 as requested
|
||||||
|
final port = 8090;
|
||||||
|
final db = TrackerDatabase();
|
||||||
|
|
||||||
|
final app = Router();
|
||||||
|
|
||||||
|
app.get('/', (Request request) {
|
||||||
|
return Response.ok('Tracker Sync Server Running');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple Sync Endpoint (Push)
|
||||||
|
// Expects JSON: { "userId": "...", "entries": [ ... ] }
|
||||||
|
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');
|
||||||
|
|
||||||
|
for (var entry in entries) {
|
||||||
|
// Basic upsert handling
|
||||||
|
db.upsertCycleEntry(userId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.ok(
|
||||||
|
jsonEncode({'status': 'success', 'synced': entries.length}));
|
||||||
|
} catch (e) {
|
||||||
|
print('Sync Error: $e');
|
||||||
|
return Response.internalServerError(body: 'Sync Failed: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pull Endpoint
|
||||||
|
// GET /sync/pull?userId=...
|
||||||
|
app.get('/sync/pull', (Request request) {
|
||||||
|
final userId = request.url.queryParameters['userId'];
|
||||||
|
if (userId == null) return Response.badRequest(body: 'Missing userId');
|
||||||
|
|
||||||
|
final entries = db.getCycleEntries(userId);
|
||||||
|
return Response.ok(jsonEncode({'entries': entries}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
final handler = Pipeline().addMiddleware((innerHandler) {
|
||||||
|
return (request) async {
|
||||||
|
final response = await innerHandler(request);
|
||||||
|
return response.change(headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Origin, Content-Type',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}).addHandler(app);
|
||||||
|
|
||||||
|
final server = await io.serve(handler, InternetAddress.anyIPv4, port);
|
||||||
|
print('Server running on localhost:${server.port}');
|
||||||
|
}
|
||||||
BIN
backend/data/tracker.db
Normal file
BIN
backend/data/tracker.db
Normal file
Binary file not shown.
117
backend/lib/database.dart
Normal file
117
backend/lib/database.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:sqlite3/sqlite3.dart' as sql;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
class TrackerDatabase {
|
||||||
|
late final sql.Database _db;
|
||||||
|
|
||||||
|
TrackerDatabase() {
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _init() {
|
||||||
|
// Ensure data directory exists
|
||||||
|
final dataDir = Directory('data');
|
||||||
|
if (!dataDir.existsSync()) {
|
||||||
|
dataDir.createSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
final dbPath = p.join('data', 'tracker.db');
|
||||||
|
print('Opening database at $dbPath');
|
||||||
|
_db = sql.sqlite3.open(dbPath);
|
||||||
|
|
||||||
|
_createTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _createTables() {
|
||||||
|
print('Creating tables if not exist...');
|
||||||
|
|
||||||
|
// Users table
|
||||||
|
_db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
partner_id TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// Cycle Entries table
|
||||||
|
_db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS cycle_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
flow_intensity TEXT,
|
||||||
|
is_period_day INTEGER DEFAULT 0,
|
||||||
|
symptoms TEXT, -- JSON string
|
||||||
|
moods TEXT, -- JSON string
|
||||||
|
notes TEXT,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, date)
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// Teaching Plans table
|
||||||
|
_db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS teaching_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
topic TEXT,
|
||||||
|
scripture_reference TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
application_question TEXT,
|
||||||
|
prayer_points TEXT, -- JSON string
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
print('Tables created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic CRUD placeholders for sync
|
||||||
|
|
||||||
|
void upsertCycleEntry(String userId, Map<String, dynamic> entry) {
|
||||||
|
// Assuming entry contains fields matching DB
|
||||||
|
final stmt = _db.prepare('''
|
||||||
|
INSERT OR REPLACE INTO cycle_entries (
|
||||||
|
id, user_id, date, flow_intensity, is_period_day, symptoms, moods, notes, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
''');
|
||||||
|
|
||||||
|
stmt.execute([
|
||||||
|
entry['id'],
|
||||||
|
userId,
|
||||||
|
entry['date'],
|
||||||
|
entry['flowIntensity'],
|
||||||
|
entry['isPeriodDay'] == true ? 1 : 0,
|
||||||
|
entry['symptoms'], // JSON
|
||||||
|
entry['moods'], // JSON
|
||||||
|
entry['notes'],
|
||||||
|
]);
|
||||||
|
stmt.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> getCycleEntries(String userId, {String? since}) {
|
||||||
|
// If since is provided, filter by updated_at
|
||||||
|
// For MVP sync, we might just return all or a simple diff
|
||||||
|
final result =
|
||||||
|
_db.select('SELECT * FROM cycle_entries WHERE user_id = ?', [userId]);
|
||||||
|
return result
|
||||||
|
.map((row) => {
|
||||||
|
'id': row['id'],
|
||||||
|
'date': row['date'],
|
||||||
|
'flowIntensity': row['flow_intensity'],
|
||||||
|
'isPeriodDay': row['is_period_day'] == 1,
|
||||||
|
'symptoms': row['symptoms'],
|
||||||
|
'moods': row['moods'],
|
||||||
|
'notes': row['notes'],
|
||||||
|
'updatedAt': row['updated_at']
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
173
backend/pubspec.lock
Normal file
173
backend/pubspec.lock
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.0"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
crypto:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
http_methods:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_methods
|
||||||
|
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.17.0"
|
||||||
|
path:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
shelf:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_router:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf_router
|
||||||
|
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.4"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.1"
|
||||||
|
sqlite3:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.4"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.2"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.7.0 <4.0.0"
|
||||||
18
backend/pubspec.yaml
Normal file
18
backend/pubspec.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: tracker_backend
|
||||||
|
description: A simple backend for Tracker sync.
|
||||||
|
version: 1.0.0
|
||||||
|
publish_to: "none"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.5.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
shelf: ^1.4.1
|
||||||
|
shelf_router: ^1.1.4
|
||||||
|
sqlite3: ^2.4.0
|
||||||
|
path: ^1.8.3
|
||||||
|
uuid: ^4.0.0
|
||||||
|
crypto: ^3.0.3
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
lints: ^2.1.1
|
||||||
@@ -3,8 +3,8 @@ import 'package:hive_flutter/hive_flutter.dart';
|
|||||||
import '../models/user_profile.dart';
|
import '../models/user_profile.dart';
|
||||||
import '../models/cycle_entry.dart';
|
import '../models/cycle_entry.dart';
|
||||||
|
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
import '../services/cycle_service.dart';
|
import '../services/cycle_service.dart';
|
||||||
|
import '../services/sync_service.dart';
|
||||||
|
|
||||||
/// Provider for the user profile
|
/// Provider for the user profile
|
||||||
final userProfileProvider =
|
final userProfileProvider =
|
||||||
@@ -66,6 +66,7 @@ final cycleEntriesProvider =
|
|||||||
class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
|
class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
|
||||||
CycleEntriesNotifier() : super([]) {
|
CycleEntriesNotifier() : super([]) {
|
||||||
_loadEntries();
|
_loadEntries();
|
||||||
|
syncData(); // Auto-sync on load
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadEntries() {
|
void _loadEntries() {
|
||||||
@@ -77,18 +78,21 @@ class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
|
|||||||
final box = Hive.box<CycleEntry>('cycle_entries');
|
final box = Hive.box<CycleEntry>('cycle_entries');
|
||||||
await box.put(entry.id, entry);
|
await box.put(entry.id, entry);
|
||||||
_loadEntries();
|
_loadEntries();
|
||||||
|
_push();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateEntry(CycleEntry entry) async {
|
Future<void> updateEntry(CycleEntry entry) async {
|
||||||
final box = Hive.box<CycleEntry>('cycle_entries');
|
final box = Hive.box<CycleEntry>('cycle_entries');
|
||||||
await box.put(entry.id, entry);
|
await box.put(entry.id, entry);
|
||||||
_loadEntries();
|
_loadEntries();
|
||||||
|
_push();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteEntry(String id) async {
|
Future<void> deleteEntry(String id) async {
|
||||||
final box = Hive.box<CycleEntry>('cycle_entries');
|
final box = Hive.box<CycleEntry>('cycle_entries');
|
||||||
await box.delete(id);
|
await box.delete(id);
|
||||||
_loadEntries();
|
_loadEntries();
|
||||||
|
_push();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteEntriesForMonth(int year, int month) async {
|
Future<void> deleteEntriesForMonth(int year, int month) async {
|
||||||
@@ -101,63 +105,52 @@ class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
|
|||||||
}
|
}
|
||||||
await box.deleteAll(keysToDelete);
|
await box.deleteAll(keysToDelete);
|
||||||
_loadEntries();
|
_loadEntries();
|
||||||
|
_push();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearEntries() async {
|
Future<void> clearEntries() async {
|
||||||
final box = Hive.box<CycleEntry>('cycle_entries');
|
final box = Hive.box<CycleEntry>('cycle_entries');
|
||||||
await box.clear();
|
await box.clear();
|
||||||
state = [];
|
state = [];
|
||||||
|
_push();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> generateExampleData(String userId) async {
|
// Sync Logic
|
||||||
await clearEntries();
|
|
||||||
final box = Hive.box<CycleEntry>('cycle_entries');
|
|
||||||
final today = DateTime.now();
|
|
||||||
|
|
||||||
// Generate 3 past cycles (~28 days each)
|
Future<void> syncData() async {
|
||||||
DateTime cycleStart = today.subtract(const Duration(days: 84)); // 3 * 28
|
await _pull();
|
||||||
|
// After pull, we might want to push any local changes not in remote?
|
||||||
|
// For now, simpler consistency: Pull then Push current state?
|
||||||
|
// Or just Pull. Push happens on edit.
|
||||||
|
// Let's just Pull.
|
||||||
|
}
|
||||||
|
|
||||||
while (cycleStart.isBefore(today)) {
|
Future<void> _push() async {
|
||||||
// Create Period (5 days)
|
final userBox = Hive.box<UserProfile>('user_profile');
|
||||||
for (int i = 0; i < 5; i++) {
|
final user = userBox.get('current_user');
|
||||||
final date = cycleStart.add(Duration(days: i));
|
if (user != null) {
|
||||||
if (date.isAfter(today)) break;
|
await SyncService().pushSyncData(user.id, state);
|
||||||
|
|
||||||
final isHeavy = i == 1 || i == 2;
|
|
||||||
final entry = CycleEntry(
|
|
||||||
id: const Uuid().v4(),
|
|
||||||
date: date,
|
|
||||||
isPeriodDay: true,
|
|
||||||
flowIntensity: isHeavy ? FlowIntensity.heavy : FlowIntensity.medium,
|
|
||||||
mood: i == 1 ? MoodLevel.sad : null,
|
|
||||||
crampIntensity: i == 0 ? 3 : null,
|
|
||||||
hasHeadache: i == 0,
|
|
||||||
createdAt: date,
|
|
||||||
updatedAt: date,
|
|
||||||
);
|
|
||||||
await box.put(entry.id, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add random ovulation symptoms near day 14
|
|
||||||
final ovulationDay = cycleStart.add(const Duration(days: 14));
|
|
||||||
if (ovulationDay.isBefore(today)) {
|
|
||||||
final entry = CycleEntry(
|
|
||||||
id: const Uuid().v4(),
|
|
||||||
date: ovulationDay,
|
|
||||||
isPeriodDay: false,
|
|
||||||
energyLevel: 4, // High energy
|
|
||||||
mood: MoodLevel.veryHappy,
|
|
||||||
createdAt: ovulationDay,
|
|
||||||
updatedAt: ovulationDay,
|
|
||||||
);
|
|
||||||
await box.put(entry.id, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
cycleStart = cycleStart.add(const Duration(days: 28));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadEntries();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pull() async {
|
||||||
|
final userBox = Hive.box<UserProfile>('user_profile');
|
||||||
|
final user = userBox.get('current_user');
|
||||||
|
if (user == null) return;
|
||||||
|
|
||||||
|
final remoteEntries = await SyncService().pullSyncData(user.id);
|
||||||
|
if (remoteEntries.isNotEmpty) {
|
||||||
|
final box = Hive.box<CycleEntry>('cycle_entries');
|
||||||
|
// Simple merge: Remote wins or Union?
|
||||||
|
// Union: Upsert all.
|
||||||
|
for (var entry in remoteEntries) {
|
||||||
|
await box.put(entry.id, entry);
|
||||||
|
}
|
||||||
|
_loadEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example data generation removed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computed provider for current cycle info
|
/// Computed provider for current cycle info
|
||||||
|
|||||||
@@ -620,6 +620,25 @@ class _SettingsTab extends ConsumerWidget {
|
|||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const ExportDataScreen()));
|
builder: (context) => const ExportDataScreen()));
|
||||||
}),
|
}),
|
||||||
|
_buildSettingsTile(
|
||||||
|
context,
|
||||||
|
Icons.sync,
|
||||||
|
'Sync Data',
|
||||||
|
onTap: () async {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Syncing data...')),
|
||||||
|
);
|
||||||
|
await ref.read(cycleEntriesProvider.notifier).syncData();
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Sync complete'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildSettingsGroup(context, 'Account', [
|
_buildSettingsGroup(context, 'Account', [
|
||||||
|
|||||||
@@ -390,6 +390,27 @@ class HusbandSettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.sync, color: Colors.blue),
|
||||||
|
title: Text('Sync Data',
|
||||||
|
style: GoogleFonts.outfit(
|
||||||
|
fontWeight: FontWeight.w500, color: Colors.blue)),
|
||||||
|
onTap: () async {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Syncing data...')),
|
||||||
|
);
|
||||||
|
await ref.read(cycleEntriesProvider.notifier).syncData();
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Sync complete'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.cloud_download_outlined,
|
leading: const Icon(Icons.cloud_download_outlined,
|
||||||
color: Colors.blue),
|
color: Colors.blue),
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||||||
lastPeriodStartDate: _lastPeriodStart,
|
lastPeriodStartDate: _lastPeriodStart,
|
||||||
isIrregularCycle: _isIrregularCycle,
|
isIrregularCycle: _isIrregularCycle,
|
||||||
hasCompletedOnboarding: true,
|
hasCompletedOnboarding: true,
|
||||||
useExampleData: _useExampleData,
|
// useExampleData: Removed
|
||||||
isPadTrackingEnabled: _isPadTrackingEnabled,
|
isPadTrackingEnabled: _isPadTrackingEnabled,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
@@ -149,15 +149,17 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||||||
|
|
||||||
await ref.read(userProfileProvider.notifier).updateProfile(userProfile);
|
await ref.read(userProfileProvider.notifier).updateProfile(userProfile);
|
||||||
|
|
||||||
// Generate example data if requested
|
// Generate example data if requested - REMOVED
|
||||||
|
/*
|
||||||
if (_useExampleData) {
|
if (_useExampleData) {
|
||||||
await ref
|
await ref
|
||||||
.read(cycleEntriesProvider.notifier)
|
.read(cycleEntriesProvider.notifier)
|
||||||
.generateExampleData(userProfile.id);
|
.generateExampleData(userProfile.id);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// Trigger partner connection notification if applicable
|
// Trigger partner connection notification if applicable
|
||||||
if (!_skipPartnerConnection && !_useExampleData) {
|
if (!_skipPartnerConnection) {
|
||||||
await NotificationService().showPartnerUpdateNotification(
|
await NotificationService().showPartnerUpdateNotification(
|
||||||
title: 'Connection Successful!',
|
title: 'Connection Successful!',
|
||||||
body: 'You are now connected with your partner. Tap to start sharing.',
|
body: 'You are now connected with your partner. Tap to start sharing.',
|
||||||
|
|||||||
135
lib/services/sync_service.dart
Normal file
135
lib/services/sync_service.dart
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../models/cycle_entry.dart';
|
||||||
|
|
||||||
|
class SyncService {
|
||||||
|
static const String _baseUrl = 'http://localhost:8090';
|
||||||
|
|
||||||
|
// Push data to backend
|
||||||
|
Future<void> pushSyncData(String userId, List<CycleEntry> entries) async {
|
||||||
|
try {
|
||||||
|
final url = Uri.parse('$_baseUrl/sync/push');
|
||||||
|
final payload = {
|
||||||
|
'userId': userId,
|
||||||
|
'entries': entries.map((e) => _cycleEntryToJson(e)).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<List<CycleEntry>> pullSyncData(String userId) async {
|
||||||
|
try {
|
||||||
|
final url = Uri.parse('$_baseUrl/sync/pull?userId=$userId');
|
||||||
|
final response = await http.get(url);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final List entriesJson = data['entries'] ?? [];
|
||||||
|
return entriesJson.map((json) => _jsonToCycleEntry(json)).toList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Sync Pull Error: $e');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers (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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -449,7 +449,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.6"
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ dependencies:
|
|||||||
collection: ^1.18.0
|
collection: ^1.18.0
|
||||||
timezone: ^0.9.4
|
timezone: ^0.9.4
|
||||||
path_provider_platform_interface: ^2.1.2
|
path_provider_platform_interface: ^2.1.2
|
||||||
|
http: ^1.6.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user