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 _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. /// /// Must be called before using any other methods. /// /// Throws [Exception] if initialization fails. Future initialize() async { try { // Initialize database final dbPath = _testDbPath ?? await _getDatabasePath(); _database = await openDatabase( dbPath, version: 1, onCreate: _onCreate, ); // Initialize cache directory 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); } } catch (e) { throw Exception('Failed to initialize LocalStorageService: $e'); } } /// Creates the database schema if it doesn't exist. Future _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 data) { return jsonEncode(data); } /// Converts JSON string from database to Map. Map _jsonToData(String jsonString) { return jsonDecode(jsonString) as Map; } /// Gets the path to the database file. Future _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 insertItem(Item item) async { _ensureInitialized(); try { final map = item.toMap(); map['data'] = _dataToJson(map['data'] as Map); 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 getItem(String id) async { _ensureInitialized(); try { final List> 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> getAllItems() async { _ensureInitialized(); try { final List> 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 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 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); 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 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 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 close() async { await _database?.close(); _database = null; } }