From bdc68fe997f0811ea4c35a95265464f281dafea7 Mon Sep 17 00:00:00 2001 From: Gregory Conrad Date: Mon, 10 Jul 2023 20:58:24 -0700 Subject: [PATCH] feat: initial impl of flutter_rearch --- .../flutter_rearch/lib/flutter_rearch.dart | 41 +++ .../flutter_rearch/lib/src/side_effects.dart | 69 ++++ .../lib/src/side_effects/animation.dart | 79 +++++ .../lib/src/side_effects/keep_alive.dart | 54 ++++ .../side_effects/text_editing_controller.dart | 10 + packages/flutter_rearch/lib/src/widgets.dart | 8 + .../lib/src/widgets/bootstrap.dart | 45 +++ .../lib/src/widgets/capsule_consumer.dart | 134 ++++++++ .../widgets/capsule_container_provider.dart | 42 +++ .../lib/src/widgets/capsule_warm_up.dart | 32 ++ packages/flutter_rearch/pubspec.yaml | 20 ++ packages/rearch/example/lib/example.dart | 2 +- packages/rearch/lib/rearch.dart | 20 +- packages/rearch/lib/src/side_effects.dart | 62 ++++ packages/rearch/lib/src/types.dart | 300 ++++++++++++++++++ packages/rearch/test/basic_test.dart | 4 +- 16 files changed, 912 insertions(+), 10 deletions(-) create mode 100644 packages/flutter_rearch/lib/flutter_rearch.dart create mode 100644 packages/flutter_rearch/lib/src/side_effects.dart create mode 100644 packages/flutter_rearch/lib/src/side_effects/animation.dart create mode 100644 packages/flutter_rearch/lib/src/side_effects/keep_alive.dart create mode 100644 packages/flutter_rearch/lib/src/side_effects/text_editing_controller.dart create mode 100644 packages/flutter_rearch/lib/src/widgets.dart create mode 100644 packages/flutter_rearch/lib/src/widgets/bootstrap.dart create mode 100644 packages/flutter_rearch/lib/src/widgets/capsule_consumer.dart create mode 100644 packages/flutter_rearch/lib/src/widgets/capsule_container_provider.dart create mode 100644 packages/flutter_rearch/lib/src/widgets/capsule_warm_up.dart create mode 100644 packages/flutter_rearch/pubspec.yaml create mode 100644 packages/rearch/lib/src/types.dart diff --git a/packages/flutter_rearch/lib/flutter_rearch.dart b/packages/flutter_rearch/lib/flutter_rearch.dart new file mode 100644 index 0000000..46a1088 --- /dev/null +++ b/packages/flutter_rearch/lib/flutter_rearch.dart @@ -0,0 +1,41 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_rearch/src/widgets.dart'; +import 'package:rearch/rearch.dart'; + +export 'src/side_effects.dart'; +export 'src/widgets.dart'; + +/// The API exposed to [CapsuleConsumer]s to extend their functionality. +abstract interface class WidgetSideEffectApi implements SideEffectApi { + /// The [BuildContext] of the associated [CapsuleConsumer]. + BuildContext get context; + + /// Adds a deactivate lifecycle listener. + void addDeactivateListener(SideEffectApiCallback callback); + + /// Removes the specified deactivate lifecycle listener. + void removeDeactivateListener(SideEffectApiCallback callback); +} + +/// Defines what a [WidgetSideEffect] should look like (a [Function] +/// that consumes a [WidgetSideEffectApi] and returns something). +/// +/// If your side effect is more advanced or requires parameters, +/// simply make a callable class instead of just a regular [Function]! +typedef WidgetSideEffect = T Function(WidgetSideEffectApi); + +/// Represents an object that can [register] [WidgetSideEffect]s. +abstract interface class WidgetSideEffectRegistrar + implements SideEffectRegistrar { + @override + T register(WidgetSideEffect sideEffect); +} + +/// The [WidgetHandle] is to [Widget]s what a [CapsuleHandle] is to +/// [Capsule]s. +/// +/// [WidgetHandle]s provide a mechanism to watch [Capsule]s and +/// register [SideEffect]s, so all Capsule-specific methodologies +/// carry over. +abstract interface class WidgetHandle + implements CapsuleReader, WidgetSideEffectRegistrar {} diff --git a/packages/flutter_rearch/lib/src/side_effects.dart b/packages/flutter_rearch/lib/src/side_effects.dart new file mode 100644 index 0000000..2261066 --- /dev/null +++ b/packages/flutter_rearch/lib/src/side_effects.dart @@ -0,0 +1,69 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_rearch/flutter_rearch.dart'; +import 'package:rearch/rearch.dart'; + +part 'side_effects/animation.dart'; +part 'side_effects/keep_alive.dart'; +part 'side_effects/text_editing_controller.dart'; + +/// A collection of the builtin [WidgetSideEffect]s. +extension BuiltinWidgetSideEffects on WidgetSideEffectRegistrar { + /// The [WidgetSideEffectApi] backing this [WidgetSideEffectRegistrar]. + WidgetSideEffectApi api() => register((api) => api); + + /// The [BuildContext] associated with this [WidgetSideEffectRegistrar]. + BuildContext context() => register((api) => api.context); + + /// Provides a way to easily get a copy of a [TextEditingController]. + /// + /// Pass an optional `initialText` to set the controller's initial text. + TextEditingController textEditingController({String? initialText}) => + _textEditingController(this, initialText: initialText); + + /// Creates a single use [TickerProvider]. + /// Used by [animationController] when `vsync` is not set. + TickerProvider singleTickerProvider() => _singleTickerProvider(this); + + /// Provides a way to easily get a copy of an [AnimationController]. + /// + /// When [vsync] is not given, one is created automatically via + /// [singleTickerProvider]. + /// You *may not* change [vsync] after the first build. + /// + /// All other fields are ignored after the first build except for [duration] + /// and [reverseDuration], whose new values will be updated in the + /// [AnimationController]. + AnimationController animationController({ + Duration? duration, + Duration? reverseDuration, + String? debugLabel, + double initialValue = 0, + double lowerBound = 0, + double upperBound = 1, + TickerProvider? vsync, + AnimationBehavior animationBehavior = AnimationBehavior.normal, + }) => + _animationController( + this, + vsync: vsync, + duration: duration, + reverseDuration: reverseDuration, + debugLabel: debugLabel, + lowerBound: lowerBound, + upperBound: upperBound, + animationBehavior: animationBehavior, + initialValue: initialValue, + ); + + /// Prevents the associated [CapsuleConsumer] from being disposed when it + /// normally would be by its lazy list container (such as in a [ListView]). + /// + /// Acts similar to [AutomaticKeepAlive]. + /// + /// When using [automaticKeepAlive], you *must* use it in a new + /// [CapsuleConsumer] that is a child of the container; + /// otherwise, the widget might still be disposed. + void automaticKeepAlive({bool keepAlive = true}) => + _automaticKeepAlive(this, keepAlive: keepAlive); +} diff --git a/packages/flutter_rearch/lib/src/side_effects/animation.dart b/packages/flutter_rearch/lib/src/side_effects/animation.dart new file mode 100644 index 0000000..3194cda --- /dev/null +++ b/packages/flutter_rearch/lib/src/side_effects/animation.dart @@ -0,0 +1,79 @@ +part of '../side_effects.dart'; + +TickerProvider _singleTickerProvider( + WidgetSideEffectRegistrar use, +) { + final context = use.context(); + final provider = use.memo(() => _SingleTickerProvider(context)); + use.effect( + () { + // During dispose, ensure Ticker was disposed. + return () { + assert( + provider._ticker == null || !provider._ticker!.isActive, + 'A Ticker created with use.singleTickerProvider() was not disposed, ' + 'causing the Ticker to leak. You need to call dispose() on the ' + 'AnimationController that consumes the Ticker to prevent this leak.', + ); + }; + }, + [provider], + ); + + provider._ticker?.muted = !TickerMode.of(context); + + return provider; +} + +final class _SingleTickerProvider implements TickerProvider { + _SingleTickerProvider(this.context); + + final BuildContext context; + Ticker? _ticker; + + @override + Ticker createTicker(TickerCallback onTick) { + assert( + _ticker == null, + '${context.widget.runtimeType} attempted to create multiple ' + 'Tickers with a single use.singleTickerProvider(). ' + 'If you need multiple Tickers, call use.singleTickerProvider() ' + 'multiple times.', + ); + return _ticker = Ticker(onTick, debugLabel: 'Created by $context'); + } +} + +AnimationController _animationController( + WidgetSideEffectRegistrar use, { + Duration? duration, + Duration? reverseDuration, + String? debugLabel, + double initialValue = 0, + double lowerBound = 0, + double upperBound = 1, + TickerProvider? vsync, + AnimationBehavior animationBehavior = AnimationBehavior.normal, +}) { + vsync ??= use.singleTickerProvider(); + + final controller = use.memo( + () => AnimationController( + vsync: vsync!, + duration: duration, + reverseDuration: reverseDuration, + debugLabel: debugLabel, + lowerBound: lowerBound, + upperBound: upperBound, + animationBehavior: animationBehavior, + value: initialValue, + ), + ); + use.effect(() => controller.dispose, [controller]); + + controller + ..duration = duration + ..reverseDuration = reverseDuration; + + return controller; +} diff --git a/packages/flutter_rearch/lib/src/side_effects/keep_alive.dart b/packages/flutter_rearch/lib/src/side_effects/keep_alive.dart new file mode 100644 index 0000000..8f5cdec --- /dev/null +++ b/packages/flutter_rearch/lib/src/side_effects/keep_alive.dart @@ -0,0 +1,54 @@ +part of '../side_effects.dart'; + +// See https://github.com/flutter/flutter/issues/123712 +class _CustomKeepAliveHandle with ChangeNotifier { + void removeKeepAlive() => super.notifyListeners(); +} + +void _automaticKeepAlive( + WidgetSideEffectRegistrar use, { + bool keepAlive = true, +}) { + final api = use.api(); + + final handle = use.memo(_CustomKeepAliveHandle.new); + use.effect(() => handle.dispose, [handle]); + + // Dirty tracks whether or not we will need to request a new keep alive + // in case keepAlive == true + final (getDirty, setDirty) = use.rawValueWrapper(() => true); + final requestKeepAlive = use.memo( + () => () { + // It is only safe to dispatch a notification when dirty is true + if (getDirty()) { + setDirty(false); + KeepAliveNotification(handle).dispatch(api.context); + } + }, + [getDirty, setDirty, handle, api.context], + ); + final removeKeepAlive = use.memo( + () => () { + // It is always safe to remove keep alives + handle.removeKeepAlive(); + setDirty(true); + }, + [handle, setDirty], + ); + + // Remove keep alives on deactivate to prevent leaks per documentation + use.effect( + () { + api.addDeactivateListener(removeKeepAlive); + return () => api.removeDeactivateListener(removeKeepAlive); + }, + [api, removeKeepAlive], + ); + + // Request/remove the keep alive as necessary + if (keepAlive) { + requestKeepAlive(); + } else { + removeKeepAlive(); + } +} diff --git a/packages/flutter_rearch/lib/src/side_effects/text_editing_controller.dart b/packages/flutter_rearch/lib/src/side_effects/text_editing_controller.dart new file mode 100644 index 0000000..a7b6f21 --- /dev/null +++ b/packages/flutter_rearch/lib/src/side_effects/text_editing_controller.dart @@ -0,0 +1,10 @@ +part of '../side_effects.dart'; + +TextEditingController _textEditingController( + WidgetSideEffectRegistrar use, { + String? initialText, +}) { + final controller = use.memo(() => TextEditingController(text: initialText)); + use.effect(() => controller.dispose, [controller]); + return controller; +} diff --git a/packages/flutter_rearch/lib/src/widgets.dart b/packages/flutter_rearch/lib/src/widgets.dart new file mode 100644 index 0000000..0d00cfb --- /dev/null +++ b/packages/flutter_rearch/lib/src/widgets.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_rearch/flutter_rearch.dart'; +import 'package:rearch/rearch.dart'; + +part 'widgets/capsule_consumer.dart'; +part 'widgets/capsule_container_provider.dart'; +part 'widgets/capsule_warm_up.dart'; +part 'widgets/bootstrap.dart'; diff --git a/packages/flutter_rearch/lib/src/widgets/bootstrap.dart b/packages/flutter_rearch/lib/src/widgets/bootstrap.dart new file mode 100644 index 0000000..7c03d87 --- /dev/null +++ b/packages/flutter_rearch/lib/src/widgets/bootstrap.dart @@ -0,0 +1,45 @@ +part of '../widgets.dart'; + +/// {@template rearch.bootstrapper} +/// Bootstraps rearch for use in Flutter. +/// +/// Provides a [CapsuleContainer] to the rest of the [Widget] tree via +/// [CapsuleContainerProvider]. +/// {@endtemplate} +class RearchBootstrapper extends StatefulWidget { + /// {@macro rearch.bootstrapper} + const RearchBootstrapper({ + required this.child, + super.key, + }); + + /// The child of this [RearchBootstrapper]. + final Widget child; + + @override + State createState() => _RearchBootstrapperState(); +} + +class _RearchBootstrapperState extends State { + late final CapsuleContainer container; + + @override + void initState() { + super.initState(); + container = CapsuleContainer(); + } + + @override + void dispose() { + super.dispose(); + container.dispose(); + } + + @override + Widget build(BuildContext context) { + return CapsuleContainerProvider( + container: container, + child: widget.child, + ); + } +} diff --git a/packages/flutter_rearch/lib/src/widgets/capsule_consumer.dart b/packages/flutter_rearch/lib/src/widgets/capsule_consumer.dart new file mode 100644 index 0000000..c10025a --- /dev/null +++ b/packages/flutter_rearch/lib/src/widgets/capsule_consumer.dart @@ -0,0 +1,134 @@ +part of '../widgets.dart'; + +/// {@template rearch.capsuleConsumer} +/// A [Widget] that has access to a [WidgetHandle], +/// and can consequently consume [Capsule]s and [SideEffect]s. +/// {@endtemplate} +abstract class CapsuleConsumer extends Widget { + /// {@macro rearch.capsuleConsumer} + const CapsuleConsumer({super.key}); + + @override + Element createElement() => _RearchElement(this); + + /// Builds the [Widget] using the supplied [context] and [use]. + @protected + Widget build(BuildContext context, WidgetHandle use); +} + +class _RearchElement extends ComponentElement { + _RearchElement(CapsuleConsumer super.widget); + + final deactivateListeners = {}; + final disposeListeners = {}; + final sideEffectData = []; + final listenerHandles = []; + + void clearHandles() { + for (final handle in listenerHandles) { + handle.dispose(); + } + listenerHandles.clear(); + } + + @override + Widget build() { + clearHandles(); // listeners will be repopulated via _WidgetHandleImpl + final container = CapsuleContainerProvider.containerOf(this); + final consumer = super.widget as CapsuleConsumer; + return consumer.build( + this, + _WidgetHandleImpl( + _WidgetSideEffectApiImpl(this), + container, + ), + ); + } + + @override + void deactivate() { + for (final listener in deactivateListeners) { + listener(); + } + clearHandles(); + + super.deactivate(); + } + + @override + void unmount() { + for (final listener in disposeListeners) { + listener(); + } + clearHandles(); + + // Clean up after any side effects to avoid possible leaks + deactivateListeners.clear(); + disposeListeners.clear(); + + super.unmount(); + } +} + +/// This is needed so that [WidgetSideEffectApi.rebuild] doesn't conflict with +/// [Element.rebuild]. +class _WidgetSideEffectApiImpl implements WidgetSideEffectApi { + const _WidgetSideEffectApiImpl(this.manager); + final _RearchElement manager; + + @override + void rebuild() => manager.markNeedsBuild(); + + @override + BuildContext get context => manager; + + @override + void addDeactivateListener(SideEffectApiCallback callback) => + manager.deactivateListeners.add(callback); + + @override + void removeDeactivateListener(SideEffectApiCallback callback) => + manager.deactivateListeners.remove(callback); + + @override + void registerDispose(SideEffectApiCallback callback) => + manager.disposeListeners.add(callback); + + @override + void unregisterDispose(SideEffectApiCallback callback) => + manager.disposeListeners.remove(callback); +} + +class _WidgetHandleImpl implements WidgetHandle { + _WidgetHandleImpl(this.api, this.container); + + final _WidgetSideEffectApiImpl api; + final CapsuleContainer container; + int sideEffectDataIndex = 0; + + @override + T call(Capsule capsule) { + // Add capsule as dependency + var hasCalledBefore = false; + final handle = container.listen((use) { + use(capsule); // mark capsule as a dependency + + // If this isn't the immediate call after registering, rebuild + if (hasCalledBefore) { + api.rebuild(); + } + hasCalledBefore = true; + }); + api.manager.listenerHandles.add(handle); + + return container.read(capsule); + } + + @override + T register(WidgetSideEffect sideEffect) { + if (sideEffectDataIndex == api.manager.sideEffectData.length) { + api.manager.sideEffectData.add(sideEffect(api)); + } + return api.manager.sideEffectData[sideEffectDataIndex++] as T; + } +} diff --git a/packages/flutter_rearch/lib/src/widgets/capsule_container_provider.dart b/packages/flutter_rearch/lib/src/widgets/capsule_container_provider.dart new file mode 100644 index 0000000..e51e4f6 --- /dev/null +++ b/packages/flutter_rearch/lib/src/widgets/capsule_container_provider.dart @@ -0,0 +1,42 @@ +part of '../widgets.dart'; + +/// Provides a [CapsuleContainer] to the rest of the [Widget] tree +/// using an [InheritedWidget]. +/// +/// Does not manage the lifecycle of the supplied [CapsuleContainer]. +/// You typically should use [RearchBootstrapper] instead. +class CapsuleContainerProvider extends InheritedWidget { + /// Constructs a [CapsuleContainerProvider] with the supplied + /// [container] and [child]. + const CapsuleContainerProvider({ + required this.container, + required super.child, + super.key, + }); + + /// The [CapsuleContainer] this [CapsuleContainerProvider] is providing + /// to the rest of the [Widget] tree. + final CapsuleContainer container; + + @override + bool updateShouldNotify(CapsuleContainerProvider oldWidget) { + return container != oldWidget.container; + } + + /// Retrieves the [CapsuleContainer] of the given [context], + /// based on an ancestor [CapsuleContainerProvider]. + /// If there is no ancestor [CapsuleContainerProvider], throws an error. + static CapsuleContainer containerOf(BuildContext context) { + final container = context + .dependOnInheritedWidgetOfExactType() + ?.container; + + assert( + container != null, + 'No CapsuleContainerProvider found in the widget tree!\n' + 'Did you forget to add RearchBootstrapper directly to runApp()?', + ); + + return container!; + } +} diff --git a/packages/flutter_rearch/lib/src/widgets/capsule_warm_up.dart b/packages/flutter_rearch/lib/src/widgets/capsule_warm_up.dart new file mode 100644 index 0000000..7481b28 --- /dev/null +++ b/packages/flutter_rearch/lib/src/widgets/capsule_warm_up.dart @@ -0,0 +1,32 @@ +part of '../widgets.dart'; + +/// Provides [toWarmUpWidget], a mechanism to create a [Widget] from a [List] +/// of the current states of some "warm up" [Capsule]s. +extension CapsuleWarmUp on List> { + /// Creates a [Widget] from a [List] of the current states of + /// some "warm up" [Capsule]s. + /// + /// - [child] is returned when all of the current states are [AsyncData]. + /// - [loading] is returned when any of the current states are [AsyncLoading]. + /// - [errorBuilder] is called to build the returned [Widget] when any + /// of the current states are [AsyncError]. + Widget toWarmUpWidget({ + required Widget Function(List>) errorBuilder, + required Widget loading, + required Widget child, + }) { + // Check for any errors first + final asyncErrors = whereType>(); + if (asyncErrors.isNotEmpty) { + return errorBuilder(asyncErrors.toList()); + } + + // Check to see if we have any still loading + if (any((value) => value is AsyncLoading)) { + return loading; + } + + // We have only AsyncData (no loading or error), so return the child + return child; + } +} diff --git a/packages/flutter_rearch/pubspec.yaml b/packages/flutter_rearch/pubspec.yaml new file mode 100644 index 0000000..fd00e1b --- /dev/null +++ b/packages/flutter_rearch/pubspec.yaml @@ -0,0 +1,20 @@ +name: flutter_rearch +description: A re-imagined declarative approach to application design and architecture +version: 0.0.0-dev.1 +homepage: https://rearch.gsconrad.com +documentation: https://rearch.gsconrad.com +repository: https://github.com/GregoryConrad/rearch-dart +issue_tracker: https://github.com/GregoryConrad/rearch-dart/issues + +environment: + sdk: ^3.0.0 + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + rearch: ^0.0.0-dev.1 + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/rearch/example/lib/example.dart b/packages/rearch/example/lib/example.dart index 6526f22..fb70b80 100644 --- a/packages/rearch/example/lib/example.dart +++ b/packages/rearch/example/lib/example.dart @@ -23,7 +23,7 @@ int countPlusOne(CapsuleHandle use) => use(count) + 1; /// Entrypoint of the application. void main() { - final container = Container(); + final container = CapsuleContainer(); assert( container.read(count) == 0, diff --git a/packages/rearch/lib/rearch.dart b/packages/rearch/lib/rearch.dart index 28ce1bc..ac9382d 100644 --- a/packages/rearch/lib/rearch.dart +++ b/packages/rearch/lib/rearch.dart @@ -1,7 +1,8 @@ import 'package:meta/meta.dart'; import 'package:rearch/src/node.dart'; -export 'package:rearch/src/side_effects.dart'; +export 'src/side_effects.dart'; +export 'src/types.dart'; // TODO(GregoryConrad): eager garbage collection mode @@ -18,7 +19,7 @@ typedef Capsule = T Function(CapsuleHandle); /// Capsule listeners are a mechanism to *temporarily* listen to changes /// to a set of [Capsule]s. -/// See [Container.listen]. +/// See [CapsuleContainer.listen]. typedef CapsuleListener = void Function(CapsuleReader); /// Provides a mechanism to read the current state of [Capsule]s. @@ -39,7 +40,12 @@ abstract interface class SideEffectRegistrar { abstract interface class CapsuleHandle implements CapsuleReader, SideEffectRegistrar {} -/// Represents a side effect. +/// Defines what a [SideEffect] should look like (a [Function] +/// that consumes a [SideEffectApi] and returns something). +/// +/// If your side effect is more advanced or requires parameters, +/// simply make a callable class instead of just a regular [Function]! +/// /// See the documentation for more. typedef SideEffect = T Function(SideEffectApi); @@ -62,7 +68,7 @@ abstract interface class SideEffectApi { /// Contains the data of [Capsule]s. /// See the documentation for more. -class Container implements Disposable { +class CapsuleContainer implements Disposable { final _capsules = <_UntypedCapsule, _UntypedCapsuleManager>{}; _CapsuleManager _managerOf(Capsule capsule) { @@ -104,13 +110,13 @@ class Container implements Disposable { } } -/// A handle onto the lifecycle of a listener from [Container.listen]. +/// A handle onto the lifecycle of a listener from [CapsuleContainer.listen]. /// You *must* [dispose] the [ListenerHandle] /// when you no longer need the listener in order to prevent leaks. class ListenerHandle implements Disposable { ListenerHandle._(this._container, this._capsule); - final Container _container; + final CapsuleContainer _container; final _UntypedCapsule _capsule; @override @@ -126,7 +132,7 @@ class _CapsuleManager extends DataflowGraphNode buildSelf(); } - final Container container; + final CapsuleContainer container; final Capsule capsule; late T data; diff --git a/packages/rearch/lib/src/side_effects.dart b/packages/rearch/lib/src/side_effects.dart index b51f18e..fdccf82 100644 --- a/packages/rearch/lib/src/side_effects.dart +++ b/packages/rearch/lib/src/side_effects.dart @@ -11,11 +11,26 @@ extension BuiltinSideEffects on SideEffectRegistrar { /// Side effect that calls the supplied [callback] once, on the first build. T callonce(T Function() callback) => register((_) => callback()); + /// Returns a raw value wrapper; i.e., a getter and setter for some value. + /// *The setter will not trigger rebuilds*. + /// The initial state will be set to the result of running [init], + /// if it was provided. Otherwise, you must manually set it + /// via the setter before ever calling the getter. + (T Function(), void Function(T)) rawValueWrapper([T Function()? init]) { + return register((api) { + late T state; + if (init != null) state = init(); + return (() => state, (T newState) => state = newState); + }); + } + /// Side effect that provides a way for capsules to contain some state, /// where the initial state is computationally expensive. /// Similar to the `useState` hook from React; /// see https://react.dev/reference/react/useState (T, void Function(T)) lazyState(T Function() init) { + // We use register directly to keep the same setter function + // across rebuilds, which actually can help skip certain rebuilds final (getter, setter) = register((api) { var state = init(); @@ -47,5 +62,52 @@ extension BuiltinSideEffects on SideEffectRegistrar { /// see https://react.dev/reference/react/useRef T value(T initial) => lazyValue(() => initial); + /// Returns the previous value passed into [previous], + /// or `null` on first build. + T? previous(T current) { + final (getter, setter) = rawValueWrapper(() => null); + final prev = getter(); + setter(current); + return prev; + } + + /// Equivalent to the `useMemo` hook from React. + /// See https://react.dev/reference/react/useMemo + T memo(T Function() memo, [List dependencies = const []]) { + final oldDependencies = previous(dependencies); + final (getData, setData) = rawValueWrapper(); + if (_didDepsListChange(dependencies, oldDependencies)) { + setData(memo()); + } + return getData(); + } + + /// Equivalent to the `useEffect` hook from React. + /// See https://react.dev/reference/react/useEffect + void effect( + void Function()? Function() effect, [ + List? dependencies, + ]) { + final oldDependencies = previous(dependencies); + final (getDispose, setDispose) = + rawValueWrapper(() => null); + register((api) => api.registerDispose(() => getDispose()?.call())); + + if (dependencies == null || + _didDepsListChange(dependencies, oldDependencies)) { + getDispose()?.call(); + setDispose(effect()); + } + } + // TODO(GregoryConrad): other side effects } + +/// Checks to see whether [newDeps] has changed from [oldDeps] +/// using a deep-ish equality check (compares `==` amongst [List] children). +bool _didDepsListChange(List newDeps, List? oldDeps) { + return oldDeps == null || + newDeps.length != oldDeps.length || + Iterable.generate(newDeps.length) + .any((i) => newDeps[i] != oldDeps[i]); +} diff --git a/packages/rearch/lib/src/types.dart b/packages/rearch/lib/src/types.dart new file mode 100644 index 0000000..f124eed --- /dev/null +++ b/packages/rearch/lib/src/types.dart @@ -0,0 +1,300 @@ +import 'package:meta/meta.dart'; + +/// Represents an optional value of type [T]. +/// +/// An [Option] is either: +/// - [Some], which contains a value of type [T] +/// - [None], which does not contain a value +/// +/// Adapted from Rust's `Option`, see more here: +/// https://doc.rust-lang.org/std/option/index.html +@immutable +sealed class Option { + /// Base constructor for [Option]s. + const Option(); + + /// Shortcut for [Some.new]. + const factory Option.some(T value) = Some; + + /// Shortcut for [None.new]. + const factory Option.none() = None; +} + +/// An [Option] that has a [value]. +@immutable +final class Some extends Option { + /// Creates an [Option] with the associated immutable [value]. + const Some(this.value); + + /// The immutable [value] associated with this [Option]. + final T value; + + @override + int get hashCode => value.hashCode; + + @override + bool operator ==(Object other) => other is Some && other.value == value; + + @override + String toString() => 'Some(value: $value)'; +} + +/// An [Option] that does not have a value. +@immutable +final class None extends Option { + /// Creates an [Option] that does not have a value. + const None(); + + @override + int get hashCode => 0; + + @override + bool operator ==(Object other) => other is None; + + @override + String toString() => 'None()'; +} + +/// Convenience methods for handling [Option]s. +/// +/// Help is wanted here! Please open PRs to add any methods you want! +/// When possible, try to follow the function names in Rust for Option: +/// - https://doc.rust-lang.org/std/option/enum.Option.html +extension OptionConvenience on Option { + /// Returns [Some.value] if `this` is a [Some]. + /// Otherwise, returns [defaultValue] (when [None]). + T unwrapOr(T defaultValue) { + return switch (this) { + Some(:final value) => value, + None() => defaultValue, + }; + } + + /// Returns [Some.value] if `this` is a [Some]. + /// Otherwise, calls and returns the result of [defaultFn] (when [None]). + T unwrapOrElse(T Function() defaultFn) { + return switch (this) { + Some(:final value) => value, + None() => defaultFn(), + }; + } + + /// Returns [Some.value] or `null` for [None]. + T? asNullable() { + return switch (this) { + Some(:final value) => value, + None() => null, + }; + } +} + +/// The current state of a [Future] or [Stream], +/// accessible from a synchronous context. +/// +/// One of three variants: [AsyncData], [AsyncError], or [AsyncLoading]. +/// +/// Often, when a [Future]/[Stream] emits an error, or is swapped out and is put +/// back into the loading state, you want access to the previous data. +/// (Example: pull-to-refresh in UI and you want to show the current data.) +/// Thus, a `previousData` is provided in the [AsyncError] and [AsyncLoading] +/// states so you can access the previous data (if it exists). +@immutable +sealed class AsyncValue { + /// Base constructor for [AsyncValue]s. + const AsyncValue(); + + /// Shortcut for [AsyncData.new]. + const factory AsyncValue.data(T data) = AsyncData; + + /// Shortcut for [AsyncError.new]. + const factory AsyncValue.error( + Object error, + StackTrace stackTrace, + Option previousData, + ) = AsyncError; + + /// Shortcut for [AsyncLoading.new]. + const factory AsyncValue.loading(Option previousData) = AsyncLoading; + + /// Transforms a fallible [Future] into a safe-to-read [AsyncValue]. + /// Useful when mutating state. + static Future> guard(Future Function() fn) async { + try { + return AsyncData(await fn()); + } catch (error, stackTrace) { + return AsyncError(error, stackTrace, None()); + } + } +} + +/// The data variant for an [AsyncValue]. +/// +/// To be in this state, a [Future] or [Stream] emitted a data event. +@immutable +final class AsyncData extends AsyncValue { + /// Creates an [AsyncData] with the supplied [data]. + const AsyncData(this.data); + + /// The data of this [AsyncData]. + final T data; + + @override + int get hashCode => data.hashCode; + + @override + bool operator ==(Object other) => other is AsyncData && other.data == data; + + @override + String toString() => 'AsyncData(data: $data)'; +} + +/// The loading variant for an [AsyncValue]. +/// +/// To be in this state, a new [Future] or [Stream] has not emitted +/// a data or error event yet. +@immutable +final class AsyncLoading extends AsyncValue { + /// Creates an [AsyncLoading] with the supplied [previousData]. + const AsyncLoading(this.previousData); + + /// The previous data (from a predecessor [AsyncData]), if it exists. + /// This can happen if a new [Future]/[Stream] is watched and the + /// [Future]/[Stream] it is replacing was in the [AsyncData] state. + final Option previousData; + + @override + int get hashCode => previousData.hashCode; + + @override + bool operator ==(Object other) => + other is AsyncLoading && other.previousData == previousData; + + @override + String toString() => 'AsyncLoading(previousData: $previousData)'; +} + +/// The error variant for an [AsyncValue]. +/// +/// To be in this state, a [Future] or [Stream] emitted an error event. +@immutable +final class AsyncError extends AsyncValue { + /// Creates an [AsyncError] with the supplied [error], [stackTrace], + /// and [previousData]. + const AsyncError(this.error, this.stackTrace, this.previousData); + + /// The emitted error associated with this [AsyncError]. + final Object error; + + /// The [StackTrace] corresponding with the [error]. + final StackTrace stackTrace; + + /// The previous data (from a predecessor [AsyncData]), if it exists. + /// This can happen if a new [Future]/[Stream] is watched and the + /// [Future]/[Stream] it is replacing was in the [AsyncData] state. + final Option previousData; + + @override + int get hashCode => Object.hash(error, stackTrace, previousData); + + @override + bool operator ==(Object other) => + other is AsyncError && + other.error == error && + other.stackTrace == stackTrace && + other.previousData == previousData; + + @override + String toString() => 'AsyncError(previousData: $previousData, ' + 'error: $error, stackTrace: $stackTrace)'; +} + +/// Convenience methods for handling [AsyncValue]s. +/// +/// Help is wanted here! Please open PRs to add any methods you want! +/// When possible, try to follow function names in Rust for Result/Option: +/// - https://doc.rust-lang.org/std/option/enum.Option.html +/// - https://doc.rust-lang.org/std/result/enum.Result.html +extension AsyncValueConvenience on AsyncValue { + /// Returns *any* data contained within this [AsyncValue], + /// including `previousData` for the [AsyncLoading] and [AsyncError] cases. + /// + /// Returns [Some] of [AsyncData.data] if `this` is an [AsyncData]. + /// Returns [AsyncLoading.previousData] if `this` is an [AsyncLoading]. + /// Returns [AsyncError.previousData] if `this` is an [AsyncError]. + /// + /// See also [unwrapOr], which will only return the value + /// on the [AsyncData] case. + Option get data { + return switch (this) { + AsyncData(:final data) => Some(data), + AsyncLoading(:final previousData) => previousData, + AsyncError(:final previousData) => previousData, + }; + } + + /// Returns [AsyncData.data] if `this` is an [AsyncData]. + /// Otherwise, returns [defaultValue]. + /// + /// See also [dataOr], which will always return any `data`/`previousData` + /// contained within the [AsyncValue]. + T unwrapOr(T defaultValue) { + return switch (this) { + AsyncData(:final data) => data, + _ => defaultValue, + }; + } + + /// Returns [AsyncData.data] if `this` is an [AsyncData]. + /// Otherwise, calls and returns the result of [defaultFn]. + /// + /// See also [dataOrElse], which will always return any `data`/`previousData` + /// contained within the [AsyncValue]. + T unwrapOrElse(T Function() defaultFn) { + return switch (this) { + AsyncData(:final data) => data, + _ => defaultFn(), + }; + } + + /// Returns *any* data contained within this [AsyncValue], + /// including `previousData` for the [AsyncLoading] and [AsyncError] cases. + /// + /// Returns [AsyncData.data] if `this` is an [AsyncData]. + /// Returns the value contained in [AsyncLoading.previousData] if `this` is + /// an [AsyncLoading] and [AsyncLoading.previousData] is [Some]. + /// Returns the value contained in [AsyncError.previousData] if `this` is + /// an [AsyncError] and [AsyncError.previousData] is [Some]. + /// Otherwise, returns [defaultValue]. + /// + /// See also [unwrapOr], which will only return the value + /// on the [AsyncData] case. + T dataOr(T defaultValue) => data.unwrapOr(defaultValue); + + /// Returns *any* data contained within this [AsyncValue], + /// including `previousData` for the [AsyncLoading] and [AsyncError] cases. + /// + /// Returns [AsyncData.data] if `this` is an [AsyncData]. + /// Returns the value contained in [AsyncLoading.previousData] if `this` is + /// an [AsyncLoading] and [AsyncLoading.previousData] is [Some]. + /// Returns the value contained in [AsyncError.previousData] if `this` is + /// an [AsyncError] and [AsyncError.previousData] is [Some]. + /// Otherwise, calls and returns the result of [defaultFn]. + /// + /// See also [unwrapOrElse], which will only return the value + /// on the [AsyncData] case. + T dataOrElse(T Function() defaultFn) => data.unwrapOrElse(defaultFn); + + /// Fills in the [AsyncLoading.previousData] or [AsyncError.previousData] with + /// [newPreviousData] if [AsyncLoading.previousData] or + /// [AsyncError.previousData] are [None]. + /// If [AsyncLoading.previousData] or [AsyncError.previousData] are [Some], + /// then [newPreviousData] will not be filled in. + AsyncValue fillInPreviousData(Option newPreviousData) { + return switch (this) { + AsyncLoading(previousData: None()) => AsyncLoading(newPreviousData), + AsyncError(:final error, :final stackTrace, previousData: None()) => + AsyncError(error, stackTrace, newPreviousData), + _ => this, + }; + } +} diff --git a/packages/rearch/test/basic_test.dart b/packages/rearch/test/basic_test.dart index 3cd57e7..b90d19f 100644 --- a/packages/rearch/test/basic_test.dart +++ b/packages/rearch/test/basic_test.dart @@ -1,8 +1,8 @@ import 'package:rearch/rearch.dart'; import 'package:test/test.dart'; -Container useContainer() { - final container = Container(); +CapsuleContainer useContainer() { + final container = CapsuleContainer(); addTearDown(container.dispose); return container; }