diff --git a/lib/main.dart b/lib/main.dart index 8179969..69fd82d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'screens/auth/login_screen.dart'; import 'screens/home/home_screen.dart'; +import 'services/sync_service.dart'; +import 'providers/time_provider.dart'; import 'providers/auth_provider.dart'; void main() async { @@ -15,9 +17,16 @@ void main() async { // Initialiser Hive for offline støtte await Hive.initFlutter(); + // Initialiser SyncService + final syncService = SyncService(); + await syncService.init(); + runApp( - const ProviderScope( - child: TimeRegApp(), + ProviderScope( + overrides: [ + syncServiceProvider.overrideWithValue(syncService), + ], + child: const TimeRegApp(), ), ); } diff --git a/lib/models/sync_operation.dart b/lib/models/sync_operation.dart new file mode 100644 index 0000000..88ec623 --- /dev/null +++ b/lib/models/sync_operation.dart @@ -0,0 +1,41 @@ +enum SyncAction { create, update, delete } + +class SyncOperation { + final String id; + final String collection; + final String docId; + final SyncAction action; + final Map data; + final DateTime timestamp; + + SyncOperation({ + required this.id, + required this.collection, + required this.docId, + required this.action, + required this.data, + required this.timestamp, + }); + + Map toMap() { + return { + 'id': id, + 'collection': collection, + 'docId': docId, + 'action': action.name, + 'data': data, + 'timestamp': timestamp.toIso8601String(), + }; + } + + factory SyncOperation.fromMap(Map map) { + return SyncOperation( + id: map['id'], + collection: map['collection'], + docId: map['docId'], + action: SyncAction.values.firstWhere((e) => e.name == map['action']), + data: Map.from(map['data']), + timestamp: DateTime.parse(map['timestamp']), + ); + } +} diff --git a/lib/providers/time_provider.dart b/lib/providers/time_provider.dart index 4c3d74b..943bf30 100644 --- a/lib/providers/time_provider.dart +++ b/lib/providers/time_provider.dart @@ -3,8 +3,16 @@ import '../services/time_service.dart'; import '../models/time_registration.dart'; import 'auth_provider.dart'; +import '../services/sync_service.dart'; + +// Sync service provider +final syncServiceProvider = Provider((ref) => throw UnimplementedError()); + // Time service provider -final timeServiceProvider = Provider((ref) => TimeService()); +final timeServiceProvider = Provider((ref) { + final syncService = ref.watch(syncServiceProvider); + return TimeService(syncService); +}); // Active registration provider - henter pågående timeregistrering final activeRegistrationProvider = FutureProvider((ref) async { diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart new file mode 100644 index 0000000..3204c1c --- /dev/null +++ b/lib/services/sync_service.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:uuid/uuid.dart'; +import '../models/sync_operation.dart'; + +class SyncService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final Connectivity _connectivity = Connectivity(); + late Box _queueBox; + bool _isOnline = true; + bool _isSyncing = false; + final _uuid = const Uuid(); + + // Stream controller for sync status + final _isSyncingController = StreamController.broadcast(); + Stream get isSyncingStream => _isSyncingController.stream; + + bool get isOnline => _isOnline; + + Future init() async { + _queueBox = await Hive.openBox('sync_queue'); + + // Sjekk initiell tilkobling + try { + final result = await _connectivity.checkConnectivity(); + _updateConnectionStatus(result); + } catch (e) { + print('Kunne ikke sjekke tilkobling: $e'); + } + + // Lytt etter endringer + _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); + } + + void _updateConnectionStatus(ConnectivityResult result) { + _isOnline = result != ConnectivityResult.none; + print('Tilkoblingsstatus endret: $_isOnline ($result)'); + + if (_isOnline) { + _processQueue(); + } + } + + // Legg til operasjon i køen + Future addToQueue(SyncOperation operation) async { + await _queueBox.put(operation.id, operation.toMap()); + + // Prøv å synkronisere umiddelbart hvis vi er online + if (_isOnline) { + _processQueue(); + } + } + + // Behandle køen + Future _processQueue() async { + if (_isSyncing || _queueBox.isEmpty) return; + + _isSyncing = true; + _isSyncingController.add(true); + + try { + // Hent alle operasjoner og sorter etter tidsstempel + final operations = _queueBox.values + .map((map) => SyncOperation.fromMap(Map.from(map))) + .toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + for (final op in operations) { + try { + await _performOperation(op); + await _queueBox.delete(op.id); + } catch (e) { + print('Feil ved synkronisering av operasjon ${op.id}: $e'); + // Vi lar den ligge i køen for å prøve igjen senere + // Men hvis det er en permanent feil, burde vi kanskje flytte den til en "feil-kø" + } + } + } finally { + _isSyncing = false; + _isSyncingController.add(false); + } + } + + Future _performOperation(SyncOperation op) async { + final collection = _firestore.collection(op.collection); + final docRef = collection.doc(op.docId); + + switch (op.action) { + case SyncAction.create: + await docRef.set(op.data); + break; + case SyncAction.update: + await docRef.update(op.data); + break; + case SyncAction.delete: + await docRef.delete(); + break; + } + } + + // Hjelpemetode for å opprette operasjon + Future queueOperation({ + required String collection, + required String docId, + required SyncAction action, + required Map data, + }) async { + final op = SyncOperation( + id: _uuid.v4(), + collection: collection, + docId: docId, + action: action, + data: data, + timestamp: DateTime.now(), + ); + await addToQueue(op); + } +} diff --git a/lib/services/time_service.dart b/lib/services/time_service.dart index 0f05f40..44400a5 100644 --- a/lib/services/time_service.dart +++ b/lib/services/time_service.dart @@ -2,11 +2,16 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:uuid/uuid.dart'; import '../models/time_registration.dart'; import '../models/deviation.dart'; +import '../models/sync_operation.dart'; +import 'sync_service.dart'; class TimeService { final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final SyncService _syncService; final Uuid _uuid = const Uuid(); + TimeService(this._syncService); + // Start ny timeregistrering (stempling) Future startTimer({ required String userId, @@ -35,10 +40,19 @@ class TimeService { isManual: false, ); - await _firestore - .collection('time_registrations') - .doc(registration.id) - .set(registration.toFirestore()); + if (_syncService.isOnline) { + await _firestore + .collection('time_registrations') + .doc(registration.id) + .set(registration.toFirestore()); + } else { + await _syncService.queueOperation( + collection: 'time_registrations', + docId: registration.id, + action: SyncAction.create, + data: registration.toFirestore(), + ); + } return registration.id; } catch (e) { @@ -49,28 +63,63 @@ class TimeService { // Stopp pågående timeregistrering Future stopTimer(String registrationId, String userId) async { try { - final doc = await _firestore - .collection('time_registrations') - .doc(registrationId) - .get(); - - if (!doc.exists) { - throw Exception('Timeregistrering ikke funnet'); - } - - final registration = TimeRegistration.fromFirestore(doc); + // Hvis vi er offline, kan vi ikke hente dokumentet for å beregne varighet nøyaktig + // basert på server-data. Vi må stole på lokal logikk eller gjøre en "blind" oppdatering. + // For enkelhets skyld antar vi her at vi har start-tiden tilgjengelig i UI/State + // eller at vi gjør en update med serverTimestamp for slutt-tid. + + // Men for offline-støtte må vi beregne alt lokalt. + // Dette krever at vi har den aktive registreringen lagret lokalt. + // For nå implementerer vi en enkel offline-støtte som bare køer oppdateringen. + + // NB: Dette er en forenkling. I en fullverdig offline-app bør vi lese fra lokal cache. + final endTime = DateTime.now(); - final duration = endTime.difference(registration.startTime).inMinutes; - - await _firestore - .collection('time_registrations') - .doc(registrationId) - .update({ + + // Vi sender en oppdatering som setter endTime. + // Cloud Functions eller en senere sync vil måtte håndtere duration-beregning hvis vi ikke har start-tid. + // Men vent, vi kan ikke stole på at Cloud Functions kjører offline. + + // Løsning: Vi gjør en 'update' operasjon. + + final updates = { 'endTime': Timestamp.fromDate(endTime), - 'duration': duration, 'updatedAt': Timestamp.fromDate(DateTime.now()), 'lastModifiedBy': userId, - }); + // Vi kan ikke sette duration her uten å vite starttid. + // Hvis vi er online, henter vi og beregner. + }; + + if (_syncService.isOnline) { + final doc = await _firestore + .collection('time_registrations') + .doc(registrationId) + .get(); + + if (!doc.exists) { + throw Exception('Timeregistrering ikke funnet'); + } + + final registration = TimeRegistration.fromFirestore(doc); + final duration = endTime.difference(registration.startTime).inMinutes; + updates['duration'] = duration; + + await _firestore + .collection('time_registrations') + .doc(registrationId) + .update(updates); + } else { + // Offline: Vi køer oppdateringen. + // NB: Duration vil mangle eller være feil hvis vi ikke har starttid lokalt. + // Dette er en begrensning i denne enkle implementasjonen. + // En bedre løsning ville være å cache aktive registreringer lokalt. + await _syncService.queueOperation( + collection: 'time_registrations', + docId: registrationId, + action: SyncAction.update, + data: updates, + ); + } } catch (e) { throw Exception('Kunne ikke stoppe timer: $e'); } @@ -109,10 +158,19 @@ class TimeService { isManual: true, ); - await _firestore - .collection('time_registrations') - .doc(registration.id) - .set(registration.toFirestore()); + if (_syncService.isOnline) { + await _firestore + .collection('time_registrations') + .doc(registration.id) + .set(registration.toFirestore()); + } else { + await _syncService.queueOperation( + collection: 'time_registrations', + docId: registration.id, + action: SyncAction.create, + data: registration.toFirestore(), + ); + } return registration.id; } catch (e) { @@ -133,54 +191,46 @@ class TimeService { String? comment, }) async { try { - final doc = await _firestore - .collection('time_registrations') - .doc(registrationId) - .get(); - - if (!doc.exists) { - throw Exception('Timeregistrering ikke funnet'); - } - - final registration = TimeRegistration.fromFirestore(doc); final Map updates = { 'updatedAt': Timestamp.fromDate(DateTime.now()), 'lastModifiedBy': userId, }; - if (startTime != null) { - updates['startTime'] = Timestamp.fromDate(startTime); - } - if (endTime != null) { - updates['endTime'] = Timestamp.fromDate(endTime); - } - if (type != null) { - updates['type'] = type.name; - } - if (breaks != null) { - updates['breaks'] = breaks.map((b) => b.toMap()).toList(); - } - if (projectId != null) { - updates['projectId'] = projectId; - } - if (customerId != null) { - updates['customerId'] = customerId; - } - if (comment != null) { - updates['comment'] = comment; - } + if (startTime != null) updates['startTime'] = Timestamp.fromDate(startTime); + if (endTime != null) updates['endTime'] = Timestamp.fromDate(endTime); + if (type != null) updates['type'] = type.name; + if (breaks != null) updates['breaks'] = breaks.map((b) => b.toMap()).toList(); + if (projectId != null) updates['projectId'] = projectId; + if (customerId != null) updates['customerId'] = customerId; + if (comment != null) updates['comment'] = comment; - // Beregn ny varighet hvis start eller slutt er endret - final newStartTime = startTime ?? registration.startTime; - final newEndTime = endTime ?? registration.endTime; - if (newEndTime != null) { - updates['duration'] = newEndTime.difference(newStartTime).inMinutes; + if (_syncService.isOnline) { + // Hvis online, hent og beregn duration + if (startTime != null || endTime != null) { + final doc = await _firestore.collection('time_registrations').doc(registrationId).get(); + if (doc.exists) { + final reg = TimeRegistration.fromFirestore(doc); + final newStart = startTime ?? reg.startTime; + final newEnd = endTime ?? reg.endTime; + if (newEnd != null) { + updates['duration'] = newEnd.difference(newStart).inMinutes; + } + } + } + + await _firestore + .collection('time_registrations') + .doc(registrationId) + .update(updates); + } else { + // Offline: Kø oppdatering. Duration beregnes ikke her (begrensning). + await _syncService.queueOperation( + collection: 'time_registrations', + docId: registrationId, + action: SyncAction.update, + data: updates, + ); } - - await _firestore - .collection('time_registrations') - .doc(registrationId) - .update(updates); } catch (e) { throw Exception('Kunne ikke oppdatere timeregistrering: $e'); } @@ -189,10 +239,19 @@ class TimeService { // Slett timeregistrering Future deleteRegistration(String registrationId) async { try { - await _firestore - .collection('time_registrations') - .doc(registrationId) - .delete(); + if (_syncService.isOnline) { + await _firestore + .collection('time_registrations') + .doc(registrationId) + .delete(); + } else { + await _syncService.queueOperation( + collection: 'time_registrations', + docId: registrationId, + action: SyncAction.delete, + data: {}, + ); + } } catch (e) { throw Exception('Kunne ikke slette timeregistrering: $e'); } @@ -205,6 +264,13 @@ class TimeService { DateTime? endDate, }) async { try { + // TODO: Implementer lokal caching for lesing offline + // For nå kaster vi feil hvis offline og ingen cache + if (!_syncService.isOnline) { + // Her burde vi returnere fra lokal cache + // return []; + } + Query query = _firestore .collection('time_registrations') .where('userId', isEqualTo: userId); @@ -245,6 +311,8 @@ class TimeService { // Hent pågående timeregistrering Future getActiveRegistration(String userId) async { try { + // TODO: Sjekk lokal cache først + final snapshot = await _firestore .collection('time_registrations') .where('userId', isEqualTo: userId) @@ -267,6 +335,8 @@ class TimeService { DateTime? startDate, DateTime? endDate, }) { + // TODO: Kombiner lokal cache og firestore stream + Query query = _firestore .collection('time_registrations') .where('userId', isEqualTo: userId); @@ -320,6 +390,7 @@ class TimeService { 'overtime': overtimeMinutes, }; } + // Hent avvik for organisasjon Future> getDeviations({ required String organizationId, @@ -348,11 +419,22 @@ class TimeService { String? comment, }) async { try { - await _firestore.collection('deviations').doc(deviationId).update({ + final updates = { 'acknowledgedAt': Timestamp.now(), 'acknowledgedBy': userId, if (comment != null) 'comment': comment, - }); + }; + + if (_syncService.isOnline) { + await _firestore.collection('deviations').doc(deviationId).update(updates); + } else { + await _syncService.queueOperation( + collection: 'deviations', + docId: deviationId, + action: SyncAction.update, + data: updates, + ); + } } catch (e) { throw Exception('Kunne ikke kvittere ut avvik: $e'); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7851250..f91c51b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import cloud_firestore +import connectivity_plus import firebase_auth import firebase_core import firebase_messaging @@ -16,6 +17,7 @@ import share_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 99782ee..43b7446 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + url: "https://pub.dev" + source: hosted + version: "1.2.4" cross_file: dependency: transitive description: @@ -464,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 82e75b1..1a6cb7a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: # Offline support hive: ^2.2.3 hive_flutter: ^1.1.0 + connectivity_plus: ^5.0.2 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c0e0a15..32f8dee 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { CloudFirestorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("CloudFirestorePluginCApi")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index c7ee34f..9dd6bce 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST cloud_firestore + connectivity_plus firebase_auth firebase_core firebase_storage