diff --git a/example/example.dart b/example/example.dart index 833fe67..1c0ecaf 100644 --- a/example/example.dart +++ b/example/example.dart @@ -33,6 +33,10 @@ class ConnectionExample extends StatelessWidget { store: store, child: new StoreConnection( connect: (state) => state.count, + onInit: (state, actions) => print('onInit'), + onDidChange: (state, action) => print('onDidChange'), + onAfterFirstBuild: (state, action) => print('onAfterFirstBuild'), + onDispose: (action) => print('onDispose'), builder: (BuildContext context, int count, CounterActions actions) { return new Scaffold( body: new Row( @@ -73,6 +77,26 @@ class ConnectorExample extends StatelessWidget { class CounterWidget extends StoreConnector { CounterWidget(); + @override + void onInit(int state, CounterActions actions) { + print('onInit'); + } + + @override + void onDidChange(int state, CounterActions actions) { + print('onDidChange'); + } + + @override + void onFirstBuild(int state, CounterActions actions) { + print('onFirstBuild'); + } + + @override + void onDispose(CounterActions actions) { + print('onDispose'); + } + @override int connect(Counter state) => state.count; diff --git a/lib/flutter_built_redux.dart b/lib/flutter_built_redux.dart index 09bb9b7..e8a9131 100644 --- a/lib/flutter_built_redux.dart +++ b/lib/flutter_built_redux.dart @@ -12,6 +12,39 @@ typedef LocalState Connect(StoreState state); typedef Widget StoreConnectionBuilder( BuildContext context, LocalState state, Actions actions); +/// Called once right before building the widget. +/// Your state will be the result of [StoreConnector.connect]. +/// You can safely use context in this callback. +/// Perfect to fetch data/dispatch action. +typedef OnInitCallback = void + Function( + LocalState state, + Actions actions, +); + +/// Will be called after your Widget has been built for the first time. +/// Your state will be the result of [StoreConnector.connect]. +/// This callback is useful for certain animations, showing dialogs or snackbars +/// after your layout has been built +typedef OnFirstBuildCallback = void + Function( + LocalState state, + Actions actions, +); + +/// Will be called every time the state has been changed and the widget has been rebuilt. +typedef OnDidChangeCallback = void + Function( + LocalState state, + Actions actions, +); + +/// Clean up some data or dispatch an action. +/// This is called in [State.dispose] before the widget is removed from the +/// Widget tree +typedef OnDisposeCallback = void Function( + Actions actions); + /// [StoreConnection] is a widget that rebuilds when the redux store /// has triggered and the connect function yields a new result. It is an implementation /// of `StoreConnector` that takes a connect function and builder function as parameters @@ -34,19 +67,62 @@ class StoreConnection extends StoreConnector { final Connect _connect; final StoreConnectionBuilder _builder; + final OnInitCallback _onInit; + final OnDisposeCallback _onDispose; + final OnFirstBuildCallback _onFirstBuild; + final OnDidChangeCallback _onDidChange; StoreConnection({ @required LocalState connect(StoreState state), @required Widget builder(BuildContext context, LocalState state, Actions actions), + OnInitCallback onInit, + OnDisposeCallback onDispose, + OnFirstBuildCallback onAfterFirstBuild, + OnDidChangeCallback onDidChange, Key key, - }) - : assert(connect != null, 'StoreConnection: connect must not be null'), + }) : assert(connect != null, 'StoreConnection: connect must not be null'), assert(builder != null, 'StoreConnection: builder must not be null'), _connect = connect, _builder = builder, + _onInit = onInit, + _onDispose = onDispose, + _onFirstBuild = onAfterFirstBuild, + _onDidChange = onDidChange, super(key: key); + @protected + @override + void onInit(LocalState state, Actions actions) { + if (null != _onInit) { + _onInit(state, actions); + } + } + + @protected + @override + void onDispose(Actions actions) { + if (null != _onDispose) { + _onDispose(actions); + } + } + + @protected + @override + void onFirstBuild(LocalState state, Actions actions) { + if (null != _onFirstBuild) { + _onFirstBuild(state, actions); + } + } + + @protected + @override + void onDidChange(LocalState state, Actions actions) { + if (null != _onDidChange) { + _onDidChange(state, actions); + } + } + @protected LocalState connect(StoreState state) => _connect(state); @@ -65,6 +141,18 @@ abstract class StoreConnector extends StatefulWidget { StoreConnector({Key key}) : super(key: key); + @protected + void onInit(LocalState state, Actions actions) {} + + @protected + void onDispose(Actions actions) {} + + @protected + void onFirstBuild(LocalState state, Actions actions) {} + + @protected + void onDidChange(LocalState state, Actions actions) {} + /// [connect] takes the current state of the redux store and retuns an object that contains /// the subset of the redux state tree that this component cares about. /// It requires that you return a comparable type to ensure your props setState is only called when necessary. @@ -89,23 +177,7 @@ class _StoreConnectorState /// cares about. LocalState _state; - Store get _store { - // get the store from the ReduxProvider ancestor - final ReduxProvider reduxProvider = - context.inheritFromWidgetOfExactType(ReduxProvider); - - // if it is not found raise an error - assert(reduxProvider != null, - 'Store was not found, make sure ReduxProvider is an ancestor of this component.'); - - assert(reduxProvider.store.state is StoreState, - 'Store found was not the correct type, make sure StoreConnector\'s generic for StoreState matches the state type of your built_redux store.'); - - assert(reduxProvider.store.actions is Actions, - 'Store found was not the correct type, make sure StoreConnector\'s generic for Actions matches the actions type of your built_redux store.'); - - return reduxProvider.store; - } + Store _store; /// sets up a subscription to the store @override @@ -121,9 +193,31 @@ class _StoreConnectorState // See https://github.com/flutter/flutter/blob/0.0.20/packages/flutter/lib/src/widgets/framework.dart#L3721 if (_storeSub != null) return; + // get the store from the ReduxProvider ancestor + final ReduxProvider reduxProvider = + context.inheritFromWidgetOfExactType(ReduxProvider); + + // if it is not found raise an error + assert(reduxProvider != null, + 'Store was not found, make sure ReduxProvider is an ancestor of this component.'); + + assert(reduxProvider.store.state is StoreState, + 'Store found was not the correct type, make sure StoreConnector\'s generic for StoreState matches the state type of your built_redux store.'); + + assert(reduxProvider.store.actions is Actions, + 'Store found was not the correct type, make sure StoreConnector\'s generic for Actions matches the actions type of your built_redux store.'); + + _store = reduxProvider.store; + // set the initial state _state = widget.connect(_store.state as StoreState); + widget.onInit(_state, _store.actions as Actions); + + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onFirstBuild(_state, _store.actions as Actions); + }); + // listen to changes _storeSub = _store .substateStream((state) => widget.connect(state as StoreState)) @@ -131,6 +225,10 @@ class _StoreConnectorState setState(() { _state = change.next; }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onDidChange(_state, _store.actions as Actions); + }); }); } @@ -139,6 +237,7 @@ class _StoreConnectorState @mustCallSuper void dispose() { _storeSub.cancel(); + widget.onDispose(_store.actions as Actions); super.dispose(); } diff --git a/test/unit/flutter_built_redux_test.dart b/test/unit/flutter_built_redux_test.dart index 1dd0693..17c3513 100644 --- a/test/unit/flutter_built_redux_test.dart +++ b/test/unit/flutter_built_redux_test.dart @@ -99,6 +99,106 @@ void main() { expect(counterWidget.numBuilds, 1); expect(incrementTextWidget.data, 'Count: 0'); }); + + testWidgets('triggers onInit', (WidgetTester tester) async { + final providerWidget = new ProviderCallbackWidgetConnector(store); + + await tester.pumpWidget(providerWidget); + + CounterCallbackWidget counterWidget = tester.firstWidget( + find.byKey(counterKey), + ); + + expect(counterWidget.onInitCount, 1); + + await tester.tap(find.byKey(incrementButtonKey)); + await tester.pump(); + + expect(counterWidget.onInitCount, 1); + }); + + testWidgets('triggers onDispose', (WidgetTester tester) async { + final providerWidget = new ProviderCallbackWidgetConnector(store); + final providerOtherWidget = + new ProviderCallbackWidgetConnector(store, providerOtherKey); + + await tester.pumpWidget(providerWidget); + + CounterCallbackWidget counterWidget() => tester.firstWidget( + find.byKey(counterKey), + ); + CounterCallbackWidget counterWidget1 = counterWidget(); + + expect(counterWidget1.onDisposeCount, 0); + + await tester.pump(); + + expect(counterWidget1.onDisposeCount, 0); + + await tester.pumpWidget(providerOtherWidget); + + expect(counterWidget1.onDisposeCount, 1); + + CounterCallbackWidget counterWidget2 = counterWidget(); + + expect(counterWidget2.onDisposeCount, 0); + + await tester.pump(); + + expect(counterWidget1.onDisposeCount, 1); + expect(counterWidget2.onDisposeCount, 0); + }); + + testWidgets('triggers onFirstBuild', (WidgetTester tester) async { + final firstState = store.state; + final providerWidget = new ProviderCallbackWidgetConnector(store); + + await tester.pumpWidget(providerWidget); + + CounterCallbackWidget counterWidget = tester.firstWidget( + find.byKey(counterKey), + ); + + expect(counterWidget.onFirstBuildCount, 1); + expect(firstState, store.state); + + await tester.tap(find.byKey(incrementButtonKey)); + await tester.pump(); + + expect(counterWidget.onFirstBuildCount, 1); + + await tester.pumpWidget(providerWidget); + await tester.tap(find.byKey(incrementButtonKey)); + + expect(counterWidget.onFirstBuildCount, 1); + }); + + testWidgets('triggers onDidChange', (WidgetTester tester) async { + final providerWidget = new ProviderCallbackWidgetConnector(store); + + await tester.pumpWidget(providerWidget); + + CounterCallbackWidget counterWidget = tester.firstWidget( + find.byKey(counterKey), + ); + + expect(counterWidget.onDidChangeCount, 0); + expect(counterWidget.onDidChangeState, null); + + await tester.pump(); + expect(counterWidget.onDidChangeCount, 0); + expect(counterWidget.onDidChangeState, null); + + await tester.tap(find.byKey(incrementButtonKey)); + await tester.pump(); + expect(counterWidget.onDidChangeCount, 1); + expect(counterWidget.onDidChangeState, 1); + + await tester.tap(find.byKey(incrementButtonKey)); + await tester.pump(); + expect(counterWidget.onDidChangeCount, 2); + expect(counterWidget.onDidChangeState, 2); + }); }); group('StoreConnection: ', () { @@ -163,6 +263,86 @@ void main() { expect(providerWidget.numBuilds, 1); expect(incrementTextWidget.data, 'Count: 0'); }); + + testWidgets('triggers onInit', (WidgetTester tester) async { + final providerWidget = new ProviderCallbackWidgetConnection(store); + + await tester.pumpWidget(providerWidget); + + expect(providerWidget.onInitCount, 1); + + await tester.tap(find.byKey(incrementButtonKey)); + await tester.pump(); + + expect(providerWidget.onInitCount, 1); + }); + + testWidgets('triggers onDispose', (WidgetTester tester) async { + final providerWidget = new ProviderCallbackWidgetConnection(store); + final providerOtherWidget = + new ProviderCallbackWidgetConnection(store, providerOtherKey); + + await tester.pumpWidget(providerWidget); + + expect(providerWidget.onDisposeCount, 0); + + await tester.pump(); + + expect(providerWidget.onDisposeCount, 0); + expect(providerOtherWidget.onDisposeCount, 0); + + await tester.pumpWidget(providerOtherWidget); + + expect(providerWidget.onDisposeCount, 1); + expect(providerOtherWidget.onDisposeCount, 0); + + await tester.pump(); + + expect(providerWidget.onDisposeCount, 1); + }); + + testWidgets('triggers onFirstBuild', (WidgetTester tester) async { + final firstState = store.state; + final providerWidget = new ProviderCallbackWidgetConnection(store); + expect(providerWidget.onFirstBuildCount, 0); + await tester.pumpWidget(providerWidget); + + expect(providerWidget.onFirstBuildCount, 1); + expect(firstState, store.state); + + await tester.tap(find.byKey(incrementButtonKey)); + await tester.pump(); + + expect(providerWidget.onFirstBuildCount, 1); + + await tester.pumpWidget(providerWidget); + await tester.tap(find.byKey(incrementButtonKey)); + + expect(providerWidget.onFirstBuildCount, 1); + }); + + testWidgets('triggers onDidChange', (WidgetTester tester) async { + final providerWidget = new ProviderCallbackWidgetConnection(store); + + await tester.pumpWidget(providerWidget); + + expect(providerWidget.onDidChangeCount, 0); + expect(providerWidget.onDidChangeState, null); + + await tester.pump(); + expect(providerWidget.onDidChangeCount, 0); + expect(providerWidget.onDidChangeState, null); + + await tester.tap(find.byKey(incrementButtonKey)); + await tester.pump(); + expect(providerWidget.onDidChangeCount, 1); + expect(providerWidget.onDidChangeState, 1); + + await tester.tap(find.byKey(incrementButtonKey)); + await tester.pump(); + expect(providerWidget.onDidChangeCount, 2); + expect(providerWidget.onDidChangeState, 2); + }); }); }); } diff --git a/test/unit/test_widget.dart b/test/unit/test_widget.dart index 354f8db..6f191f3 100644 --- a/test/unit/test_widget.dart +++ b/test/unit/test_widget.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'test_models.dart'; final providerKey = new Key('providerKey'); +final providerOtherKey = new Key('providerOtherKey '); final counterKey = new Key('counterKey'); final incrementTextKey = new Key('incrementTextKey'); final incrementButtonKey = new Key('incrementButtonKey'); @@ -107,3 +108,111 @@ class CounterWidget extends StoreConnector { ); } } + +// ignore: must_be_immutable +class ProviderCallbackWidgetConnection extends StatelessWidget { + final Store store; + int onInitCount = 0; + int onDisposeCount = 0; + int onFirstBuildCount = 0; + int onDidChangeCount = 0; + int onDidChangeState; + + ProviderCallbackWidgetConnection(this.store, [Key key]) + : super(key: key ?? providerKey); + + @override + Widget build(BuildContext context) => new MaterialApp( + title: 'flutter_built_redux_test', + home: new ReduxProvider( + store: store, + child: new StoreConnection( + onInit: (state, actions) { + onInitCount++; + }, + onDispose: (actions) { + onDisposeCount++; + }, + onAfterFirstBuild: (state, actions) { + onFirstBuildCount++; + }, + onDidChange: (state, actions) { + onDidChangeState = state; + onDidChangeCount++; + }, + connect: (state) => state.count, + key: counterKey, + builder: (BuildContext context, int count, CounterActions actions) { + return new Scaffold( + body: new Row( + children: [ + new RaisedButton( + onPressed: actions.increment, + child: new Text('Increment'), + key: incrementButtonKey, + ), + new RaisedButton( + onPressed: actions.incrementOther, + child: new Text('Increment Other'), + key: incrementOtherButtonKey, + ), + new Text( + 'Count: $count', + key: incrementTextKey, + ), + ], + ), + ); + }, + ), + ), + ); +} + +class ProviderCallbackWidgetConnector extends StatelessWidget { + final Store store; + + ProviderCallbackWidgetConnector(this.store, [Key key]) + : super(key: key ?? providerKey); + + @override + Widget build(BuildContext context) => new MaterialApp( + title: 'flutter_built_redux_test', + home: new ReduxProvider( + store: store, + child: new CounterCallbackWidget(), + ), + ); +} + +// ignore: must_be_immutable +class CounterCallbackWidget extends CounterWidget { + int onInitCount = 0; + int onDisposeCount = 0; + int onFirstBuildCount = 0; + int onDidChangeCount = 0; + int onDidChangeState; + + CounterCallbackWidget() : super(); + + @override + void onInit(int state, CounterActions actions) { + onInitCount++; + } + + @override + void onDispose(actions) { + onDisposeCount++; + } + + @override + void onFirstBuild(int state, CounterActions actions) { + onFirstBuildCount++; + } + + @override + void onDidChange(int state, CounterActions actions) { + onDidChangeState = state; + onDidChangeCount++; + } +}