456 lines
15 KiB
Dart
456 lines
15 KiB
Dart
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<String, dynamic> toMap() => {
|
|
'annotationsJson': annotationsJson,
|
|
'lastModified': lastModified.toIso8601String(),
|
|
};
|
|
|
|
factory StoredAnnotation.fromMap(Map<dynamic, dynamic> 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<String, dynamic> toMap() => {
|
|
'sheetUuid': sheetUuid,
|
|
'page': page,
|
|
'annotationsJson': annotationsJson,
|
|
'lastModified': lastModified.toIso8601String(),
|
|
};
|
|
|
|
factory PendingAnnotationUpload.fromMap(Map<dynamic, dynamic> 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(
|
|
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secure Storage (Credentials & Tokens)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Reads a value from secure storage.
|
|
Future<String?> readSecure(SecureStorageKey key) {
|
|
return _secureStorage.read(key: key.name);
|
|
}
|
|
|
|
/// Writes a value to secure storage.
|
|
///
|
|
/// Pass `null` to delete the key.
|
|
Future<void> writeSecure(SecureStorageKey key, String? value) {
|
|
return _secureStorage.write(key: key.name, value: value);
|
|
}
|
|
|
|
/// Clears the JWT token from secure storage.
|
|
Future<void> clearToken() {
|
|
return writeSecure(SecureStorageKey.jwt, null);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Reads the app configuration from storage.
|
|
Future<Config> 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<void> 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<Map<String, DateTime>> 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<void> 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<void> writeChange(Change change) async {
|
|
final box = await Hive.openBox(_changeQueueBox);
|
|
await box.add(change.toMap());
|
|
}
|
|
|
|
/// Reads all pending changes from the queue.
|
|
Future<ChangeQueue> readChangeQueue() async {
|
|
final box = await Hive.openBox(_changeQueueBox);
|
|
final queue = ChangeQueue();
|
|
|
|
for (final map in box.values) {
|
|
queue.addChange(Change.fromMap(map as Map<dynamic, dynamic>));
|
|
}
|
|
|
|
return queue;
|
|
}
|
|
|
|
/// Removes the oldest change from the queue.
|
|
///
|
|
/// Call this after successfully syncing a change to the server.
|
|
Future<void> 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<String?> 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<StoredAnnotation?> 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<void> 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<void> 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<Map<int, String>> readAllAnnotations(String sheetUuid) async {
|
|
final box = await Hive.openBox(_annotationsBox);
|
|
final prefix = '${sheetUuid}_page_';
|
|
final result = <int, String>{};
|
|
|
|
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<Map<int, StoredAnnotation>> readAllAnnotationsWithMetadata(
|
|
String sheetUuid,
|
|
) async {
|
|
final box = await Hive.openBox(_annotationsBox);
|
|
final prefix = '${sheetUuid}_page_';
|
|
final result = <int, StoredAnnotation>{};
|
|
|
|
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<void> 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<List<Sheet>> readCachedSheets() async {
|
|
final box = await Hive.openBox(_sheetsBox);
|
|
final sheetsJson = box.get('sheets');
|
|
|
|
if (sheetsJson == null) return [];
|
|
|
|
final List<dynamic> sheetsList = jsonDecode(sheetsJson as String);
|
|
return sheetsList
|
|
.map((json) => Sheet.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
}
|
|
|
|
/// Caches the sheets list to local storage.
|
|
Future<void> writeCachedSheets(List<Sheet> 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<void> writePendingAnnotationUpload(
|
|
PendingAnnotationUpload upload,
|
|
) async {
|
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
|
await box.put(upload.key, upload.toMap());
|
|
}
|
|
|
|
/// Reads all pending annotation uploads.
|
|
Future<List<PendingAnnotationUpload>> readPendingAnnotationUploads() async {
|
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
|
final uploads = <PendingAnnotationUpload>[];
|
|
|
|
for (final value in box.values) {
|
|
uploads.add(PendingAnnotationUpload.fromMap(value as Map));
|
|
}
|
|
|
|
return uploads;
|
|
}
|
|
|
|
/// Removes a pending annotation upload after successful sync.
|
|
Future<void> deletePendingAnnotationUpload(String key) async {
|
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
|
await box.delete(key);
|
|
}
|
|
|
|
/// Checks if there are any pending annotation uploads.
|
|
Future<bool> hasPendingAnnotationUploads() async {
|
|
final box = await Hive.openBox(_pendingAnnotationsBox);
|
|
return box.isNotEmpty;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Change Queue Enhancements
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Returns the number of pending changes.
|
|
Future<int> 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<void> clearChangeQueue() async {
|
|
final box = await Hive.openBox(_changeQueueBox);
|
|
await box.clear();
|
|
}
|
|
|
|
/// Gets all changes as a list (for batch upload).
|
|
Future<List<Change>> readChangeList() async {
|
|
final box = await Hive.openBox(_changeQueueBox);
|
|
return box.values
|
|
.map((map) => Change.fromMap(map as Map<dynamic, dynamic>))
|
|
.toList();
|
|
}
|
|
}
|