Skip to content

Controllers

View raw .md
On this page

Introduction#

Controllers in magic_starter are the single source of truth for business logic and async state. Every page-level view delegates all API calls, state transitions, and navigation to its paired controller. Views contain zero business logic — they render state and forward user input.

The plugin ships six controllers covering auth, profile, teams, OTP, guest auth, and newsletter flows. All six share the same structural core: lazy singleton via Magic.findOrPut and MagicController + MagicStateMixin for state. Controllers that handle page navigation also mix in NavigatesRoutes (auth, guest auth, profile), while others (team, newsletter) manage state without navigation concerns. Consumer apps building custom features on top of magic_starter should follow the same conventions for consistency.

Notification preferences state moved with the rest of the notification UI: NotificationPreferencesController now lives in magic_notifications and follows the same lazy-singleton shape.

Lazy Singleton Pattern#

Every controller exposes a single static accessor that delegates instantiation to the Magic IoC container:

dart
class MagicStarterAuthController extends MagicController
    with MagicStateMixin<bool>, ValidatesRequests, NavigatesRoutes {
  static MagicStarterAuthController get instance =>
      Magic.findOrPut(MagicStarterAuthController.new);
}

Magic.findOrPut checks whether a binding already exists under the controller's runtime type. On first access it calls MagicStarterAuthController.new (the default constructor), stores the result, and returns it. Every subsequent call returns the cached instance. This means a controller is created only when first needed and lives for the lifetime of the IoC container.

The same pattern across all seven controllers:

Controller Singleton key
MagicStarterAuthController.instance Magic.findOrPut(MagicStarterAuthController.new)
MagicStarterProfileController.instance Magic.findOrPut(MagicStarterProfileController.new)
MagicStarterTeamController.instance Magic.findOrPut(MagicStarterTeamController.new)
MagicStarterOtpController.instance Magic.findOrPut(MagicStarterOtpController.new)
MagicStarterGuestAuthController.instance Magic.findOrPut(MagicStarterGuestAuthController.new)
MagicStarterNewsletterController.instance Magic.findOrPut(MagicStarterNewsletterController.new)

MagicController and MagicStateMixin#

All magic_starter controllers extend MagicController and mix in MagicStateMixin<T>.

MagicController extends ChangeNotifier, so every controller is a Listenable. MagicStateMixin<T> adds a typed state machine on top of it.

The mixin provides these state-transition methods and read properties:

Method / Property Description
setLoading() Transitions to loading state, calls notifyListeners()
setSuccess(T value) Transitions to success state with a typed payload, notifies listeners
setError(String message) Transitions to error state with a message, notifies listeners
setEmpty() Resets to empty/idle state, notifies listeners
clearErrors() Clears any error state without changing the primary state
isLoading true while the controller is in loading state
isSuccess true after a successful transition
hasErrors true when the controller holds an error message
renderState(builder, {onEmpty, onError}) Widget factory — dispatches to the correct builder based on current state

The type parameter T is the success payload type. MagicStateMixin<bool> is the most common pattern because many controllers only need to signal success or failure, not carry data. However, some controllers use a different success payload type (or omit the explicit type argument) and call setSuccess(...) with non-boolean data when the state itself needs to carry a result. Controllers may also expose structured data through ValueNotifier<T> fields alongside MagicStateMixin<T> when that produces a cleaner API.

A standard async action looks like this:

dart
Future<void> doLogin({
  required String email,
  required String password,
}) async {
  setLoading();
  clearErrors();

  try {
    final response = await Http.post('/auth/login', data: {
      'email': email,
      'password': password,
    });

    if (!response.successful) {
      setError(trans('auth.login_failed'));
      return;
    }

    await Auth.restore();
    setSuccess(true);
  } catch (e, stackTrace) {
    Log.error('[MagicStarterAuthController.doLogin] $e\n$stackTrace');
    setError(trans('errors.unexpected'));
  }
}

Controller Lifecycle#

Because Magic.findOrPut stores the instance in the IoC container, a controller outlives any individual view. A user may navigate away from the login screen and back — MagicStarterAuthController.instance returns the same object both times.

Practical consequences:

  • State carries over between visits. If a controller is in the error state when the view is dismissed, it will still be in the error state the next time the view mounts. Reset state in the view's onInit() hook (see View Binding).
  • ValueNotifier fields must be disposed when the controller is no longer needed. In tests, call controller.dispose() in tearDown. In production, controllers that live for the full app session typically do not need explicit disposal.
  • The container is reset by calling Magic.flush() or MagicApp.reset(), which replaces the IoC container entirely. After a reset, the next call to Magic.findOrPut creates a fresh instance. This is the standard test isolation mechanism.

View Binding#

Every page-level view in magic_starter extends MagicStatefulView<ControllerType>. The base class resolves the controller via Magic.findOrPut and exposes it through the controller getter, available throughout the state class.

dart
class MagicStarterLoginView
    extends MagicStatefulView<MagicStarterAuthController> {
  const MagicStarterLoginView({super.key});

  @override
  State<MagicStarterLoginView> createState() => _MagicStarterLoginViewState();
}

