diff --git a/example/lib/main.dart b/example/lib/main.dart index 008f35f..4414f46 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:indexed_entity_store_example/src/examples/async_value_group_and_detail.dart'; import 'package:indexed_entity_store_example/src/examples/binary_data_storage.dart'; +import 'package:indexed_entity_store_example/src/examples/disk_file_store/disk_file_storage_example.dart'; import 'package:indexed_entity_store_example/src/examples/future_value_listenable_group_and_detail.dart'; import 'package:indexed_entity_store_example/src/examples/simple_synchronous.dart'; import 'package:path_provider/path_provider.dart'; @@ -47,6 +48,7 @@ class _ExampleSelectorState extends State { '`Future>`-based product list & detail view': AsynchronousGroupDetailExample(), 'Binary data storage': BinaryDataStorageExample(), + 'Disk-based file storage': DiskFileStorageExample(), }; @override diff --git a/example/lib/src/examples/disk_file_store/disk_file_storage_example.dart b/example/lib/src/examples/disk_file_store/disk_file_storage_example.dart new file mode 100644 index 0000000..b7d8faa --- /dev/null +++ b/example/lib/src/examples/disk_file_store/disk_file_storage_example.dart @@ -0,0 +1,228 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:indexed_entity_store_example/src/examples/disk_file_store/disk_file_store.dart'; +import 'package:indexed_entity_store_example/src/stores/database_helper.dart'; +import 'package:path_provider/path_provider.dart'; + +class DiskFileStorageExample extends StatefulWidget { + const DiskFileStorageExample({super.key}); + + @override + State createState() => _DiskFileStorageExampleState(); +} + +class _DiskFileStorageExampleState extends State { + late final String baseDirectory; + + var baseDirectoryFiles = []; + + final database = getNewDatabase(); + + DiskFileStore? fileStore; + + late final storeFiles = fileStore!.query(); + + @override + void initState() { + super.initState(); + + getApplicationDocumentsDirectory().then((value) { + setState(() { + debugPrint('getApplicationDocumentsDirectory: ${value.path}'); + + baseDirectory = (Directory.fromUri(value.uri + .resolve('./file_store_example/${FlutterTimeline.now}')) + ..createSync(recursive: true)) + .path; + + if (kDebugMode) { + print('baseDirectory: $baseDirectory'); + } + + fileStore = DiskFileStore( + database, + entityKey: 'files', + baseDirectory: baseDirectory, + getPrimaryKey: (i) => i.id, + getIndices: (index) {}, + serializeMetadata: (e) => e.toJSONString(), + deserializeMetadata: ImageMetadata.fromJSONString, + ); + }); + }); + } + + @override + Widget build(BuildContext context) { + if (fileStore == null) { + return const CupertinoActivityIndicator(); + } + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Files in `baseDirectory`:', + style: TextStyle(fontSize: 20)), + CupertinoButton( + onPressed: _updateDirectoryListing, + child: const Text('Reload'), + ), + for (final file in baseDirectoryFiles) Text(file), + const Text('Files in `DiskFileStore`:', + style: TextStyle(fontSize: 20)), + Row( + children: [ + CupertinoButton( + onPressed: _loadNewFile, + child: const Text('Load another'), + ), + CupertinoButton( + onPressed: _updateFirstFile, + child: const Text('Update first'), + ), + CupertinoButton( + onPressed: _deleteLast, + child: const Text('Delete last'), + ), + ], + ), + ValueListenableBuilder( + valueListenable: storeFiles, + builder: (context, storeFiles, _) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final storeFile in storeFiles) + Row( + children: [ + SizedBox( + width: 100, + // NOTE(tp): Even though the image's (PNG) data is available synchronously now, the decoding still happens asynchronously in the framework as multiple frames, + // thus the UI minimally flickers initially as no explicit height is given here. + child: Image.file(File(storeFile.filepath)), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'ID: ${storeFile.metadata.id}\n' + 'Filepath: ${storeFile.filepath}\n' + 'Fetched at: ${storeFile.metadata.fetchedAt}', + ), + ) + ], + ), + ], + ); + }, + ) + ], + ), + ); + } + + void _updateDirectoryListing() { + setState(() { + baseDirectoryFiles = [ + for (final entry in Directory(baseDirectory) + .listSync(recursive: true) + .whereType()) + entry.path, + ]; + }); + } + + Future<(String, File)> fetchNewFile() async { + final tempDirectory = await getApplicationCacheDirectory(); + + final imageKey = DateTime.now().microsecondsSinceEpoch; + + final url = 'https://api.multiavatar.com/$imageKey.png'; + final response = await http.readBytes(Uri.parse(url)); + final file = await File.fromUri( + tempDirectory.uri.resolve('./profile_image_$imageKey.png')) + .writeAsBytes(response); + + return (url, file); + } + + Future _loadNewFile() async { + final (url, file) = await fetchNewFile(); + + fileStore!.write(( + metadata: ImageMetadata( + id: DateTime.now().microsecondsSinceEpoch, + fetchedAt: DateTime.now(), + url: url, + ), + filepath: file.path + )); + + _updateDirectoryListing(); + } + + Future _updateFirstFile() async { + final (url, file) = await fetchNewFile(); + + fileStore!.write(( + metadata: ImageMetadata( + id: storeFiles.value.first.metadata.id, + fetchedAt: DateTime.now(), + url: url, + ), + filepath: file.path + )); + + _updateDirectoryListing(); + } + + void _deleteLast() { + fileStore!.delete(key: storeFiles.value.last.metadata.id); + + _updateDirectoryListing(); + } + + @override + void dispose() { + storeFiles.dispose(); + database.dispose(); + + super.dispose(); + } +} + +class ImageMetadata { + final int id; + final DateTime fetchedAt; + final String url; + + ImageMetadata({ + required this.id, + required this.fetchedAt, + required this.url, + }); + + // These would very likely be created by [json_serializable](https://pub.dev/packages/json_serializable) + // or [freezed](https://pub.dev/packages/freezed) already for your models + String toJSONString() { + return jsonEncode({ + 'id': id, + 'fetchedAt': fetchedAt.millisecondsSinceEpoch, + 'url': url, + }); + } + + static ImageMetadata fromJSONString(String json) { + final jsonData = jsonDecode(json); + + return ImageMetadata( + id: jsonData['id'], + fetchedAt: DateTime.fromMillisecondsSinceEpoch(jsonData['fetchedAt']), + url: jsonData['url'], + ); + } +} diff --git a/example/lib/src/examples/disk_file_store/disk_file_store.dart b/example/lib/src/examples/disk_file_store/disk_file_store.dart new file mode 100644 index 0000000..6b8873b --- /dev/null +++ b/example/lib/src/examples/disk_file_store/disk_file_store.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:indexed_entity_store/indexed_entity_store.dart'; + +typedef FileWithMetadata = ({T metadata, String filepath}); + +class DiskFileStore< + // Metadata + T, + // Primary key + K> { + late final IndexedEntityStore, K> _store; + + final String _baseDirectory; + + final String _entityKey; + + final K Function(T) _getPrimaryKey; + + DiskFileStore( + IndexedEntityDabase database, { + required String entityKey, + + /// The base directory where files from this connector are stored + /// + /// Files are stored in subfolders like `$metadataId/$originalFilename` in order to preserver the filename and extension for e.g. sharing + /// + /// If a given file is outside of this directory, a copy will be created inside this structure. + /// Any previously existing file will be removed. + required String baseDirectory, + required K Function(T) getPrimaryKey, + required void Function(IndexCollector> index) + getIndices, + required String Function(T) serializeMetadata, + required T Function(String) deserializeMetadata, + }) : _baseDirectory = baseDirectory, + _entityKey = entityKey, + _getPrimaryKey = getPrimaryKey { + _store = database.entityStore( + _DiskFileConnector( + entityKey, + getPrimaryKey, + getIndices, + serializeMetadata, + deserializeMetadata, + ), + ); + } + + QueryResult?> read(K key) { + return _store.read(key); + } + + /// Writes the file + metadata to the store + /// + /// If needed, it copies the file to the entries directory, so that the store has its own copy of the data. + /// It is valid to the outside to modify this file in-place, without needed to update the store on every modification. + /// + /// Any previous files attached to this entry (under the same or different file name) are removed upon insert. + void write(FileWithMetadata e) { + final entityDirectoryPath = Uri.directory(_baseDirectory) + .resolve('./$_entityKey/${_getPrimaryKey(e.metadata)}/'); + final entityDirectory = Directory.fromUri(entityDirectoryPath); + + var filePath = e.filepath; + + // If file exists outside desired storage dir, copy it in + if (!e.filepath.startsWith( + entityDirectoryPath.toFilePath(windows: Platform.isWindows))) { + entityDirectory.createSync(recursive: true); + + filePath = File(e.filepath) + .copySync(entityDirectoryPath + .resolve('./${Uri.parse(e.filepath).pathSegments.last}') + .toFilePath(windows: Platform.isWindows)) + .path; + + debugPrint('Copied file from ${e.filepath} to $filePath'); + } + + for (final existingFile in entityDirectory.listSync().whereType()) { + if (existingFile.path != filePath) { + debugPrint('Deleting previous file $existingFile'); + existingFile.deleteSync(); + } + } + + return _store.write((metadata: e.metadata, filepath: filePath)); + } + + void delete({ + required K key, + }) { + final existingEntry = _store.readOnce(key); + if (existingEntry != null) { + File(existingEntry.filepath).deleteSync(); + + _store.delete(key: key); + } + } + + QueryResult>> query({ + QueryBuilder? where, + OrderByClause? orderBy, + int? limit, + }) { + return _store.query( + where: where, + orderBy: orderBy, + limit: limit, + ); + } +} + +class _DiskFileConnector + implements IndexedEntityConnector, K, Uint8List> { + _DiskFileConnector( + this.entityKey, + this._getPrimaryKey, + this._getIndices, + this._serialize, + this._deserialize, + ); + + @override + final String entityKey; + + final K Function(T) _getPrimaryKey; + + final void Function(IndexCollector> index) _getIndices; + + final String Function(T) _serialize; + + final T Function(String) _deserialize; + + @override + K getPrimaryKey(FileWithMetadata e) => _getPrimaryKey(e.metadata); + + @override + Uint8List serialize(FileWithMetadata e) { + final serializedMetadata = + const Utf8Encoder().convert(_serialize(e.metadata)); + final serializedFilepath = const Utf8Encoder().convert(e.filepath); + + final lengthHeader = Uint8List.view( + // uint32 is enough for 4GB of metadata + (ByteData(4)..setUint32(0, serializedMetadata.length)).buffer, + ); + + return (BytesBuilder(copy: false) + ..add(lengthHeader) + ..add(serializedMetadata) + ..add(serializedFilepath)) + .takeBytes(); + } + + @override + FileWithMetadata deserialize(Uint8List s) { + final metaDataLength = ByteData.view(s.buffer).getUint32(0); + + final metadata = _deserialize(const Utf8Decoder() + .convert(Uint8List.view(s.buffer, 4, metaDataLength))); + final filepath = const Utf8Decoder() + .convert(Uint8List.view(s.buffer, 4 + metaDataLength)); + + return ( + metadata: metadata, + filepath: filepath, + ); + } + + @override + void getIndices(IndexCollector> index) { + index((e) => e.filepath, as: '_internal_filepath', unique: true); + _getIndices(index); + } +}