JaspervanRiet / duck_router

A Flutter router with intents
MIT License
4 stars 3 forks source link

How to get the current `Location` of `DuckRouter`? #30

Closed Luckey-Elijah closed 1 week ago

Luckey-Elijah commented 4 weeks ago

Given the following code that would exist in a Widget.build method how could I read the current Location or top-most in the stack?

final router = DuckRouter.of(context);
Location location = router. // ??? what method/getter

I tried using router.routeInformationProvider.value.state.location

Location location = 
  // ".state" is a LocationState but that is not exported/available
  (router.routeInformationProvider.value.state as dynamic)
  .location as Location;

But this is obviously not an ideal way to access.

Could you expose a type-safe API on DuckRouter that let's us get it?

Luckey-Elijah commented 4 weeks ago

after some experiment with the hack

extension CurrentLocation on DuckRouter {
  Location? get currentLocation {
    final state = routeInformationProvider.value.state as dynamic;
    try {
      final location = state.location; // ignore: avoid_dynamic_calls
      if (location is Location) return location;
      return null;
    } catch (_) {
      return null;
    }
  }
}

It only provides the initial location. Which is not useful :/ So this maybe would be a feature request?

JaspervanRiet commented 3 weeks ago

As a direct answer: you can do this:

    final stack =
        DuckRouter.of(context).routerDelegate.currentConfiguration;
    final currentLocation = stack.locations.last;

However, you will need to keep in mind that this will quickly get complicated if you use stateful locations, because then you might need to dig deeper into that location.

Could you describe what problem you're facing that you would find this useful?

Luckey-Elijah commented 3 weeks ago

I am using StatefulLocation to build a "shell" widget and want to style a button that is a part of the shell widget if it's the current location.

@override
StatefulLocationBuilder get childBuilder {
  return (BuildContext context, DuckShell shell) {
    final Location location = /* get location here */;
    Color? color<T extends Location>() {
      return (location is T)
          ? Theme.of(context).colorScheme.secondaryContainer
          : null;
    }

    return Row(
      children: [
        SizedBox(
          width: 120,
          child: Column(
            children: [
              TextButton(
                style: TextButton.styleFrom(
                  backgroundColor: color<RouteOneLocation>(),
                ),
                onPressed: () {},
                child: const Text('Route One'),
              ),
            ],
          ),
        ),
        Expanded(child: shell),
      ],
    );
  };

So I do wan to be aware of the StatefulLocation that is mounted..

As I inspect the DuckShellState, I can imagine that it would not be too tricky to expose a currentIndex getter and then build retrieve the from there

Location getLocation(DuckRouter router) {
  final last = router.routerDelegate.currentConfiguration.locations.last;

  if (last is StatefulLocation) {
    //                   this value is not yet available
    return last.children[last.state.currentIndex];
  }
  return last;
}

What I am currently doing as a work around is using a LocationInterceptor and setting a value in my state management through that interceptor but it of course doesn't work for redirect from other interceptor

class CurrentLocationInterceptor extends LocationInterceptor {
  CurrentLocationInterceptor({required this.add, super.pushesOnTop});

  final ValueSetter<Location> add;

  @override
  Location? execute(Location to, Location? from) {
    add(to);
    return null;
  }
}
JaspervanRiet commented 3 weeks ago

Thanks for the use case.

Have you tried using onNavigate on DuckRouter? It's a callback that reports about every navigational change.

Alternatively, I think your function should work like this:

Location getLocation(DuckRouter router) {
  final last = router.routerDelegate.currentConfiguration.locations.last;

  if (last is StatefulLocation) {
    return last.state.currentRouterDelegate.currentConfigurations.locations.last;
  }
  return last;
}
Luckey-Elijah commented 3 weeks ago

the last.state.currentRouterDelegate.currentConfiguration seems like it would work. I did not try it out because currentRouterDelegate was not "correctly" typed to anything except RouterDelegate<T?>/RouterDelegate<dynamic>. Could you expose some interface to that last.state.currentRouterDelegate in order to promote discoverability?

Luckey-Elijah commented 3 weeks ago

More specifically this is how i used since the Router delegate doesn't have the type i expect RouterDelegate<LocationStack>

if (last is StatefulLocation) {
  // ignore: avoid_dynamic_calls
  return last.state.currentRouterDelegate.currentConfiguration.locations.last as Location;
}
JaspervanRiet commented 3 weeks ago

Hmm. I feel conflicted about that. I don't really want to promote this kind of usage, it goes against a lot of the philosophy behind DuckRouter (straightforward, no magic). Does onNavigate work for you?

Luckey-Elijah commented 3 weeks ago

Does onNavigate work for you?

Note really.. When I set a new value in the ValueNotifer notifyListeners() is called, but this notification happens while the navigation is happening so I get setState() or markNeedsBuild called during build expections.

I don't really want to promote this kind of usage

If you are talking about this https://github.com/collectiveuk/packages/issues/30#issuecomment-2353990047 "solution", then yes I agree. I don't want to do this. Could you expose an API to safely access this value?

JaspervanRiet commented 3 weeks ago

Sorry, we're getting deeper into this problem so I just have a few more questions. I am starting to feel like this is an actual problem (and a legit use case), I just want to make sure there is no existing way to solve it.

In your code example here: https://github.com/collectiveuk/packages/issues/30#issuecomment-2351042794. How do you switch children in the duck shell? In theory, when you change child you could take that index and use it to update your color, right? For example:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: NavigationBar(
        destinations: ...
        selectedIndex: _currentIndex,
        onDestinationSelected: (int i) {
            widget.shell.switchChild(i);
            setState(() {
              _currentIndex = i;
            });
        },
        backgroundColor: colors[_currentIndex], // <----
      ),
      body: widget.shell,
    );
  }

