feat: Introduce offline synchronization for time registrations via a new sync service.
This commit is contained in:
+11
-2
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SyncService>((ref) => throw UnimplementedError());
|
||||
|
||||
// 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
|
||||
final activeRegistrationProvider = FutureProvider<TimeRegistration?>((ref) async {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+155
-73
@@ -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<String> 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<void> 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<String, dynamic> 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<void> 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<TimeRegistration?> 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<List<Deviation>> 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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user