flow_builder icon indicating copy to clipboard operation
flow_builder copied to clipboard

[Proposal] Add Routing Support

Open felangel opened this issue 3 years ago • 16 comments

[Proposal] Add Routing Support

Currently FlowBuilder does not have custom routing support for deep linking, dynamic linking, custom paths, query parameters, browser url synchronization, etc (#20).

This proposal outlines a potential FlowBuilder API which can accomodate for the above use-cases by leveraging the Navigator 2.0 router APIs. Ideally, the proposed enhancements should be backward compatible with the existing API.

Routing API

FlowBuilder<FlowState>(
  // The state of the flow.
  state: FlowState(),
  // `VoidCallback` invoked whenever the route location changes.
  // Responsible for reacting to changes in the location.
  //
  // * Will be invoked immediately when the app is launched
  // to determine what the initial state of the flow should be.
  // * Will be invoked when the current route changes via `pushNamed`, `pushReplacementNamed`, etc.
  onLocationChanged: (BuildContext context, FlowLocation location) {
    // Equivalent to `window.location.pathname`.
    final String path = location.path;

    // Map of query parameters which are analagous to `window.location.search`.
    final Map<String, String> params = location.params;

    // Determine the flow state based on the current location.
    final FlowState state = _determineFlowState(path, params);

    /// Update the flow state.
    context.flow<FlowState>().update((_) => state);
  },
  // Called whenever the flow state changes. Will remaing unchanged.
  // Responsible for determining the correct navigation stack
  // based on the current flow state.
  onGeneratePages: (FlowState state, List<Page> currentPages) {
    final List<Page> pages = _determinePages(state, currentPages);
    return pages;
  }
)

Execution Flow

  1. FlowBuilder is initialized with a state
  2. onLocationChanged is invoked when a location change occurs.
  3. Flow state can be updated based on the location change.
  4. onGeneratePages is triggered when the flow state changes & updates the nav stack.

The developer can optionally react to changes in FlowLocation (abstraction on top of RouteInformation) and trigger updates in flow state.

Named Routes

Named routes can be achieved by defining a FlowPage which extends Page.

const profilePath = 'profile';

...

FlowBuilder<Profile>(
  state: Profile(),
  onLocationChanged: (BuildContext context, FlowLocation location) {
    if (location.path != profilePath) return;
    // Alternatively we can potentially use `fromJson` with `package:json_serializable`.
    // `final profile = Profile.fromJson(location.params);`
    final profile = Profile(
      name: location.params['name'] as String?,
      age: int.tryParse(location.params['age'] as String?)
    );
    context.flow<Profile>().update((_) => profile);
  },
  onGeneratePages: (Profile profile, List<Page> pages) {
    return [
      FlowPage<void>(
        child: ProfileNameForm(),
        location: FlowLocation(path: profilePath),
      ),
      if (profile.name != null)
        FlowPage<void>(
          child: ProfileAgeForm(),
          location: FlowLocation(path: profilePath, params: {'name': profile.name})
        ),
    ]
  }
)

The above code will result in the following state to route mapping:

  • Profile() (default): /profile
  • Profile(name: 'Felix'): /profile?name=Felix
  • Profile(name: 'Felix', age: 26): /profile?name=Felix&age=26

Navigation

Navigation will largely remain unchanged. Using context.flow or a FlowController, developers can update the flow state or complete the flow. The main difference would be updates to the flow state can potentially be accompanied by location changes if the associated pages are of type FlowPage with a custom location. When a named route is pushed via Navigator.of(context).pushNamed, all available FlowBuilder instances will be notified via onLocationChanged.

Nested Routes

FlowBuilders can be nested to support nested routing. For example:

enum AuthState { uninitialized, unauthenticated, authenticated }

class AuthBloc extends Bloc<AuthEvent, AuthState> {...}

FlowBuilder<AuthState>(
  state: context.watch<AuthBloc>().state,
  onGeneratePages: (AuthState state, List<Page> pages) {
    switch (state) {
      case AuthState.uninitialized:
        return [Splash.page()];
      case AuthState.unauthenticated:
        return [Login.page()];
      case AuthState.authenticated:
        return [Home.page()];
    }
  }
)

We can push a nested flow from within Login to initiate a Sign Up flow.

class Login extends StatelessWidget {

  ...

  @override
  Widget build(BuildContext context) {
    ...
    ElevatedButton(
      onPressed: () => Navigator.of(context).push(SignUp.page());
    )
  }
}
class SignUp extends StatelessWidget {

  ...

  @override
  Widget build(BuildContext context) {
    return FlowBuilder<SignUpState>(
      ...
    )
  }
}

felangel avatar Mar 25 '21 04:03 felangel

Regarding nested routes. Would such thing as below be possible? It feels more inline with routes being the representation of state, where you don't have to explicitly push any new routes onto the stack and all the routes in the Navigator have corresponding pages.

class AppRouting extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FlowBuilder<AuthState>(
        state: context.watch<AuthBloc>().state,
        onGeneratePages: (AuthState state, List<Page> pages) {
          switch (state) {
            case AuthState.uninitialized:
              return [Splash.page()];
            case AuthState.unauthenticated:
            case AuthState.signInInProgress:
              return [
                Login.page(),
                if (state == AuthState.signInInProgress) SignInRouting.page(),
              ];
            case AuthState.authenticated:
              return [Home.page()];
          }
        });
  }
}

enum AuthState {
  uninitialized,
  unauthenticated,
  signInInProgress,
  authenticated
}

class SignInRouting extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FlowBuilder<SignInState>(
        state: context.watch<SignInBloc>().state,
        onGeneratePages: (SignInState state, List<Page> pages) {
          switch (state) {
            case SignInState.step1:
              return [SignInStep1.page()];
            case SignInState.step2:
              return [SignInStep2.page()];
            case SignInState.verify:
              return [SignInVerify.page()];
          }
        });
  }
}

enum SignInState {
  step1,
  step2,
  verify,
}

Of course when using enums like AuthState it's hard to represent more complex states where it can have 2 dimensions (e.g. authentication state and sign in process started), but with class it could be more readable.

class AppRouting extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FlowBuilder<AppState>(
        state: context.watch<AppState>().state,
        onGeneratePages: (AppState state, List<Page> pages) {
          switch (state.authState) {
            case AuthState.uninitialized:
              return [Splash.page()];
            case AuthState.unauthenticated:
              return [
                Login.page(),
                if (state.signState == SignState.signIn) 
                  SignInRouting.page(),
                if (state.signState == SignState.signUp) 
                  SignUpRouting.page(),
              ];
            case AuthState.authenticated:
              return [Home.page()];
          }
        });
  }
}

class AppState {
  AppState(this.authState, this.signState);
  final AuthState authState;
  final SignState signState;
}

enum SignState {
  signIn,
  signUp,
}

enum AuthState { uninitialized, unauthenticated, authenticated }

class SignInRouting extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FlowBuilder<SignInState>(
        state: context.watch<SignInBloc>().state,
        onGeneratePages: (SignInState state, List<Page> pages) {
          switch (state) {
            case SignInState.step1:
              return [SignInStep1.page()];
            case SignInState.step2:
              return [SignInStep2.page()];
            case SignInState.verify:
              return [SignInVerify.page()];
          }
        });
  }
}

enum SignInState {
  step1,
  step2,
  verify,
}

orestesgaolin avatar Mar 25 '21 08:03 orestesgaolin

The developer can optionally react to changes in FlowLocation (abstraction on top of RouteInformation) and trigger updates in flow state.

What would be structure of FlowLocation? Currently RouteInformation contains location property being a string. I find having Uri a bit more convenient, but it also imposes some constraints like validity of schema or path. Would that .path property be a string as well?

orestesgaolin avatar Mar 25 '21 09:03 orestesgaolin

For everyones benefit, here are the scenarios that this proposal would support: https://github.com/flutter/uxr/blob/master/nav2-usability/storyboards/%5BPublic%5D%20Flutter%20Navigator%20Scenarios%20Storyboards%20v2.pdf

I believe the onLocationChange and the FlowLocation really make sense for the deep linking path and query parameters and now I can clearly see how we can declaratively do this.

With the FlowLocation, we really just add a named parameter and that's pretty simple.

As always, it's not so much about what the API looks like as to how you use the API ! This means those little examples could go a long way and I look forward to helping apply the examples to these scenarios.

The real feedback will come when more people use it and we uncover desire paths. I personally cannot imagine any more scenarios and I have full faith in the communities ability to use this in ways we never imagined, for better or for worse !

Gene-Dana avatar Mar 25 '21 19:03 Gene-Dana

In your examples, you have this function which I think really helps being up at the top of the file

List<Page> onGenerateProfilePages(Profile profile, List<Page> pages) {
  return [
    MaterialPage<void>(child: ProfileNameForm()),
    if (profile.name != null) MaterialPage<void>(child: ProfileAgeForm()),
    if (profile.age != null) MaterialPage<void>(child: ProfileWeightForm()),
  ];
}

