escamoteur / watch_it

MIT License
103 stars 8 forks source link

Allow `pushScope` to use `isFinal` #26

Closed jefflongo closed 5 months ago

jefflongo commented 5 months ago

pushScope is a great solution for controlling the lifecycle of a Widget manager class. However, it causes issues with other parts of the app that wish to register objects to the baseline scope at runtime. They will get caught in a Widget's scope, leading to the object getting unexpectedly unregistered.

There are two options to solve this problem:

  1. Create a named scope for the object to register at runtime
    • This works, but now requires unnecessarily naming a scope, when the app would prefer to register to the baseline scope
  2. Create the Widget scopes as isFinal so that other registered objects are propagated up to the parent scope
    • This is currently not possible with watch_it because pushScope does not expose the isFinal field for when it calls get_it's pushNewScope

Proposed solution: Allow watch_it's pushScope to be supplied with isFinal

Below is an example application which exemplifies the issue. It contains three Widgets, each with their own manager class. Notably, the LoginManager registers a singleton at runtime, but because the Widget scopes are not final, LoginManager registers its singleton to the Form scope, which gets disposed. This triggers an exception when trying to retrieve the object from LoggedInPage. If the Widget scopes can be created with isFinal, the app behaves as expected.

import 'package:flutter/material.dart';
import 'package:watch_it/watch_it.dart';

void main() {
  runApp(const MaterialApp(home: Scaffold(body: LoginPage())));
}

class Model {
  final int data;
  Model(this.data);
}

class FormManager {
  int getData() => 69;
}

class Form extends StatelessWidget with WatchItMixin {
  const Form({super.key});

  @override
  Widget build(BuildContext context) {
    pushScope(init: (g) => g.registerSingleton<FormManager>(FormManager()));

    return Center(
      child: FilledButton.tonal(
        onPressed: () {
          // Submitting the form will cause this widget to be disposed and the
          // GetIt scope will be popped
          Navigator.pop(context, di.get<FormManager>().getData());
        },
        child: const Text('Submit'),
      ),
    );
  }
}

class LoginManager {
  void createModel(int data) {
    di.registerSingleton<Model>(Model(data));
  }
}

class LoginPage extends StatelessWidget with WatchItMixin {
  const LoginPage({super.key});

  void showModal(BuildContext context) {
    showModalBottomSheet(context: context, builder: (context) => const Form())
        .then(
      (data) {
        if (data != null) {
          // PROBLEM:
          // `createModel` will register a singleton but it will register to the
          // `Form`'s scope, which will soon be disposed. `createModel` would
          // like to register to the baseline scope
          di.get<LoginManager>().createModel(data);
          Navigator.of(context).push(
              MaterialPageRoute(builder: (context) => const LoggedInPage()));
        }
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    pushScope(init: (g) => g.registerSingleton<LoginManager>(LoginManager()));

    return Scaffold(
      body: Center(
        child: FilledButton.tonal(
          onPressed: () => showModal(context),
          child: const Text('Login'),
        ),
      ),
    );
  }
}

class LoggedInManager {
  void doSomethingWithModel() {
    di.get<Model>();
  }

  void logOut() {
    di.unregister<Model>();
  }
}

class LoggedInPage extends StatelessWidget with WatchItMixin {
  const LoggedInPage({super.key});

  @override
  Widget build(BuildContext context) {
    pushScope(
        init: (g) => g.registerSingleton<LoggedInManager>(LoggedInManager()));

    return PopScope(
      onPopInvoked: (didPop) {
        if (didPop) {
          di.get<LoggedInManager>().logOut();
        }
      },
      child: Scaffold(
        body: Center(
          child: FilledButton.tonal(
            onPressed: () {
              // PROBLEM:
              // This will trigger an exception because `Model` has already been
              // unregistered when `Form`'s scope was popped
              di.get<LoggedInManager>().doSomethingWithModel();
            },
            child: const Text('Do Something'),
          ),
        ),
      ),
    );
  }
}
escamoteur commented 5 months ago

Thanks for the PR!!!