diff --git a/.github/.cspell/gamedev_dictionary.txt b/.github/.cspell/gamedev_dictionary.txt index 7b5f53dc8e4..ee4f59bd91c 100644 --- a/.github/.cspell/gamedev_dictionary.txt +++ b/.github/.cspell/gamedev_dictionary.txt @@ -154,3 +154,4 @@ viewport's viewports vsync widget's +unawaited diff --git a/.gitignore b/.gitignore index 047dce184b2..b239f8dda87 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ pubspec.lock .pub/ android/ ios/ +macos/ +windows/ +linux/ desktop/ build/ coverage/ diff --git a/doc/bridge_packages/bridge_packages.md b/doc/bridge_packages/bridge_packages.md index df03395d513..a64f508c327 100644 --- a/doc/bridge_packages/bridge_packages.md +++ b/doc/bridge_packages/bridge_packages.md @@ -25,6 +25,11 @@ A Box2D physics engine (bridge package for [Forge2D]). Use isolates to offload heavy computations to another thread. ::: +:::{package} flame_network_assets + +Fetch assets over the network. +::: + :::{package} flame_oxygen Replace FCS with the Oxygen Entity Component System. @@ -68,6 +73,7 @@ flame_fire_atlas flame_forge2d flame_isolate flame_lottie +flame_network_assets flame_oxygen flame_rive flame_splash_screen diff --git a/doc/bridge_packages/flame_network_assets/flame_network_assets.md b/doc/bridge_packages/flame_network_assets/flame_network_assets.md new file mode 100644 index 00000000000..29d80596cc5 --- /dev/null +++ b/doc/bridge_packages/flame_network_assets/flame_network_assets.md @@ -0,0 +1,35 @@ +# FlameNetworkAssets + +`FlameNetworkAssets` is a bridge package focused on providing a solution to fetch, and cache assets +from the network. + +The `FlameNetworkAssets` class provides an abstraction that should be extended in order to create +asset specific handler. + +By default, the package relies on the `http` package to make http requests, and `path_provider` +to get the place to store the local cache, to use a different approach for those, use the +optional arguments in the constructor. + +This package bundles a specific asset handler class for images: + +```dart +final networkAssets = FlameNetworkImages(); +final playerSprite = await networkAssets.load('https://url.com/image.png'); +``` + +To create a specific asset handler class, you just need to extend the `FlameNetworkAssets` class +and define the `decodeAsset` and `endcodeAsset` arguments: + +```dart +class FlameNetworkCustomAsset extends FlameNetworkAssets { + FlameNetworkImages({ + super.get, + super.getAppDirectory, + super.cacheInMemory, + super.cacheInStorage, + }) : super( + decodeAsset: (bytes) => CustomAsset.decode(bytes), + encodeAsset: (CustomAsset asset) => asset.encode(), + ); +} +``` diff --git a/doc/flame/rendering/images.md b/doc/flame/rendering/images.md index 63858c8da6c..44ce55c02d0 100644 --- a/doc/flame/rendering/images.md +++ b/doc/flame/rendering/images.md @@ -120,6 +120,32 @@ class MyGame extends Game { ``` +## Loading images over the network + +The Flame core package doesn't offer a built in method to loading images from the network. + +The reason for that is that Flutter/Dart does not have a built in http client, which requires +a package to be used and since there are a couple of packages available out there, we refrain +from forcing the user to use a specific package. + +With that said, it is quite simple to load images from the network once a http client package +is chosen by the user. The following snippet shows how an `Image` can be fetched from the web +using the [http](https://pub.dev/packages/http) package. + +```dart +import 'package:http/http.dart' as http; +import 'package:flutter/painting.dart'; + +final response = await http.get('https://url.com/image.png'); +final image = await decodeImageFromList(response.bytes); +``` + +```{note} +Check [`flame_network_assets`](https://pub.dev/packages/flame_network_assets) +for a ready to use network assets solution that provides a built in cache. +``` + + ## Sprite Flame offers a `Sprite` class that represents an image, or a region of an image. diff --git a/packages/flame_network_assets/.metadata b/packages/flame_network_assets/.metadata new file mode 100644 index 00000000000..4161da6ea4e --- /dev/null +++ b/packages/flame_network_assets/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + channel: stable + +project_type: package diff --git a/packages/flame_network_assets/CHANGELOG.md b/packages/flame_network_assets/CHANGELOG.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/flame_network_assets/LICENSE b/packages/flame_network_assets/LICENSE new file mode 100644 index 00000000000..fdf29892d27 --- /dev/null +++ b/packages/flame_network_assets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/flame_network_assets/README.md b/packages/flame_network_assets/README.md new file mode 100644 index 00000000000..824353e1147 --- /dev/null +++ b/packages/flame_network_assets/README.md @@ -0,0 +1,25 @@ +# flame_network_assets + + +