Would you suggest similar for onLocationUpdate?

Gene-Dana avatar Mar 25 '21 20:03 Gene-Dana

Sorry to derail a bit - is the intention of this to be a full drop in replacement for all routing within an app, or an enhancement for better flow support that should be used in conjunction with an existing routing solution?

theweiweiway avatar Apr 01 '21 04:04 theweiweiway

For those who need the web working today you can try auto_route with authguard + authentication bloc

There are some limitations but it works somehow

herman-the-worm avatar Apr 22 '21 09:04 herman-the-worm

Sorry to derail a bit - is the intention of this to be a full drop in replacement for all routing within an app, or an enhancement for better flow support that should be used in conjunction with an existing routing solution?

This can do both. Whether you would like to make it for a full replacement, or piecewise, that's the flexibility it offers

For those who need the web working today you can try auto_route with authguard + authentication bloc

There are some limitations but it works somehow

I think you will appreciate this ! https://github.com/felangel/bloc/tree/master/examples/flutter_firebase_login

Gene-Dana avatar May 12 '21 16:05 Gene-Dana

@felangel any ETA of this proposal?

adamsmaka avatar Jun 03 '21 08:06 adamsmaka

@felangel any ETA of this proposal?

Sorry for the delay! I'm planning to pick this up later this week or this weekend 👍

felangel avatar Jun 08 '21 03:06 felangel

Are there any updates about this? This would be very helpful for some deep link cases me and my team are working on!

Thank you all for your efforts!

magicleon94 avatar Jul 23 '21 08:07 magicleon94

For anyone interested, there's a WIP branch for this proposal at https://github.com/felangel/flow_builder/tree/feat/url-routing-and-deep-linking 🎉

felangel avatar Nov 03 '21 20:11 felangel

@felangel How sync the browser url?

PRAJINPRAKASH avatar Dec 05 '21 12:12 PRAJINPRAKASH

For anyone interested, there's a WIP branch for this proposal at https://github.com/felangel/flow_builder/tree/feat/url-routing-and-deep-linking 🎉

I am interested, but it is not working as expected

Here is my code

FlowBuilder<NavigationStack>(
              state: context.watch<NavigationCubit>().state,
              onGeneratePages: (NavigationStack state, List<Page> pages) {
                print(state.all);
                return state.all.map((e){
                  return MaterialPage<dynamic>(child: MyScreen.getByRouteLocation(e.location).widget);
                }).toList();
              },
              onDidPop: (dynamic result) {
                print('hallo=$result');
                context.watch<NavigationCubit>().popRoute();
              },
              onLocationChanged: (Uri uri, NavigationStack stack) {
                print('uri=$uri');
                print('stack=$stack');
                return stack;
              },
            )

onDidPop is never called, onLocationChanged only on initial routing when starting the app. everything else works fine, I can see that the state is correctly updated and routing/navigation works also fine

filly82 avatar Jan 25 '22 09:01 filly82

the problem seems to be that FlowBuilder expects the state to be a single route, which in my case would be a NavigationRoute, but my state is a NavigationStack with a List<NavigationRoute>

_history has always 1 item in my case, the NavigationStack, that's why the onDidPop never is called _pages however has the correct amount of items (routes)

child: Navigator(
          key: _navigatorKey,
          pages: _pages,
          observers: [_FlowNavigatorObserver(), ...widget.observers],
          onPopPage: (route, dynamic result) {

            if (_history.length > 1) { // <-- problem lies here
              _history.removeLast();
              _didPop = true;
              widget.onDidPop?.call(result);
              _controller.update((_) => _history.last);
            }
            if (_pages.length > 1) _pages.removeLast();
            final onLocationChanged = widget.onLocationChanged;
            final pageLocation = _pages.last.name;
            if (onLocationChanged != null && pageLocation != null) {
              _SystemNavigationObserver._updateLocation(pageLocation);
              _controller.update(
                (state) => onLocationChanged(Uri.parse(pageLocation), state),
              );
            }
            setState(() {});
            return route.didPop(result);
          },
        )

is this a Bug or do I need to rethink my state for Navigation?

UPDATE: I was just playing around and made sure that _history and _pages have always the same amount of items, and everything worked as I was expecting it with the popping

filly82 avatar Jan 25 '22 11:01 filly82

Any update on this?

MdeBruin93 avatar Mar 21 '22 12:03 MdeBruin93