Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Best way to open a dialog #79

Open
ghost opened this issue Dec 13, 2021 · 3 comments
Open

Best way to open a dialog #79

ghost opened this issue Dec 13, 2021 · 3 comments

Comments

@ghost
Copy link

ghost commented Dec 13, 2021

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
Copy link
Contributor

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
Copy link
Author

ghost commented Dec 18, 2021

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
Copy link

long1eu commented Dec 19, 2023

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'),
                      );
                    },
                  ),
                );
              }
            },
          ),
        );
      },
    );
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants