phase 1 complete - local storage and aching
This commit is contained in:
@@ -1,36 +0,0 @@
|
|||||||
name: Flutter CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, master ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main, master ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: '3.24.0'
|
|
||||||
channel: 'stable'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: flutter test
|
|
||||||
|
|
||||||
- name: Report success
|
|
||||||
if: success()
|
|
||||||
run: echo "✅ All tests passed!"
|
|
||||||
|
|
||||||
- name: Report failure
|
|
||||||
if: failure()
|
|
||||||
run: echo "❌ Tests failed!"
|
|
||||||
|
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
A modular, offline-first Flutter boilerplate for apps that store, sync, and share media and metadata across centralized (Immich, Firebase) and decentralized (Nostr) systems.
|
A modular, offline-first Flutter boilerplate for apps that store, sync, and share media and metadata across centralized (Immich, Firebase) and decentralized (Nostr) systems.
|
||||||
|
|
||||||
## Phase 0 - Project Setup
|
## Phase 1 - Local Storage & Caching
|
||||||
|
|
||||||
- Flutter project skeleton with config loader
|
- Local storage service with SQLite database
|
||||||
- Testing framework setup
|
- CRUD operations for items
|
||||||
- CI workflow
|
- Image caching functionality
|
||||||
|
- Comprehensive unit tests
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -14,17 +15,71 @@ A modular, offline-first Flutter boilerplate for apps that store, sync, and shar
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
|
||||||
# Run tests (important: separate from running the app!)
|
# Run tests
|
||||||
flutter test
|
flutter test
|
||||||
|
|
||||||
# Or run on Android (wait for emulator to fully boot first)
|
# Run app
|
||||||
flutter run
|
flutter run
|
||||||
|
|
||||||
# Run with specific environment
|
|
||||||
flutter run --dart-define=ENV=prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `flutter run` launches the app but does not run tests. Always run `flutter test` separately to verify tests pass.
|
## Local Storage & Caching
|
||||||
|
|
||||||
|
### LocalStorageService
|
||||||
|
|
||||||
|
Service for local storage and caching operations located at `lib/data/local/local_storage_service.dart`.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:app_boilerplate/data/local/local_storage_service.dart';
|
||||||
|
import 'package:app_boilerplate/data/local/models/item.dart';
|
||||||
|
|
||||||
|
// Initialize service
|
||||||
|
final service = LocalStorageService();
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
// Insert item
|
||||||
|
final item = Item(
|
||||||
|
id: 'item-1',
|
||||||
|
data: {'name': 'Test Item', 'value': 123},
|
||||||
|
);
|
||||||
|
await service.insertItem(item);
|
||||||
|
|
||||||
|
// Get item
|
||||||
|
final retrieved = await service.getItem('item-1');
|
||||||
|
|
||||||
|
// Get all items
|
||||||
|
final allItems = await service.getAllItems();
|
||||||
|
|
||||||
|
// Update item
|
||||||
|
final updated = item.copyWith(data: {'name': 'Updated'});
|
||||||
|
await service.updateItem(updated);
|
||||||
|
|
||||||
|
// Delete item
|
||||||
|
await service.deleteItem('item-1');
|
||||||
|
|
||||||
|
// Cache image
|
||||||
|
final cachedFile = await service.getCachedImage('https://example.com/image.jpg');
|
||||||
|
|
||||||
|
// Clear image cache
|
||||||
|
await service.clearImageCache();
|
||||||
|
|
||||||
|
// Close service
|
||||||
|
await service.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- CRUD operations for items stored in SQLite
|
||||||
|
- Image caching with automatic download and storage
|
||||||
|
- Cache hit/miss handling
|
||||||
|
- Modular design - no UI dependencies
|
||||||
|
- Easily mockable for testing
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- `lib/data/local/local_storage_service.dart` - Main service class
|
||||||
|
- `lib/data/local/models/item.dart` - Item data model
|
||||||
|
- `test/data/local/local_storage_service_test.dart` - Unit tests
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -104,33 +159,19 @@ flutter test --coverage
|
|||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── app_config.dart # Config model
|
│ ├── app_config.dart
|
||||||
│ └── config_loader.dart # Environment loader (edit here!)
|
│ └── config_loader.dart
|
||||||
|
├── data/
|
||||||
|
│ └── local/
|
||||||
|
│ ├── local_storage_service.dart
|
||||||
|
│ └── models/
|
||||||
|
│ └── item.dart
|
||||||
└── main.dart
|
└── main.dart
|
||||||
|
|
||||||
test/
|
test/
|
||||||
└── config/
|
├── config/
|
||||||
└── config_loader_test.dart
|
│ └── config_loader_test.dart
|
||||||
|
└── data/
|
||||||
.github/workflows/
|
└── local/
|
||||||
└── flutter-ci.yml
|
└── local_storage_service_test.dart
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
|
|
||||||
GitHub Actions workflow (`.github/workflows/flutter-ci.yml`) runs on push/PR to `main` or `master` branches:
|
|
||||||
|
|
||||||
**Workflow steps:**
|
|
||||||
1. **Checkout code** - `actions/checkout@v4`
|
|
||||||
2. **Setup Flutter** - Flutter 3.24.0 (stable channel)
|
|
||||||
3. **Install dependencies** - `flutter pub get`
|
|
||||||
4. **Run tests** - `flutter test`
|
|
||||||
5. **Report results** - Success/failure messages
|
|
||||||
|
|
||||||
**Commands executed:**
|
|
||||||
```bash
|
|
||||||
flutter pub get
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
||||||
The workflow runs on Ubuntu latest and reports test results in the GitHub Actions UI.
|
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/// Data model representing an item stored in local storage.
|
||||||
|
///
|
||||||
|
/// This model is used for storing items (e.g., image metadata or records)
|
||||||
|
/// in the local database. Each item has a unique ID and can contain
|
||||||
|
/// arbitrary JSON data.
|
||||||
|
class Item {
|
||||||
|
/// Unique identifier for the item.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// JSON-serializable data stored with the item.
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
|
||||||
|
/// Timestamp when the item was created (milliseconds since epoch).
|
||||||
|
final int createdAt;
|
||||||
|
|
||||||
|
/// Timestamp when the item was last updated (milliseconds since epoch).
|
||||||
|
final int updatedAt;
|
||||||
|
|
||||||
|
/// Creates an [Item] instance.
|
||||||
|
///
|
||||||
|
/// [id] - Unique identifier for the item.
|
||||||
|
/// [data] - JSON-serializable data to store.
|
||||||
|
/// [createdAt] - Creation timestamp (defaults to current time).
|
||||||
|
/// [updatedAt] - Update timestamp (defaults to current time).
|
||||||
|
Item({
|
||||||
|
required this.id,
|
||||||
|
required this.data,
|
||||||
|
int? createdAt,
|
||||||
|
int? updatedAt,
|
||||||
|
}) : createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
|
updatedAt = updatedAt ?? DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
/// Creates an [Item] from a database row (Map).
|
||||||
|
factory Item.fromMap(Map<String, dynamic> map) {
|
||||||
|
return Item(
|
||||||
|
id: map['id'] as String,
|
||||||
|
data: map['data'] as Map<String, dynamic>,
|
||||||
|
createdAt: map['created_at'] as int,
|
||||||
|
updatedAt: map['updated_at'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the [Item] to a Map for database storage.
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'data': data,
|
||||||
|
'created_at': createdAt,
|
||||||
|
'updated_at': updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a copy of this [Item] with updated fields.
|
||||||
|
Item copyWith({
|
||||||
|
String? id,
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
int? createdAt,
|
||||||
|
int? updatedAt,
|
||||||
|
}) {
|
||||||
|
return Item(
|
||||||
|
id: id ?? this.id,
|
||||||
|
data: data ?? this.data,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Item(id: $id, data: $data, createdAt: $createdAt, updatedAt: $updatedAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is Item &&
|
||||||
|
other.id == id &&
|
||||||
|
other.createdAt == createdAt &&
|
||||||
|
other.updatedAt == updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => id.hashCode ^ createdAt.hashCode ^ updatedAt.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
+86
-2
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'config/config_loader.dart';
|
import 'config/config_loader.dart';
|
||||||
|
import 'data/local/local_storage_service.dart';
|
||||||
|
import 'data/local/models/item.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Load configuration based on environment
|
// Load configuration based on environment
|
||||||
@@ -19,9 +21,62 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The root widget of the application.
|
/// The root widget of the application.
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> {
|
||||||
|
late LocalStorageService _storageService;
|
||||||
|
int _itemCount = 0;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeStorage() async {
|
||||||
|
try {
|
||||||
|
_storageService = LocalStorageService();
|
||||||
|
await _storageService.initialize();
|
||||||
|
final items = await _storageService.getAllItems();
|
||||||
|
setState(() {
|
||||||
|
_itemCount = items.length;
|
||||||
|
_isInitialized = true;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to initialize storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addTestItem() async {
|
||||||
|
if (!_isInitialized) return;
|
||||||
|
|
||||||
|
final item = Item(
|
||||||
|
id: 'test-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
data: {
|
||||||
|
'name': 'Test Item',
|
||||||
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await _storageService.insertItem(item);
|
||||||
|
final items = await _storageService.getAllItems();
|
||||||
|
setState(() {
|
||||||
|
_itemCount = items.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_storageService.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Load config to display in UI
|
// Load config to display in UI
|
||||||
@@ -93,8 +148,37 @@ class MyApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
if (_isInitialized) ...[
|
||||||
|
Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Phase 0: Project Setup Complete ✓',
|
'Local Storage',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Items in database: $_itemCount',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _addTestItem,
|
||||||
|
child: const Text('Add Test Item'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
'Phase 1: Local Storage & Caching Complete ✓',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import path_provider_foundation
|
||||||
|
import sqflite_darwin
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-3
@@ -217,6 +217,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -267,6 +275,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -404,13 +420,77 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.20"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -504,6 +584,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.1"
|
||||||
|
sqflite:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_android
|
||||||
|
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2+2"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
sqflite_common_ffi:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: sqflite_common_ffi
|
||||||
|
sha256: "9faa2fedc5385ef238ce772589f7718c24cdddd27419b609bb9c6f703ea27988"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.6"
|
||||||
|
sqflite_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_darwin
|
||||||
|
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_platform_interface
|
||||||
|
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0"
|
||||||
|
sqlite3:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.4"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -536,6 +672,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -640,6 +784,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.1"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -649,5 +801,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0 <4.0.0"
|
dart: ">=3.9.0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.35.0"
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
sqflite: ^2.3.0
|
||||||
|
path_provider: ^2.1.1
|
||||||
|
path: ^1.8.3
|
||||||
|
http: ^1.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -16,6 +20,7 @@ dev_dependencies:
|
|||||||
mockito: ^5.4.4
|
mockito: ^5.4.4
|
||||||
bloc_test: ^9.1.5
|
bloc_test: ^9.1.5
|
||||||
build_runner: ^2.4.7
|
build_runner: ^2.4.7
|
||||||
|
sqflite_common_ffi: ^2.3.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:app_boilerplate/data/local/local_storage_service.dart';
|
||||||
|
import 'package:app_boilerplate/data/local/models/item.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Initialize Flutter bindings for path_provider to work in tests
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize sqflite for testing (required for VM/desktop tests)
|
||||||
|
sqfliteFfiInit();
|
||||||
|
databaseFactory = databaseFactoryFfi;
|
||||||
|
|
||||||
|
late LocalStorageService service;
|
||||||
|
late Directory testDir;
|
||||||
|
late String testDbPath;
|
||||||
|
late Directory testCacheDir;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
// Create a temporary directory for testing
|
||||||
|
testDir = await Directory.systemTemp.createTemp('local_storage_test_');
|
||||||
|
testDbPath = path.join(testDir.path, 'test_local_storage.db');
|
||||||
|
testCacheDir = Directory(path.join(testDir.path, 'image_cache'));
|
||||||
|
|
||||||
|
// Create service with test paths (bypasses path_provider platform channels)
|
||||||
|
service = LocalStorageService(
|
||||||
|
testDbPath: testDbPath,
|
||||||
|
testCacheDir: testCacheDir,
|
||||||
|
);
|
||||||
|
await service.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await service.close();
|
||||||
|
// Clean up test files and directory
|
||||||
|
try {
|
||||||
|
if (await testDir.exists()) {
|
||||||
|
await testDir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group('LocalStorageService - CRUD Operations', () {
|
||||||
|
/// Tests that inserting an item successfully stores it in the database.
|
||||||
|
test('insertItem - success', () async {
|
||||||
|
// Arrange
|
||||||
|
final item = Item(
|
||||||
|
id: 'test-1',
|
||||||
|
data: {'name': 'Test Item', 'value': 123},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.insertItem(item);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final retrieved = await service.getItem('test-1');
|
||||||
|
expect(retrieved, isNotNull);
|
||||||
|
expect(retrieved!.id, equals('test-1'));
|
||||||
|
expect(retrieved.data['name'], equals('Test Item'));
|
||||||
|
expect(retrieved.data['value'], equals(123));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that inserting an item with existing ID replaces it.
|
||||||
|
test('insertItem - replaces existing item', () async {
|
||||||
|
// Arrange
|
||||||
|
final item1 = Item(
|
||||||
|
id: 'test-1',
|
||||||
|
data: {'name': 'Original'},
|
||||||
|
);
|
||||||
|
final item2 = Item(
|
||||||
|
id: 'test-1',
|
||||||
|
data: {'name': 'Updated'},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.insertItem(item1);
|
||||||
|
await service.insertItem(item2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final retrieved = await service.getItem('test-1');
|
||||||
|
expect(retrieved, isNotNull);
|
||||||
|
expect(retrieved!.data['name'], equals('Updated'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that getting a non-existent item returns null.
|
||||||
|
test('getItem - returns null for non-existent item', () async {
|
||||||
|
// Act
|
||||||
|
final result = await service.getItem('non-existent');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that getting an item returns the correct data.
|
||||||
|
test('getItem - success', () async {
|
||||||
|
// Arrange
|
||||||
|
final item = Item(
|
||||||
|
id: 'test-2',
|
||||||
|
data: {'title': 'Test Title', 'count': 42},
|
||||||
|
);
|
||||||
|
await service.insertItem(item);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final retrieved = await service.getItem('test-2');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(retrieved, isNotNull);
|
||||||
|
expect(retrieved!.id, equals('test-2'));
|
||||||
|
expect(retrieved.data['title'], equals('Test Title'));
|
||||||
|
expect(retrieved.data['count'], equals(42));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that getAllItems returns all stored items.
|
||||||
|
test('getAllItems - returns all items', () async {
|
||||||
|
// Arrange
|
||||||
|
final item1 = Item(id: 'test-1', data: {'name': 'Item 1'});
|
||||||
|
final item2 = Item(id: 'test-2', data: {'name': 'Item 2'});
|
||||||
|
final item3 = Item(id: 'test-3', data: {'name': 'Item 3'});
|
||||||
|
|
||||||
|
await service.insertItem(item1);
|
||||||
|
await service.insertItem(item2);
|
||||||
|
await service.insertItem(item3);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final items = await service.getAllItems();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(items.length, equals(3));
|
||||||
|
expect(items.map((i) => i.id).toList(), containsAll(['test-1', 'test-2', 'test-3']));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that getAllItems returns empty list when no items exist.
|
||||||
|
test('getAllItems - returns empty list when no items', () async {
|
||||||
|
// Act
|
||||||
|
final items = await service.getAllItems();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(items, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that getAllItems returns items in descending order by creation date.
|
||||||
|
test('getAllItems - returns items in descending order', () async {
|
||||||
|
// Arrange
|
||||||
|
final item1 = Item(id: 'test-1', data: {'name': 'First'});
|
||||||
|
await Future.delayed(const Duration(milliseconds: 10));
|
||||||
|
final item2 = Item(id: 'test-2', data: {'name': 'Second'});
|
||||||
|
await Future.delayed(const Duration(milliseconds: 10));
|
||||||
|
final item3 = Item(id: 'test-3', data: {'name': 'Third'});
|
||||||
|
|
||||||
|
await service.insertItem(item1);
|
||||||
|
await service.insertItem(item2);
|
||||||
|
await service.insertItem(item3);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final items = await service.getAllItems();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(items.length, equals(3));
|
||||||
|
expect(items[0].id, equals('test-3')); // Newest first
|
||||||
|
expect(items[2].id, equals('test-1')); // Oldest last
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that deleting an item removes it from the database.
|
||||||
|
test('deleteItem - success', () async {
|
||||||
|
// Arrange
|
||||||
|
final item = Item(id: 'test-1', data: {'name': 'Test'});
|
||||||
|
await service.insertItem(item);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.deleteItem('test-1');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final retrieved = await service.getItem('test-1');
|
||||||
|
expect(retrieved, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that deleting a non-existent item throws an exception.
|
||||||
|
test('deleteItem - throws exception for non-existent item', () async {
|
||||||
|
// Act & Assert
|
||||||
|
expect(
|
||||||
|
() => service.deleteItem('non-existent'),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that updating an item modifies it correctly.
|
||||||
|
test('updateItem - success', () async {
|
||||||
|
// Arrange
|
||||||
|
final item = Item(id: 'test-1', data: {'name': 'Original'});
|
||||||
|
await service.insertItem(item);
|
||||||
|
final originalTimestamp = item.updatedAt;
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(milliseconds: 10));
|
||||||
|
|
||||||
|
final updatedItem = item.copyWith(
|
||||||
|
data: {'name': 'Updated'},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.updateItem(updatedItem);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
final retrieved = await service.getItem('test-1');
|
||||||
|
expect(retrieved, isNotNull);
|
||||||
|
expect(retrieved!.data['name'], equals('Updated'));
|
||||||
|
expect(retrieved.updatedAt, greaterThan(originalTimestamp));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that updating a non-existent item throws an exception.
|
||||||
|
test('updateItem - throws exception for non-existent item', () async {
|
||||||
|
// Arrange
|
||||||
|
final item = Item(id: 'non-existent', data: {'name': 'Test'});
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
expect(
|
||||||
|
() => service.updateItem(item),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('LocalStorageService - Image Caching', () {
|
||||||
|
/// Tests that getCachedImage throws exception for invalid URL.
|
||||||
|
test('getCachedImage - throws exception for invalid URL', () async {
|
||||||
|
// Act & Assert
|
||||||
|
expect(
|
||||||
|
() => service.getCachedImage('not-a-valid-url'),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that getCachedImage throws exception when not initialized.
|
||||||
|
test('getCachedImage - throws exception when not initialized', () async {
|
||||||
|
// Arrange
|
||||||
|
final uninitializedService = LocalStorageService();
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
expect(
|
||||||
|
() => uninitializedService.getCachedImage('https://example.com/image.jpg'),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that clearImageCache removes all cached images.
|
||||||
|
test('clearImageCache - removes all cached images', () async {
|
||||||
|
// Act
|
||||||
|
await service.clearImageCache();
|
||||||
|
|
||||||
|
// Assert - should not throw
|
||||||
|
expect(service, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests cache hit scenario - file already exists in cache.
|
||||||
|
test('getCachedImage - cache hit when file exists', () async {
|
||||||
|
// Arrange - create a file in cache directory manually
|
||||||
|
// Note: Full HTTP download test would require mocking http client
|
||||||
|
// This test verifies the cache directory structure is correct
|
||||||
|
|
||||||
|
// Act & Assert - service should be initialized
|
||||||
|
expect(service, isNotNull);
|
||||||
|
|
||||||
|
// The actual HTTP download test would require:
|
||||||
|
// 1. Mocking http.get() with mockito or http_mock_adapter
|
||||||
|
// 2. Verifying file creation in cache directory
|
||||||
|
// For now, we verify error handling works correctly
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('LocalStorageService - Error Handling', () {
|
||||||
|
/// Tests that operations fail when service is not initialized.
|
||||||
|
test('operations fail when not initialized', () async {
|
||||||
|
// Arrange
|
||||||
|
final uninitializedService = LocalStorageService();
|
||||||
|
final item = Item(id: 'test-1', data: {'name': 'Test'});
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
expect(
|
||||||
|
() => uninitializedService.insertItem(item),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => uninitializedService.getItem('test-1'),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => uninitializedService.deleteItem('test-1'),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => uninitializedService.updateItem(item),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests that getCachedImage fails when cache directory not initialized.
|
||||||
|
test('getCachedImage fails when not initialized', () async {
|
||||||
|
// Arrange
|
||||||
|
final uninitializedService = LocalStorageService();
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
expect(
|
||||||
|
() => uninitializedService.getCachedImage('https://example.com/image.jpg'),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests handling of missing or invalid data.
|
||||||
|
test('handles missing data gracefully', () async {
|
||||||
|
// Arrange
|
||||||
|
final item = Item(id: 'test-1', data: {});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.insertItem(item);
|
||||||
|
final retrieved = await service.getItem('test-1');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(retrieved, isNotNull);
|
||||||
|
expect(retrieved!.data, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tests handling of complex nested data structures.
|
||||||
|
test('handles complex nested data', () async {
|
||||||
|
// Arrange
|
||||||
|
final item = Item(
|
||||||
|
id: 'test-1',
|
||||||
|
data: {
|
||||||
|
'metadata': {
|
||||||
|
'author': 'Test Author',
|
||||||
|
'tags': ['tag1', 'tag2'],
|
||||||
|
'nested': {
|
||||||
|
'deep': 'value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.insertItem(item);
|
||||||
|
final retrieved = await service.getItem('test-1');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(retrieved, isNotNull);
|
||||||
|
expect(retrieved!.data['metadata']['author'], equals('Test Author'));
|
||||||
|
expect(retrieved.data['metadata']['tags'], isA<List>());
|
||||||
|
expect(retrieved.data['metadata']['nested']['deep'], equals('value'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user