class _MagicStarterLoginViewState extends MagicStatefulViewState<
    MagicStarterAuthController, MagicStarterLoginView> {
  late final form = MagicFormData(
    {'email': '', 'password': '', 'remember_me': false},
    controller: controller,
  );

  @override
  void onInit() {
    controller.clearErrors();
    controller.setEmpty();
  }

  @override
  void onClose() => form.dispose();

  @override
  Widget build(BuildContext context) {
    return controller.renderState(
      (_) => _buildForm(),
      onEmpty: _buildForm(),
      onError: (message) => _buildForm(errorMessage: message),
    );
  }
}

MagicStatefulViewState lifecycle hooks:

Hook Called when Typical use
onInit() After initState() — view is mounted Reset controller state, set initial empty state
onClose() In dispose() — view is removed Dispose MagicFormData, cancel subscriptions

The view automatically subscribes to controller (via ChangeNotifier) and calls setState on every notifyListeners, so renderState always reflects the latest controller state without any manual subscription code.

Consumer App Pattern#

Consumer apps building custom features should pick a registration strategy based on their initialization requirements.

Option 1 — Lazy Singleton (default recommendation)#

Use Magic.findOrPut for controllers that require no upfront initialization. The instance is created on first access and cached for the app session.

dart
class ProjectController extends MagicController
    with MagicStateMixin<bool> {
  static ProjectController get instance =>
      Magic.findOrPut(ProjectController.new);

  Future<void> loadProjects() async {
    setLoading();
    clearErrors();

    try {
      final response = await Http.get('/projects');
      if (!response.successful) {
        setError('Failed to load projects.');
        return;
      }
      setSuccess(true);
    } catch (e, stackTrace) {
      Log.error('[ProjectController.loadProjects] $e\n$stackTrace');
      setError('An unexpected error occurred.');
    }
  }
}

No registration in AppServiceProvider is needed. The first view that accesses ProjectController.instance initialises it.

Option 2 — Eager Registration in AppServiceProvider.boot()#

Register the controller explicitly when it needs to connect to a WebSocket, subscribe to an event, or perform auth-dependent work at startup — before any view has mounted.

dart
// In AppServiceProvider.boot()
@override
Future<void> boot() async {
  final controller = Magic.findOrPut(ProjectController.new);
  await controller.connectRealtime();
}

The explicit Magic.findOrPut call in boot() forces instantiation and stores the singleton. Later calls from views return the same already-initialised instance.

Option 3 — Per-View Instance (form / wizard state)#

When state should not persist across view visits — multi-step forms, wizards, ephemeral UI — use a plain ChangeNotifier created inside the StatefulWidget. No IoC container involvement.

dart
class ProjectFormState extends ChangeNotifier {
  String name = '';
  bool isSubmitting = false;

  void setName(String value) {
    name = value;
    notifyListeners();
  }
}

class _ProjectCreateViewState extends State<ProjectCreateView> {
  late final _state = ProjectFormState();

  @override
  void dispose() {
    _state.dispose();
    super.dispose();
  }
}

Decision Table#

Scenario Strategy
Standard CRUD — loaded on demand Lazy singleton (Magic.findOrPut)
Needs WebSocket / boot-time init Eager via AppServiceProvider.boot()
Multi-step form — state should reset on dismiss Per-view ChangeNotifier
Shared state accessed from multiple screens Lazy singleton
Controller tied to a single, ephemeral modal Per-view ChangeNotifier

View Integration for Consumer Apps#

Consumer apps have two options for wiring a controller to a view.

Option A — MagicStatefulView (auto-listens)#

Extend MagicStatefulView<T> to get the controller resolved and subscribed automatically. This matches the pattern used by every plugin view and is the recommended choice. magic_notifications's NotificationsListView used to be the one exception, implemented as a plain StatefulWidget managing state locally; it now runs on NotificationsListController like the rest, so there is no longer a shipped counter-example for a new view to copy.

dart
class ProjectListView extends MagicStatefulView<ProjectController> {
  const ProjectListView({super.key});

  @override
  State<ProjectListView> createState() => _ProjectListViewState();
}

class _ProjectListViewState
    extends MagicStatefulViewState<ProjectController, ProjectListView> {
  @override
  void onInit() => controller.loadProjects();

  @override
  Widget build(BuildContext context) {
    return controller.renderState(
      (_) => _buildList(),
      onEmpty: const WText('No projects yet.'),
      onError: (message) => WText(message),
    );
  }
}

Option B — StatefulWidget + Magic.find (more control)#

Use a plain StatefulWidget and resolve the controller manually when you need more control over subscription granularity or want to avoid the full MagicStatefulView lifecycle.

dart
class _ProjectListViewState extends State<ProjectListView> {
  late final ProjectController _controller;

  @override
  void initState() {
    super.initState();
    _controller = Magic.findOrPut(ProjectController.new);
    _controller.addListener(_onControllerUpdate);
    _controller.loadProjects();
  }

  @override
  void dispose() {
    _controller.removeListener(_onControllerUpdate);
    super.dispose();
  }

  void _onControllerUpdate() => setState(() {});

  @override
  Widget build(BuildContext context) { /* ... */ }
}

