/
store.dart
2285 lines (2081 loc) · 90.1 KB
/
store.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg
// Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan.
// Uses code from package equatable by Felix Angelov.
// For more info, see: https://pub.dartlang.org/packages/async_redux
library async_redux_store;
import 'dart:async';
import 'dart:collection';
import 'package:async_redux/async_redux.dart';
import 'package:async_redux/src/process_persistence.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'connector_tester.dart';
part 'redux_action.dart';
typedef Reducer<St> = FutureOr<St?> Function();
typedef Dispatch<St> = FutureOr<ActionStatus> Function(
ReduxAction<St> action, {
bool notify,
});
typedef DispatchSync<St> = ActionStatus Function(
ReduxAction<St> action, {
bool notify,
});
@Deprecated("Use `DispatchAndWait` instead. This will be removed.")
typedef DispatchAsync<St> = Future<ActionStatus> Function(
ReduxAction<St> action, {
bool notify,
});
typedef DispatchAndWait<St> = Future<ActionStatus> Function(
ReduxAction<St> action, {
bool notify,
});
/// Creates a Redux store that holds the app state.
///
/// The only way to change the state in the store is to dispatch a ReduxAction.
/// You may implement these methods:
///
/// 1) `AppState reduce()` ➜
/// To run synchronously, just return the state:
/// AppState reduce() { ... return state; }
/// To run asynchronously, return a future of the state:
/// Future<AppState> reduce() async { ... return state; }
/// Note that changing the state is optional. If you return null (or Future of null)
/// the state will not be changed. Just the same, if you return the same instance
/// of state (or its Future) the state will not be changed.
///
/// 2) `FutureOr<void> before()` ➜ Runs before the reduce method.
/// If it throws an error, then `reduce` will NOT run.
/// To run `before` synchronously, just return void:
/// void before() { ... }
/// To run asynchronously, return a future of void:
/// Future<void> before() async { ... }
/// Note: If this method runs asynchronously, then `reduce` will also be async,
/// since it must wait for this one to finish.
///
/// 3) `void after()` ➜ Runs after `reduce`, even if an error was thrown by
/// `before` or `reduce` (akin to a "finally" block). If the `after` method itself
/// throws an error, this error will be "swallowed" and ignored. Avoid `after`
/// methods which can throw errors.
///
/// 4) `bool abortDispatch()` ➜ If this returns true, the action will not be
/// dispatched: `before`, `reduce` and `after` will not be called. This is only useful
/// under rare circumstances, and you should only use it if you know what you are doing.
///
/// 5) `Object? wrapError(error)` ➜ If any error is thrown by `before` or `reduce`,
/// you have the chance to further process it by using `wrapError`. Usually this
/// is used to wrap the error inside of another that better describes the failed action.
/// For example, if some action converts a String into a number, then instead of
/// throwing a FormatException you could do:
/// `wrapError(error) => UserException("Please enter a valid number.", cause: error)`
///
/// ---
///
/// • ActionObserver observes the dispatching of actions,
/// and may be used to print or log the dispatching of actions.
///
/// • StateObservers receive the action, prevState (state right before the new State is applied),
/// newState (state that was applied), and are used to track metrics and more.
///
/// • ErrorObservers may be used to observe or process errors thrown by actions.
///
/// • GlobalWrapError may be used to wrap action errors globally.
///
/// For more info, see: https://pub.dartlang.org/packages/async_redux
///
class Store<St> {
Store({
required St initialState,
Object? environment,
Map<Object?, Object?> props = const {},
bool syncStream = false,
TestInfoPrinter? testInfoPrinter,
List<ActionObserver<St>>? actionObservers,
List<StateObserver<St>>? stateObservers,
Persistor<St>? persistor,
ModelObserver? modelObserver,
ErrorObserver<St>? errorObserver,
WrapReduce<St>? wrapReduce,
@Deprecated("Use `globalWrapError` instead. This will be removed.") WrapError<St>? wrapError,
GlobalWrapError<St>? globalWrapError,
bool? defaultDistinct,
CompareBy? immutableCollectionEquality,
int? maxErrorsQueued,
}) : _state = initialState,
_environment = environment,
_props = HashMap()..addAll(props),
_stateTimestamp = DateTime.now().toUtc(),
_changeController = StreamController.broadcast(sync: syncStream),
_actionObservers = actionObservers,
_stateObservers = stateObservers,
_processPersistence = persistor == null
? //
null
: ProcessPersistence(persistor, initialState),
_modelObserver = modelObserver,
_errorObserver = errorObserver,
_wrapError = wrapError,
_globalWrapError = globalWrapError,
_wrapReduce = wrapReduce,
_defaultDistinct = defaultDistinct ?? true,
_immutableCollectionEquality = immutableCollectionEquality,
_errors = Queue<UserException>(),
_maxErrorsQueued = maxErrorsQueued ?? 10,
_dispatchCount = 0,
_reduceCount = 0,
_shutdown = false,
_testInfoPrinter = testInfoPrinter,
_testInfoController = (testInfoPrinter == null)
? //
null
: StreamController.broadcast(sync: syncStream);
St _state;
final Object? _environment;
final Map<Object?, Object?> _props;
/// Gets the store environment.
/// This can be used to create a global value, but scoped to the store.
/// For example, you could have a service locator, here, or a configuration value.
///
/// This is also directly accessible in [ReduxAction] and in [VmFactory], as `env`.
///
/// See also: [prop] and [setProp].
Object? get env => _environment;
/// Gets a property from the store.
/// This can be used to save global values, but scoped to the store.
/// For example, you could save timers, streams or futures used by actions.
///
/// ```dart
/// setProp("timer", Timer(Duration(seconds: 1), () => print("tick")));
/// var timer = prop<Timer>("timer");
/// timer.cancel();
/// ```
///
/// This is also directly accessible in [ReduxAction] and in [VmFactory], as `prop`.
///
/// See also: [setProp] and [env].
V prop<V>(Object? key) => _props[key] as V;
/// Sets a property in the store.
/// This can be used to save global values, but scoped to the store.
/// For example, you could save timers, streams or futures used by actions.
///
/// ```dart
/// setProp("timer", Timer(Duration(seconds: 1), () => print("tick")));
/// var timer = prop<Timer>("timer");
/// timer.cancel();
/// ```
///
/// This is also directly accessible in [ReduxAction] and in [VmFactory], as `prop`.
///
/// See also: [prop] and [env].
void setProp(Object? key, Object? value) => _props[key] = value;
/// The [disposeProps] method is used to clean up resources associated with the store's
/// properties, by stopping, closing, ignoring and removing timers, streams, sinks, and futures
/// that are saved as properties in the store.
///
/// In more detail: This method accepts an optional predicate function that takes a prop `key`
/// and a `value` as an argument and returns a boolean.
///
/// * If you don't provide a predicate function, all properties which are `Timer`, `Future`, or
/// `Stream` related will be closed/cancelled/ignored as appropriate, and then removed from the
/// props. Other properties will not be removed.
///
/// * If the predicate function is provided and returns `true` for a given property, that
/// property will be removed from the props. Also, if the property is a `Timer`, `Future`, or
/// `Stream` related, it will be closed/cancelled/ignored as appropriate.
///
/// * If the predicate function is provided and returns `false` for a given property,
/// that property will not be removed from the props.
///
/// This method is particularly useful when the store is being shut down, right before or after
/// you called the [shutdown] method.
///
/// Example usage:
///
/// ```dart
/// // Dispose of all Timers, Futures, Stream related etc.
/// store.disposeProps();
///
/// // Dispose only Timers.
/// store.disposeProps(({Object? key, Object? value}) => value is Timer);
/// ```
///
void disposeProps([bool Function({Object? key, Object? value})? predicate]) {
var keysToRemove = [];
for (var MapEntry(key: key, value: value) in _props.entries) {
bool removeIt = true;
if (predicate == null) {
bool ifClosed = _closeTimerFutureStream(value);
if (!ifClosed) removeIt = false;
}
//
// A predicate was provided,
else {
var removeIt = predicate(key: key, value: value);
if (removeIt) _closeTimerFutureStream(value);
}
if (removeIt) keysToRemove.add(key);
}
// After the iteration, remove all keys at the same time.
keysToRemove.forEach((key) => _props.remove(key));
}
/// If [obj] is a timer, future or stream related, it will be closed/cancelled/ignored,
/// and `true` will be returned. For other object types, the method returns `false`.
bool _closeTimerFutureStream(Object? obj) {
if (obj is Timer)
obj.cancel();
else if (obj is Future)
obj.ignore();
else if (obj is StreamSubscription)
obj.cancel();
else if (obj is StreamConsumer)
obj.close();
else if (obj is Sink)
obj.close();
else
return false;
return true;
}
DateTime _stateTimestamp;
/// The current state of the app.
St get state => _state;
/// The timestamp of the current state in the store, in UTC.
DateTime get stateTimestamp => _stateTimestamp;
bool get defaultDistinct => _defaultDistinct;
/// 1) If `null` (the default), view-models which are immutable collections will be compared
/// by their default equality.
///
/// 2) If `CompareBy.byDeepEquals`, view-models which are immutable collections will be compared
/// by their items, one by one (potentially slow comparison).
///
/// 3) If `CompareBy.byIdentity`, view-models which are immutable collections will be compared
/// by their internals being identical (very fast comparison).
///
/// Note: This works with immutable collections `IList`, `ISet`, `IMap` and `IMapOfSets` from
/// the https://pub.dev/packages/fast_immutable_collections package.
///
CompareBy? get immutableCollectionEquality => _immutableCollectionEquality;
ModelObserver? get modelObserver => _modelObserver;
int get dispatchCount => _dispatchCount;
int get reduceCount => _reduceCount;
final StreamController<St> _changeController;
final List<ActionObserver>? _actionObservers;
final List<StateObserver>? _stateObservers;
final ProcessPersistence<St>? _processPersistence;
final ModelObserver? _modelObserver;
final ErrorObserver<St>? _errorObserver;
@Deprecated("Use `_globalWrapError` instead. This will be removed.")
final WrapError<St>? _wrapError;
final GlobalWrapError<St>? _globalWrapError;
final WrapReduce<St>? _wrapReduce;
final bool _defaultDistinct;
final CompareBy? _immutableCollectionEquality;
final Queue<UserException> _errors;
/// [UserException]s may be queued to be shown to the user by a
/// [UserExceptionDialog] widgets. Usually, if you are not planning on using
/// that dialog (or something similar) you should probably not throw
/// [UserException]s, so this should not be a problem. Still, to further
/// prevent memory problems, there is a maximum number of exceptions the
/// queue can hold.
final int _maxErrorsQueued;
bool _shutdown;
// For testing:
int _dispatchCount;
int _reduceCount;
TestInfoPrinter? _testInfoPrinter;
StreamController<TestInfo<St>>? _testInfoController;
TestInfoPrinter? get testInfoPrinter => _testInfoPrinter;
/// A stream that emits the current state when it changes.
///
/// # Example
///
/// // Create the Store;
/// final store = new Store<int>(initialState: 0);
///
/// // Listen to the Store's onChange stream, and print the latest
/// // state to the console whenever the reducer produces a new state.
/// // Store StreamSubscription as a variable, so you can stop listening later.
/// final subscription = store.onChange.listen(print);
///
/// // Dispatch some actions, which prints the state.
/// store.dispatch(IncrementAction());
///
/// // When you want to stop printing, cancel the subscription.
/// subscription.cancel();
///
Stream<St> get onChange => _changeController.stream;
/// Used by the storeTester.
Stream<TestInfo<St>> get onReduce => (_testInfoController != null)
? //
_testInfoController!.stream
: Stream<TestInfo<St>>.empty();
/// Pause the [Persistor] temporarily.
///
/// When [pausePersistor] is called, the Persistor will not start a new persistence process, until method
/// [resumePersistor] is called. This will not affect the current persistence process, if one is currently
/// running.
///
/// Note: A persistence process starts when the [Persistor.persistDifference] method is called,
/// and finishes when the future returned by that method completes.
///
void pausePersistor() {
_processPersistence?.pause();
}
/// Persists the current state (if it's not yet persisted), then pauses the [Persistor]
/// temporarily.
///
///
/// When [persistAndPausePersistor] is called, this will not affect the current persistence
/// process, if one is currently running. If no persistence process was running, it will
/// immediately start a new persistence process (ignoring [Persistor.throttle]).
///
/// Then, the Persistor will not start another persistence process, until method
/// [resumePersistor] is called.
///
/// Note: A persistence process starts when the [Persistor.persistDifference] method is called,
/// and finishes when the future returned by that method completes.
///
void persistAndPausePersistor() {
_processPersistence?.persistAndPause();
}
/// Resumes persistence by the [Persistor],
/// after calling [pausePersistor] or [persistAndPausePersistor].
void resumePersistor() {
_processPersistence?.resume();
}
/// Asks the [Persistor] to save the [initialState] in the local persistence.
Future<void> saveInitialStateInPersistence(St initialState) async {
return _processPersistence?.saveInitialState(initialState);
}
/// Asks the [Persistor] to read the state from the local persistence.
/// Important: If you use this, you MUST put this state into the store.
/// The Persistor will assume that's the case, and will not work properly otherwise.
Future<St?> readStateFromPersistence() async {
return _processPersistence?.readState();
}
/// Asks the [Persistor] to delete the saved state from the local persistence.
Future<void> deleteStateFromPersistence() async {
return _processPersistence?.deleteState();
}
/// Gets, from the [Persistor], the last state that was saved to the local persistence.
St? getLastPersistedStateFromPersistor() => _processPersistence?.lastPersistedState;
/// Turns on testing capabilities, if not already.
void initTestInfoController() {
_testInfoController ??= StreamController.broadcast(sync: false);
}
/// Changes the testInfoPrinter.
void initTestInfoPrinter(TestInfoPrinter testInfoPrinter) {
_testInfoPrinter = testInfoPrinter;
initTestInfoController();
}
/// Beware: Changes the state directly. Use only for TESTS.
/// This will not notify the listeners nor complete wait conditions.
void defineState(St state) {
_state = state;
_stateTimestamp = DateTime.now().toUtc();
}
/// The global default timeout for the wait functions like [waitCondition] etc
/// is 10 minutes. This value is not final and can be modified.
/// To disable the timeout, make it -1.
static int defaultTimeoutMillis = 60 * 1000 * 10;
/// Returns a future which will complete when the given state [condition] is true.
///
/// If [completeImmediately] is `true` (the default) and the condition was already true when
/// the method was called, the future will complete immediately and throw no errors.
///
/// If [completeImmediately] is `false` and the condition was already true when
/// the method was called, it will throw a [StoreException].
///
/// Note: The default here is `true`, while in the other `wait` methods
/// like [waitActionCondition] it's `false`. This makes sense because of
/// the different use cases for these methods.
///
/// You may also provide a [timeoutMillis], which by default is 10 minutes.
/// To disable the timeout, make it -1.
/// If you want, you can modify [defaultTimeoutMillis] to change the default timeout.
///
/// This method is useful in tests, and it returns the action which changed
/// the store state into the condition, in case you need it:
///
/// ```dart
/// var action = await store.waitCondition((state) => state.name == "Bill");
/// expect(action, isA<ChangeNameAction>());
/// ```
///
/// This method is also eventually useful in production code, in which case you
/// should avoid waiting for conditions that may take a very long time to complete,
/// as checking the condition is an overhead to every state change.
///
/// Examples:
///
/// ```ts
/// // Dispatches an actions that changes the state, then await for the state change:
/// expect(store.state.name, 'John')
/// dispatch(ChangeNameAction("Bill"));
/// var action = await store.waitCondition((state) => state.name == "Bill");
/// expect(action, isA<ChangeNameAction>());
/// expect(store.state.name, 'Bill');
///
/// // Dispatches actions and wait until no actions are in progress.
/// dispatch(BuyStock('IBM'));
/// dispatch(BuyStock('TSLA'));
/// await waitAllActions([]);
/// expect(state.stocks, ['IBM', 'TSLA']);
///
/// // Dispatches two actions in PARALLEL and wait for their TYPES:
/// expect(store.state.portfolio, ['TSLA']);
/// dispatch(BuyAction('IBM'));
/// dispatch(SellAction('TSLA'));
/// await store.waitAllActionTypes([BuyAction, SellAction]);
/// expect(store.state.portfolio, ['IBM']);
///
/// // Dispatches actions in PARALLEL and wait until no actions are in progress.
/// dispatch(BuyAction('IBM'));
/// dispatch(BuyAction('TSLA'));
/// await store.waitAllActions([]);
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Dispatches two actions in PARALLEL and wait for them:
/// let action1 = BuyAction('IBM');
/// let action2 = SellAction('TSLA');
/// dispatch(action1);
/// dispatch(action2);
/// await store.waitAllActions([action1, action2]);
/// expect(store.state.portfolio.contains('IBM'), isTrue);
/// expect(store.state.portfolio.contains('TSLA'), isFalse);
///
/// // Dispatches two actions in SERIES and wait for them:
/// await dispatchAndWait(BuyAction('IBM'));
/// await dispatchAndWait(SellAction('TSLA'));
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Wait until some action of a given type is dispatched.
/// dispatch(DoALotOfStuffAction());
/// var action = store.waitActionType(ChangeNameAction);
/// expect(action, isA<ChangeNameAction>());
/// expect(action.status.isCompleteOk, isTrue);
/// expect(store.state.name, 'Bill');
///
/// // Wait until some action of the given types is dispatched.
/// dispatch(ProcessStocksAction());
/// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]);
/// expect(store.state.portfolio.contains('IBM'), isTrue);
/// ```
///
/// See also:
/// [waitCondition] - Waits until the state is in a given condition.
/// [waitActionCondition] - Waits until the actions in progress meet a given condition.
/// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress.
/// [waitActionType] - Waits until an action of a given type is NOT in progress.
/// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress.
/// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching.
///
Future<ReduxAction<St>?> waitCondition(
bool Function(St) condition, {
//
/// If `completeImmediately` is `true` (the default) and the condition was already true when
/// the method was called, the future will complete immediately and throw no errors.
///
/// If `completeImmediately` is `false` and the condition was already true when
/// the method was called, it will throw a [StoreException].
///
/// Note: The default here is `true`, while in the other `wait` methods
/// like [waitActionCondition] it's `false`. This makes sense because of
/// the different use cases for these methods.
bool completeImmediately = true,
//
/// The maximum time to wait for the condition to be met. The default is 10 minutes.
/// To disable the timeout, make it -1.
int? timeoutMillis,
}) async {
//
// If the condition is already true when `waitCondition` is called.
if (condition(_state)) {
// Complete and return null (no trigger action).
if (completeImmediately)
return Future.value(null);
// else throw an error.
else
throw StoreException("Awaited state condition was already true, "
"and the future completed immediately.");
}
//
else {
var completer = Completer<ReduxAction<St>?>();
_stateConditionCompleters[condition] = completer;
int timeout = timeoutMillis ?? defaultTimeoutMillis;
var future = completer.future;
if (timeout >= 0)
future = completer.future.timeout(
Duration(milliseconds: timeout),
onTimeout: () {
_stateConditionCompleters.remove(condition);
throw TimeoutException(null, Duration(milliseconds: timeout));
},
);
return future;
}
}
// This map will hold the completers for each ACTION condition checker function.
// 1) The set key is the condition checker function.
// 2) The value is the completer, that informs of:
// - The set of actions in progress when the condition is met.
// - The action that triggered the condition.
final _actionConditionCompleters = <bool Function(Set<ReduxAction<St>>, ReduxAction<St>?),
Completer<(Set<ReduxAction<St>>, ReduxAction<St>?)>>{};
// This map will hold the completers for each STATE condition checker function.
// 1) The set key is the condition checker function.
// 2) The value is the completer, that informs the action that triggered the condition.
final _stateConditionCompleters = <bool Function(St), Completer<ReduxAction<St>?>>{};
/// Returns a future that completes when some actions meet the given [condition].
///
/// If [completeImmediately] is `false` (the default), this method will throw [StoreException]
/// if the condition was already true when the method was called. Otherwise, the future will
/// complete immediately and throw no error.
///
/// The [condition] is a function that takes the set of actions "in progress", as well as an
/// action that just entered the set (by being dispatched) or left the set (by finishing
/// dispatching). The function should return `true` when the condition is met, and `false`
/// otherwise. For example:
///
/// ```dart
/// var action = await store.waitActionCondition((actionsInProgress, triggerAction) { ... }
/// ```
///
/// You get back an unmodifiable set of the actions being dispatched that met the condition,
/// as well as the action that triggered the condition by being added or removed from the set.
///
/// Note: The condition is only checked when some action is dispatched or finishes dispatching.
/// It's not checked every time action statuses change.
///
/// You may also provide a [timeoutMillis], which by default is 10 minutes.
/// To disable the timeout, make it -1.
/// If you want, you can modify [defaultTimeoutMillis] to change the default timeout.
///
/// Examples:
///
/// ```ts
/// // Dispatches an actions that changes the state, then await for the state change:
/// expect(store.state.name, 'John')
/// dispatch(ChangeNameAction("Bill"));
/// var action = await store.waitCondition((state) => state.name == "Bill");
/// expect(action, isA<ChangeNameAction>());
/// expect(store.state.name, 'Bill');
///
/// // Dispatches actions and wait until no actions are in progress.
/// dispatch(BuyStock('IBM'));
/// dispatch(BuyStock('TSLA'));
/// await waitAllActions([]);
/// expect(state.stocks, ['IBM', 'TSLA']);
///
/// // Dispatches two actions in PARALLEL and wait for their TYPES:
/// expect(store.state.portfolio, ['TSLA']);
/// dispatch(BuyAction('IBM'));
/// dispatch(SellAction('TSLA'));
/// await store.waitAllActionTypes([BuyAction, SellAction]);
/// expect(store.state.portfolio, ['IBM']);
///
/// // Dispatches actions in PARALLEL and wait until no actions are in progress.
/// dispatch(BuyAction('IBM'));
/// dispatch(BuyAction('TSLA'));
/// await store.waitAllActions([]);
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Dispatches two actions in PARALLEL and wait for them:
/// let action1 = BuyAction('IBM');
/// let action2 = SellAction('TSLA');
/// dispatch(action1);
/// dispatch(action2);
/// await store.waitAllActions([action1, action2]);
/// expect(store.state.portfolio.contains('IBM'), isTrue);
/// expect(store.state.portfolio.contains('TSLA'), isFalse);
///
/// // Dispatches two actions in SERIES and wait for them:
/// await dispatchAndWait(BuyAction('IBM'));
/// await dispatchAndWait(SellAction('TSLA'));
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Wait until some action of a given type is dispatched.
/// dispatch(DoALotOfStuffAction());
/// var action = store.waitActionType(ChangeNameAction);
/// expect(action, isA<ChangeNameAction>());
/// expect(action.status.isCompleteOk, isTrue);
/// expect(store.state.name, 'Bill');
///
/// // Wait until some action of the given types is dispatched.
/// dispatch(ProcessStocksAction());
/// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]);
/// expect(store.state.portfolio.contains('IBM'), isTrue);
/// ```
///
/// See also:
/// [waitCondition] - Waits until the state is in a given condition.
/// [waitActionCondition] - Waits until the actions in progress meet a given condition.
/// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress.
/// [waitActionType] - Waits until an action of a given type is NOT in progress.
/// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress.
/// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching.
///
/// You should only use this method in tests.
@visibleForTesting
Future<(Set<ReduxAction<St>>, ReduxAction<St>?)> waitActionCondition(
//
//
/// The condition receives the current actions in progress, and the action that triggered the condition.
bool Function(Set<ReduxAction<St>> actions, ReduxAction<St>? triggerAction) condition, {
//
/// If `completeImmediately` is `false` (the default), this method will throw an error if the
/// condition is already true when the method is called. Otherwise, the future will complete
/// immediately and throw no error.
bool completeImmediately = false,
//
/// Error message in case the condition was already true when the method was called,
/// and `completeImmediately` is false.
String completedErrorMessage = "Awaited action condition was already true",
//
/// The maximum time to wait for the condition to be met. The default is 10 minutes.
/// To disable the timeout, make it -1.
int? timeoutMillis,
}) {
//
// If the condition is already true when `waitActionCondition` is called.
if (condition(actionsInProgress(), null)) {
// Complete and return the actions in progress and the trigger action.
if (completeImmediately)
return Future.value((actionsInProgress(), null));
// else throw an error.
else
throw StoreException(completedErrorMessage + ", and the future completed immediately.");
}
//
else {
var completer = Completer<(Set<ReduxAction<St>>, ReduxAction<St>?)>();
_actionConditionCompleters[condition] = completer;
int timeout = timeoutMillis ?? defaultTimeoutMillis;
var future = completer.future;
if (timeout >= 0)
future = completer.future.timeout(
Duration(milliseconds: timeout),
onTimeout: () {
_actionConditionCompleters.remove(condition);
throw TimeoutException(null, Duration(milliseconds: timeout));
},
);
return future;
}
}
/// Returns a future that completes when ALL given [actions] finish dispatching.
///
/// If [completeImmediately] is `false` (the default), this method will throw [StoreException]
/// if none of the given actions are in progress when the method is called. Otherwise, the future
/// will complete immediately and throw no error.
///
/// However, if you don't provide any actions (empty list or `null`), the future will complete
/// when ALL current actions in progress finish dispatching. In other words, when no actions are
/// currently in progress. In this case, if [completeImmediately] is `false`, the method will
/// throw an error if no actions are in progress when the method is called.
///
/// Note: Waiting until no actions are in progress should only be done in test, never in
/// production, as it's very easy to create a deadlock. However, waiting for specific actions to
/// finish is safe in production, as long as you're waiting for actions you just dispatched.
///
/// You may also provide a [timeoutMillis], which by default is 10 minutes.
/// To disable the timeout, make it -1.
/// If you want, you can modify [defaultTimeoutMillis] to change the default timeout.
///
/// Examples:
///
/// ```ts
/// // Dispatches an actions that changes the state, then await for the state change:
/// expect(store.state.name, 'John')
/// dispatch(ChangeNameAction("Bill"));
/// var action = await store.waitCondition((state) => state.name == "Bill");
/// expect(action, isA<ChangeNameAction>());
/// expect(store.state.name, 'Bill');
///
/// // Dispatches actions and wait until no actions are in progress.
/// dispatch(BuyStock('IBM'));
/// dispatch(BuyStock('TSLA'));
/// await waitAllActions([]);
/// expect(state.stocks, ['IBM', 'TSLA']);
///
/// // Dispatches two actions in PARALLEL and wait for their TYPES:
/// expect(store.state.portfolio, ['TSLA']);
/// dispatch(BuyAction('IBM'));
/// dispatch(SellAction('TSLA'));
/// await store.waitAllActionTypes([BuyAction, SellAction]);
/// expect(store.state.portfolio, ['IBM']);
///
/// // Dispatches actions in PARALLEL and wait until no actions are in progress.
/// dispatch(BuyAction('IBM'));
/// dispatch(BuyAction('TSLA'));
/// await store.waitAllActions([]);
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Dispatches two actions in PARALLEL and wait for them:
/// let action1 = BuyAction('IBM');
/// let action2 = SellAction('TSLA');
/// dispatch(action1);
/// dispatch(action2);
/// await store.waitAllActions([action1, action2]);
/// expect(store.state.portfolio.contains('IBM'), isTrue);
/// expect(store.state.portfolio.contains('TSLA'), isFalse);
///
/// // Dispatches two actions in SERIES and wait for them:
/// await dispatchAndWait(BuyAction('IBM'));
/// await dispatchAndWait(SellAction('TSLA'));
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Wait until some action of a given type is dispatched.
/// dispatch(DoALotOfStuffAction());
/// var action = store.waitActionType(ChangeNameAction);
/// expect(action, isA<ChangeNameAction>());
/// expect(action.status.isCompleteOk, isTrue);
/// expect(store.state.name, 'Bill');
///
/// // Wait until some action of the given types is dispatched.
/// dispatch(ProcessStocksAction());
/// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]);
/// expect(store.state.portfolio.contains('IBM'), isTrue);
/// ```
///
/// See also:
/// [waitCondition] - Waits until the state is in a given condition.
/// [waitActionCondition] - Waits until the actions in progress meet a given condition.
/// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress.
/// [waitActionType] - Waits until an action of a given type is NOT in progress.
/// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress.
/// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching.
///
Future<void> waitAllActions(
List<ReduxAction<St>>? actions, {
bool completeImmediately = false,
int? timeoutMillis,
}) {
if (actions == null || actions.isEmpty) {
return this.waitActionCondition(
completeImmediately: completeImmediately,
completedErrorMessage: "No actions were in progress",
timeoutMillis: timeoutMillis,
(actions, triggerAction) => actions.isEmpty);
} else {
return this.waitActionCondition(
completeImmediately: completeImmediately,
completedErrorMessage: "None of the given actions were in progress",
timeoutMillis: timeoutMillis,
//
(actionsInProgress, triggerAction) {
for (var action in actions) {
if (actionsInProgress.contains(action)) return false;
}
return true;
},
);
}
}
/// Returns a future that completes when an action of the given type in NOT in progress
/// (it's not being dispatched):
///
/// - If NO action of the given type is currently in progress when the method is called,
/// and [completeImmediately] is `false` (the default), this method will throw an error.
///
/// - If NO action of the given type is currently in progress when the method is called,
/// and [completeImmediately] is `true`, the future completes immediately, returns `null`,
/// and throws no error.
///
/// - If an action of the given type is in progress, the future completes when the action
/// finishes, and returns the action. You can use the returned action to check its `status`:
///
/// ```dart
/// var action = await store.waitActionType(MyAction);
/// expect(action.status.originalError, isA<UserException>());
/// ```
///
/// You may also provide a [timeoutMillis], which by default is 10 minutes.
/// To disable the timeout, make it -1.
/// If you want, you can modify [defaultTimeoutMillis] to change the default timeout.
///
/// Examples:
///
/// ```ts
/// // Dispatches an actions that changes the state, then await for the state change:
/// expect(store.state.name, 'John')
/// dispatch(ChangeNameAction("Bill"));
/// var action = await store.waitCondition((state) => state.name == "Bill");
/// expect(action, isA<ChangeNameAction>());
/// expect(store.state.name, 'Bill');
///
/// // Dispatches actions and wait until no actions are in progress.
/// dispatch(BuyStock('IBM'));
/// dispatch(BuyStock('TSLA'));
/// await waitAllActions([]);
/// expect(state.stocks, ['IBM', 'TSLA']);
///
/// // Dispatches two actions in PARALLEL and wait for their TYPES:
/// expect(store.state.portfolio, ['TSLA']);
/// dispatch(BuyAction('IBM'));
/// dispatch(SellAction('TSLA'));
/// await store.waitAllActionTypes([BuyAction, SellAction]);
/// expect(store.state.portfolio, ['IBM']);
///
/// // Dispatches actions in PARALLEL and wait until no actions are in progress.
/// dispatch(BuyAction('IBM'));
/// dispatch(BuyAction('TSLA'));
/// await store.waitAllActions([]);
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Dispatches two actions in PARALLEL and wait for them:
/// let action1 = BuyAction('IBM');
/// let action2 = SellAction('TSLA');
/// dispatch(action1);
/// dispatch(action2);
/// await store.waitAllActions([action1, action2]);
/// expect(store.state.portfolio.contains('IBM'), isTrue);
/// expect(store.state.portfolio.contains('TSLA'), isFalse);
///
/// // Dispatches two actions in SERIES and wait for them:
/// await dispatchAndWait(BuyAction('IBM'));
/// await dispatchAndWait(SellAction('TSLA'));
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Wait until some action of a given type is dispatched.
/// dispatch(DoALotOfStuffAction());
/// var action = store.waitActionType(ChangeNameAction);
/// expect(action, isA<ChangeNameAction>());
/// expect(action.status.isCompleteOk, isTrue);
/// expect(store.state.name, 'Bill');
///
/// // Wait until some action of the given types is dispatched.
/// dispatch(ProcessStocksAction());
/// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]);
/// expect(store.state.portfolio.contains('IBM'), isTrue);
/// ```
///
/// See also:
/// [waitCondition] - Waits until the state is in a given condition.
/// [waitActionCondition] - Waits until the actions in progress meet a given condition.
/// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress.
/// [waitActionType] - Waits until an action of a given type is NOT in progress.
/// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress.
/// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching.
///
/// You should only use this method in tests.
@visibleForTesting
Future<ReduxAction<St>?> waitActionType(
Type actionType, {
bool completeImmediately = false,
int? timeoutMillis,
}) async {
var (_, triggerAction) = await this.waitActionCondition(
completeImmediately: completeImmediately,
completedErrorMessage: "No action of the given type was in progress",
timeoutMillis: timeoutMillis,
//
(actionsInProgress, triggerAction) {
return !actionsInProgress.any((action) => action.runtimeType == actionType);
},
);
return triggerAction;
}
/// Returns a future that completes when ALL actions of the given types are NOT in progress
/// (none of them are being dispatched):
///
/// - If NO action of the given types is currently in progress when the method is called,
/// and [completeImmediately] is `false` (the default), this method will throw an error.
///
/// - If NO action of the given type is currently in progress when the method is called,
/// and [completeImmediately] is `true`, the future completes immediately and throws no error.
///
/// - If any action of the given types is in progress, the future completes only when
/// no action of the given types is in progress anymore.
///
/// You may also provide a [timeoutMillis], which by default is 10 minutes.
/// To disable the timeout, make it -1.
/// If you want, you can modify [defaultTimeoutMillis] to change the default timeout.
///
/// Examples:
///
/// ```ts
/// // Dispatches an actions that changes the state, then await for the state change:
/// expect(store.state.name, 'John')
/// dispatch(ChangeNameAction("Bill"));
/// var action = await store.waitCondition((state) => state.name == "Bill");
/// expect(action, isA<ChangeNameAction>());
/// expect(store.state.name, 'Bill');
///
/// // Dispatches actions and wait until no actions are in progress.
/// dispatch(BuyStock('IBM'));
/// dispatch(BuyStock('TSLA'));
/// await waitAllActions([]);
/// expect(state.stocks, ['IBM', 'TSLA']);
///
/// // Dispatches two actions in PARALLEL and wait for their TYPES:
/// expect(store.state.portfolio, ['TSLA']);
/// dispatch(BuyAction('IBM'));
/// dispatch(SellAction('TSLA'));
/// await store.waitAllActionTypes([BuyAction, SellAction]);
/// expect(store.state.portfolio, ['IBM']);
///
/// // Dispatches actions in PARALLEL and wait until no actions are in progress.
/// dispatch(BuyAction('IBM'));
/// dispatch(BuyAction('TSLA'));
/// await store.waitAllActions([]);
/// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse);
///
/// // Dispatches two actions in PARALLEL and wait for them: