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'; /// 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). 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'; late final FlutterSecureStorage _secureStorage; StorageService() { _secureStorage = FlutterSecureStorage( aOptions: const AndroidOptions(encryptedSharedPreferences: true), ); } // --------------------------------------------------------------------------- // 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; } /// Reads annotations with metadata for a specific sheet page. /// /// Returns [StoredAnnotation] with annotations and lastModified, or null if none exist. Future readAnnotationsWithMetadata( String sheetUuid, int pageNumber, ) async { final box = await Hive.openBox(_annotationsBox); final value = box.get(_annotationKey(sheetUuid, pageNumber)); if (value == null) return null; // Handle legacy format (plain string) - treat as very old if (value is String) { return StoredAnnotation( annotationsJson: value, lastModified: DateTime.fromMillisecondsSinceEpoch(0), ); } if (value is Map) { return StoredAnnotation.fromMap(value); } return null; } /// Writes annotations for a specific sheet page. /// /// Pass null or empty string to delete annotations for that page. /// Automatically sets lastModified to current time. Future writeAnnotations( String sheetUuid, int pageNumber, String? annotationsJson, ) async { await writeAnnotationsWithMetadata( sheetUuid, pageNumber, annotationsJson, DateTime.now(), ); } /// 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); } } }