Fine-Grained Reactivity#

MagicStateMixin calls notifyListeners() on every state transition, which rebuilds the entire view tree listening to the controller. For complex views with multiple independent loading sections, this full-page rebuild can cause UI flicker.

The solution is ValueNotifier<T> fields — one per independently-loading section. magic_notifications's NotificationPreferencesController demonstrates this with a preference matrix; it lives in that package now, but the pattern it demonstrates is this package's own:

dart
class NotificationPreferencesController extends MagicController
    with MagicStateMixin<bool> {
  static NotificationPreferencesController get instance =>
      Magic.findOrPut(NotificationPreferencesController.new);

  /// Preference matrix updated independently of the page-level state machine.
  final matrixNotifier = ValueNotifier<Map<String, dynamic>>({});

  Future<void> fetchPreferences() async {
    setLoading();
    try {
      final response = await Http.get('/notification-preferences');
      if (!response.successful) {
        setError(trans('notifications.fetch_error'));
        return;
      }
      matrixNotifier.value = _normalizeMap(response.data['data'] as Map);
      setSuccess(true);
    } catch (e, stackTrace) {
      Log.error('[NotificationPreferencesController.fetchPreferences] $e\n$stackTrace');
      setError(trans('errors.unexpected'));
    }
  }
}

The team controller uses the same technique for member and invitation lists:

dart
final ValueNotifier<List<Map<String, dynamic>>> members = ValueNotifier([]);
final ValueNotifier<List<Map<String, dynamic>>> invitations = ValueNotifier([]);

In the view, wrap only the section that depends on the notifier:

dart
ValueListenableBuilder<Map<String, dynamic>>(
  valueListenable: controller.matrixNotifier,
  builder: (_, matrix, __) => _buildPreferenceGrid(matrix),
)

The ValueListenableBuilder rebuilds only its own subtree when matrixNotifier.value changes, leaving the rest of the view untouched.

MagicStarterProfileController uses withoutNotifying() to suppress full-page notifyListeners calls when a section-level operation is already driving its own loading indicator via MagicFormData.process:

dart
await form.process(() => controller.withoutNotifying(
  () => controller.doUpdateProfile(name: 'Alice', email: '[email protected]'),
));

Testing Controllers#

Every controller test follows the same setup sequence: reset the IoC container, bind mock drivers, create a fresh controller instance.

dart
void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  group('ProjectController', () {
    late MockNetworkDriver mockDriver;
    late ProjectController controller;

    setUp(() {
      // 1. Reset IoC container — clears all singletons from previous tests.
      MagicApp.reset();
      Magic.flush();

      // 2. Bind mock network driver so Http facade is intercepted.
      Magic.singleton('network', () => MockNetworkDriver());

      // 3. Bind log service so Log.error() works inside catch blocks.
      Magic.singleton('log', () => LogManager());
      Config.set('logging', {
        'default': 'console',
        'channels': {
          'console': {'driver': 'console', 'level': 'debug'},
        },
      });

      // 4. Bind auth guard.
      Auth.manager.forgetGuards();
      Auth.manager.extend('mock', (_) => MockGuard());
      Config.set('auth.defaults.guard', 'mock');
      Config.set('auth.guards', {'mock': {'driver': 'mock'}});

      // 5. Bind MagicStarterManager if the controller uses MagicStarter.*
      Magic.singleton('magic_starter', () => MagicStarterManager());

      // 6. Create a fresh controller — NOT via .instance (avoids cached state).
      controller = ProjectController();

      // 7. Resolve the mock driver reference for response setup.
      mockDriver = Magic.make<NetworkDriver>('network') as MockNetworkDriver;
    });

    tearDown(() {
      controller.dispose();
      Auth.manager.forgetGuards();
    });

    test('loadProjects — success sets isSuccess true', () async {
      mockDriver.mockResponse(statusCode: 200, data: {'data': []});

      await controller.loadProjects();

      expect(controller.isSuccess, isTrue);
      expect(mockDriver.lastMethod, 'GET');
      expect(mockDriver.lastUrl, contains('/projects'));
    });

    test('loadProjects — API error sets hasErrors true', () async {
      mockDriver.mockResponse(statusCode: 422, data: {});

      await controller.loadProjects();

      expect(controller.hasErrors, isTrue);
      expect(controller.isSuccess, isFalse);
    });
  });
}

Key rules:

  • Always use controller = ProjectController() in tests, not ProjectController.instance. The .instance accessor uses Magic.findOrPut, which returns a cached singleton. After Magic.flush() the cache is empty, but using the accessor in tests couples test isolation to container state. Constructing directly is unambiguous.
  • Dispose ValueNotifier fields in tearDown. Forgetting causes "ValueNotifier used after dispose" errors in subsequent tests. Call controller.dispose() which triggers the ChangeNotifier disposal chain.
  • Queue multiple responses for multi-request flows (e.g., OTP send followed by verify) using mockDriver.mockQueue(responses) when available in the test driver, or call mockDriver.mockResponse() between the two actions.
  • Feature flags are set via Config.set in setUp before the controller is created: Config.set('magic_starter.features.teams', true).
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.