You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
375 lines
11 KiB
375 lines
11 KiB
import 'dart:io';
|
|
import 'dart:convert';
|
|
import 'package:sqflite/sqflite.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'models/item.dart';
|
|
|
|
/// Service for local storage and caching operations.
|
|
///
|
|
/// This service provides:
|
|
/// - CRUD operations for items stored in a local SQLite database
|
|
/// - Image caching functionality that downloads and stores images locally
|
|
///
|
|
/// The service is designed to be modular and does not depend on UI components.
|
|
/// All methods are easily mockable for testing and integration with higher-level modules.
|
|
class LocalStorageService {
|
|
/// Database instance (null until initialized).
|
|
Database? _database;
|
|
|
|
/// Path to the cache directory for images.
|
|
Directory? _cacheDirectory;
|
|
|
|
/// Map to track cache status (in-memory cache for faster lookups).
|
|
final Map<String, bool> _cacheStatus = {};
|
|
|
|
/// Optional database path for testing (null uses default).
|
|
final String? _testDbPath;
|
|
|
|
/// Optional cache directory path for testing (null uses default).
|
|
final Directory? _testCacheDir;
|
|
|
|
/// Creates a LocalStorageService instance.
|
|
///
|
|
/// [testDbPath] - Optional database path for testing.
|
|
/// [testCacheDir] - Optional cache directory for testing.
|
|
LocalStorageService({
|
|
String? testDbPath,
|
|
Directory? testCacheDir,
|
|
}) : _testDbPath = testDbPath,
|
|
_testCacheDir = testCacheDir;
|
|
|
|
/// Initializes the database and cache directory.
|
|
///
|
|
/// [sessionDbPath] - Optional database path for session-specific storage.
|
|
/// [sessionCacheDir] - Optional cache directory for session-specific storage.
|
|
///
|
|
/// Must be called before using any other methods.
|
|
///
|
|
/// Throws [Exception] if initialization fails.
|
|
Future<void> initialize({
|
|
String? sessionDbPath,
|
|
Directory? sessionCacheDir,
|
|
}) async {
|
|
try {
|
|
// Close existing database if switching sessions
|
|
if (_database != null) {
|
|
await _database!.close();
|
|
_database = null;
|
|
}
|
|
|
|
// Initialize database with session-specific or default path
|
|
final dbPath = sessionDbPath ?? _testDbPath ?? await _getDatabasePath();
|
|
_database = await openDatabase(
|
|
dbPath,
|
|
version: 1,
|
|
onCreate: _onCreate,
|
|
);
|
|
|
|
// Initialize cache directory with session-specific or default path
|
|
if (sessionCacheDir != null) {
|
|
_cacheDirectory = sessionCacheDir;
|
|
} else if (_testCacheDir != null) {
|
|
_cacheDirectory = _testCacheDir;
|
|
} else {
|
|
final appDir = await getApplicationDocumentsDirectory();
|
|
_cacheDirectory = Directory(path.join(appDir.path, 'image_cache'));
|
|
}
|
|
|
|
if (!await _cacheDirectory!.exists()) {
|
|
await _cacheDirectory!.create(recursive: true);
|
|
}
|
|
|
|
// Clear in-memory cache status when switching sessions
|
|
_cacheStatus.clear();
|
|
} catch (e) {
|
|
throw Exception('Failed to initialize LocalStorageService: $e');
|
|
}
|
|
}
|
|
|
|
/// Reinitializes the service with a new database path (for session switching).
|
|
///
|
|
/// [newDbPath] - New database path to use.
|
|
/// [newCacheDir] - New cache directory to use.
|
|
///
|
|
/// Throws [Exception] if reinitialization fails.
|
|
Future<void> reinitializeForSession({
|
|
required String newDbPath,
|
|
required Directory newCacheDir,
|
|
}) async {
|
|
await initialize(
|
|
sessionDbPath: newDbPath,
|
|
sessionCacheDir: newCacheDir,
|
|
);
|
|
}
|
|
|
|
/// Clears all cached data (for session logout).
|
|
///
|
|
/// Throws [Exception] if clearing fails.
|
|
Future<void> clearAllData() async {
|
|
_ensureInitialized();
|
|
try {
|
|
// Clear all items from database
|
|
await _database!.delete('items');
|
|
|
|
// Clear cache directory
|
|
if (_cacheDirectory != null && await _cacheDirectory!.exists()) {
|
|
await _cacheDirectory!.delete(recursive: true);
|
|
await _cacheDirectory!.create(recursive: true);
|
|
}
|
|
|
|
// Clear in-memory cache status
|
|
_cacheStatus.clear();
|
|
} catch (e) {
|
|
throw Exception('Failed to clear all data: $e');
|
|
}
|
|
}
|
|
|
|
/// Creates the database schema if it doesn't exist.
|
|
Future<void> _onCreate(Database db, int version) async {
|
|
await db.execute('''
|
|
CREATE TABLE items (
|
|
id TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
)
|
|
''');
|
|
}
|
|
|
|
/// Converts Map to JSON string for database storage.
|
|
String _dataToJson(Map<String, dynamic> data) {
|
|
return jsonEncode(data);
|
|
}
|
|
|
|
/// Converts JSON string from database to Map.
|
|
Map<String, dynamic> _jsonToData(String jsonString) {
|
|
return jsonDecode(jsonString) as Map<String, dynamic>;
|
|
}
|
|
|
|
/// Gets the path to the database file.
|
|
Future<String> _getDatabasePath() async {
|
|
final documentsDirectory = await getApplicationDocumentsDirectory();
|
|
return path.join(documentsDirectory.path, 'local_storage.db');
|
|
}
|
|
|
|
/// Ensures the database is initialized.
|
|
///
|
|
/// Throws [Exception] if database is not initialized.
|
|
void _ensureInitialized() {
|
|
if (_database == null) {
|
|
throw Exception('LocalStorageService not initialized. Call initialize() first.');
|
|
}
|
|
}
|
|
|
|
/// Inserts a new item into the database.
|
|
///
|
|
/// [item] - The item to insert.
|
|
///
|
|
/// Throws [Exception] if insertion fails or if item with same ID already exists.
|
|
Future<void> insertItem(Item item) async {
|
|
_ensureInitialized();
|
|
try {
|
|
final map = item.toMap();
|
|
map['data'] = _dataToJson(map['data'] as Map<String, dynamic>);
|
|
await _database!.insert(
|
|
'items',
|
|
map,
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
} catch (e) {
|
|
throw Exception('Failed to insert item: $e');
|
|
}
|
|
}
|
|
|
|
/// Retrieves an item by its ID.
|
|
///
|
|
/// [id] - The unique identifier of the item.
|
|
///
|
|
/// Returns the [Item] if found, null otherwise.
|
|
/// Throws [Exception] if retrieval fails.
|
|
Future<Item?> getItem(String id) async {
|
|
_ensureInitialized();
|
|
try {
|
|
final List<Map<String, dynamic>> maps = await _database!.query(
|
|
'items',
|
|
where: 'id = ?',
|
|
whereArgs: [id],
|
|
limit: 1,
|
|
);
|
|
|
|
if (maps.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
// Parse data from JSON string
|
|
final row = maps.first;
|
|
final dataMap = _jsonToData(row['data'] as String);
|
|
|
|
return Item(
|
|
id: row['id'] as String,
|
|
data: dataMap,
|
|
createdAt: row['created_at'] as int,
|
|
updatedAt: row['updated_at'] as int,
|
|
);
|
|
} catch (e) {
|
|
throw Exception('Failed to get item: $e');
|
|
}
|
|
}
|
|
|
|
/// Retrieves all items from the database.
|
|
///
|
|
/// Returns a list of all [Item] instances, ordered by creation date (newest first).
|
|
/// Returns empty list if no items exist.
|
|
/// Throws [Exception] if retrieval fails.
|
|
Future<List<Item>> getAllItems() async {
|
|
_ensureInitialized();
|
|
try {
|
|
final List<Map<String, dynamic>> maps = await _database!.query(
|
|
'items',
|
|
orderBy: 'created_at DESC',
|
|
);
|
|
|
|
return maps.map((row) {
|
|
final dataMap = _jsonToData(row['data'] as String);
|
|
return Item(
|
|
id: row['id'] as String,
|
|
data: dataMap,
|
|
createdAt: row['created_at'] as int,
|
|
updatedAt: row['updated_at'] as int,
|
|
);
|
|
}).toList();
|
|
} catch (e) {
|
|
throw Exception('Failed to get all items: $e');
|
|
}
|
|
}
|
|
|
|
/// Deletes an item by its ID.
|
|
///
|
|
/// [id] - The unique identifier of the item to delete.
|
|
///
|
|
/// Throws [Exception] if deletion fails.
|
|
Future<void> deleteItem(String id) async {
|
|
_ensureInitialized();
|
|
try {
|
|
final deleted = await _database!.delete(
|
|
'items',
|
|
where: 'id = ?',
|
|
whereArgs: [id],
|
|
);
|
|
|
|
if (deleted == 0) {
|
|
throw Exception('Item with id $id not found');
|
|
}
|
|
} catch (e) {
|
|
throw Exception('Failed to delete item: $e');
|
|
}
|
|
}
|
|
|
|
/// Updates an existing item in the database.
|
|
///
|
|
/// [item] - The item with updated data. The ID must exist in the database.
|
|
///
|
|
/// Throws [Exception] if update fails or if item doesn't exist.
|
|
Future<void> updateItem(Item item) async {
|
|
_ensureInitialized();
|
|
try {
|
|
// Update the updated_at timestamp
|
|
final updatedItem = item.copyWith(
|
|
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
|
);
|
|
|
|
final map = updatedItem.toMap();
|
|
map['data'] = _dataToJson(map['data'] as Map<String, dynamic>);
|
|
|
|
final updated = await _database!.update(
|
|
'items',
|
|
map,
|
|
where: 'id = ?',
|
|
whereArgs: [item.id],
|
|
);
|
|
|
|
if (updated == 0) {
|
|
throw Exception('Item with id ${item.id} not found');
|
|
}
|
|
} catch (e) {
|
|
throw Exception('Failed to update item: $e');
|
|
}
|
|
}
|
|
|
|
/// Gets a cached image file, downloading it if not available in cache.
|
|
///
|
|
/// [url] - The URL of the image to fetch.
|
|
///
|
|
/// Returns a [File] pointing to the cached image.
|
|
///
|
|
/// Behavior:
|
|
/// - If image exists in cache, returns the cached file immediately.
|
|
/// - If image is not cached, downloads it, stores in cache, and returns the file.
|
|
///
|
|
/// Throws [Exception] if download fails or cache directory is not initialized.
|
|
Future<File> getCachedImage(String url) async {
|
|
if (_cacheDirectory == null) {
|
|
throw Exception('Cache directory not initialized. Call initialize() first.');
|
|
}
|
|
|
|
// Generate cache file name from URL (using hash to avoid invalid file names)
|
|
final urlHash = url.hashCode.toString();
|
|
final cacheFile = File(path.join(_cacheDirectory!.path, urlHash));
|
|
|
|
// Check if file exists in cache
|
|
if (await cacheFile.exists()) {
|
|
_cacheStatus[url] = true;
|
|
return cacheFile;
|
|
}
|
|
|
|
// Download and cache the image
|
|
try {
|
|
final response = await http.get(Uri.parse(url));
|
|
|
|
if (response.statusCode != 200) {
|
|
throw Exception('Failed to download image: HTTP ${response.statusCode}');
|
|
}
|
|
|
|
await cacheFile.writeAsBytes(response.bodyBytes);
|
|
_cacheStatus[url] = true;
|
|
|
|
return cacheFile;
|
|
} catch (e) {
|
|
throw Exception('Failed to cache image: $e');
|
|
}
|
|
}
|
|
|
|
/// Clears all cached images.
|
|
///
|
|
/// Throws [Exception] if cache directory is not initialized.
|
|
Future<void> clearImageCache() async {
|
|
if (_cacheDirectory == null) {
|
|
throw Exception('Cache directory not initialized. Call initialize() first.');
|
|
}
|
|
|
|
try {
|
|
if (await _cacheDirectory!.exists()) {
|
|
await for (final entity in _cacheDirectory!.list()) {
|
|
if (entity is File) {
|
|
await entity.delete();
|
|
}
|
|
}
|
|
}
|
|
_cacheStatus.clear();
|
|
} catch (e) {
|
|
throw Exception('Failed to clear image cache: $e');
|
|
}
|
|
}
|
|
|
|
/// Closes the database connection.
|
|
///
|
|
/// Should be called when the service is no longer needed.
|
|
Future<void> close() async {
|
|
await _database?.close();
|
|
_database = null;
|
|
}
|
|
}
|
|
|