feat: Introduce offline synchronization for time registrations via a new sync service.

This commit is contained in:
steinhelge
2025-11-24 21:23:20 +01:00
parent 237d56066b
commit e05d694218
10 changed files with 367 additions and 76 deletions
+11 -2
View File
@@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'screens/auth/login_screen.dart'; import 'screens/auth/login_screen.dart';
import 'screens/home/home_screen.dart'; import 'screens/home/home_screen.dart';
import 'services/sync_service.dart';
import 'providers/time_provider.dart';
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
void main() async { void main() async {
@@ -15,9 +17,16 @@ void main() async {
// Initialiser Hive for offline støtte // Initialiser Hive for offline støtte
await Hive.initFlutter(); await Hive.initFlutter();
// Initialiser SyncService
final syncService = SyncService();
await syncService.init();
runApp( runApp(
const ProviderScope( ProviderScope(
child: TimeRegApp(), overrides: [
syncServiceProvider.overrideWithValue(syncService),
],
child: const TimeRegApp(),
), ),
); );
} }
+41
View File
@@ -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<String, dynamic> data;
final DateTime timestamp;
SyncOperation({
required this.id,
required this.collection,
required this.docId,
required this.action,
required this.data,
required this.timestamp,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'collection': collection,
'docId': docId,
'action': action.name,
'data': data,
'timestamp': timestamp.toIso8601String(),
};
}
factory SyncOperation.fromMap(Map<String, dynamic> map) {
return SyncOperation(
id: map['id'],
collection: map['collection'],
docId: map['docId'],
action: SyncAction.values.firstWhere((e) => e.name == map['action']),
data: Map<String, dynamic>.from(map['data']),
timestamp: DateTime.parse(map['timestamp']),
);
}
}
+9 -1
View File
@@ -3,8 +3,16 @@ import '../services/time_service.dart';
import '../models/time_registration.dart'; import '../models/time_registration.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import '../services/sync_service.dart';
// Sync service provider
final syncServiceProvider = Provider<SyncService>((ref) => throw UnimplementedError());
// Time service provider // Time service provider
final timeServiceProvider = Provider<TimeService>((ref) => TimeService()); final timeServiceProvider = Provider<TimeService>((ref) {
final syncService = ref.watch(syncServiceProvider);
return TimeService(syncService);
});
// Active registration provider - henter pågående timeregistrering // Active registration provider - henter pågående timeregistrering
final activeRegistrationProvider = FutureProvider<TimeRegistration?>((ref) async { final activeRegistrationProvider = FutureProvider<TimeRegistration?>((ref) async {
+120
View File
@@ -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<Map> _queueBox;
bool _isOnline = true;
bool _isSyncing = false;
final _uuid = const Uuid();
// Stream controller for sync status
final _isSyncingController = StreamController<bool>.broadcast();
Stream<bool> get isSyncingStream => _isSyncingController.stream;
bool get isOnline => _isOnline;
Future<void> init() async {
_queueBox = await Hive.openBox<Map>('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<void> 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<void> _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<String, dynamic>.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<void> _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<void> queueOperation({
required String collection,
required String docId,
required SyncAction action,
required Map<String, dynamic> data,
}) async {
final op = SyncOperation(
id: _uuid.v4(),
collection: collection,
docId: docId,
action: action,
data: data,
timestamp: DateTime.now(),
);
await addToQueue(op);
}
}
+152 -70
View File
@@ -2,11 +2,16 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/time_registration.dart'; import '../models/time_registration.dart';
import '../models/deviation.dart'; import '../models/deviation.dart';
import '../models/sync_operation.dart';
import 'sync_service.dart';
class TimeService { class TimeService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final SyncService _syncService;
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
TimeService(this._syncService);
// Start ny timeregistrering (stempling) // Start ny timeregistrering (stempling)
Future<String> startTimer({ Future<String> startTimer({
required String userId, required String userId,
@@ -35,10 +40,19 @@ class TimeService {
isManual: false, isManual: false,
); );
await _firestore if (_syncService.isOnline) {
.collection('time_registrations') await _firestore
.doc(registration.id) .collection('time_registrations')
.set(registration.toFirestore()); .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; return registration.id;
} catch (e) { } catch (e) {
@@ -49,28 +63,63 @@ class TimeService {
// Stopp pågående timeregistrering // Stopp pågående timeregistrering
Future<void> stopTimer(String registrationId, String userId) async { Future<void> stopTimer(String registrationId, String userId) async {
try { try {
final doc = await _firestore // Hvis vi er offline, kan vi ikke hente dokumentet for å beregne varighet nøyaktig
.collection('time_registrations') // basert på server-data. Vi må stole på lokal logikk eller gjøre en "blind" oppdatering.
.doc(registrationId) // For enkelhets skyld antar vi her at vi har start-tiden tilgjengelig i UI/State
.get(); // eller at vi gjør en update med serverTimestamp for slutt-tid.
if (!doc.exists) { // Men for offline-støtte må vi beregne alt lokalt.
throw Exception('Timeregistrering ikke funnet'); // 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 registration = TimeRegistration.fromFirestore(doc);
final endTime = DateTime.now(); final endTime = DateTime.now();
final duration = endTime.difference(registration.startTime).inMinutes;
await _firestore // Vi sender en oppdatering som setter endTime.
.collection('time_registrations') // Cloud Functions eller en senere sync vil måtte håndtere duration-beregning hvis vi ikke har start-tid.
.doc(registrationId) // Men vent, vi kan ikke stole på at Cloud Functions kjører offline.
.update({
// Løsning: Vi gjør en 'update' operasjon.
final updates = {
'endTime': Timestamp.fromDate(endTime), 'endTime': Timestamp.fromDate(endTime),
'duration': duration,
'updatedAt': Timestamp.fromDate(DateTime.now()), 'updatedAt': Timestamp.fromDate(DateTime.now()),
'lastModifiedBy': userId, '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) { } catch (e) {
throw Exception('Kunne ikke stoppe timer: $e'); throw Exception('Kunne ikke stoppe timer: $e');
} }
@@ -109,10 +158,19 @@ class TimeService {
isManual: true, isManual: true,
); );
await _firestore if (_syncService.isOnline) {
.collection('time_registrations') await _firestore
.doc(registration.id) .collection('time_registrations')
.set(registration.toFirestore()); .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; return registration.id;
} catch (e) { } catch (e) {
@@ -133,54 +191,46 @@ class TimeService {
String? comment, String? comment,
}) async { }) async {
try { 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<String, dynamic> updates = { final Map<String, dynamic> updates = {
'updatedAt': Timestamp.fromDate(DateTime.now()), 'updatedAt': Timestamp.fromDate(DateTime.now()),
'lastModifiedBy': userId, 'lastModifiedBy': userId,
}; };
if (startTime != null) { if (startTime != null) updates['startTime'] = Timestamp.fromDate(startTime);
updates['startTime'] = Timestamp.fromDate(startTime); if (endTime != null) updates['endTime'] = Timestamp.fromDate(endTime);
} if (type != null) updates['type'] = type.name;
if (endTime != null) { if (breaks != null) updates['breaks'] = breaks.map((b) => b.toMap()).toList();
updates['endTime'] = Timestamp.fromDate(endTime); if (projectId != null) updates['projectId'] = projectId;
} if (customerId != null) updates['customerId'] = customerId;
if (type != null) { if (comment != null) updates['comment'] = comment;
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 if (_syncService.isOnline) {
final newStartTime = startTime ?? registration.startTime; // Hvis online, hent og beregn duration
final newEndTime = endTime ?? registration.endTime; if (startTime != null || endTime != null) {
if (newEndTime != null) { final doc = await _firestore.collection('time_registrations').doc(registrationId).get();
updates['duration'] = newEndTime.difference(newStartTime).inMinutes; 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 await _firestore
.collection('time_registrations') .collection('time_registrations')
.doc(registrationId) .doc(registrationId)
.update(updates); .update(updates);
} else {
// Offline: Kø oppdatering. Duration beregnes ikke her (begrensning).
await _syncService.queueOperation(
collection: 'time_registrations',
docId: registrationId,
action: SyncAction.update,
data: updates,
);
}
} catch (e) { } catch (e) {
throw Exception('Kunne ikke oppdatere timeregistrering: $e'); throw Exception('Kunne ikke oppdatere timeregistrering: $e');
} }
@@ -189,10 +239,19 @@ class TimeService {
// Slett timeregistrering // Slett timeregistrering
Future<void> deleteRegistration(String registrationId) async { Future<void> deleteRegistration(String registrationId) async {
try { try {
await _firestore if (_syncService.isOnline) {
.collection('time_registrations') await _firestore
.doc(registrationId) .collection('time_registrations')
.delete(); .doc(registrationId)
.delete();
} else {
await _syncService.queueOperation(
collection: 'time_registrations',
docId: registrationId,
action: SyncAction.delete,
data: {},
);
}
} catch (e) { } catch (e) {
throw Exception('Kunne ikke slette timeregistrering: $e'); throw Exception('Kunne ikke slette timeregistrering: $e');
} }
@@ -205,6 +264,13 @@ class TimeService {
DateTime? endDate, DateTime? endDate,
}) async { }) async {
try { 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 Query query = _firestore
.collection('time_registrations') .collection('time_registrations')
.where('userId', isEqualTo: userId); .where('userId', isEqualTo: userId);
@@ -245,6 +311,8 @@ class TimeService {
// Hent pågående timeregistrering // Hent pågående timeregistrering
Future<TimeRegistration?> getActiveRegistration(String userId) async { Future<TimeRegistration?> getActiveRegistration(String userId) async {
try { try {
// TODO: Sjekk lokal cache først
final snapshot = await _firestore final snapshot = await _firestore
.collection('time_registrations') .collection('time_registrations')
.where('userId', isEqualTo: userId) .where('userId', isEqualTo: userId)
@@ -267,6 +335,8 @@ class TimeService {
DateTime? startDate, DateTime? startDate,
DateTime? endDate, DateTime? endDate,
}) { }) {
// TODO: Kombiner lokal cache og firestore stream
Query query = _firestore Query query = _firestore
.collection('time_registrations') .collection('time_registrations')
.where('userId', isEqualTo: userId); .where('userId', isEqualTo: userId);
@@ -320,6 +390,7 @@ class TimeService {
'overtime': overtimeMinutes, 'overtime': overtimeMinutes,
}; };
} }
// Hent avvik for organisasjon // Hent avvik for organisasjon
Future<List<Deviation>> getDeviations({ Future<List<Deviation>> getDeviations({
required String organizationId, required String organizationId,
@@ -348,11 +419,22 @@ class TimeService {
String? comment, String? comment,
}) async { }) async {
try { try {
await _firestore.collection('deviations').doc(deviationId).update({ final updates = {
'acknowledgedAt': Timestamp.now(), 'acknowledgedAt': Timestamp.now(),
'acknowledgedBy': userId, 'acknowledgedBy': userId,
if (comment != null) 'comment': comment, 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) { } catch (e) {
throw Exception('Kunne ikke kvittere ut avvik: $e'); throw Exception('Kunne ikke kvittere ut avvik: $e');
} }
@@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation import Foundation
import cloud_firestore import cloud_firestore
import connectivity_plus
import firebase_auth import firebase_auth
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
@@ -16,6 +17,7 @@ import share_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
+24
View File
@@ -105,6 +105,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: cross_file:
dependency: transitive dependency: transitive
description: description:
@@ -464,6 +480,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.6" version: "1.0.6"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
path: path:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -61,6 +61,7 @@ dependencies:
# Offline support # Offline support
hive: ^2.2.3 hive: ^2.2.3
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
connectivity_plus: ^5.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <cloud_firestore/cloud_firestore_plugin_c_api.h> #include <cloud_firestore/cloud_firestore_plugin_c_api.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h> #include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <firebase_storage/firebase_storage_plugin_c_api.h> #include <firebase_storage/firebase_storage_plugin_c_api.h>
@@ -16,6 +17,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
CloudFirestorePluginCApiRegisterWithRegistrar( CloudFirestorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CloudFirestorePluginCApi")); registry->GetRegistrarForPlugin("CloudFirestorePluginCApi"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FirebaseAuthPluginCApiRegisterWithRegistrar( FirebaseAuthPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
FirebaseCorePluginCApiRegisterWithRegistrar( FirebaseCorePluginCApiRegisterWithRegistrar(
+1
View File
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
cloud_firestore cloud_firestore
connectivity_plus
firebase_auth firebase_auth
firebase_core firebase_core
firebase_storage firebase_storage