From c527f820b2543ab954e0e73576f043eebb8a9d0e Mon Sep 17 00:00:00 2001 From: Timm Preetz Date: Sat, 9 Nov 2024 11:43:34 +0100 Subject: [PATCH] Add example showcasing storage of binary data --- example/lib/main.dart | 2 + .../lib/src/examples/binary_data_storage.dart | 287 ++++++++++++++++++ .../macos/Runner/DebugProfile.entitlements | 2 + example/pubspec.lock | 16 + example/pubspec.yaml | 2 + 5 files changed, 309 insertions(+) create mode 100644 example/lib/src/examples/binary_data_storage.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index f1aebc9..008f35f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,6 +2,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/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'; @@ -45,6 +46,7 @@ class _ExampleSelectorState extends State { AsyncValueGroupDetailExample(), '`Future>`-based product list & detail view': AsynchronousGroupDetailExample(), + 'Binary data storage': BinaryDataStorageExample(), }; @override diff --git a/example/lib/src/examples/binary_data_storage.dart b/example/lib/src/examples/binary_data_storage.dart new file mode 100644 index 0000000..3f85908 --- /dev/null +++ b/example/lib/src/examples/binary_data_storage.dart @@ -0,0 +1,287 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:http/http.dart' as http; +import 'package:indexed_entity_store/indexed_entity_store.dart'; +import 'package:indexed_entity_store_example/src/stores/database_helper.dart'; +import 'package:value_listenable_extensions/value_listenable_extensions.dart'; + +class BinaryDataStorageExample extends StatefulWidget { + const BinaryDataStorageExample({ + super.key, + }); + + @override + State createState() => + _BinaryDataStorageExampleState(); +} + +class _BinaryDataStorageExampleState extends State { + final database = getNewDatabase(); + + late final simpleRepository = SimpleImageRepository( + store: database.entityStore(plainImageConnector), + ); + late final withMetadataRepository = ImageWithMetadataRepository( + store: database.entityStore(imageWithMetadataConnector), + ); + + late var simpleImage = simpleRepository.getImage(); + late var metadataImage = withMetadataRepository.getImage(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Shows 2 approaches of how to store binary data. Press the refresh button to re-init, loading the images anew from memory.', + ), + const SizedBox(height: 20), + CupertinoButton.filled( + onPressed: _reInit, + child: const Text('Re-init'), + ), + const SizedBox(height: 20), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Simple binary storage', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ValueListenableBuilder( + valueListenable: simpleImage, + builder: (context, image, _) { + if (image == null) { + return const CupertinoActivityIndicator(); + } + + return 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.memory(image), + ); + }, + ), + const SizedBox(height: 20), + const Text( + 'Binary storage with metadata prefix', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ValueListenableBuilder( + valueListenable: metadataImage, + builder: (context, image, _) { + if (image == null) { + return const CupertinoActivityIndicator(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Image.memory(image.data), + ), + Text( + 'Fetched at ${image.metadata.fetchedAt}\nfrom ${image.metadata.url}', + ), + ], + ); + }, + ), + ], + ), + ), + ), + ], + ); + } + + @override + void dispose() { + simpleImage.dispose(); + metadataImage.dispose(); + + database.dispose(); + + super.dispose(); + } + + void _reInit() { + setState(() { + simpleImage.dispose(); + simpleImage = simpleRepository.getImage(); + + metadataImage.dispose(); + metadataImage = withMetadataRepository.getImage(); + }); + } +} + +/// Shows how to straightforwardly store binary data +/// +/// This is generally only advisable when no meta-data is needed, and the users know the key needed to access the items afterwards (as they can not be retrieved with the connector's current `deserialize` interface) +class SimpleImageRepository { + final PlainImageStore _simpleStore; + + SimpleImageRepository({ + required PlainImageStore store, + }) : _simpleStore = store; + + DisposableValueListenable getImage() { + const imageKey = 'profile_picture'; + + final data = _simpleStore.read(imageKey).transform((r) => r?.data); + + if (data.value == null) { + debugPrint('ImageRepository: Fetching image from network'); + // Fetch the data from the network if we don't have it yet + http + .readBytes(Uri.parse('https://api.multiavatar.com/$imageKey.png')) + .then((response) { + _simpleStore.write((key: imageKey, data: response)); + }); + } else { + debugPrint('ImageRepository: Using stored image'); + } + + return data; + } +} + +typedef PlainImageStore = IndexedEntityStore; + +typedef ImageRow = ({String key, Uint8List data}); + +/// Connector which stores a plain image (or any binary data) by key +final plainImageConnector = IndexedEntityConnector>( + entityKey: 'plain_image', + getPrimaryKey: (t) => t.key, + getIndices: (index) {}, + serialize: (t) => t.data, + // TODO(tp): In this example the key is not returned, which just shows the need to have support for additional meta-data which is shown in the second connector + deserialize: (s) => (key: '', data: Uint8List.fromList(s)), +); + +/// Shows how to store binary data with meta data +class ImageWithMetadataRepository { + final ImageWithMetadataStore _store; + + ImageWithMetadataRepository({ + required ImageWithMetadataStore store, + }) : _store = store; + + DisposableValueListenable getImage() { + const imageId = 12345678; + + final data = _store.read(imageId); + + if (data.value == null || + data.value!.metadata.fetchedAt + .isBefore(DateTime.now().subtract(const Duration(seconds: 10)))) { + debugPrint('ImageWithMetadataRepository: Fetching image from network'); + + final url = + 'https://api.multiavatar.com/${DateTime.now().millisecondsSinceEpoch}.png'; + + // Fetch the data from the network if we don't have it yet + http.readBytes(Uri.parse(url)).then((response) { + _store.write(( + metadata: ImageMetadata( + id: imageId, + fetchedAt: DateTime.now(), + url: url, + ), + data: response + )); + }); + } else { + debugPrint('ImageWithMetadataRepository: Using stored image'); + } + + return data; + } +} + +typedef ImageWithMetadataStore = IndexedEntityStore; + +typedef ImageWithMetadata = ({ImageMetadata metadata, Uint8List data}); + +final imageWithMetadataConnector = + IndexedEntityConnector( + entityKey: 'metadata_image', + getPrimaryKey: (t) => t.metadata.id, + getIndices: (index) { + index((t) => t.metadata.fetchedAt, as: 'fetchedAt'); + }, + serialize: (t) { + final metadataJSON = JsonUtf8Encoder().convert(t.metadata.toJSON()); + + final lengthHeader = Uint8List.view( + // uint32 is enough for 4GB of metadata + (ByteData(4)..setUint32(0, metadataJSON.length)).buffer, + ); + + return (BytesBuilder(copy: false) + ..add(lengthHeader) + ..add(metadataJSON) + ..add(t.data)) + .takeBytes(); + }, + deserialize: (s) { + final metaDataLength = ByteData.view(s.buffer).getUint32(0); + + // This creates a more efficient UTF8 JSON Decoder internally (https://stackoverflow.com/a/79158945) + final jsonDecoder = const Utf8Decoder().fuse(const JsonDecoder()); + final metaData = ImageMetadata.fromJSON( + jsonDecoder.convert(Uint8List.view(s.buffer, 4, metaDataLength)) + as Map, + ); + + return ( + metadata: metaData, + + // Assuming that the binary data is much bigger than the meta-data, + // this a view in the underlying storage and doesn't copy it out (this could be made adaptive, to + // copy when the binary data is actually less than e.g. half of the bytes) + data: Uint8List.view(s.buffer, 4 + metaDataLength).asUnmodifiableView(), + ); + }, +); + +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 + Map toJSON() { + return { + 'id': id, + 'fetchedAt': fetchedAt.millisecondsSinceEpoch, + 'url': url, + }; + } + + static ImageMetadata fromJSON(Map json) { + return ImageMetadata( + id: json['id'], + fetchedAt: DateTime.fromMillisecondsSinceEpoch(json['fetchedAt']), + url: json['url'], + ); + } +} + +// TODO(tp): Write abstraction for "binary with metadata" store diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements index dddb8a3..08c3ab1 100644 --- a/example/macos/Runner/DebugProfile.entitlements +++ b/example/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/example/pubspec.lock b/example/pubspec.lock index f493b6d..04d774d 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -99,6 +99,22 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" indexed_entity_store: dependency: "direct main" description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index d1485ac..8691e5c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -31,6 +31,8 @@ dependencies: riverpod: ^2.6.1 + http: ^1.2.2 + dev_dependencies: flutter_test: sdk: flutter