import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive/hive.dart'; import 'package:sheetless/core/models/change.dart'; import 'package:sheetless/core/models/config.dart'; import 'package:sheetless/core/models/sheet.dart'; /// Keys for secure storage (credentials and tokens). enum SecureStorageKey { url, jwt, email } /// Data class for storing annotations with metadata. class StoredAnnotation { final String annotationsJson; final DateTime lastModified; StoredAnnotation({required this.annotationsJson, required this.lastModified}); Map toMap() => { 'annotationsJson': annotationsJson, 'lastModified': lastModified.toIso8601String(), }; factory StoredAnnotation.fromMap(Map map) { return StoredAnnotation( annotationsJson: map['annotationsJson'] as String, lastModified: DateTime.parse(map['lastModified'] as String), ); } } /// Service for managing local storage operations. /// /// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens) /// and [Hive] for general app data (config, sheet access times, change queue, /// and PDF annotations). /// Data class for a pending annotation upload. class PendingAnnotationUpload { final String sheetUuid; final int page; final String annotationsJson; final DateTime lastModified; PendingAnnotationUpload({ required this.sheetUuid, required this.page, required this.annotationsJson, required this.lastModified, }); Map toMap() => { 'sheetUuid': sheetUuid, 'page': page, 'annotationsJson': annotationsJson, 'lastModified': lastModified.toIso8601String(), }; factory PendingAnnotationUpload.fromMap(Map map) { return PendingAnnotationUpload( sheetUuid: map['sheetUuid'] as String, page: map['page'] as int, annotationsJson: map['annotationsJson'] as String, lastModified: DateTime.parse(map['lastModified'] as String), ); } /// Unique key for deduplication (newer uploads replace older ones). String get key => '${sheetUuid}_page_$page'; } /// Service for managing local storage operations. /// /// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens) /// and [Hive] for general app data (config, sheet access times, change queue, /// and PDF annotations). class StorageService { // Hive box names static const String _sheetAccessTimesBox = 'sheetAccessTimes'; static const String _configBox = 'config'; static const String _changeQueueBox = 'changeQueue'; static const String _annotationsBox = 'annotations'; static const String _sheetsBox = 'sheets'; static const String _pendingAnnotationsBox = 'pendingAnnotations'; late final FlutterSecureStorage _secureStorage; StorageService() { _secureStorage = FlutterSecureStorage(); } // --------------------------------------------------------------------------- // Secure Storage (Credentials & Tokens) // --------------------------------------------------------------------------- /// Reads a value from secure storage. Future readSecure(SecureStorageKey key) { return _secureStorage.read(key: key.name); } /// Writes a value to secure storage. /// /// Pass `null` to delete the key. Future writeSecure(SecureStorageKey key, String? value) { return _secureStorage.write(key: key.name, value: value); } /// Clears the JWT token from secure storage. Future clearToken() { return writeSecure(SecureStorageKey.jwt, null); } // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- /// Reads the app configuration from storage. Future readConfig() async { final box = await Hive.openBox(_configBox); return Config( twoPageMode: box.get(Config.keyTwoPageMode) ?? false, fullscreen: box.get(Config.keyFullscreen) ?? false, ); } /// Writes the app configuration to storage. Future writeConfig(Config config) async { final box = await Hive.openBox(_configBox); await box.put(Config.keyTwoPageMode, config.twoPageMode); await box.put(Config.keyFullscreen, config.fullscreen); } // --------------------------------------------------------------------------- // Sheet Access Times (for sorting by recency) // --------------------------------------------------------------------------- /// Reads all sheet access times. /// /// Returns a map of sheet UUID to last access time. Future> readSheetAccessTimes() async { final box = await Hive.openBox(_sheetAccessTimesBox); return box.toMap().map( (key, value) => MapEntry(key as String, DateTime.parse(value as String)), ); } /// Records when a sheet was last accessed. Future writeSheetAccessTime(String uuid, DateTime datetime) async { final box = await Hive.openBox(_sheetAccessTimesBox); await box.put(uuid, datetime.toIso8601String()); } // --------------------------------------------------------------------------- // Change Queue (Offline Sync) // --------------------------------------------------------------------------- /// Adds a change to the offline queue. Future writeChange(Change change) async { final box = await Hive.openBox(_changeQueueBox); await box.add(change.toMap()); } /// Reads all pending changes from the queue. Future readChangeQueue() async { final box = await Hive.openBox(_changeQueueBox); final queue = ChangeQueue(); for (final map in box.values) { queue.addChange(Change.fromMap(map as Map)); } return queue; } /// Removes the oldest change from the queue. /// /// Call this after successfully syncing a change to the server. Future deleteOldestChange() async { final box = await Hive.openBox(_changeQueueBox); if (box.isNotEmpty) { await box.deleteAt(0); } } // --------------------------------------------------------------------------- // Annotations (PDF Drawing Persistence) // --------------------------------------------------------------------------- /// Generates a storage key for a specific page's annotations. String _annotationKey(String sheetUuid, int pageNumber) { return '${sheetUuid}_page_$pageNumber'; } /// Reads annotations for a specific sheet page. /// /// Returns the JSON string of annotations, or null if none exist. Future readAnnotations(String sheetUuid, int pageNumber) async { final box = await Hive.openBox(_annotationsBox); final value = box.get(_annotationKey(sheetUuid, pageNumber)); // Handle legacy format (plain string) and new format (map with metadata) if (value == null) return null; if (value is String) return value; if (value is Map) { final stored = StoredAnnotation.fromMap(value); return stored.annotationsJson; } return null; } /// Writes annotations with a specific lastModified timestamp. /// /// Used when syncing from server to preserve server's timestamp. Future writeAnnotationsWithMetadata( String sheetUuid, int pageNumber, String? annotationsJson, DateTime lastModified, ) async { final box = await Hive.openBox(_annotationsBox); final key = _annotationKey(sheetUuid, pageNumber); if (annotationsJson == null || annotationsJson.isEmpty) { await box.delete(key); } else { final stored = StoredAnnotation( annotationsJson: annotationsJson, lastModified: lastModified, ); await box.put(key, stored.toMap()); } } /// Reads all annotations for a sheet (all pages). /// /// Returns a map of page number to JSON string. Future> readAllAnnotations(String sheetUuid) async { final box = await Hive.openBox(_annotationsBox); final prefix = '${sheetUuid}_page_'; final result = {}; for (final key in box.keys) { if (key is String && key.startsWith(prefix)) { final pageStr = key.substring(prefix.length); final pageNumber = int.tryParse(pageStr); if (pageNumber != null) { final value = box.get(key); if (value != null) { // Handle legacy format (plain string) and new format (map) if (value is String && value.isNotEmpty) { result[pageNumber] = value; } else if (value is Map) { final stored = StoredAnnotation.fromMap(value); if (stored.annotationsJson.isNotEmpty) { result[pageNumber] = stored.annotationsJson; } } } } } } return result; } /// Reads all annotations with metadata for a sheet (all pages). /// /// Returns a map of page number to [StoredAnnotation]. Future> readAllAnnotationsWithMetadata( String sheetUuid, ) async { final box = await Hive.openBox(_annotationsBox); final prefix = '${sheetUuid}_page_'; final result = {}; for (final key in box.keys) { if (key is String && key.startsWith(prefix)) { final pageStr = key.substring(prefix.length); final pageNumber = int.tryParse(pageStr); if (pageNumber != null) { final value = box.get(key); if (value != null) { StoredAnnotation? stored; // Handle legacy format (plain string) and new format (map) if (value is String && value.isNotEmpty) { stored = StoredAnnotation( annotationsJson: value, lastModified: DateTime.fromMillisecondsSinceEpoch(0), ); } else if (value is Map) { stored = StoredAnnotation.fromMap(value); } if (stored != null && stored.annotationsJson.isNotEmpty) { result[pageNumber] = stored; } } } } } return result; } /// Deletes all annotations for a sheet. Future deleteAllAnnotations(String sheetUuid) async { final box = await Hive.openBox(_annotationsBox); final prefix = '${sheetUuid}_page_'; final keysToDelete = box.keys.where( (key) => key is String && key.startsWith(prefix), ); for (final key in keysToDelete.toList()) { await box.delete(key); } } // --------------------------------------------------------------------------- // Sheets Cache (Offline Support) // --------------------------------------------------------------------------- /// Reads cached sheets from local storage. /// /// Returns an empty list if no cached sheets exist. Future> readCachedSheets() async { final box = await Hive.openBox(_sheetsBox); final sheetsJson = box.get('sheets'); if (sheetsJson == null) return []; final List sheetsList = jsonDecode(sheetsJson as String); return sheetsList .map((json) => Sheet.fromJson(json as Map)) .toList(); } /// Caches the sheets list to local storage. Future writeCachedSheets(List sheets) async { final box = await Hive.openBox(_sheetsBox); final sheetsJson = jsonEncode(sheets.map((s) => s.toJson()).toList()); await box.put('sheets', sheetsJson); } // --------------------------------------------------------------------------- // Pending Annotation Uploads (Offline Support) // --------------------------------------------------------------------------- /// Adds or updates a pending annotation upload. /// /// If an upload for the same sheet/page already exists, it will be replaced /// with the newer version. Future writePendingAnnotationUpload( PendingAnnotationUpload upload, ) async { final box = await Hive.openBox(_pendingAnnotationsBox); await box.put(upload.key, upload.toMap()); } /// Reads all pending annotation uploads. Future> readPendingAnnotationUploads() async { final box = await Hive.openBox(_pendingAnnotationsBox); final uploads = []; for (final value in box.values) { uploads.add(PendingAnnotationUpload.fromMap(value as Map)); } return uploads; } /// Removes a pending annotation upload after successful sync. Future deletePendingAnnotationUpload(String key) async { final box = await Hive.openBox(_pendingAnnotationsBox); await box.delete(key); } /// Checks if there are any pending annotation uploads. Future hasPendingAnnotationUploads() async { final box = await Hive.openBox(_pendingAnnotationsBox); return box.isNotEmpty; } // --------------------------------------------------------------------------- // Change Queue Enhancements // --------------------------------------------------------------------------- /// Returns the number of pending changes. Future getChangeQueueLength() async { final box = await Hive.openBox(_changeQueueBox); return box.length; } /// Clears all pending changes. /// /// Use with caution - only call after all changes are synced. Future clearChangeQueue() async { final box = await Hive.openBox(_changeQueueBox); await box.clear(); } /// Gets all changes as a list (for batch upload). Future> readChangeList() async { final box = await Hive.openBox(_changeQueueBox); return box.values .map((map) => Change.fromMap(map as Map)) .toList(); } }