Skip to content

Introduce ListenableFutureBuilder to Simplify Using Controllers in the Composition of a StatelessWidget #121962

Open

Description

@MelbourneDeveloper

Use case

I should be able to instantiate a Listenable controller with async work and use this controller to trigger rebuilds without using some external means to hold onto the Listenable variable. I also shouldn't have to subtype StatefulWidget to do this. I should be able to use straightforward composition to achieve this. I've created a widget (See PR #121963) that solves this problem, and there is a draft PR for it. But, before I jump to that, I will try to explain the existing problem.

Check out a live sample of the proposed solution here on Dartpad.

Existing Problem

On its face, this seems like something that would already be straightforward in Flutter, but it isn't. If you have a controller that extends ValueNotifier or ChangeNotifier, there is no straightforward way to compose a widget that listens to this without subtyping StatefulWidget, using a top-level singleton or something like Provider. Let's take a look at a simple example with AnimatedBuilder and ValueNotifier.

This example is obviously wrong because the parent widget could reinstantiate MyHomePage.

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) => MaterialApp(
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MyHomePage(),
      );
}

class MyHomePage extends StatelessWidget {
  MyHomePage({super.key});

  //A StatelessWidget should never hold onto state in this way
  ValueNotifier<int> _counter = ValueNotifier<int>(0);

  @override
  Widget build(BuildContext context) => AnimatedBuilder(
        animation: _counter,
        builder: (context, child) => Scaffold(
          appBar: AppBar(),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                const Text(
                  'You have pushed the button this many times:',
                ),
                Text(
                  '${_counter.value}',
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => _counter.value++,
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
        ),
      );
}

The above works well but it violates a fundamental principle of Flutter: StatelessWidgets should not hold onto state. This is because the parent widget could be reinstantiated, and the child widget would lose its state. This is why StatefulWidget exists. It allows the child widget to hold onto its state. So, we have a couple of choices:

  • Turn this into a StatefulWidget and move the controller to the State
  • Declare the controller as a top-level singleton
  • Use something like Provider to hold onto the variable and pass it down to the child widget

None of these options are elegant solutions to the problem, and the problem gets worse when we consider async work. They all add complexity to the codebase and make it harder to reason about.

Let's expand on the existing, already problematic example with async work.

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) => MaterialApp(
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MyHomePage(),
      );
}

class MyHomePage extends StatelessWidget {
  MyHomePage({super.key});

  @override
  Widget build(BuildContext context) => FutureBuilder<ValueNotifier<int>>(
        //This is a problem because even though the FutureBuilder will store the result in 
        //State, if the parent rebuilds, this function will execute many times
        future: getController(),
        builder: (context, snapshot) => AnimatedBuilder(
          animation: snapshot.data ?? ValueNotifier<int>(0),
          builder: (context, child) => Scaffold(
            appBar: AppBar(),
            body: Center(
                child: snapshot.connectionState == ConnectionState.done
                    ? Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: <Widget>[
                          const Text(
                            'You have pushed the button this many times:',
                          ),
                          Text(
                            '${snapshot.data!.value}',
                            style: Theme.of(context).textTheme.headlineMedium,
                          ),
                        ],
                      )
                    : const CircularProgressIndicator.adaptive()),
            floatingActionButton: FloatingActionButton(
              onPressed: () => snapshot.data?.value++,
              tooltip: 'Increment',
              child: const Icon(Icons.add),
            ),
          ),
        ),
      );
}

Future<ValueNotifier<int>> getController() async =>
    Future.delayed(const Duration(seconds: 2), () => ValueNotifier<int>(0));

Again, this example works, but it has a problem. If the parent widget reinstantiates this widget, the async function will execute again, causing the controller to be reinstantiated. Again, the controller resets. You can also see that this version is already getting more complicated and harder to read.

Take a look at the future property of the FutureBuilder. This is problematic because it's not a function. It forces the composition to reference a declaration or call a function to get the Future. Every time the parent widget rebuilds, the FutureBuilder will reference the declaration or call the function again. This recently caused the Flutter team to redo a YouTube video for clarification (#112676). In an ideal world, the FutureBuilder would hold onto the value from the Future, as it does, but only call a function lazily when it needs that value.

Proposal

The good news is that there is an easy way to solve several of these issues in one hit and massively simplify using controllers in the composition of a StatelessWidget.

Introducing ListenableFutureBuilder:

import 'package:flutter/material.dart';
import 'package:flutter_application_4/listenable_future_builder.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) => MaterialApp(
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: const MyHomePage(),
      );
}

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

  @override
  Widget build(BuildContext context) =>
      ListenableFutureBuilder<ValueNotifier<int>>(
        //This function is safe because it will only execute once
        //ListenableFutureBuilder makes the first call, it will not execute the call again
        listenable: getController,
        builder: (context, child, snapshot) => Scaffold(
          appBar: AppBar(),
          body: Center(
              child: snapshot.connectionState == ConnectionState.done
                  ? Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
                        const Text(
                          'You have pushed the button this many times:',
                        ),
                        Text(
                          '${snapshot.data!.value}',
                          style: Theme.of(context).textTheme.headlineMedium,
                        ),
                      ],
                    )
                  : const CircularProgressIndicator.adaptive()),
          floatingActionButton: FloatingActionButton(
            onPressed: () => snapshot.data?.value++,
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
        ),
      );
}

Future<ValueNotifier<int>> getController() async =>
    Future.delayed(const Duration(seconds: 2), () => ValueNotifier<int>(0));

This widget simplifies the code from the previous two examples, but more importantly, it solves the problem of the controller being reinstantiated when the parent widget is reinstantiated. It does this by calling listenable function only once and then listening to the Listenable returned by that function. ListenableFutureBuilder is a StatefulWidget and it holds onto the Listenable controller. This means that the controller will not be reinstantiated when the parent widget is reinstantiated. The result is a large simplification, but also removes dependence on things like Provider to ferry the controller in via an inherited widget.