+ + flame + +

+ +

+Adds network images support to Flame. +

+ +

+ Test + +

+ + +--- + +This package makes it easy to use and cache assets from the network inside a Flame game. + +For instructions on how to use this package to load images, +check [Flame docs](https://docs.flame-engine.org/1.6.0/bridge_packages/flame_network_assets/flame_network_assets.html). diff --git a/packages/flame_network_assets/analysis_options.yaml b/packages/flame_network_assets/analysis_options.yaml new file mode 100644 index 00000000000..10e961d26fa --- /dev/null +++ b/packages/flame_network_assets/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flame_lint/analysis_options.yaml + +linter: + rules: + - public_member_api_docs diff --git a/packages/flame_network_assets/example/.metadata b/packages/flame_network_assets/example/.metadata new file mode 100644 index 00000000000..083a42e9bd5 --- /dev/null +++ b/packages/flame_network_assets/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: android + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: ios + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: linux + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: macos + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: web + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: windows + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/flame_network_assets/example/README.md b/packages/flame_network_assets/example/README.md new file mode 100644 index 00000000000..8d457466468 --- /dev/null +++ b/packages/flame_network_assets/example/README.md @@ -0,0 +1,3 @@ +# flame_network_images example + +An example app that shows how to use the `flame_network_images` package. diff --git a/packages/flame_network_assets/example/analysis_options.yaml b/packages/flame_network_assets/example/analysis_options.yaml new file mode 100644 index 00000000000..85732fa02fd --- /dev/null +++ b/packages/flame_network_assets/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/flame_network_assets/example/lib/main.dart b/packages/flame_network_assets/example/lib/main.dart new file mode 100644 index 00000000000..0993f1d7fd0 --- /dev/null +++ b/packages/flame_network_assets/example/lib/main.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/game.dart'; +import 'package:flame/input.dart'; +import 'package:flame_network_assets/flame_network_assets.dart'; +import 'package:flutter/material.dart' hide Image; + +void main() { + runApp(const GameWidget.controlled(gameFactory: MyGame.new)); +} + +class MyGame extends FlameGame with TapDetector { + final networkImages = FlameNetworkImages(); + late Image playerSprite; + + @override + Future onLoad() async { + playerSprite = await networkImages.load( + 'https://examples.flame-engine.org/assets/assets/images/bomb_ptero.png', + ); + } + + @override + bool onTapUp(TapUpInfo info) { + add( + SpriteAnimationComponent.fromFrameData( + playerSprite, + SpriteAnimationData.sequenced( + textureSize: Vector2(48, 32), + amount: 4, + stepTime: .2, + ), + size: Vector2(100, 50), + anchor: Anchor.center, + position: info.eventPosition.game, + ), + ); + + return true; + } +} diff --git a/packages/flame_network_assets/example/pubspec.yaml b/packages/flame_network_assets/example/pubspec.yaml new file mode 100644 index 00000000000..25354a4b817 --- /dev/null +++ b/packages/flame_network_assets/example/pubspec.yaml @@ -0,0 +1,17 @@ +name: flame_network_assets_example +description: A flame network assets example. +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=2.18.0 <3.0.0' + +dependencies: + flame: ^1.6.0 + flame_network_assets: ^0.1.0 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^0.1.3 diff --git a/packages/flame_network_assets/lib/flame_network_assets.dart b/packages/flame_network_assets/lib/flame_network_assets.dart new file mode 100644 index 00000000000..97162792f00 --- /dev/null +++ b/packages/flame_network_assets/lib/flame_network_assets.dart @@ -0,0 +1,5 @@ +library flame_network_assets; + +export 'src/flame_asset_response.dart'; +export 'src/flame_network_assets.dart'; +export 'src/flame_network_images.dart'; diff --git a/packages/flame_network_assets/lib/src/flame_asset_response.dart b/packages/flame_network_assets/lib/src/flame_asset_response.dart new file mode 100644 index 00000000000..319b7e8cf57 --- /dev/null +++ b/packages/flame_network_assets/lib/src/flame_asset_response.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; + +/// {@template flame_assets_response} +/// A class containing the relevant http attributes to +/// Flame Assets Network package. +/// {@endtemplate} +class FlameAssetResponse { + /// {@macro flame_assets_response} + const FlameAssetResponse({ + required this.statusCode, + required this.bytes, + }); + + /// Http status code. + final int statusCode; + + /// response bytes. + final Uint8List bytes; +} diff --git a/packages/flame_network_assets/lib/src/flame_network_assets.dart b/packages/flame_network_assets/lib/src/flame_network_assets.dart new file mode 100644 index 00000000000..11d2d947c0b --- /dev/null +++ b/packages/flame_network_assets/lib/src/flame_network_assets.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flame/cache.dart'; +import 'package:flame_network_assets/flame_network_assets.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +/// Function signature used by Flame Network Assets to fetch assets. +typedef GetAssetFunction = Future Function( + String url, { + Map? headers, +}); + +/// Function signature used by Flame Network Assets to decode assets from a +/// raw format. +typedef DecodeAssetFunction = Future Function(Uint8List); + +/// Function signature used by Flame Network Assets to encode assets to a +/// raw format. +typedef EncodeAssetFunction = Future Function(T); + +/// Function signature by Flame Network Assets to get the app directory +/// which is used for the local storage caching. +typedef GetAppDirectoryFunction = Future Function(); + +/// {@template flame_network_assets} +/// +/// [FlameNetworkAssets] is a class similar to Flame's assets classes (like +/// [Images] for example), but instead of loading assets from the assets bundle, +/// it loads from networks urls. +/// +/// By default, [FlameNetworkAssets] uses the [http.get] method to make the +/// requests. It can be customized by passing a different [GetAssetFunction] to +/// the `get` argument on the constructor. +/// +/// [FlameNetworkAssets] also will automatically cache files in a two layer +/// system. +/// +/// The first layer is an in-memory cache, handled by an internal [MemoryCache], +/// while the second one is the device's own file system, where images are +/// cached in the application document directory, which by default is provided +/// by path_providers' [getApplicationDocumentsDirectory] method, and can +/// be customized using the `getAppDirectory` argument in the constructor. +/// +/// When an asset is requested, [FlameAssetResponse] will first check on its +/// cache layers before making the http request, if both layer are cache miss, +/// then the request is made and both layers set with the response. +/// +/// Another important note about the cache layers is that the first layer, is a +/// per instance cache, while the local storage is an app global cache. This +/// means that two different [FlameAssetResponse] instances will have the same +/// local storage cache, but not the same memory cache. +/// +/// Note that the local storage layer is not present when running on web since +/// that platform doesn't really have a file system. The browser caching will +/// work as a similar replacement for this layer, though that can't be +/// controlled by this package, make sure that the server where the images +/// are being fetched returns the correct cache header to make the browser +/// cache the assets. +/// +/// Each cache layer can be disabled by the [cacheInMemory] or [cacheInStorage] +/// argument on the constructor. +/// +/// {@endtemplate} +abstract class FlameNetworkAssets { + /// {@macro flame_network_assets} + /// + /// - [decodeAsset] a [DecodeAssetFunction] responsible for decoding the asset + /// from its raw format. + /// - [encodeAsset] a [EncodeAssetFunction] responsible for encoding the asset + /// to its raw format. + /// - [get] is an optional [GetAssetFunction], if omitted [http.get] is used + /// by default. + /// - [getAppDirectory] is an optional [GetAppDirectoryFunction], if omitted + /// [getApplicationDocumentsDirectory] is used by default. + /// - [cacheInMemory] will not cache assets in the memory when false, + /// (true by default). + /// - [cacheInStorage] will not cache assets in the file system when false, + /// (true by default). + FlameNetworkAssets({ + required DecodeAssetFunction decodeAsset, + required EncodeAssetFunction encodeAsset, + GetAssetFunction? get, + GetAppDirectoryFunction? getAppDirectory, + this.cacheInMemory = true, + this.cacheInStorage = true, + }) : _isWeb = kIsWeb, + _decode = decodeAsset, + _encode = encodeAsset { + _get = get ?? + ( + String url, { + Map? headers, + }) => + http.get(Uri.parse(url), headers: headers).then((response) { + return FlameAssetResponse( + statusCode: response.statusCode, + bytes: response.bodyBytes, + ); + }); + + _getAppDirectory = getAppDirectory ?? getApplicationDocumentsDirectory; + } + + late final GetAssetFunction _get; + late final GetAppDirectoryFunction _getAppDirectory; + final DecodeAssetFunction _decode; + final EncodeAssetFunction _encode; + + /// Flag indicating if files will be cached in memory. + final bool cacheInMemory; + + /// Flag indicating if files will be cached in the local storage. + final bool cacheInStorage; + + final bool _isWeb; + + final _memoryCache = MemoryCache(); + + String _urlToId(String url) { + final bytes = utf8.encode(url); + return base64.encode(bytes); + } + + /// Loads the asset from the given url. + Future load( + String url, { + Map? headers, + }) async { + final id = _urlToId(url); + + final memoryCacheValue = _memoryCache.getValue(id); + if (memoryCacheValue != null) { + return memoryCacheValue; + } + + if (!_isWeb && cacheInStorage) { + final storageAsset = await _fetchAssetFromStorageCache(id); + if (storageAsset != null) { + if (cacheInMemory) { + _memoryCache.setValue(id, storageAsset); + } + return storageAsset; + } + } + + final response = await _get(url, headers: headers); + if (response.statusCode >= 200 && response.statusCode < 400) { + final image = await _decode(response.bytes); + + if (cacheInMemory) { + _memoryCache.setValue(id, image); + } + + if (!_isWeb && cacheInStorage) { + unawaited(_saveAssetInLocalStorage(id, image)); + } + + return image; + } else { + throw Exception( + 'Error fetching asset from $url, response return status code ' + '${response.statusCode}', + ); + } + } + + Future _fetchAssetFromStorageCache(String id) async { + try { + final appDir = await _getAppDirectory(); + final file = File(path.join(appDir.path, id)); + + if (file.existsSync()) { + final bytes = await file.readAsBytes(); + return await _decode(bytes); + } + } on Exception catch (_) { + return null; + } + return null; + } + + Future _saveAssetInLocalStorage(String id, T asset) async { + try { + final appDir = await _getAppDirectory(); + final file = File(path.join(appDir.path, id)); + + await file.writeAsBytes(await _encode(asset)); + } on Exception catch (_) {} + } +} diff --git a/packages/flame_network_assets/lib/src/flame_network_images.dart b/packages/flame_network_assets/lib/src/flame_network_images.dart new file mode 100644 index 00000000000..ed438d1c7f2 --- /dev/null +++ b/packages/flame_network_assets/lib/src/flame_network_images.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +import 'package:flame_network_assets/flame_network_assets.dart'; +import 'package:flutter/rendering.dart'; + +/// {@template flame_network_images} +/// A specialized [FlameAssetResponse] that can be used to load [Image]s. +/// +/// {@macro flame_network_assets} +/// +/// {@endtemplate} +class FlameNetworkImages extends FlameNetworkAssets { + /// {@macro flame_network_images} + FlameNetworkImages({ + super.get, + super.getAppDirectory, + super.cacheInMemory, + super.cacheInStorage, + }) : super( + decodeAsset: decodeImageFromList, + encodeAsset: (Image image) async { + final data = await image.toByteData(format: ImageByteFormat.png); + + return data!.buffer.asUint8List(); + }, + ); +} diff --git a/packages/flame_network_assets/pubspec.yaml b/packages/flame_network_assets/pubspec.yaml new file mode 100644 index 00000000000..8ced78d689d --- /dev/null +++ b/packages/flame_network_assets/pubspec.yaml @@ -0,0 +1,27 @@ +name: flame_network_assets +description: Network assets support for Flame. +version: 0.1.0 +homepage: https://github.com/flame-engine/flame/tree/main/packages/flame_network_assets +funding: + - https://patreon.com/bluefireoss + - https://www.buymeacoffee.com/bluefire + +environment: + sdk: '>=2.18.0 <3.0.0' + flutter: ">=1.17.0" + +dependencies: + dev: ^1.0.0 + flame: ^1.6.0 + flutter: + sdk: flutter + http: ^0.13.5 + path: ^1.8.2 + path_provider: ^2.0.12 + +dev_dependencies: + dartdoc: ^6.0.1 + flame_lint: ^0.1.3 + flutter_test: + sdk: flutter + mocktail: ^0.3.0 diff --git a/packages/flame_network_assets/test/flame_network_image_test.dart b/packages/flame_network_assets/test/flame_network_image_test.dart new file mode 100644 index 00000000000..607939328d1 --- /dev/null +++ b/packages/flame_network_assets/test/flame_network_image_test.dart @@ -0,0 +1,192 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flame_network_assets/flame_network_assets.dart'; +import 'package:flutter/material.dart' hide Image; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as path; + +abstract class __MockHttpClient { + Future get( + String url, { + Map? headers, + }); +} + +class _MockHttpClient extends Mock implements __MockHttpClient {} + +abstract class __MockPathProvider { + Future getAppDirectory(); +} + +class _MockPathProvider extends Mock implements __MockPathProvider {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FlameNetworkAssets', () { + late __MockHttpClient httpClient; + late __MockPathProvider pathProvider; + late Directory testDirectory; + late FlameNetworkImages networkAssets; + late Image image; + + setUpAll(() { + testDirectory = Directory( + path.join( + Directory.systemTemp.path, + 'flame_network_assets_test', + ), + )..createSync(); + }); + + tearDownAll(() { + testDirectory.deleteSync(recursive: true); + }); + + setUp(() async { + httpClient = _MockHttpClient(); + pathProvider = _MockPathProvider(); + + when(pathProvider.getAppDirectory).thenAnswer((_) async => testDirectory); + + networkAssets = FlameNetworkImages( + get: httpClient.get, + getAppDirectory: pathProvider.getAppDirectory, + ); + + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + const Rect.fromLTWH(0, 0, 50, 50), + Paint()..color = Colors.pink, + ); + final picture = recorder.endRecording(); + image = await picture.toImage(50, 50); + + final pngImage = await image.toByteData(format: ImageByteFormat.png); + + when(() => httpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer( + (_) async => FlameAssetResponse( + statusCode: 200, + bytes: pngImage!.buffer.asUint8List(), + ), + ); + }); + + test('can be instantiated', () { + expect( + FlameNetworkImages(), + isNotNull, + ); + }); + + test('returns the image', () async { + const url = 'https://image1.com'; + final loadedImage = await networkAssets.load(url); + expect(loadedImage, isA()); + }); + + test('fetches the image in the network', () async { + const url = 'https://image2.com'; + await networkAssets.load(url); + verify(() => httpClient.get(url)).called(1); + }); + + test('returns the image from memory once it is cached', () async { + const url = 'https://image3.com'; + final image1 = await networkAssets.load(url); + final image2 = await networkAssets.load(url); + + verify(() => httpClient.get(url)).called(1); + verify(pathProvider.getAppDirectory).called(2); + + expect(image1, equals(image2)); + }); + + test('returns the image from local storage', () async { + const url = 'https://image4.com'; + + final image1 = await networkAssets.load(url); + + final secondNetworkAssets = FlameNetworkImages( + getAppDirectory: pathProvider.getAppDirectory, + get: httpClient.get, + ); + + await Future.delayed(const Duration(milliseconds: 100)); + + final image2 = await secondNetworkAssets.load(url); + + verify(() => httpClient.get(url)).called(1); + verify(pathProvider.getAppDirectory).called(3); + + expect(image1.width, equals(image2.width)); + expect(image1.height, equals(image2.height)); + }); + + test('can still get the image if the local storage breaks', () async { + const url = 'https://image5.com'; + + final image1 = await networkAssets.load(url); + + final brokenPathProvider = _MockPathProvider(); + when(brokenPathProvider.getAppDirectory).thenThrow(Exception()); + final secondNetworkAssets = FlameNetworkImages( + getAppDirectory: brokenPathProvider.getAppDirectory, + get: httpClient.get, + ); + + await Future.delayed(const Duration(milliseconds: 100)); + + final image2 = await secondNetworkAssets.load(url); + + verify(() => httpClient.get(url)).called(2); + + expect(image1.width, equals(image2.width)); + expect(image1.height, equals(image2.height)); + }); + + test('does not cache in memory when cacheInMemory is false', () async { + final secondNetworkAssets = FlameNetworkImages( + getAppDirectory: pathProvider.getAppDirectory, + get: httpClient.get, + cacheInMemory: false, + ); + const url = 'https://image6.com'; + final image1 = await secondNetworkAssets.load(url); + await Future.delayed(const Duration(milliseconds: 100)); + final image2 = await secondNetworkAssets.load(url); + + verify(() => httpClient.get(url)).called(1); + verify(pathProvider.getAppDirectory).called(3); + + expect(image1.width, equals(image2.width)); + expect(image1.height, equals(image2.height)); + }); + + test( + 'does not cache in local storage when cacheInMemory is false', + () async { + final secondNetworkAssets = FlameNetworkImages( + getAppDirectory: pathProvider.getAppDirectory, + get: httpClient.get, + cacheInMemory: false, + cacheInStorage: false, + ); + const url = 'https://image7.com'; + final image1 = await secondNetworkAssets.load(url); + await Future.delayed(const Duration(milliseconds: 100)); + final image2 = await secondNetworkAssets.load(url); + + verify(() => httpClient.get(url)).called(2); + verifyNever(pathProvider.getAppDirectory); + + expect(image1.width, equals(image2.width)); + expect(image1.height, equals(image2.height)); + }, + ); + }); +}