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.

320 lines
9.1 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.
///
/// Must be called before using any other methods.
///
/// Throws [Exception] if initialization fails.
Future<void> 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<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;
}
}

Powered by TurnKey Linux.