Because I think my core question is: is this a problem for DuckRouter to solve or for app developers (in their own code)?

Luckey-Elijah commented 3 weeks ago

No worries! I'm more than happy to work this out. I believe that the router should be the "source of truth" of the current location. Which is why I'm hesitant to hook into the the side effects of the router (onNavigate and interceptors) and user interactions (like onTap: router.navigate(...)) to assume the current location.

In the example with StatefulLocation, it's definitely doable with using the same value as what is passed to shell.switchChild. But there are other factors that impact what is actually set in the router such as an Interceptor or widgets lower in the widget tree will may navigate to other locations - not just what's called with switchChild.

Take this following example

Example App

```dart import 'package:duck_router/duck_router.dart'; import 'package:flutter/material.dart'; void main() => runApp(App()); class App extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router(routerConfig: router); } } final router = DuckRouter(initialLocation: RootLocation()); class RootLocation extends StatefulLocation { @override String get path => 'root'; @override List get children => [Child1Location(), Child2Location()]; @override StatefulLocationBuilder get childBuilder => (c, s) => ScaffoldShell(shell: s); } class ScaffoldShell extends StatefulWidget { ScaffoldShell({super.key, required this.shell}); final DuckShell shell; @override State createState() => _ScaffoldShellState(); } class _ScaffoldShellState extends State { var index = 0; void onTap(int i) { widget.shell.switchChild(i); setState(() => index = i); } @override Widget build(BuildContext context) { return Scaffold( body: widget.shell, bottomNavigationBar: BottomNavigationBar( // how will I know when a "child" navigates somewhere else currentIndex: index, items: [ BottomNavigationBarItem( icon: Icon(Icons.home), label: 'Page 1', ), BottomNavigationBarItem( icon: Icon(Icons.settings), label: 'Page 2', ), ], onTap: onTap, ), ); } } class Child1Location extends Location { @override String get path => 'child1'; @override LocationBuilder get builder => (context) => Page1Screen(); } class Child2Location extends Location { @override String get path => 'child2'; @override LocationBuilder get builder => (context) => Page2Screen(); } class Page1Screen extends StatelessWidget { @override Widget build(BuildContext context) { return Column( children: [ Text('Page1Screen'), TextButton( onPressed: () => DuckRouter.of(context).navigate( to: Child2Location(), replace: true, ), child: Text('Go to Page2Screen'), ) ], ); } } class Page2Screen extends StatelessWidget { @override Widget build(BuildContext context) { return Column( children: [ Text('Page2Screen'), TextButton( onPressed: () => DuckRouter.of(context).navigate( to: Child1Location(), replace: true, ), child: Text('Go to Page1Screen'), ) ], ); } } ```

Each of the child screens are able to navigate.The _ScaffoldShellState does not know that a navigation event happened and thus does not update its index.

class Page1Screen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Page1Screen'),
        TextButton(
          // navigating "inside" the shell
          onPressed: () => DuckRouter.of(context).navigate(
            to: Child2Location(),
            replace: true,
          ),
          child: Text('Go to Page2Screen'),
        )
      ],
    );
  }
}

I want to know how to get the current location in instances like this ^. Does this example help what I'm trying to achieve?

JaspervanRiet commented 3 weeks ago

I believe that the router should be the "source of truth" of the current location.

This is key to our conversation I think! My general stance in designing DuckRouter has been that this is not the responsibility of a router. I observe that a lot of other routers do consider this their problem. Because of that, you have to make concessions to the API that I think complicate things, and often even compromise on features. I consider this part of state management, and I do not necessarily see why DuckRouter would have to save this state for the developer. Yes, of course, we track routing internally. But so does the developer. They tell DuckRouter to go somewhere. In our app at Onsi, we do similar things. But we save the state for that in external services from DuckRouter. We use Riverpod but a developer could also use an InheritedWidget, or Blocs, etc;

If I think about how to support this feature to get the current location for example, things quickly become complicated. What exactly is the current location when dealing with nested state? If I have RootLocation containing two locations, which have both navigated somewhere, what exactly is the current location? Is it RootLocation? Is it one of the children? etc. What if one of the child pages shows a dialog on top of the root? I think an app developer implementing DuckRouter is much more able to conclusively answer that question than the router.

Luckey-Elijah commented 1 week ago

So does the mean the answer is to the original question (How to get the current Location of DuckRouter?) "you can not know unless you track it outside of the router"? Also could you share an example of how it can be achieved using riverpod, ChangeNotifer, or Bloc? I don't want to leave future developers without some type of "canonical" solution to this use case

JaspervanRiet commented 1 week ago

The answer to the original question is that you can find this from the delegate. However, for more advanced use cases the recommendation is to keep track of this yourself. For example, if we want to specify the color of the bottom bar in our stateful location based on what child is selected (and what's happening inside those), we could do something like this when using riverpod:

@riverpod
class BottomBarColorService extends _$BottomBarColorService {
  @override
  Color build() {
    return Colors.red;
  }

  void changeColor(Color color) {
    state = color;
  }
}

Then just watch that value in the bottom bar, and change it whenever you navigate, or change index.

Luckey-Elijah commented 1 week ago

@JaspervanRiet thanks for all your help. I think all my questions are answered (even though it's not exactly what I want), I can achieve what I need to with this info :)