diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index 0be443c4bad..8ad92a409f6 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -13,5 +13,6 @@ subosito # github.com/subosito spydon # github.com/spydon stpasha # github.com/stpasha tavian # tavianator.com +videon # github.com/markvideon wolfenrain # github.com/wolfenrain xaha # github.com/xvrh diff --git a/.github/.cspell/words_dictionary.txt b/.github/.cspell/words_dictionary.txt index 8b919758586..7c9bfd6cb3d 100644 --- a/.github/.cspell/words_dictionary.txt +++ b/.github/.cspell/words_dictionary.txt @@ -29,6 +29,7 @@ proxying ptero # short for pterodactyl rebalance redeclaration +refreshable renderable rescan tappable diff --git a/doc/bridge_packages/bridge_packages.md b/doc/bridge_packages/bridge_packages.md index 0f55a853303..abe27282f0c 100644 --- a/doc/bridge_packages/bridge_packages.md +++ b/doc/bridge_packages/bridge_packages.md @@ -45,6 +45,11 @@ Replace FCS with the Oxygen Entity Component System. Create interactive animations (bridge package for [Rive]). ::: +:::{package} flame_riverpod + +A reactive caching and data-binding framework (bridge package for [Riverpod]). +::: + :::{package} flame_spine Use Spine skeletal animations (bridge package for [Spine]). @@ -71,6 +76,7 @@ Draw SVG files in Flutter (bridge package for [flutter_svg]). [Forge2D]: https://github.com/flame-engine/forge2d [Lottie]: https://pub.dev/packages/lottie [Rive]: https://rive.app/ +[Riverpod]: https://github.com/rrousselGit/riverpod [Spine]: https://pub.dev/packages/spine_flutter [Tiled]: https://www.mapeditor.org/ [flutter_svg]: https://github.com/dnfield/flutter_svg @@ -88,6 +94,7 @@ flame_lottie flame_network_assets flame_oxygen flame_rive +flame_riverpod flame_splash_screen flame_spine flame_svg diff --git a/doc/bridge_packages/flame_riverpod/component.md b/doc/bridge_packages/flame_riverpod/component.md new file mode 100644 index 00000000000..6b18aeba9a2 --- /dev/null +++ b/doc/bridge_packages/flame_riverpod/component.md @@ -0,0 +1,46 @@ +# Component + + +## ComponentRef + +`ComponentRef` exposes Riverpod functionality to individual `Component`s, and is comparable to +`flutter_riverpod`'s `WidgetRef`. + + +## RiverpodComponentMixin + +`RiverpodComponentMixin` manages the lifecycle of listeners on behalf of individual `Component`s. + +`Component`s using this mixin must use `addToGameWidgetBuild` in their `onMount` method to add +listeners (e.g. `ref.watch` or `ref.listen`) *prior to* calling `super.onMount`, which manages the +staged listeners and disposes of them on the user's behalf inside `onRemove`. + +```dart + +class RiverpodAwareTextComponent extends PositionComponent + with RiverpodComponentMixin { + late TextComponent textComponent; + int currentValue = 0; + + @override + void onMount() { + addToGameWidgetBuild(() { + ref.listen(countingStreamProvider, (p0, p1) { + if (p1.hasValue) { + currentValue = p1.value!; + textComponent.text = '$currentValue'; + } + }); + }); + super.onMount(); + add(textComponent = TextComponent(position: position + Vector2(0, 27))); + } +} + +``` + + +## RiverpodGameMixin + +`RiverpodGameMixin` provides listeners from all components to the build method of the +`RiverpodAwareGameWidget`. diff --git a/doc/bridge_packages/flame_riverpod/flame_riverpod.md b/doc/bridge_packages/flame_riverpod/flame_riverpod.md new file mode 100644 index 00000000000..b63c85a8beb --- /dev/null +++ b/doc/bridge_packages/flame_riverpod/flame_riverpod.md @@ -0,0 +1,8 @@ +# flame_riverpod + +```{toctree} +Overview +Component +Widget +``` + diff --git a/doc/bridge_packages/flame_riverpod/riverpod.md b/doc/bridge_packages/flame_riverpod/riverpod.md new file mode 100644 index 00000000000..f75ffdc3e34 --- /dev/null +++ b/doc/bridge_packages/flame_riverpod/riverpod.md @@ -0,0 +1,67 @@ +# flame_riverpod + + +## Riverpod + +[Riverpod](https://riverpod.dev/) is a reactive caching and data-binding +framework for Dart & Flutter. + +In `flutter_riverpod`, widgets can be configured to rebuild when the state +of a provider changes. + +When using Flame, we are interacting with components, which are *not* Widgets. + +`flame_riverpod` provides the `RiverpodAwareGameWidget`, `RiverpodGameMixin`, and +`RiverpodComponentMixin` to facilitate managing state from `Provider`s in your Flame Game. + + +## Usage + +You should use the `RiverpodAwareGameWidget` as your Flame `GameWidget`, the `RiverpodGameMixin` +mixin on your game that extends `FlameGame`, and the `RiverpodComponentMixin` on any components +interacting with Riverpod providers. + +Subscriptions to a provider are managed in accordance with the lifecycle +of a Flame Component: initialization occurs when a Component is mounted, and disposal +occurs when a Component is removed. + +By default, the `RiverpodAwareGameWidget` is rebuilt when +Riverpod-aware (i.e. using the `RiverpodComponentMixin`) components are mounted and when they are +removed. + +```dart +/// An excerpt from the Example. Check it out! +class RefExampleGame extends FlameGame with RiverpodGameMixin { + @override + Future onLoad() async { + await super.onLoad(); + add(TextComponent(text: 'Flame')); + add(RiverpodAwareTextComponent()); + } +} + +class RiverpodAwareTextComponent extends PositionComponent + with RiverpodComponentMixin { + late TextComponent textComponent; + int currentValue = 0; + + @override + void onMount() { + addToGameWidgetBuild(() { + ref.listen(countingStreamProvider, (p0, p1) { + if (p1.hasValue) { + currentValue = p1.value!; + textComponent.text = '$currentValue'; + } + }); + }); + super.onMount(); + add(textComponent = TextComponent(position: position + Vector2(0, 27))); + } +} + +``` + +The order of operations in `Component.onMount` is important. The `RiverpodComponentMixin` +interacts with `RiverpodGameMixin` (inside of `RiverpodComponentMixin.onMount`) to co-ordinate +adding and removing listeners as the corresponding component is mounted and removed, respectively. diff --git a/doc/bridge_packages/flame_riverpod/widget.md b/doc/bridge_packages/flame_riverpod/widget.md new file mode 100644 index 00000000000..fd9a8a280b1 --- /dev/null +++ b/doc/bridge_packages/flame_riverpod/widget.md @@ -0,0 +1,16 @@ +# Widget + + +## RiverpodAwareGameWidget + +`RiverpodAwareGameWidget` is a GameWidget with a `State` object of type +`RiverpodAwareGameWidgetState`. + +The required `GlobalKey` argument is used to provide `Component`s using `RiverpodComponentMixin` +access to `Provider`s via `RiverpodAwareGameWidgetState`. + + +## RiverpodAwareGameWidgetState + +`RiverpodAwareGameWidgetState` performs the duties associated with the +`ConsumerStatefulElement` in `flutter_riverpod` and `GameWidgetState` in `flame`. diff --git a/packages/flame_riverpod/CHANGELOG.md b/packages/flame_riverpod/CHANGELOG.md new file mode 100644 index 00000000000..311e7c493b7 --- /dev/null +++ b/packages/flame_riverpod/CHANGELOG.md @@ -0,0 +1,62 @@ +## 5.0.0 + +* New API with breaking changes. Added [RiverpodAwareGameWidget], [RiverpodGameMixin], [RiverpodComponentMixin]. See the example for details. + +## 4.0.0+2 + +* Miscellaneous format post-processing on the files. + +## 4.0.0+1 + +* Miscellaneous tidy-up of package internals. + +## 4.0.0 + +* Made [WidgetRef] property on [ComponentRef] private. It should not be accessed directly. +* Removed the [riverpodAwar`eGameProvider]. If required, this is better handled at the application-level. + +## 3.0.0 + +* Changes to focus on [FlameGame]. + * [riverpodAwareGameProvider] now expects a [FlameGame]. + * Removed the [HasComponentRef] on Game. + * Renamed [RiverpodComponentMixin] to [HasComponentRef] +* [HasComponentRef] now has a static setter for a WidgetRef. Components that use the new [HasComponentRef] mixin no +longer need to explicitly provide a [ComponentRef]. +* Renamed the [WidgetRef] property on the [ComponentRef] to [widgetRef]. +* Updated Example to reflect changes. +* Updated README to reflect changes. + +## 2.0.0 + +* Pruned the public API, removing custom widget definitions (these have now been defined inside the example for +reference) +* Renamed [RiverpodAwareGameMixin] -> [HasComponentRef] to bring closer to the Flame 'house-style' for mixins. + +## 1.1.0+2 + +* Another correction to README and example code. onMount should not call super.onLoad. + +## 1.1.0+1 + +* Correction to README to reflect API change. + +## 1.1.0 + +* Added [RiverpodComponentMixin] to handle disposing of [ProviderSubscription]s. +* Correction to the [RiverpodGameWidget] initializeGame constructor - param is now + [RiverpodAwareGameMixin Function (ref)] as originally intended. + +## 1.0.0+1 + +* Reduced package description length. +* Ran dart format. + +## 1.0.0 + +* Initial release. + * ComponentRef + * riverpodAwareGameProvider + * RiverpodAwareFlameGame + * RiverpodAwareGame + * RiverpodGameWidget diff --git a/packages/flame_riverpod/LICENSE b/packages/flame_riverpod/LICENSE new file mode 100644 index 00000000000..fdf29892d27 --- /dev/null +++ b/packages/flame_riverpod/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_riverpod/README.md b/packages/flame_riverpod/README.md new file mode 100644 index 00000000000..99302ef755e --- /dev/null +++ b/packages/flame_riverpod/README.md @@ -0,0 +1,78 @@ +# flame_riverpod + +[Riverpod](https://pub.dev/packages/flutter_riverpod) is a reactive caching and data-binding +framework for Dart & Flutter. + +In `flutter_riverpod`, widgets can be configured to rebuild when the state +of a provider changes. + +When using Flame, we are interacting with components, which are *not* Widgets. + +`flame_riverpod` provides the `RiverpodAwareGameWidget`, `RiverpodGameMixin`, and +`RiverpodComponentMixin` to facilitate managing state from Providers in your Flame Game. + + +## Usage + +You should use the `RiverpodAwareGameWidget` as your Flame `GameWidget`, the `RiverpodGameMixin` +mixin on your game that extends `FlameGame`, and the `RiverpodComponentMixin` on any components +interacting with Riverpod providers. + +The full range of operations defined in Riverpod's `WidgetRef` definition are accessible from +components. + +Subscriptions to a provider are managed in accordance with the lifecycle +of a Flame Component: initialization occurs when a Component is mounted, and disposal +occurs when a Component is removed. By default, the `RiverpodAwareGameWidget` is rebuilt when +Riverpod-aware (i.e. using the `RiverpodComponentMixin`) components are mounted and when they are +removed. + +```dart + +/// An excerpt from the Example. Check it out! +class RefExampleGame extends FlameGame with RiverpodGameMixin { + @override + Future onLoad() async { + await super.onLoad(); + add(TextComponent(text: 'Flame')); + add(RiverpodAwareTextComponent()); + } +} + +class RiverpodAwareTextComponent extends PositionComponent + with RiverpodComponentMixin { + late TextComponent textComponent; + int currentValue = 0; + + /// [onMount] should be used over [onLoad] to initialize subscriptions, + /// cancellation is handled for the user inside [onRemove], + /// which is only called if the [Component] was mounted. + /// + /// [RiverpodComponentMixin.addToGameWidgetBuild] **must** be invoked in + /// your Component **before** [RiverpodComponentMixin.onMount] in order to + /// have the provided function invoked on + /// [RiverpodAwareGameWidgetState.build]. + /// + /// From `flame_riverpod` 5.0.0, [WidgetRef.watch], is also accessible from + /// components. + @override + void onMount() { + addToGameWidgetBuild(() { + ref.listen(countingStreamProvider, (p0, p1) { + if (p1.hasValue) { + currentValue = p1.value!; + textComponent.text = '$currentValue'; + } + }); + }); + super.onMount(); + add(textComponent = TextComponent(position: position + Vector2(0, 27))); + } +} + +``` + + +## Credits + +[Mark Videon](https://markvideon.dev) for the initial groundwork and implementation. diff --git a/packages/flame_riverpod/analysis_options.yaml b/packages/flame_riverpod/analysis_options.yaml new file mode 100644 index 00000000000..85732fa02fd --- /dev/null +++ b/packages/flame_riverpod/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/flame_riverpod/example/.metadata b/packages/flame_riverpod/example/.metadata new file mode 100644 index 00000000000..05cc1899526 --- /dev/null +++ b/packages/flame_riverpod/example/.metadata @@ -0,0 +1,30 @@ +# 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: "efbf63d9c66b9f6ec30e9ad4611189aa80003d31" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + - platform: macos + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + + # 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_riverpod/example/README.md b/packages/flame_riverpod/example/README.md new file mode 100644 index 00000000000..557aa8d7755 --- /dev/null +++ b/packages/flame_riverpod/example/README.md @@ -0,0 +1,10 @@ +# flame_riverpod_example + +The example consists of a very simple FlameGame with a custom +Component, updated alongside a comparable Flutter widget. + +Both the Component and the Widget depend on a `StreamProvider` +that counts upwards indefinitely. + +Both a Flame Component and a Flutter Text Widget update in real-time from +the same data source. diff --git a/packages/flame_riverpod/example/analysis_options.yaml b/packages/flame_riverpod/example/analysis_options.yaml new file mode 100644 index 00000000000..85732fa02fd --- /dev/null +++ b/packages/flame_riverpod/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/flame_riverpod/example/lib/main.dart b/packages/flame_riverpod/example/lib/main.dart new file mode 100644 index 00000000000..e2cca1544a1 --- /dev/null +++ b/packages/flame_riverpod/example/lib/main.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flame/components.dart' hide Timer; +import 'package:flame/game.dart'; +import 'package:flame_riverpod/flame_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final countingStreamProvider = StreamProvider((ref) { + return Stream.periodic(const Duration(seconds: 1), (inc) => inc); +}); + +void main() { + runApp(const ProviderScope(child: MyApp())); +} + +final gameInstance = RefExampleGame(); +final GlobalKey gameWidgetKey = + GlobalKey(); + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + home: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Expanded(child: FlutterCountingComponent()), + Expanded( + child: RiverpodAwareGameWidget( + key: gameWidgetKey, + game: gameInstance, + ), + ), + ], + ), + ); + } +} + +class FlutterCountingComponent extends ConsumerWidget { + const FlutterCountingComponent({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textStyle = Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(color: Colors.white); + + final stream = ref.watch(countingStreamProvider); + return Material( + color: Colors.transparent, + child: Column( + children: [ + Text('Flutter', style: textStyle), + stream.when( + data: (value) => Text('$value', style: textStyle), + error: (error, stackTrace) => Text('$error', style: textStyle), + loading: () => Text('Loading...', style: textStyle), + ), + ], + ), + ); + } +} + +class RefExampleGame extends FlameGame with RiverpodGameMixin { + @override + Future onLoad() async { + await super.onLoad(); + add(TextComponent(text: 'Flame')); + add(RiverpodAwareTextComponent()); + } +} + +class RiverpodAwareTextComponent extends PositionComponent + with RiverpodComponentMixin { + late TextComponent textComponent; + int currentValue = 0; + + /// [onMount] should be used over [onLoad] to initialize subscriptions, + /// which is only called if the [Component] was mounted. + /// Cancellation is handled for the user automatically inside [onRemove]. + /// + /// [RiverpodComponentMixin.addToGameWidgetBuild] **must** be invoked in + /// your Component **before** [RiverpodComponentMixin.onMount] in order to + /// have the provided function invoked on + /// [RiverpodAwareGameWidgetState.build]. + /// + /// From `flame_riverpod` 5.0.0, [WidgetRef.watch], is also accessible from + /// components. + @override + void onMount() { + addToGameWidgetBuild(() { + ref.listen(countingStreamProvider, (p0, p1) { + if (p1.hasValue) { + currentValue = p1.value!; + textComponent.text = '$currentValue'; + } + }); + }); + super.onMount(); + add(textComponent = TextComponent(position: position + Vector2(0, 27))); + } +} diff --git a/packages/flame_riverpod/example/pubspec.yaml b/packages/flame_riverpod/example/pubspec.yaml new file mode 100644 index 00000000000..76b0953f054 --- /dev/null +++ b/packages/flame_riverpod/example/pubspec.yaml @@ -0,0 +1,20 @@ +name: flame_riverpod_example +description: Showcasing the flame_riverpod functionality. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev +version: 1.0.0+1 +environment: + sdk: '>=2.18.5 <3.0.0' +dependencies: + flame: ^1.10.1 + flame_riverpod: ^5.0.0 + flutter: + sdk: flutter + flutter_riverpod: ^2.1.3 + +dev_dependencies: + flame_lint: ^0.2.0 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/packages/flame_riverpod/example/test/widget_test.dart b/packages/flame_riverpod/example/test/widget_test.dart new file mode 100644 index 00000000000..9f2d697aac1 --- /dev/null +++ b/packages/flame_riverpod/example/test/widget_test.dart @@ -0,0 +1,75 @@ +import 'package:flame/game.dart'; +import 'package:flame_riverpod/flame_riverpod.dart'; +import 'package:flame_riverpod_example/main.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Test equality of Flutter Text Widget and Flame Text Component', + (widgetTester) async { + await widgetTester.pumpWidget( + const ProviderScope(child: MyApp()), + ); + + await widgetTester.pump(const Duration(seconds: 5)); + + // Expect FlutterCountingComponent to exist on the page + final flutterCounterFinder = find.byType(FlutterCountingComponent); + expect(flutterCounterFinder, findsOneWidget); + final flutterCounterTextFinder = find.descendant( + of: flutterCounterFinder, + matching: find.byType(Text), + ); + + // Expect a title 'e.g. Flutter' and the current count of the stream as + // separate [Text] widget. + expect(flutterCounterTextFinder, findsNWidgets(2)); + + final flutterCounterTextWidgets = + widgetTester.widgetList(flutterCounterTextFinder); + + // Expect RiverpodAwareGameWidget to exist + final riverpodGameWidgetFinder = find.byType(RiverpodAwareGameWidget); + expect(riverpodGameWidgetFinder, findsOneWidget); + + final gameWidget = widgetTester.widget(riverpodGameWidgetFinder) + as RiverpodAwareGameWidget; + + // GameWidget contains a FutureBuilder, which calls setState when a Future + // completes. We therefore need to pump / re-render the widget to ensure + // that the game mounts properly. Alternatively, we could manually trigger + // lifecycle events. + await widgetTester.pump(const Duration(seconds: 1)); + + final flameGame = gameWidget.game as FlameGame?; + expect(flameGame?.isAttached, true); + expect(flameGame?.isLoaded, true); + expect(flameGame?.isMounted, true); + + // Pump again to provide the gameRenderBox with a [BuildContext]. + await widgetTester.pump(const Duration(seconds: 1)); + + // Check components are mounted as expected. + expect(flameGame?.children.isNotEmpty ?? false, true); + + final riverpodAwareTextComponent = + flameGame?.children.elementAt(2) as RiverpodAwareTextComponent?; + expect(riverpodAwareTextComponent is RiverpodAwareTextComponent, true); + + // Current count of the stream from the [Text] widget. This is best + // retrieved after all pumps. + final flutterCounterTextWidgetOfInterest = + flutterCounterTextWidgets.elementAt(1); + + final currentCount = + int.parse((flutterCounterTextWidgetOfInterest as Text).data!); + + // Expect equality (in the presented string value) + // of the Text Component and the Text Widget + expect( + riverpodAwareTextComponent?.textComponent.text == currentCount.toString(), + true, + ); + }); +} diff --git a/packages/flame_riverpod/lib/flame_riverpod.dart b/packages/flame_riverpod/lib/flame_riverpod.dart new file mode 100644 index 00000000000..b29aa2b82e3 --- /dev/null +++ b/packages/flame_riverpod/lib/flame_riverpod.dart @@ -0,0 +1,7 @@ +/// Helpers for using Riverpod in conjunction with Flame, to share state from +/// the game into other parts of your application, or from other parts of your +/// application into your game. +library flame_riverpod; + +export 'src/consumer.dart'; +export 'src/widget.dart'; diff --git a/packages/flame_riverpod/lib/src/consumer.dart b/packages/flame_riverpod/lib/src/consumer.dart new file mode 100644 index 00000000000..d55f826e8f0 --- /dev/null +++ b/packages/flame_riverpod/lib/src/consumer.dart @@ -0,0 +1,153 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_riverpod/src/widget.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ComponentRef implements WidgetRef { + ComponentRef({required this.game}); + + RiverpodGameMixin? game; + + @override + BuildContext get context => game!.buildContext!; + + RiverpodAwareGameWidgetState? get _container { + return game?.key?.currentState; + } + + @override + Res watch(ProviderListenable target) { + return _container!.watch(target); + } + + @override + void listen( + ProviderListenable provider, + void Function(T? previous, T value) listener, { + void Function(Object error, StackTrace stackTrace)? onError, + }) { + _container!.listen(provider, listener, onError: onError); + } + + @override + bool exists(ProviderBase provider) { + return _container!.exists(provider); + } + + @override + T read(ProviderListenable provider) { + return _container!.read(provider); + } + + @override + State refresh(Refreshable provider) { + return _container!.refresh(provider); + } + + @override + void invalidate(ProviderOrFamily provider) { + _container!.invalidate(provider); + } + + @override + ProviderSubscription listenManual( + ProviderListenable provider, + void Function(T? previous, T next) listener, { + void Function(Object error, StackTrace stackTrace)? onError, + bool fireImmediately = false, + }) { + return _container!.listenManual( + provider, + listener, + onError: onError, + fireImmediately: fireImmediately, + ); + } +} + +mixin RiverpodComponentMixin on Component { + final ComponentRef ref = ComponentRef(game: null); + final List _onBuildCallbacks = []; + + /// Whether to immediately call [RiverpodAwareGameWidgetState.build] when + /// this component is mounted. + bool rebuildOnMountWhen(ComponentRef ref) => true; + + /// Whether to immediately call [RiverpodAwareGameWidgetState.build] when + /// this component is removed. + bool rebuildOnRemoveWhen(ComponentRef ref) => true; + + @mustCallSuper + @override + void onLoad() { + ref.game = findGame()! as RiverpodGameMixin; + super.onLoad(); + } + + /// Adds a callback method to be invoked in the build method of + /// [RiverpodAwareGameWidgetState]. + void addToGameWidgetBuild(Function() cb) { + _onBuildCallbacks.add(cb); + } + + @mustCallSuper + @override + void onMount() { + super.onMount(); + ref.game!._onBuildCallbacks.addAll(_onBuildCallbacks); + + if (rebuildOnMountWhen(ref) == true) { + rebuildGameWidget(); + } + } + + @mustCallSuper + @override + void onRemove() { + // Remove this component's onBuild callbacks from the GameWidget + _onBuildCallbacks.forEach(ref.game!._onBuildCallbacks.remove); + + // Clear the local store of build callbacks - if the component is + // re-mounted, it would be undesirable to double-up. + _onBuildCallbacks.clear(); + + // Force build to flush dependencies + if (rebuildOnRemoveWhen(ref) == true) { + rebuildGameWidget(); + } + + // Clear game reference as the component is no longer mounted to this game + ref.game = null; + + super.onRemove(); + } + + void rebuildGameWidget() { + assert(ref.game!.isMounted == true); + if (ref.game!.isMounted) { + ref.game!.key!.currentState!.forceBuild(); + } + } +} + +mixin RiverpodGameMixin on FlameGame { + /// [GlobalKey] associated with the [RiverpodAwareGameWidget] that this game + /// was provided to. + /// + /// Used to facilitate [Component] access to the [ProviderContainer]. + GlobalKey? key; + + final List _onBuildCallbacks = []; + + /// Invoked in [RiverpodAwareGameWidgetState.build]. Each callback is + /// expected to consist of calls to methods implemented in [WidgetRef]. + /// E.g. [WidgetRef.watch], [WidgetRef.listen], etc. + void onBuild() { + for (final callback in _onBuildCallbacks) { + callback.call(); + } + } + + bool get hasBuildCallbacks => _onBuildCallbacks.isNotEmpty; +} diff --git a/packages/flame_riverpod/lib/src/widget.dart b/packages/flame_riverpod/lib/src/widget.dart new file mode 100644 index 00000000000..c3e23d92e46 --- /dev/null +++ b/packages/flame_riverpod/lib/src/widget.dart @@ -0,0 +1,214 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_riverpod/flame_riverpod.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// A [GameWidget] that provides access to [Component]s using +/// [RiverpodComponentMixin] attached to [FlameGame]s using [RiverpodGameMixin] +/// access to Riverpod [Provider]s. +/// +/// The corresponding [State] object ([RiverpodAwareGameWidgetState]) assumes +/// responsibilities associated with [ConsumerStatefulElement] in +/// `flutter_riverpod`. +class RiverpodAwareGameWidget extends GameWidget { + RiverpodAwareGameWidget({required super.game, required this.key}) + : super(key: key); + + @override + final GlobalKey> key; + + @override + GameWidgetState createState() => RiverpodAwareGameWidgetState(); +} + +class RiverpodAwareGameWidgetState extends GameWidgetState + implements WidgetRef { + RiverpodGameMixin get game => widget.game! as RiverpodGameMixin; + + late ProviderContainer _container = ProviderScope.containerOf(context); + var _dependencies = + , ProviderSubscription>{}; + Map, ProviderSubscription>? + _oldDependencies; + final _listeners = >[]; + List<_ListenManual>? _manualListeners; + + /// Rebuilds the [RiverpodAwareGameWidget] by calling [setState]. + void forceBuild() { + setState(() {}); + } + + @override + void initState() { + super.initState(); + game.key = (widget as RiverpodAwareGameWidget).key; + } + + @override + void didUpdateWidget(covariant GameWidget oldWidget) { + super.didUpdateWidget(oldWidget); + game.key = (widget as RiverpodAwareGameWidget).key; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final newContainer = ProviderScope.containerOf(context); + if (_container != newContainer) { + _container = newContainer; + for (final dependency in _dependencies.values) { + dependency.close(); + } + _dependencies.clear(); + } + } + + @override + void dispose() { + // Below comments are from the implementation of ConsumerStatefulWidget: + + // Calling `super.unmount()` will call `dispose` on the state + // And [ListenManual] subscriptions should be closed after `dispose` + super.dispose(); + + for (final dependency in _dependencies.values) { + dependency.close(); + } + for (var i = 0; i < _listeners.length; i++) { + _listeners[i].close(); + } + final manualListeners = _manualListeners?.toList(); + if (manualListeners != null) { + for (final listener in manualListeners) { + listener.close(); + } + _manualListeners = null; + } + } + + @override + Widget build(BuildContext context) { + try { + _oldDependencies = _dependencies; + for (var i = 0; i < _listeners.length; i++) { + _listeners[i].close(); + } + _listeners.clear(); + _dependencies = {}; + game.onBuild(); + return super.build(context); + } finally { + for (final dep in _oldDependencies!.values) { + dep.close(); + } + _oldDependencies = null; + } + } + + void _assertNotDisposed() { + if (!context.mounted) { + throw StateError('Cannot use "ref" after the widget was disposed.'); + } + } + + @override + Res watch(ProviderListenable target) { + _assertNotDisposed(); + return _dependencies.putIfAbsent(target, () { + final oldDependency = _oldDependencies?.remove(target); + + if (oldDependency != null) { + return oldDependency; + } + + return _container.listen( + target, + (_, __) => setState(() {}), + ); + }).read() as Res; + } + + @override + void listen( + ProviderListenable provider, + void Function(U? previous, U value) listener, { + void Function(Object error, StackTrace stackTrace)? onError, + }) { + _assertNotDisposed(); + assert( + context.debugDoingBuild, + 'ref.listen can only be used within the build method of a ConsumerWidget', + ); + + // We can't implement a fireImmediately flag because we wouldn't know + // which listen call was preserved between widget rebuild, and we wouldn't + // want to call the listener on every rebuild. + final sub = _container.listen(provider, listener, onError: onError); + _listeners.add(sub); + } + + @override + bool exists(ProviderBase provider) { + _assertNotDisposed(); + return ProviderScope.containerOf(context, listen: false).exists(provider); + } + + @override + Res read(ProviderListenable provider) { + _assertNotDisposed(); + return ProviderScope.containerOf(context, listen: false).read(provider); + } + + @override + State refresh(Refreshable provider) { + _assertNotDisposed(); + return ProviderScope.containerOf(context, listen: false).refresh(provider); + } + + @override + void invalidate(ProviderOrFamily provider) { + _assertNotDisposed(); + _container.invalidate(provider); + } + + @override + ProviderSubscription listenManual( + ProviderListenable provider, + void Function(Res? previous, Res next) listener, { + void Function(Object error, StackTrace stackTrace)? onError, + bool fireImmediately = false, + }) { + _assertNotDisposed(); + final listeners = _manualListeners ??= []; + + final sub = _ListenManual( + ProviderScope.containerOf(context, listen: false).listen( + provider, + listener, + onError: onError, + fireImmediately: fireImmediately, + ), + this, + ); + listeners.add(sub); + + return sub; + } +} + +class _ListenManual implements ProviderSubscription { + _ListenManual(this._subscription, this._element); + + final ProviderSubscription _subscription; + final RiverpodAwareGameWidgetState _element; + + @override + void close() { + _subscription.close(); + _element._manualListeners?.remove(this); + } + + @override + T read() => _subscription.read(); +} diff --git a/packages/flame_riverpod/pubspec.yaml b/packages/flame_riverpod/pubspec.yaml new file mode 100644 index 00000000000..2a37cd9d1ba --- /dev/null +++ b/packages/flame_riverpod/pubspec.yaml @@ -0,0 +1,17 @@ +name: flame_riverpod +description: Helpers for using Riverpod - a reactive caching and data-binding framework, + in conjunction with Flame. +version: 5.0.0 +homepage: https://github.com/flame-engine/flame/tree/main/packages/flame_riverpod +environment: + sdk: '>=2.18.5 <4.0.0' + flutter: ">=1.17.0" +dependencies: + flame: ^1.10.1 + flutter: + sdk: flutter + flutter_riverpod: ^2.1.3 +dev_dependencies: + flame_lint: ^0.2.0 + flutter_test: + sdk: flutter diff --git a/packages/flame_riverpod/test/widget_test.dart b/packages/flame_riverpod/test/widget_test.dart new file mode 100644 index 00000000000..06f5569f21b --- /dev/null +++ b/packages/flame_riverpod/test/widget_test.dart @@ -0,0 +1,139 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_riverpod/flame_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final numberProvider = Provider.autoDispose((ref) { + return 1; +}); + +class MyGame extends FlameGame with RiverpodGameMixin {} + +class EmptyComponent extends Component with RiverpodComponentMixin { + @override + void onLoad() { + super.onLoad(); + addToGameWidgetBuild(() { + // do nothing + }); + } +} + +class WatchingComponent extends Component with RiverpodComponentMixin { + @override + void onLoad() { + super.onLoad(); + addToGameWidgetBuild(() { + ref.watch(numberProvider); + }); + } +} + +void main() { + testWidgets( + 'Test registration and de-registration of GameWidget build callbacks', + (widgetTester) async { + final game = MyGame(); + final component = EmptyComponent(); + final key = GlobalKey(); + + await widgetTester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: RiverpodAwareGameWidget( + game: game, + key: key, + ), + ), + ), + ); + await widgetTester.pump(const Duration(seconds: 5)); + + expect(game.hasBuildCallbacks, false); + + // Add the custom component + game.add(component); + + // Expect the game is ready to play + expect(game.isAttached, true); + expect(game.isMounted, true); + expect(game.isLoaded, true); + + // Pump to ensure the custom component's lifecycle events are handled + await widgetTester.pump(const Duration(seconds: 1)); + + // Expect the component has added a callback for the game widget's build + // method. + expect(game.hasBuildCallbacks, true); + + // Remove the custom component. + game.remove(component); + + // Pump to ensure the component has been removed. + await widgetTester.pump(Duration.zero); + + // When the component is removed there should be no onBuild callbacks + // remaining. + expect(game.hasBuildCallbacks, false); + + // When the component is removed, there should be no game reference on the + // component. + expect(component.ref.game == null, true); + }); + + testWidgets('Test registration and de-registration of Provider listeners', + (widgetTester) async { + final game = MyGame(); + final component = WatchingComponent(); + final key = GlobalKey(); + + await widgetTester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: RiverpodAwareGameWidget( + game: game, + key: key, + ), + ), + ), + ); + await widgetTester.pump(const Duration(seconds: 5)); + + // Expect that the GameWidget is not initially listening to + // numberProvider + expect(key.currentState?.exists(numberProvider), false); + + // Add the custom component + game.add(component); + + // Expect the game is ready to play + expect(game.isAttached, true); + expect(game.isMounted, true); + expect(game.isLoaded, true); + + // Pump to ensure the custom component's lifecycle events are handled + await widgetTester.pump(Duration.zero); + + // Expect that the GameWidget is now listening to + // numberProvider as the watching component has been added. + expect(key.currentState?.exists(numberProvider), true); + + // Remove the custom component from the game. + game.remove(component); + + // Pump to ensure the component has been removed. + await widgetTester.pump(Duration.zero); + + // Expect the component has been removed from the game. + expect(component.isRemoved, true); + + // Pump to ensure the listener has been cancelled by ProviderScope. + await widgetTester.pump(const Duration(seconds: 5)); + + // Expect that the GameWidget is no longer listening to + // numberProvider as the watching component has been removed. + expect(key.currentState?.exists(numberProvider), false); + }); +}