Activity

  1. MelbourneDeveloper commented on Mar 5, 2023

    @MelbourneDeveloper
    Author

    Side Notes

    It is important to look at the structure of AnimatedWidget (AnimatedBuilder) and ValueListenableBuilder. They have a similar caveat to FutureBuilder: they don't take a factory method to instantiate the controller. That means that the widget, instead of the State, holds the reference to Listenable. If the parent widget rebuilds, there is a chance that the widget could get a reference to a different Listenable. That creates the potential for hard-to-trace bugs and creates the preconditions for controllers instantiating again and again.

    abstract class AnimatedWidget extends StatefulWidget {
      const AnimatedWidget({
        super.key,
        required this.listenable,
      });
    
      //Keeping this mutable object at the level of the widget is very questionable.
      //If anything is going to hang onto the controller, shouldn't that be the `State`? 
      //And, it seems to me that this would be better as a factory 
      final Listenable listenable;
    
      @protected
      Widget build(BuildContext context);
    
      @override
      State<AnimatedWidget> createState() => _AnimatedState();
    
      @override
      void debugFillProperties(DiagnosticPropertiesBuilder properties) {
        super.debugFillProperties(properties);
        properties.add(DiagnosticsProperty<Listenable>('listenable', listenable));
      }
    }

    All this leads to the issue documented in the first code sample above. Even though AnimatedBuilder is stateful, it doesn't hang onto the Listenable. Listenable relates to the observer pattern. So, this design is understandable, but allowing the State to hold a reference to the Listenable gives us distinct advantages.

    Of course, we don't want the State object to hang onto a reference of the Listenable after disposal, so we must remove the reference like this.

    void dispose() {
      _unsubscribe();
      //This removes the reference to the listenable on dispose
      _snapshot = AsyncSnapshot<T>.nothing();
      super.dispose();
    }

    Why Hang Onto the Listenable At All?

    The ListenableBuilder base class accepts a builder through the constructor. This is the type definition.

    typedef TransitionBuilder = Widget Function(BuildContext context, Widget? child);

    This builder does not give us a great deal to work with when the rebuild occurs. We don't have access to the controller (Listenable) or even the state. We must store the controller in a variable somewhere and access it, which is what creates the dependency on singletons, or something like Provider.

    ValueListenableBuilder takes a big step forward in this sense because it exposes the state (value) of the controller via the builder's type definition.

    typedef ValueWidgetBuilder<T> = Widget Function(BuildContext context, T value, Widget? child);

    We can access the value to see the state of the controller in this case. However, if you look back at the first code example above, you see that the code depends on holding a reference to the controller. If we want to call methods on the controller, we can't do so via the builder call.

    The builder function solves this problem by exposing the controller (Listenable), so we can call methods on it and pass that to child widgets. It also allows us to know if the Future completed or not.

    typedef AsyncListenableBuilder<T extends Listenable> = Widget Function(
      BuildContext context,
      Widget? child,
      AsyncSnapshot<T> listenableSnapshot,
    );

    All that said, I'm not personally wedded to hanging onto the Listenable in the State. It is possible to subscribe and forget. However, that means the consumer must manage the controller reference itself, which leads back to the issue in the first code example. The onus returns to the consumer to do something with the Listenable instance. This is where we leap from a simple StatelessWidget to a StatefulWidget. Ultimately, something needs to hold onto the controller, or there would be no way to call methods on the controller.

    ListenableFutureBuilder is a StatefulWidget, so it makes sense to allow it to hold a reference to the controller. If it doesn't do that, something else needs to.

  2. added
    in triagePresently being triaged by the triage team
    c: new featureNothing broken; request for a new capability
    frameworkflutter/packages/flutter repository. See also f: labels.
    c: proposalA detailed proposal for a change to Flutter
    and removed
    in triagePresently being triaged by the triage team
    on Mar 6, 2023
  3. added a commit that references this issue on Mar 9, 2023
  4. added a commit that references this issue on Mar 21, 2023
  5. goderbauer commented on Mar 27, 2023

    @goderbauer
    Member

    Do you have an actual real-life use case where this widget would be beneficial? I am wondering who or what would provide the getController method. It seems odd to me that the listenable it creates is not owned by whoever implements getController and instead ownership implicitly passes on to the ListenableFutureBuilder. That setup seems like it could easily lead to bugs if whatever implements getController is going away or the controller it returns need to change. That last case is especially concerning to me: There is no way to replace the controller ListenableFutureBuilder is using, so it may be operating with an invalid one leading to bugs and crashes.

  6. AathifMahir commented on Mar 27, 2023

    @AathifMahir

    Do you have an actual real-life use case where this widget would be beneficial? I am wondering who or what would provide the getController method. It seems odd to me that the listenable it creates is not owned by whoever implements getController and instead ownership implicitly passes on to the ListenableFutureBuilder. That setup seems like it could easily lead to bugs if whatever implements getController is going away or the controller it returns need to change. That last case is especially concerning to me: There is no way to replace the controller ListenableFutureBuilder is using, so it may be operating with an invalid one leading to bugs and crashes.

    Hey,

    Would you mind elaborating this with a better example, can't seem understand what your referring exactly

  7. samuelkchris commented on Mar 28, 2023

    @samuelkchris

    Here are a few things you should be aware of that can cause problems:

    1. The getController function returns a new ValueNotifier instance every time it is called, even if the value of the ValueNotifier is the same. This can cause unnecessary widget rebuilds and performance issues.

    2. The ValueNotifier in this example is mutable, and there are no guarantees that the widget tree will be rebuilt when its value changes. This can lead to bugs and unexpected behavior in the long run, especially as the widget tree becomes more complex.

    3. If the getController function fails to return a ValueNotifier instance, the ListenableFutureBuilder will fail to build and throw an error.

  8. MelbourneDeveloper commented on Mar 28, 2023

    @MelbourneDeveloper
    Author

    Hi @goderbauer. Let me try to address these points the best I can.

    Do you have an actual real-life use case where this widget would be beneficial?

    This widget doesn't so much enable a novel use case as it does simplify an existing one. The main example is when you need a controller for your widget, and initialization requires async work. That controller will probably be transient. I.e., not a singleton. If it's a singleton, this widget is most likely not necessary. This widget will most likely sit at the base of the widget tree. The use case will most likely be something like navigating to a route.

    The easiest way I can demonstrate this is with the simplest example. Given the above scenario, you would need some dependency manager to hold onto the controller for you, or you would have to create a StatefulWidget to hold the state. You are right. In this case, ListenableFutureBuilder picks up ownership of the controller, and there's a reason for that.

    Correct me if I'm wrong, but the above scenario would require this code (unless you manage the controller elsewhere in the tree). You could also do this without a FutureBuilder and move more logic into the state.

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(
        const MainApp(),
      );
    }
    
    class MainApp extends StatefulWidget {
      const MainApp({
        super.key,
      });
    
      @override
      State<MainApp> createState() => _MainAppState();
    }
    
    class _MainAppState extends State<MainApp> {
      late final Future<ValueNotifier<int>> _controllerFuture;
    
      @override
      void initState() {
        super.initState();
        _controllerFuture = getController();
      }
    
      @override
      Widget build(BuildContext context) => MaterialApp(
            home: FutureBuilder(
              future: _controllerFuture,
              builder: (context, snapshot) => Scaffold(
                body: Center(
                  child: snapshot.connectionState == ConnectionState.done &&
                          snapshot.data != null
                      ? AnimatedBuilder(
                          animation: snapshot.data!,
                          builder: (context, child) => Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                              Text(
                                '${snapshot.data!.value}',
                                style: Theme.of(context).textTheme.headlineMedium,
                              ),
                              TextButton(
                                onPressed: () => snapshot.data?.value++,
                                child: const Icon(Icons.add),
                              ),
                            ],
                          ),
                        )
                      : const CircularProgressIndicator.adaptive(),
                ),
              ),
            ),
          );
    }
    
    ///This is transient factory method. It creates a new controller each
    ///time it is called. This is not responsible for holding onto the controller
    ///The widget will hold onto the controller 
    Future<ValueNotifier<int>> getController() async =>
        Future.delayed(const Duration(seconds: 2), () => ValueNotifier<int>(0));

    Now take a look at the simplified version with ListenableFutureBuilder. The main difference is that you don't need to create a new type at all. You can compose your entire widget tree, including the controller without extending anything. And, as a sweetener, the logic of the AnimatedBuilder and FutureBuilder get rolled into one.

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(
        MaterialApp(
          home: ListenableFutureBuilder<ValueNotifier<int>>(
            listenable: getController,
            builder: (context, child, snapshot) => Scaffold(
              body: Center(
                child: snapshot.connectionState == ConnectionState.done &&
                        snapshot.data != null
                    ? Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Text(
                            '${snapshot.data!.value}',
                            style: Theme.of(context).textTheme.headlineMedium,
                          ),
                          TextButton(
                            onPressed: () => snapshot.data?.value++,
                            child: const Icon(Icons.add),
                          ),
                        ],
                      )
                    : const CircularProgressIndicator.adaptive(),
              ),
            ),
          ),
        ),
      );
    }
    
    ///This is transient factory method. It creates a new controller each
    ///time it is called. This is not responsible for holding onto the controller
    ///The widget will hold onto the controller
    Future<ValueNotifier<int>> getController() async =>
        Future.delayed(const Duration(seconds: 2), () => ValueNotifier<int>(0));

    I am wondering who or what would provide the getController method. It seems odd to me that the listenable it creates is not owned by whoever implements getController and instead ownership implicitly passes on to the ListenableFutureBuilder.

    The scenario I envision is that the getController() method is a transient async factory. This function probably wouldn't spit up a singleton because you probably wouldn't need this widget for that. Many apps mint controllers again and again so as to refresh the state.

    That setup seems like it could easily lead to bugs if whatever implements getController is going away

    Whatever implements getController will most likely not hang onto anything related to the controller. So, it can safely go away without affecting anything here.

    There is no way to replace the controller ListenableFutureBuilder is using, so it may be operating with an invalid one leading to bugs and crashes.

    I would like to address this one, but I'm not sure I understand it. If we look at the similar widget AnimatedBuilder (AnimatedWidget) under the hood, we see that the Listenable is final. We don't replace the controller on AnimatedBuilder.

    image

    it may be operating with an invalid one leading to bugs and crashes.

    It is true that the factory method could throw an exception. That's why the widget handles async errors in the same way that FutureBuilder does. If an error occurs, the widget can inspect the snapshot and adjust the UI accordingly.

    onError: (Object error, StackTrace stackTrace) {
      if (_activeCallbackIdentity == callbackIdentity) {
        setState(() {
          _snapshot = AsyncSnapshot<T>.withError(
            ConnectionState.done,
            error,
            stackTrace,
          );
        });
      }
    },

    It is also true that any controller can end up in an invalid state. In those cases, the normal approach is to adjust the UI accordingly, and the hope would be that the fault is transient and gets rectified by navigating back to the same page.

    Could you please give me some examples that may lead to bugs and crashes so I can work through them?

    Christian

  9. samuelkchris commented on Mar 28, 2023

    @samuelkchris

    Hello Christian, here is what i have noticed, correct me if am wrong.

    Incorrect data type:

    Let's say you modify the getController function to return a different type of Listenable, like a ChangeNotifier, like this:

    Future<ChangeNotifier> getController() async =>
        Future.delayed(const Duration(seconds: 2), () => ChangeNotifier());
    

    you'll get a type error because ListenableFutureBuilder is expecting a ValueNotifier, but it's getting a ChangeNotifier instead. This could cause your application to crash or behave unexpectedly and this is one example.

    Thank you for the great work,
    Samuel

  10. MelbourneDeveloper commented on Mar 28, 2023

    @MelbourneDeveloper
    Author

    @goderbauer the issue fundamentally addresses verbosity. In the same sense that records can allow us to reduce the number of types we need to declare, this may allow us to declare fewer widget/state pairs. Although, I'd be happy to be corrected on that.

    I wrote the PR because it was the best way to demonstrate the issue. Perhaps there are other ways to address this issue, but it was a good exercise in exemplifying the problem and I think it is a useful widget.

  11. MelbourneDeveloper commented on Mar 28, 2023

    @MelbourneDeveloper
    Author

    @samuelkchris

    you'll get a type error because ListenableFutureBuilder is expecting a ValueNotifier

    You'll get a compile time error because Dart has type safety.

  12. samuelkchris commented on Mar 28, 2023

    @samuelkchris

    @samuelkchris

    you'll get a type error because ListenableFutureBuilder is expecting a ValueNotifier

    You'll get a compile time error because Dart has type safety.

    Doesn't this limit flexibility as custom implementation may always be required?

  13. MelbourneDeveloper commented on Mar 30, 2023

    @MelbourneDeveloper
    Author

    Hi @goderbauer

    Do you have any thoughts or comments about this reply?

    #121962 (comment)

  14. goderbauer commented on Mar 31, 2023

    @goderbauer
    Member

    The scenario I envision is that the getController() method is a transient async factory. This function probably wouldn't spit up a singleton because you probably wouldn't need this widget for that. Many apps mint controllers again and again so as to refresh the state.

    What is a use case for this? What entity would vend controllers like that? Usually, the entity creating the controller also wants to own it. So, in order to evaluate if this proposal makes sense we need to know what it is supposed to be used for. In other words: what is the problem you are trying to solve with either the ListenableFutureBuilder or with the more verbose StatefulWidget approach you gave. Sure, the ListenableFutureBuilder reduces the verbosity of the StatefulWidget approach, but again, it is not clear to me what problem the StatefulWidget approach is supposed to address in the first place.

    If we look at the similar widget AnimatedBuilder (AnimatedWidget) under the hood, we see that the Listenable is final. We don't replace the controller on AnimatedBuilder.

    The listenable property on the AnimatedBuilder is final, yes, because widgets in flutter are immutable and thus must only have final fields. However, if you look at the long-living state object backing the AnimatedBuilder you will see that it very much responds if the widget its associated with is replaced with one that has a different listenable:

    void didUpdateWidget(AnimatedWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.listenable != oldWidget.listenable) {
    oldWidget.listenable.removeListener(_handleChange);
    widget.listenable.addListener(_handleChange);
    }
    }

    It will stop listing to the old listenable and instead listen to the new one.

  15. MelbourneDeveloper commented on Apr 11, 2023

    @MelbourneDeveloper
    Author

    @goderbauer

    if you look at the long-living state object backing the AnimatedBuilder you will see that it very much responds if the widget its associated with is replaced with one that has a different listenable:

    It will stop listing to the old listenable and instead listen to the new one.

    Yes, you are right, and I see your point. There are probably many use cases where an AnimatedBuilder must change the listener. I haven't encountered those, but it's probably common with animations. In the case I am describing, the controller drives the current screen, so the controller will not change.

    What entity would vend controllers like that?

    In my personal case, a service locator vends the controllers. My service locator is ioc_container, but get_it is another common one.

    Usually, the entity creating the controller also wants to own it.

    In this case, the entity doesn't want to own it. The entity (service locator) only mints transient controllers. It doesn't hold onto them and is not interested in them. In this case, we create a widget explicitly to hold onto the controller, which in turn calls setState() when notifications occur.

    But, this approach is not limited to service locators. You can mint the controllers from global factories, or libraries like Riverpod.

    What is a use case for this?

    The use case is a scenario where the controller drives the logic for some given screen. Instead of putting the state and logic in the stateful widget's State<>, we move them to a ChangeNotifier or ValueNotifier<>. It's a popular approach, and Bloc / Cubit work similarly. There is also StateNotifier, but none of these implement Listenable AFAIK. This use case is not related to animations. The main driver for doing this is separating the logic and UI concerns. There are several Builder widgets in the Flutter framework already. They listen to the Listenable, and they call setState() upon notification.

    This is a matter of choice. You don't have to move logic out of the State part of a StatefulWidget but many teams find that this makes the code easier to reason about because of the separation of concerns.

    what is the problem you are trying to solve with either the ListenableFutureBuilder or with the more verbose StatefulWidget approach you gave. [...] it is not clear to me what problem the StatefulWidget approach is supposed to address in the first place.

    It feels like I'm missing something here. As I mentioned, moving the logic of a widget into a controller such as a ChangeNotifier or a ValueNotifier is a pretty common approach that many in the community take. Everything I have mentioned here relates back to this document titled Simple app state management. Note that this solution depends on Provider, which is part of the reason why I documented this issue. I don't think this approach should depend on Provider.

    You seem to suggest a totally different way to do this. Do you mean that using something like a Listenable as a controller is a bad or wonky approach in the first place? Are you saying it's better to stick to vanilla StatefulWidget / State pairs? If not, what is the better way to do what I've documented here?

    I don't necessarily disagree. I quite often write widgets that way. This issue comes from the assumption that many people want to use Listenable to handle logic.

  16. MelbourneDeveloper commented on Apr 11, 2023

    @MelbourneDeveloper
    Author

    I have released listenable_future_builder as a package on pub dev:

    listenable_future_builder

    The documentation should explain why someone might want to use it, and there are several samples in the documentation.

  17. added
    P3Issues that are less important to the Flutter project
    and removed on Jun 28, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projectc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Flutterframeworkflutter/packages/flutter repository. See also f: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions