Skip to content

Commit 63c591f

Browse files
committed
Successfully smuggle two Completer instances through built_redux
With some effort, I have successfully managed to smuggle a new class called SpecializedCompleterTuple through some actions such that the middleware can have some influence on a UI widget which originally constructed (and is listening to the futures inside of) the SpecializedCompleterTuple. My purposes for doing this are outlined in the documentation of SpecializedCompleterTuple. I'm justifying how jank this system is because it's not far off from what Brian Egan recommended (brianegan/flutter_redux#6 (comment)). However, I do hope I find something cleaner than this for the future. This also relates to Workiva/built_redux#103 and flutter/flutter#11675.
1 parent 302df0f commit 63c591f

File tree

6 files changed

+98
-13
lines changed

6 files changed

+98
-13
lines changed

lib/actions/actions.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ library actions;
33
import 'dart:async';
44

55
import 'package:betterstatmobile/models/models.dart';
6+
import 'package:betterstatmobile/util/specialized_completer.dart';
67
import 'package:built_redux/built_redux.dart';
78
import 'package:built_value/built_value.dart';
89
import 'package:built_value/serializer.dart';
@@ -12,7 +13,7 @@ part 'actions.g.dart';
1213
abstract class AppActions implements ReduxActions {
1314
ActionDispatcher<Schedule> addScheduleAction;
1415
ActionDispatcher<String> deleteScheduleAction;
15-
ActionDispatcher<Completer<Null>> fetchSchedulesAction;
16+
ActionDispatcher<SpecializedCompleterTuple> fetchSchedulesAction;
1617
ActionDispatcher<List<Schedule>> loadSchedulesSuccess;
1718
ActionDispatcher<Object> loadSchedulesFailure;
1819
ActionDispatcher<AppTab> updateTabAction;

lib/containers/schedules_tab.dart

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:async';
33
import 'package:betterstatmobile/actions/actions.dart';
44
import 'package:betterstatmobile/models/models.dart';
55
import 'package:betterstatmobile/presentation/schedule_list.dart';
6+
import 'package:betterstatmobile/util/specialized_completer.dart';
67
import 'package:flutter/widgets.dart';
78
import 'package:flutter_built_redux/flutter_built_redux.dart';
89

@@ -14,9 +15,9 @@ class SchedulesTab
1415
Widget build(BuildContext context, List<Schedule> state, AppActions actions) {
1516
return ScheduleList(
1617
schedules: state,
17-
onRefresh: (Completer<Null> completer) {
18-
actions.fetchSchedulesAction(completer);
19-
return completer.future;
18+
onRefresh: (SpecializedCompleterTuple tuple) {
19+
actions.fetchSchedulesAction(tuple);
20+
return tuple.completer.future;
2021
},
2122
onRemove: (schedule) {
2223
actions.deleteScheduleAction(schedule.id);

lib/main.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:betterstatmobile/presentation/home_screen.dart';
99
import 'package:betterstatmobile/reducers/reducers.dart';
1010
import 'package:betterstatmobile/util/keys.dart';
1111
import 'package:betterstatmobile/util/routes.dart';
12+
import 'package:betterstatmobile/util/specialized_completer.dart';
1213
import 'package:built_redux/built_redux.dart';
1314
import 'package:flutter/material.dart';
1415
import 'package:flutter_built_redux/flutter_built_redux.dart';
@@ -43,8 +44,6 @@ class BetterstatAppState extends State<BetterstatApp> {
4344
void initState() {
4445
store = widget.store;
4546

46-
store.actions.fetchSchedulesAction(Completer<Null>());
47-
4847
super.initState();
4948
}
5049

lib/middleware/store_schedules_middleware.dart

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22

33
import 'package:betterstatmobile/repository/SchedulesRepository.dart';
4+
import 'package:betterstatmobile/util/specialized_completer.dart';
45
import 'package:built_redux/built_redux.dart';
56
import 'package:betterstatmobile/actions/actions.dart';
67
import 'package:betterstatmobile/repository/WebRepository.dart';
@@ -12,7 +13,7 @@ Middleware<AppState, AppStateBuilder, AppActions>
1213
]) {
1314
return (MiddlewareBuilder<AppState, AppStateBuilder, AppActions>()
1415
..add(AppActionsNames.fetchSchedulesAction,
15-
createFetchSchedules<Completer<Null>>(repository))
16+
createFetchSchedules<SpecializedCompleterTuple>(repository))
1617
..add(AppActionsNames.addScheduleAction,
1718
createSaveSchedule<Schedule>(repository))
1819
// ..add(AppActionsNames.loadSchedulesSuccess,
@@ -59,11 +60,14 @@ MiddlewareHandler<AppState, AppStateBuilder, AppActions, T>
5960
createFetchSchedules<T>(SchedulesRepository repository) {
6061
return (MiddlewareApi<AppState, AppStateBuilder, AppActions> api,
6162
ActionHandler next, Action<T> action) {
63+
var loadingCallback = action.payload as SpecializedCompleterTuple;
6264
repository.loadSchedules().then((schedules) {
63-
var loadingCallback = action.payload as Completer<Null>;
64-
loadingCallback.complete();
65+
loadingCallback.statusCompleter.complete();
6566
return api.actions.loadSchedulesSuccess(schedules);
66-
}).catchError(api.actions.loadSchedulesFailure);
67+
}).catchError((Object error, [StackTrace stackTrace]){
68+
loadingCallback.statusCompleter.completeError(error, stackTrace);
69+
return api.actions.loadSchedulesFailure();
70+
}).whenComplete(() => loadingCallback.completer.complete());
6771
next(action);
6872
};
6973
}

lib/presentation/schedule_list.dart

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,18 @@ import 'package:betterstatmobile/localization.dart';
1010
import 'package:betterstatmobile/models/models.dart';
1111
import 'package:betterstatmobile/presentation/schedule_item.dart';
1212
import 'package:betterstatmobile/util/keys.dart';
13+
import 'package:betterstatmobile/util/specialized_completer.dart';
1314
import 'package:flutter/foundation.dart';
1415
import 'package:flutter/material.dart';
1516

1617
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
17-
new GlobalKey<RefreshIndicatorState>();
18+
GlobalKey<RefreshIndicatorState>();
1819

1920
class ScheduleList extends StatelessWidget {
2021
final List<Schedule> schedules;
2122
final Function(Schedule) onRemove;
2223
final Function(Schedule) onUndoRemove;
23-
Future<Null> Function(Completer<Null>) onRefresh;
24+
final Future<void> Function(SpecializedCompleterTuple) onRefresh;
2425

2526
ScheduleList({
2627
@required this.schedules,
@@ -31,10 +32,15 @@ class ScheduleList extends StatelessWidget {
3132

3233
@override
3334
Widget build(BuildContext context) {
35+
WidgetsBinding.instance.addPostFrameCallback((Duration duration) => afterBuild());
3436
return AppLoading(builder: (context, loading) {
3537
return RefreshIndicator(
3638
key: _refreshIndicatorKey,
37-
onRefresh: () => onRefresh(Completer<Null>()),
39+
onRefresh: () {
40+
var tuple = SpecializedCompleterTuple();
41+
tuple.statusCompleter..future.catchError((Object error, [StackTrace stackTrace]) => _showMyDialog(context, error, stackTrace));
42+
return onRefresh(tuple);
43+
},
3844
child: Container(
3945
child: ListView.builder(
4046
key: BetterstatKeys.scheduleList,
@@ -53,6 +59,43 @@ class ScheduleList extends StatelessWidget {
5359
});
5460
}
5561

62+
Future<void> _showMyDialog(BuildContext context, Object error, [StackTrace stackTrace]) async {
63+
return showDialog<void>(
64+
context: context,
65+
barrierDismissible: false, // user must tap button!
66+
builder: (BuildContext context) {
67+
return AlertDialog(
68+
title: Text('An error occurred'),
69+
content: SingleChildScrollView(
70+
child: ListBody(
71+
children: <Widget>[
72+
Text(error.toString()),
73+
Text(stackTrace.toString()),
74+
],
75+
),
76+
),
77+
actions: <Widget>[
78+
FlatButton(
79+
child: Text('Ok'),
80+
onPressed: () {
81+
Navigator.of(context).pop();
82+
},
83+
),
84+
],
85+
);
86+
},
87+
);
88+
}
89+
90+
void afterBuild() {
91+
if(schedules.isEmpty){
92+
//When the list is first displayed, it won't be initialized with any results. I could call "store.actions.fetchSchedulesAction(SpecializedCompleterTuple());"
93+
// in `main()` but then I wouldn't have a way to handle any potential errors that may result from that. Instead, I just check if the list is empty.
94+
//It'll be empty in the case that the API call failed in some way.
95+
_refreshIndicatorKey.currentState.show();
96+
}
97+
}
98+
5699
Future waitWhile(bool Function() test,
57100
[Duration pollInterval = Duration.zero]) {
58101
var completer = Completer();

lib/util/specialized_completer.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import 'dart:async';
2+
3+
/// A special two-tuple that contains two `Completer`s. I created this class
4+
/// because I wanted to be able to use a RefreshIndicator that lasted until
5+
/// the relevant refreshing action was complete. I was able to do this but with a catch,
6+
/// I had to smuggle a Completer<void> as a type parameter of the refreshing action
7+
/// and the middleware always had to call `completer.complete()` on it regardless of
8+
/// whether it was completed successfully. I considered simply calling an `actionSuccess` and
9+
/// `actionFailure` action from the middleware and then passing [completerStatus] as the payload for them.
10+
/// However, I think this might violate the purity of the reducers that receive those which is a design no-no.
11+
/// When I tried sending back any errors using `completer.completeError()`, Flutter always complained about
12+
/// unhandled exceptions despite the fact that I was catching the errors and processing them properly.
13+
/// I narrowed this down to the fact that the `Future` that was being passed to RefreshIndicator's
14+
/// `onRefresh` function. Because the future that was passed to RefreshIndicator was completed with an error,
15+
/// it counted as being unhandled. I'd like to avoid doing that so here I am. If there's a better way to do this,
16+
/// PLEASE LET ME KNOW. This solution is way too jank for my liking.
17+
///
18+
/// You can return the [completer]'s `Future` to a RefreshIndicator's `onRefresh`
19+
/// function and use [statusCompleter] for propagating an error back from
20+
/// your middleware. This means that you should have an action that contains
21+
/// a [SpecializedCompleterTuple] as mentioned in [brianegan/flutter_redux#6](https://github.com/brianegan/flutter_redux/issues/6#issuecomment-365720043).
22+
///
23+
/// Within your middleware that intercepts this action and performs the async work,
24+
/// you should call `completer.complete()` after the async task is complete to
25+
/// let the RefreshIndicator know it's time to hide the spinner. It MUST call
26+
/// `completer.complete()` regardless of whether the middleware failed in its task execution.
27+
/// Your middleware must also call EITHER `statusCompleter.complete()` OR `statusCompleter.completeError()`
28+
/// depending on whether or not you want to display an error in the UI.
29+
///
30+
/// By using this class, you can avoid sending futures that were completed with errors
31+
/// outside the scope of your code (i.e. into RefreshIndicator).
32+
class SpecializedCompleterTuple {
33+
final Completer<void> completer = Completer();
34+
final Completer<void> statusCompleter = Completer();
35+
36+
SpecializedCompleterTuple();
37+
}

0 commit comments

Comments
 (0)