fluttercommunity / redux.dart

Redux for Dart
https://pub.dev/packages/redux
MIT License
515 stars 61 forks source link

Best way to open a dialog #79

Open ghost opened 2 years ago

ghost commented 2 years ago

I'm wondering what is the best way to open a dialog?

My scenario:

In the middleware, I'm doing some async backend tasks. Depending on the result, I want to open a dialog to display an error message or to indicate that the user needs to log in again.

When the async task completes, I send another action to change the status, such as showErrorDialog = true. In the UI code, I listen for this status (using https://github.com/brianegan/reselect_dart) and when it becomes true, I open the dialog and set this status to false (so it will not be reopened).

It works, but it also looks wrong to me.

Is there a better way to open a dialog only when an action is triggered?

MichaelMarner commented 2 years ago

The way we do this kind of thing is through a Redux Epics EpicsClass that listens for particular actions dispatched through the store. For example, this is how we show a Snackbar:

class SnackBarEpics extends EpicClass<AppState> {
  SnackBarEpics({
    required this.messengerKey,
  });

  final GlobalKey<ScaffoldMessengerState>? messengerKey;

  @override
  Stream call(Stream actions, EpicStore<AppState> store) {
    await for (final action in actions) {
      String? notificationKey;
      if (action is SuccessCreateOne<MemoResponse>) {
        notificationKey = 'notifications.createMemoSuccess';
      }
      if (notificationKey != null) {
        messengerKey!.currentState?.removeCurrentSnackBar();
        messengerKey!.currentState?.showSnackBar(SnackBar(
          content: Text(translate(notificationKey)),
        ));
      }
    }
  }
}

We pass in the GlobalKey for interacting with ScaffoldMessengerState at app startup.

I don't really like the idea of having the Store layer know anything about the UI layer, but at least here we have only one EpicClass that acts as a bridge between the store and the UI, it's reasonably clean.

I find this approach of listening for actions and doing something with Epics to be cleaner than having variables in the state to indicate whether to show a dialog. It's probably easier now with the Router's declarative way of working, as that aligns better with Redux, but for things like this where you need to call a method to trigger something, Epics are working well for us.

ghost commented 2 years ago

Thanks, @MichaelMarner for your example, but I don't like having any UI stuff in the store either :(

I thought about it a bit more and came to the conclusion that a callback added to an action would be nice. Here is an example:

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:redux/redux.dart';

part 'test_redux_callback.freezed.dart';

void main(List<String> arguments) {
  final store = Store<AppState>(
    reducer,
    initialState: AppState.initialState(),
    middleware: [middleware],
    distinct: true,
  );

  store.onChange.listen(print);

  store
    ..dispatch(CounterAction(count: 1))
    ..dispatch(CounterAction(count: 1))
    ..dispatch(CounterAction(count: 2, callback: callback))
    ..dispatch(CounterAction(count: 3, callback: callback))
    ..dispatch(CounterAction(count: 3, callback: callback))
    ..dispatch(CounterAction(count: 5, callback: callback));
}

void callback(int count) => print('count: $count');

typedef Callback = void Function(int count);

@freezed
class CounterAction with _$CounterAction implements MiddlewareAction, ReducerAction {
  CounterAction._();

  factory CounterAction({
    required int count,
    Callback? callback,
  }) = _CounterAction;

  @override
  void middleware(Store<AppState> store, Next next) {
    this.callback?.call(count);
    next();
  }

  @override
  AppState reduce(AppState state) {
    return state.copyWith(counter: count);
  }
}

@freezed
class AppState with _$AppState {
  AppState._();

  factory AppState({
    required int counter,
  }) = _AppState;

  factory AppState.initialState() => AppState(
        counter: 0,
      );
}

abstract class MiddlewareAction {
  void middleware(Store<AppState> store, Next next);
}

typedef Next = void Function();

void middleware(Store<AppState> store, action, NextDispatcher nextDispatcher) {
  void next() {
    nextDispatcher(action);
  }

  if (action is MiddlewareAction) {
    action.middleware(store, next);
  } else {
    nextDispatcher(action);
  }
}

abstract class ReducerAction {
  AppState reduce(AppState state);
}

AppState reducer(AppState state, action) {
  if (action is ReducerAction) {
    return action.reduce(state);
  }

  return state;
}
long1eu commented 8 months ago

We do something similar that, we think, separates concerns.


typedef ActionResult = void Function(AppAction action);

@noCopyFreezed
class Logout with _$Logout implements AppAction {
  const factory Logout({
    required ActionResult result,
  }) = LogoutStart;

  const factory Logout.successful() = LogoutSuccessful;

  @Implements<ErrorAction>()
  const factory Logout.error(Object error, StackTrace stackTrace) = LogoutError;
}

In the epics we have something like

@singleton
class AuthEpics implements EpicClass<AppState> {
  const AuthEpics({required AuthApi api}) : _api = api;

  final AuthApi _api;

  @override
  Stream<dynamic> call(Stream<dynamic> actions, EpicStore<AppState> store) {
    return combineEpics<AppState>(<Epic<AppState>>[
      TypedEpic<AppState, LogoutStart>(_logoutStart).call,
    ])(actions, store);
  }

  Stream<AppAction> _logoutStart(Stream<LogoutStart> actions, EpicStore<AppState> store) {
    return actions.flatMap((LogoutStart action) {
      return Stream<void>.value(null)
          .asyncMap((_) => _api.logOut())
          .map((_) => const Logout.successful())
          .onErrorReturnWith((Object error, StackTrace stackTrace) => Logout.error(error, stackTrace))
          .doOnData(action.result);
    });
  }
}

In then in UI we have something like:

class LogoutButton extends StatelessWidget {
  const LogoutButton({super.key});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: const Text('Logout'),
      onTap: () {
        final Store<AppState> store = StoreProvider.of<AppState>(context);
        final NavigatorState navigator = Navigator.of(context);

        store.dispatch(
          Logout(
            result: (AppAction action) {
              if (action is LogoutError) {
                final Object error = action.error;

                final CapturedThemes themes = InheritedTheme.capture(
                  from: context,
                  to: Navigator.of(
                    context,
                    rootNavigator: true,
                  ).context,
                );

                navigator.push(
                  DialogRoute<void>(
                    context: context,
                    themes: themes,
                    builder: (BuildContext context) {
                      return AlertDialog(
                        title: const Text('Error'),
                        content: Text('$error'),
                      );
                    },
                  ),
                );
              }
            },
          ),
        );
      },
    );
  }
}