From 1e5406072feb248b3d976b6b1ef7db17af4174c2 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sat, 4 Apr 2026 22:40:57 +0300 Subject: [PATCH 1/2] docs: document recommended state/controller registration pattern for consumer apps Add architecture reference (doc/architecture/controllers.md) covering the lazy singleton pattern, MagicController + MagicStateMixin usage, controller lifecycle, view binding, and a decision tree for eager vs lazy vs per-view registration. Add practical getting-started guide (doc/guides/state-management.md) with end-to-end examples. Update scaffolded app_service_provider.stub with state registration guidance comments. Add cross-references in service-provider.md. Closes #17 --- CHANGELOG.md | 4 + README.md | 2 + .../stubs/install/app_service_provider.stub | 19 + doc/architecture/controllers.md | 467 ++++++++++ doc/architecture/service-provider.md | 4 + doc/guides/state-management.md | 864 ++++++++++++++++++ 6 files changed, 1360 insertions(+) create mode 100644 doc/architecture/controllers.md create mode 100644 doc/guides/state-management.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bd23e37..f95ed71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- Documentation for recommended state/controller registration pattern for consumer apps (`doc/architecture/controllers.md`, `doc/guides/state-management.md`) +- State registration guidance in scaffolded `app_service_provider.stub` + ## [0.0.1-alpha.8] - 2026-03-31 ### 🐛 Bug Fixes diff --git a/README.md b/README.md index 625c5f9..bac390b 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,8 @@ App launch → MagicStarterServiceProvider.boot() | [Manager](https://magic.fluttersdk.com/packages/starter/architecture/manager) | Singleton manager and customization hooks | | [Service Provider](https://magic.fluttersdk.com/packages/starter/architecture/service-provider) | Bootstrap lifecycle, Gate abilities, IoC bindings | | [View Registry](https://magic.fluttersdk.com/packages/starter/architecture/view-registry) | String-keyed builders and host app overrides | +| [Controllers & State Registration](doc/architecture/controllers.md) | Recommended state/controller registration pattern for consumer apps | +| [State Management Guide](doc/guides/state-management.md) | State management best practices and patterns | --- diff --git a/assets/stubs/install/app_service_provider.stub b/assets/stubs/install/app_service_provider.stub index f2caf36..4dd3ab6 100644 --- a/assets/stubs/install/app_service_provider.stub +++ b/assets/stubs/install/app_service_provider.stub @@ -73,5 +73,24 @@ class AppServiceProvider extends ServiceProvider { {{ teams_block }} {{ social_login_block }} {{ notifications_block }} + + // ------------------------------------------------------------------- + // State Registration (optional — for eager initialization) + // ------------------------------------------------------------------- + // Most state classes should use the lazy singleton pattern with a + // static accessor instead of registering here: + // + // class ProjectState extends MagicController + // with MagicStateMixin> { + // static ProjectState get instance => + // Magic.findOrPut(ProjectState.new); + // } + // + // Only register here if the state needs to be ready before any view + // renders (e.g., WebSocket connection, auth-dependent init): + // + // Magic.findOrPut(DashboardState.new); + // + // See: doc/architecture/controllers.md } } diff --git a/doc/architecture/controllers.md b/doc/architecture/controllers.md new file mode 100644 index 0000000..cbdc53e --- /dev/null +++ b/doc/architecture/controllers.md @@ -0,0 +1,467 @@ +# Controllers + +- [Introduction](#introduction) +- [Lazy Singleton Pattern](#lazy-singleton-pattern) +- [MagicController and MagicStateMixin](#magiccontroller-and-magicstatemixin) +- [Controller Lifecycle](#controller-lifecycle) +- [View Binding](#view-binding) +- [Consumer App Pattern](#consumer-app-pattern) +- [View Integration for Consumer Apps](#view-integration-for-consumer-apps) +- [Fine-Grained Reactivity](#fine-grained-reactivity) +- [Testing Controllers](#testing-controllers) +- [Related](#related) + + +## 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 seven controllers covering auth, profile, teams, notifications, OTP, guest auth, and newsletter flows. All seven follow the same structural pattern: lazy singleton via `Magic.findOrPut`, `MagicController + MagicStateMixin` for state, and `NavigatesRoutes` for navigation. Consumer apps building custom features on top of magic_starter should follow the same conventions for consistency. + + +## 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, 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)` | +| `MagicStarterNotificationController.instance` | `Magic.findOrPut(MagicStarterNotificationController.new)` | +| `MagicStarterOtpController.instance` | `Magic.findOrPut(MagicStarterOtpController.new)` | +| `MagicStarterGuestAuthController.instance` | `Magic.findOrPut(MagicStarterGuestAuthController.new)` | +| `MagicStarterNewsletterController.instance` | `Magic.findOrPut(MagicStarterNewsletterController.new)` | + +> [!NOTE] +> `Magic.findOrPut` is distinct from `Magic.singleton`. `singleton` registers a factory eagerly so the container can resolve it by key string. `findOrPut` uses the concrete type as the implicit key and creates the instance on first access — no upfront registration required. + + +## MagicController and MagicStateMixin + +All magic_starter controllers extend `MagicController` and mix in `MagicStateMixin`. + +`MagicController` extends `ChangeNotifier`, so every controller is a `Listenable`. `MagicStateMixin` 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. All plugin controllers use `MagicStateMixin` because they only need to signal success or failure, not carry data. When a controller needs to expose structured data it uses `ValueNotifier` fields alongside `MagicStateMixin`. + +A standard async action looks like this: + +```dart +Future 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')); + } +} +``` + +> [!TIP] +> Always call `setLoading()` and `clearErrors()` at the top of an async action, before the first `await`. This gives the UI immediate feedback and clears stale error messages from the previous run. + + +## 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](#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`. 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 { + const MagicStarterLoginView({super.key}); + + @override + State 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 { + static ProjectController get instance => + Magic.findOrPut(ProjectController.new); + + Future 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 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 { + 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` to get the controller resolved and subscribed automatically. This matches the pattern used by all eleven plugin views and is the recommended choice. + +```dart +class ProjectListView extends MagicStatefulView { + const ProjectListView({super.key}); + + @override + State createState() => _ProjectListViewState(); +} + +class _ProjectListViewState + extends MagicStatefulViewState { + @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 { + 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) { /* ... */ } +} +``` + +> [!NOTE] +> Always use `removeListener` in `dispose()` when subscribing manually. Forgetting this causes the disposed widget to receive updates and triggers "called after dispose" Flutter errors. + + +## 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` fields — one per independently-loading section. The notification controller demonstrates this with a preference matrix: + +```dart +class MagicStarterNotificationController extends MagicController + with MagicStateMixin { + static MagicStarterNotificationController get instance => + Magic.findOrPut(MagicStarterNotificationController.new); + + /// Preference matrix updated independently of the page-level state machine. + final matrixNotifier = ValueNotifier>({}); + + Future fetchPreferences() async { + setLoading(); + try { + final response = await Http.get('/notification-preferences'); + if (!response.successful) { + setError(trans('magic_starter.notifications.fetch_error')); + return; + } + matrixNotifier.value = _normalizeMap(response.data['data'] as Map); + setSuccess(true); + } catch (e, stackTrace) { + Log.error('[MagicStarterNotificationController.fetchPreferences] $e\n$stackTrace'); + setError(trans('errors.unexpected')); + } + } +} +``` + +The team controller uses the same technique for member and invitation lists: + +```dart +final ValueNotifier>> members = ValueNotifier([]); +final ValueNotifier>> invitations = ValueNotifier([]); +``` + +In the view, wrap only the section that depends on the notifier: + +```dart +ValueListenableBuilder>( + 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: 'a@example.com'), +)); +``` + + +## 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('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)`. + + +## Related + +- [MagicStarterServiceProvider](https://magic.fluttersdk.com/packages/starter/architecture/service-provider) — bootstrap entry point, IoC bindings, and Gate ability registration +- [MagicStarterManager](https://magic.fluttersdk.com/packages/starter/architecture/manager) — central singleton holding all customization registrations +- [Views and Layouts](https://magic.fluttersdk.com/packages/starter/architecture/views-and-layouts) — MagicStatefulView lifecycle and Wind UI rendering conventions +- [Magic Framework — IoC Container](https://magic.fluttersdk.com/getting-started/ioc-container) — singleton, factory, and findOrPut reference +- [Magic Framework — Service Providers](https://magic.fluttersdk.com/getting-started/service-providers) — two-phase bootstrap lifecycle diff --git a/doc/architecture/service-provider.md b/doc/architecture/service-provider.md index f6ddc7d..0cfde86 100644 --- a/doc/architecture/service-provider.md +++ b/doc/architecture/service-provider.md @@ -176,10 +176,14 @@ Map get appConfig => { }; ``` +> [!TIP] +> For guidance on registering your own controllers and state classes in a consumer app, see [Controllers & State Registration](controllers.md). + ## Related - [MagicStarterManager](https://magic.fluttersdk.com/packages/starter/architecture/manager) — central singleton holding all customization registrations - [View Registry](https://magic.fluttersdk.com/packages/starter/architecture/view-registry) — string-keyed view factory for overridable UI +- [Controllers & State Registration](https://magic.fluttersdk.com/packages/starter/architecture/controllers) — lazy singleton pattern, consumer app state registration guide - [Magic Framework — Service Providers](https://magic.fluttersdk.com/getting-started/service-providers) — two-phase lifecycle reference - [Magic Framework — Gate](https://magic.fluttersdk.com/getting-started/gate) — authorization abilities reference diff --git a/doc/guides/state-management.md b/doc/guides/state-management.md new file mode 100644 index 0000000..ccd7308 --- /dev/null +++ b/doc/guides/state-management.md @@ -0,0 +1,864 @@ +# State Management + +- [Introduction](#introduction) +- [Creating a State Class](#creating-a-state-class) + - [Extend MagicController](#extend-magiccontroller) + - [Add the Singleton Accessor](#add-the-singleton-accessor) + - [Implement Async Methods](#implement-async-methods) +- [Registration Decision Tree](#registration-decision-tree) +- [Connecting State to Views](#connecting-state-to-views) + - [Pattern A: MagicStatefulView](#pattern-a-magicstatefulview) + - [Pattern B: StatefulWidget + Magic.find](#pattern-b-statefulwidget--magicfind) +- [Testing](#testing) +- [Complete Example](#complete-example) +- [Related](#related) + + +## Introduction + +Magic Starter uses a three-part state management model built on the Magic Framework: + +1. **`MagicController`** — base class that provides lifecycle management and ties into the IoC container. +2. **`MagicStateMixin`** — mixin that adds a five-state machine (`loading`, `success`, `error`, `empty`) with `setLoading()`, `setSuccess()`, `setError()`, `clearErrors()`, and `renderState()` helpers. +3. **IoC container** — `Magic.findOrPut()` returns the existing singleton for a class or registers and returns a new one. Views never construct controllers directly. + +Consumer app controllers follow the exact same pattern as magic_starter's own controllers. You extend `MagicController`, mix in `MagicStateMixin`, and expose a `static get instance` accessor backed by `Magic.findOrPut()`. Views bind to that accessor and call `renderState()` to drive conditional rendering. + + +## Creating a State Class + + +### Extend MagicController + +Declare your controller in a file named after the feature it manages: + +```dart +import 'package:magic/magic.dart'; + +class ProjectController extends MagicController + with MagicStateMixin>> { + // ... +} +``` + +The generic type parameter on `MagicStateMixin` is the value type passed to `setSuccess(value)`. Use `List>` for collections, `bool` for form-submission controllers, or a typed model class for single-resource controllers. + + +### Add the Singleton Accessor + +Every controller exposes a static `instance` getter backed by `Magic.findOrPut()`: + +```dart +class ProjectController extends MagicController + with MagicStateMixin>> { + static ProjectController get instance => + Magic.findOrPut(ProjectController.new); +} +``` + +`Magic.findOrPut(ProjectController.new)` checks whether a `ProjectController` is already registered in the container. If it is, it returns the existing instance. If not, it calls `ProjectController.new` (the tear-off constructor), registers it as a singleton, and returns it. All views calling `ProjectController.instance` will receive the same object. + +> [!NOTE] +> Never call `ProjectController()` directly in a view. The `instance` accessor is the only valid entry point — it ensures the same state is shared across all views that reference the controller. + + +### Implement Async Methods + +Each async action follows the same lifecycle: + +1. Guard against re-entrant calls with a `_isSubmitting` flag. +2. Call `setLoading()` to transition the state machine. +3. Perform the HTTP call via `Http.get()` / `Http.post()` / etc. +4. Call `setSuccess(value)` on the happy path or `setError(message)` on failure. +5. Reset the guard in `finally`. + +```dart +class ProjectController extends MagicController + with MagicStateMixin>> { + // ------------------------------------------------------------------------- + // Singleton + // ------------------------------------------------------------------------- + + static ProjectController get instance => + Magic.findOrPut(ProjectController.new); + + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + bool _isLoading = false; + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + /// Fetch all projects from the API and expose them via state machine. + Future loadProjects() async { + if (_isLoading) return; + _isLoading = true; + setLoading(); + + try { + final response = await Http.get('/api/projects'); + + if (!response.successful) { + setError('Failed to load projects.'); + return; + } + + final data = response.data['data']; + if (data is List) { + setSuccess(data.cast>()); + } else { + setSuccess([]); + } + } catch (e, stackTrace) { + Log.error('[ProjectController.loadProjects] $e\n$stackTrace'); + setError('An unexpected error occurred.'); + } finally { + _isLoading = false; + } + } +} +``` + +The `MagicStateMixin` state transitions and the properties they expose: + +| Method | Resulting state | View properties set | +|--------|-----------------|---------------------| +| `setLoading()` | loading | `isLoading == true` | +| `setSuccess(value)` | success | `isLoading == false`, state value available | +| `setError(message)` | error | `hasErrors == true`, error message stored | +| `clearErrors()` | (no transition) | Clears stored error messages | +| `setEmpty()` | empty | `isEmpty == true` | + + +## Registration Decision Tree + +Use this decision tree to determine how and where to register your controller: + +``` +Does the controller need to be ready before any view renders? +│ +├─ YES → Register eagerly in AppServiceProvider.boot() +│ Magic.findOrPut(ProjectController.new); +│ Views resolve via ProjectController.instance (same singleton) +│ +└─ NO → Use lazy findOrPut() (default pattern) + static ProjectController get instance => + Magic.findOrPut(ProjectController.new); + First view access triggers registration automatically + + Is the controller shared across multiple views? + │ + ├─ YES → findOrPut() — returns the same singleton everywhere + │ + └─ NO → Consider a plain StatefulWidget with local ChangeNotifier + (no IoC involvement needed for purely local state) +``` + +| Scenario | Registration approach | Example | +|----------|-----------------------|---------| +| Global nav data (unread count, current team) | Eager — `AppServiceProvider.boot()` | `NotificationController` pre-warmed on login | +| Feature-specific data (project list, report) | Lazy — `findOrPut()` | `ProjectController` only loaded when the projects screen mounts | +| Single-view transient state (modal open/close) | Local `ChangeNotifier` in `StatefulWidget` | No IoC — no need for `MagicController` | + +> [!TIP] +> Prefer the lazy `findOrPut()` pattern for most feature controllers. Eager registration in `boot()` is only justified when the data must be available immediately after login (e.g., unread notification count shown in the navigation bar). + + +## Connecting State to Views + + +### Pattern A: MagicStatefulView + +Use `MagicStatefulView` for all full-page views. The base class resolves `controller` automatically via `instance` and listens to state changes. + +```dart +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +class ProjectListView extends MagicStatefulView { + const ProjectListView({super.key}); + + @override + State createState() => _ProjectListViewState(); +} + +class _ProjectListViewState + extends MagicStatefulViewState { + @override + void onInit() { + controller.loadProjects(); + } + + @override + Widget build(BuildContext context) { + return controller.renderState( + (projects) => _buildList(projects), + onEmpty: _buildEmpty(), + onError: (message) => _buildError(message), + ); + } + + Widget _buildList(List> projects) { + final isLoading = controller.isLoading; + + return WDiv( + className: 'flex flex-col', + children: [ + MagicStarterPageHeader( + title: 'Projects', + subtitle: 'Manage your projects', + actions: [ + WButton( + onTap: isLoading ? null : controller.loadProjects, + className: 'py-2 px-4 rounded-lg bg-primary text-white text-sm', + child: WText('Refresh'), + ), + ], + ), + WDiv( + className: 'flex flex-col gap-2 p-6', + children: [ + for (final project in projects) + MagicStarterCard( + child: WText( + project['name'] as String, + className: 'text-gray-900 dark:text-white text-sm', + ), + ), + ], + ), + ], + ); + } + + Widget _buildEmpty() { + return WDiv( + className: 'flex flex-col items-center justify-center p-12', + children: [ + WText( + 'No projects yet.', + className: 'text-gray-500 dark:text-gray-400 text-sm', + ), + ], + ); + } + + Widget _buildError(String message) { + return WDiv( + className: 'flex flex-col items-center justify-center p-12', + children: [ + WText(message, className: 'text-sm text-red-500'), + ], + ); + } +} +``` + +The `renderState()` method selects the builder based on the current `MagicStateMixin` state: + +| Argument | When rendered | +|----------|---------------| +| First positional `(T value) => Widget` | Controller called `setSuccess(value)` | +| `onEmpty:` | Controller called `setEmpty()` or state is initial | +| `onError: (String message) => Widget` | Controller called `setError(message)` | + +While the state is `loading`, `renderState()` displays a built-in loading indicator — no `onLoading` builder is needed. + + +### Pattern B: StatefulWidget + Magic.find + +Use `Magic.find()` when you need controller access inside a non-page widget (e.g., a dropdown, a card action, or a sub-widget inside an existing view): + +```dart +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +class ProjectCountBadge extends StatefulWidget { + const ProjectCountBadge({super.key}); + + @override + State createState() => _ProjectCountBadgeState(); +} + +class _ProjectCountBadgeState extends State { + late final ProjectController _controller; + + @override + void initState() { + super.initState(); + _controller = ProjectController.instance; + _controller.addListener(_onStateChanged); + } + + @override + void dispose() { + _controller.removeListener(_onStateChanged); + super.dispose(); + } + + void _onStateChanged() => setState(() {}); + + @override + Widget build(BuildContext context) { + final count = _controller.isSuccess + ? (_controller.state as List).length + : 0; + + return WDiv( + className: 'flex items-center gap-1', + children: [ + WIcon(Icons.folder_outlined, className: 'text-gray-500 text-base'), + WText( + '$count Projects', + className: 'text-sm text-gray-700 dark:text-gray-300', + ), + ], + ); + } +} +``` + +> [!NOTE] +> `ProjectController.instance` calls `findOrPut()` under the hood — it registers the controller on demand if it is not already in the container. Always prefer the static `instance` accessor over raw `Magic.find()`, which returns `null` when the controller has not been registered yet. + + +## Testing + +Tests follow the same pattern as magic_starter's own controller tests: reset the IoC container in `setUp`, bind a `MockNetworkDriver`, and assert state transitions. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +// --------------------------------------------------------------------------- +// Mock +// --------------------------------------------------------------------------- + +class MockNetworkDriver implements NetworkDriver { + MagicResponse? nextResponse; + + String? lastMethod; + String? lastUrl; + dynamic lastData; + + void mockResponse({required int statusCode, dynamic data}) { + nextResponse = MagicResponse( + data: data ?? {}, + statusCode: statusCode, + ); + } + + MagicResponse _respond(String method, String url, {dynamic data}) { + lastMethod = method; + lastUrl = url; + lastData = data; + return nextResponse ?? MagicResponse(data: {}, statusCode: 500); + } + + @override + void addInterceptor(MagicNetworkInterceptor interceptor) {} + + @override + Future get( + String url, { + Map? query, + Map? headers, + }) async => + _respond('GET', url); + + @override + Future post( + String url, { + dynamic data, + Map? headers, + }) async => + _respond('POST', url, data: data); + + @override + Future put( + String url, { + dynamic data, + Map? headers, + }) async => + _respond('PUT', url, data: data); + + @override + Future delete( + String url, { + Map? headers, + }) async => + _respond('DELETE', url); + + @override + Future index( + String resource, { + Map? filters, + Map? headers, + }) async => + _respond('INDEX', resource); + + @override + Future show( + String resource, + String id, { + Map? headers, + }) async => + _respond('SHOW', '$resource/$id'); + + @override + Future store( + String resource, + Map data, { + Map? headers, + }) async => + _respond('STORE', resource, data: data); + + @override + Future update( + String resource, + String id, + Map data, { + Map? headers, + }) async => + _respond('UPDATE', '$resource/$id', data: data); + + @override + Future destroy( + String resource, + String id, { + Map? headers, + }) async => + _respond('DESTROY', '$resource/$id'); + + @override + Future upload( + String url, { + required Map data, + required Map files, + Map? headers, + }) async => + _respond('UPLOAD', url, data: data); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('ProjectController', () { + late MockNetworkDriver mockDriver; + late ProjectController controller; + + setUp(() { + MagicApp.reset(); + Magic.flush(); + + Magic.singleton('network', () => MockNetworkDriver()); + Magic.singleton('log', () => LogManager()); + Config.set('logging', { + 'default': 'console', + 'channels': { + 'console': {'driver': 'console', 'level': 'debug'}, + }, + }); + + controller = ProjectController(); + mockDriver = Magic.make('network') as MockNetworkDriver; + }); + + tearDown(() { + controller.dispose(); + Auth.manager.forgetGuards(); + }); + + test('loadProjects success — state transitions to success', () async { + mockDriver.mockResponse( + statusCode: 200, + data: { + 'data': [ + {'id': 1, 'name': 'Alpha'}, + {'id': 2, 'name': 'Beta'}, + ], + }, + ); + + await controller.loadProjects(); + + expect(mockDriver.lastMethod, equals('GET')); + expect(mockDriver.lastUrl, equals('/api/projects')); + }); + + test('loadProjects error (500) — state transitions to error', () async { + mockDriver.mockResponse(statusCode: 500); + + await controller.loadProjects(); + + expect(controller.hasErrors, isTrue); + }); + + test('loadProjects is not re-entrant', () async { + mockDriver.mockResponse( + statusCode: 200, + data: {'data': []}, + ); + + final first = controller.loadProjects(); + final second = controller.loadProjects(); + await Future.wait([first, second]); + + // Only one GET should have been issued. + expect(mockDriver.lastMethod, equals('GET')); + }); + }); +} +``` + + +## Complete Example + +End-to-end implementation of a `ProjectList` feature — state class, view, and test. + +### State class (`lib/src/http/controllers/project_controller.dart`) + +```dart +import 'package:magic/magic.dart'; + +class ProjectController extends MagicController + with MagicStateMixin>> { + // ------------------------------------------------------------------------- + // Singleton + // ------------------------------------------------------------------------- + + static ProjectController get instance => + Magic.findOrPut(ProjectController.new); + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + bool _isLoading = false; + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + /// Fetch all projects from the API. + Future loadProjects() async { + if (_isLoading) return; + _isLoading = true; + setLoading(); + + try { + final response = await Http.get('/api/projects'); + + if (!response.successful) { + setError('Failed to load projects.'); + return; + } + + final data = response.data['data']; + setSuccess( + data is List ? data.cast>() : [], + ); + } catch (e, stackTrace) { + Log.error('[ProjectController.loadProjects] $e\n$stackTrace'); + setError('An unexpected error occurred.'); + } finally { + _isLoading = false; + } + } +} +``` + +### View (`lib/src/ui/views/projects/project_list_view.dart`) + +```dart +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +class ProjectListView extends MagicStatefulView { + const ProjectListView({super.key}); + + @override + State createState() => _ProjectListViewState(); +} + +class _ProjectListViewState + extends MagicStatefulViewState { + @override + void onInit() { + controller.loadProjects(); + } + + @override + Widget build(BuildContext context) { + return controller.renderState( + (projects) => _buildList(projects), + onEmpty: _buildEmpty(), + onError: (message) => _buildError(message), + ); + } + + // ------------------------------------------------------------------------- + // Private builders + // ------------------------------------------------------------------------- + + Widget _buildList(List> projects) { + final isLoading = controller.isLoading; + + return WDiv( + className: 'flex flex-col', + children: [ + MagicStarterPageHeader( + title: 'Projects', + subtitle: '${projects.length} total', + actions: [ + WButton( + onTap: isLoading ? null : controller.loadProjects, + className: 'py-2 px-4 rounded-lg bg-primary text-white text-sm', + child: WText('Refresh'), + ), + ], + ), + WDiv( + className: 'flex flex-col gap-3 p-6', + children: [ + for (final project in projects) + MagicStarterCard( + child: WDiv( + className: 'flex flex-col gap-1', + children: [ + WText( + project['name'] as String, + className: 'text-sm font-medium text-gray-900 dark:text-white', + ), + if (project['description'] != null) + WText( + project['description'] as String, + className: 'text-xs text-gray-500 dark:text-gray-400', + ), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildEmpty() { + return WDiv( + className: 'flex flex-col items-center justify-center p-12', + children: [ + WIcon(Icons.folder_outlined, className: 'text-4xl text-gray-300 dark:text-gray-600'), + WSpacer(className: 'h-4'), + WText( + 'No projects yet.', + className: 'text-sm text-gray-500 dark:text-gray-400', + ), + ], + ); + } + + Widget _buildError(String message) { + return WDiv( + className: 'flex flex-col items-center justify-center p-12', + children: [ + WText(message, className: 'text-sm text-red-500 dark:text-red-400'), + ], + ); + } +} +``` + +### Test (`test/http/controllers/project_controller_test.dart`) + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +class MockNetworkDriver implements NetworkDriver { + MagicResponse? nextResponse; + String? lastMethod; + String? lastUrl; + dynamic lastData; + + void mockResponse({required int statusCode, dynamic data}) { + nextResponse = MagicResponse(data: data ?? {}, statusCode: statusCode); + } + + MagicResponse _respond(String method, String url, {dynamic data}) { + lastMethod = method; + lastUrl = url; + lastData = data; + return nextResponse ?? MagicResponse(data: {}, statusCode: 500); + } + + @override + void addInterceptor(MagicNetworkInterceptor interceptor) {} + + @override + Future get( + String url, { + Map? query, + Map? headers, + }) async => + _respond('GET', url); + + @override + Future post( + String url, { + dynamic data, + Map? headers, + }) async => + _respond('POST', url, data: data); + + @override + Future put( + String url, { + dynamic data, + Map? headers, + }) async => + _respond('PUT', url, data: data); + + @override + Future delete( + String url, { + Map? headers, + }) async => + _respond('DELETE', url); + + @override + Future index( + String resource, { + Map? filters, + Map? headers, + }) async => + _respond('INDEX', resource); + + @override + Future show( + String resource, + String id, { + Map? headers, + }) async => + _respond('SHOW', '$resource/$id'); + + @override + Future store( + String resource, + Map data, { + Map? headers, + }) async => + _respond('STORE', resource, data: data); + + @override + Future update( + String resource, + String id, + Map data, { + Map? headers, + }) async => + _respond('UPDATE', '$resource/$id', data: data); + + @override + Future destroy( + String resource, + String id, { + Map? headers, + }) async => + _respond('DESTROY', '$resource/$id'); + + @override + Future upload( + String url, { + required Map data, + required Map files, + Map? headers, + }) async => + _respond('UPLOAD', url, data: data); +} + +void main() { + group('ProjectController', () { + late MockNetworkDriver mockDriver; + late ProjectController controller; + + setUp(() { + MagicApp.reset(); + Magic.flush(); + + Magic.singleton('network', () => MockNetworkDriver()); + Magic.singleton('log', () => LogManager()); + Config.set('logging', { + 'default': 'console', + 'channels': { + 'console': {'driver': 'console', 'level': 'debug'}, + }, + }); + + controller = ProjectController(); + mockDriver = Magic.make('network') as MockNetworkDriver; + }); + + tearDown(() { + controller.dispose(); + Auth.manager.forgetGuards(); + }); + + test('loadProjects success — hits correct endpoint', () async { + mockDriver.mockResponse( + statusCode: 200, + data: { + 'data': [ + {'id': 1, 'name': 'Alpha'}, + {'id': 2, 'name': 'Beta'}, + ], + }, + ); + + await controller.loadProjects(); + + expect(mockDriver.lastMethod, equals('GET')); + expect(mockDriver.lastUrl, equals('/api/projects')); + expect(controller.hasErrors, isFalse); + }); + + test('loadProjects 500 — transitions to error state', () async { + mockDriver.mockResponse(statusCode: 500); + + await controller.loadProjects(); + + expect(controller.hasErrors, isTrue); + }); + + test('loadProjects re-entrant guard — only one GET issued', () async { + mockDriver.mockResponse( + statusCode: 200, + data: {'data': []}, + ); + + final first = controller.loadProjects(); + final second = controller.loadProjects(); + await Future.wait([first, second]); + + expect(mockDriver.lastUrl, equals('/api/projects')); + }); + }); +} +``` + + +## Related + +- [Controllers](https://magic.fluttersdk.com/packages/starter/basics/controllers) — full HTTP controller reference with error handling patterns +- [Service Providers](https://magic.fluttersdk.com/packages/starter/architecture/service-provider) — two-phase bootstrap and eager controller registration in `boot()` +- [Views and Layouts](https://magic.fluttersdk.com/packages/starter/basics/views-and-layouts) — `MagicStatefulView` lifecycle, `renderState()`, and form handling From b985ba595ea3a9ced771e6625913de9023ff6e3f Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sat, 4 Apr 2026 22:53:09 +0300 Subject: [PATCH 2/2] fix: address PR #18 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - controllers.md: clarify MagicStateMixin is most common, not universal (Newsletter/OTP use untyped); qualify NavigatesRoutes usage; note NotificationsListView as MagicStatefulView exception; fix views-and-layouts Related URL (architecture → basics) - state-management.md: fix "five-state" → "four-state"; fix _isSubmitting → _isLoading consistency; fix flutter/widgets.dart → flutter/material.dart for Icons.* usage; rename Pattern B section to "Manual Access" matching actual content - README.md: use hosted doc URLs instead of repo-relative paths - stub: replace local doc path with generic package docs reference --- README.md | 4 ++-- assets/stubs/install/app_service_provider.stub | 3 ++- doc/architecture/controllers.md | 8 ++++---- doc/guides/state-management.md | 18 +++++++++--------- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bac390b..c99b02f 100644 --- a/README.md +++ b/README.md @@ -379,8 +379,8 @@ App launch → MagicStarterServiceProvider.boot() | [Manager](https://magic.fluttersdk.com/packages/starter/architecture/manager) | Singleton manager and customization hooks | | [Service Provider](https://magic.fluttersdk.com/packages/starter/architecture/service-provider) | Bootstrap lifecycle, Gate abilities, IoC bindings | | [View Registry](https://magic.fluttersdk.com/packages/starter/architecture/view-registry) | String-keyed builders and host app overrides | -| [Controllers & State Registration](doc/architecture/controllers.md) | Recommended state/controller registration pattern for consumer apps | -| [State Management Guide](doc/guides/state-management.md) | State management best practices and patterns | +| [Controllers & State Registration](https://magic.fluttersdk.com/packages/starter/architecture/controllers) | Recommended state/controller registration pattern for consumer apps | +| [State Management Guide](https://magic.fluttersdk.com/packages/starter/guides/state-management) | State management best practices and patterns | --- diff --git a/assets/stubs/install/app_service_provider.stub b/assets/stubs/install/app_service_provider.stub index 4dd3ab6..ce53979 100644 --- a/assets/stubs/install/app_service_provider.stub +++ b/assets/stubs/install/app_service_provider.stub @@ -91,6 +91,7 @@ class AppServiceProvider extends ServiceProvider { // // Magic.findOrPut(DashboardState.new); // - // See: doc/architecture/controllers.md + // See the Magic Starter package documentation/repository for the + // controller architecture guide. } } diff --git a/doc/architecture/controllers.md b/doc/architecture/controllers.md index cbdc53e..81f4b3b 100644 --- a/doc/architecture/controllers.md +++ b/doc/architecture/controllers.md @@ -16,7 +16,7 @@ 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 seven controllers covering auth, profile, teams, notifications, OTP, guest auth, and newsletter flows. All seven follow the same structural pattern: lazy singleton via `Magic.findOrPut`, `MagicController + MagicStateMixin` for state, and `NavigatesRoutes` for navigation. Consumer apps building custom features on top of magic_starter should follow the same conventions for consistency. +The plugin ships seven controllers covering auth, profile, teams, notifications, OTP, guest auth, and newsletter flows. All seven 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 (notification, team, newsletter) manage state without navigation concerns. Consumer apps building custom features on top of magic_starter should follow the same conventions for consistency. ## Lazy Singleton Pattern @@ -69,7 +69,7 @@ The mixin provides these state-transition methods and read properties: | `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. All plugin controllers use `MagicStateMixin` because they only need to signal success or failure, not carry data. When a controller needs to expose structured data it uses `ValueNotifier` fields alongside `MagicStateMixin`. +The type parameter `T` is the success payload type. `MagicStateMixin` 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` fields alongside `MagicStateMixin` when that produces a cleaner API. A standard async action looks like this: @@ -259,7 +259,7 @@ Consumer apps have two options for wiring a controller to a view. ### Option A — MagicStatefulView (auto-listens) -Extend `MagicStatefulView` to get the controller resolved and subscribed automatically. This matches the pattern used by all eleven plugin views and is the recommended choice. +Extend `MagicStatefulView` to get the controller resolved and subscribed automatically. This matches the pattern used by most plugin views and is the recommended choice; `MagicStarterNotificationsListView` is the current exception — it is implemented as a plain `StatefulWidget` that manages state locally. ```dart class ProjectListView extends MagicStatefulView { @@ -462,6 +462,6 @@ Key rules: - [MagicStarterServiceProvider](https://magic.fluttersdk.com/packages/starter/architecture/service-provider) — bootstrap entry point, IoC bindings, and Gate ability registration - [MagicStarterManager](https://magic.fluttersdk.com/packages/starter/architecture/manager) — central singleton holding all customization registrations -- [Views and Layouts](https://magic.fluttersdk.com/packages/starter/architecture/views-and-layouts) — MagicStatefulView lifecycle and Wind UI rendering conventions +- [Views and Layouts](https://magic.fluttersdk.com/packages/starter/basics/views-and-layouts) — MagicStatefulView lifecycle and Wind UI rendering conventions - [Magic Framework — IoC Container](https://magic.fluttersdk.com/getting-started/ioc-container) — singleton, factory, and findOrPut reference - [Magic Framework — Service Providers](https://magic.fluttersdk.com/getting-started/service-providers) — two-phase bootstrap lifecycle diff --git a/doc/guides/state-management.md b/doc/guides/state-management.md index ccd7308..780cd93 100644 --- a/doc/guides/state-management.md +++ b/doc/guides/state-management.md @@ -8,7 +8,7 @@ - [Registration Decision Tree](#registration-decision-tree) - [Connecting State to Views](#connecting-state-to-views) - [Pattern A: MagicStatefulView](#pattern-a-magicstatefulview) - - [Pattern B: StatefulWidget + Magic.find](#pattern-b-statefulwidget--magicfind) + - [Pattern B: StatefulWidget + Manual Access](#pattern-b-statefulwidget--manual-access) - [Testing](#testing) - [Complete Example](#complete-example) - [Related](#related) @@ -19,7 +19,7 @@ Magic Starter uses a three-part state management model built on the Magic Framework: 1. **`MagicController`** — base class that provides lifecycle management and ties into the IoC container. -2. **`MagicStateMixin`** — mixin that adds a five-state machine (`loading`, `success`, `error`, `empty`) with `setLoading()`, `setSuccess()`, `setError()`, `clearErrors()`, and `renderState()` helpers. +2. **`MagicStateMixin`** — mixin that adds a four-state machine (`loading`, `success`, `error`, `empty`) with `setLoading()`, `setSuccess()`, `setError()`, `clearErrors()`, and `renderState()` helpers. 3. **IoC container** — `Magic.findOrPut()` returns the existing singleton for a class or registers and returns a new one. Views never construct controllers directly. Consumer app controllers follow the exact same pattern as magic_starter's own controllers. You extend `MagicController`, mix in `MagicStateMixin`, and expose a `static get instance` accessor backed by `Magic.findOrPut()`. Views bind to that accessor and call `renderState()` to drive conditional rendering. @@ -66,7 +66,7 @@ class ProjectController extends MagicController Each async action follows the same lifecycle: -1. Guard against re-entrant calls with a `_isSubmitting` flag. +1. Guard against re-entrant calls with a `_isLoading` flag. 2. Call `setLoading()` to transition the state machine. 3. Perform the HTTP call via `Http.get()` / `Http.post()` / etc. 4. Call `setSuccess(value)` on the happy path or `setError(message)` on failure. @@ -175,7 +175,7 @@ Does the controller need to be ready before any view renders? Use `MagicStatefulView` for all full-page views. The base class resolves `controller` automatically via `instance` and listens to state changes. ```dart -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:magic/magic.dart'; import 'package:magic_starter/magic_starter.dart'; @@ -268,13 +268,13 @@ The `renderState()` method selects the builder based on the current `MagicStateM While the state is `loading`, `renderState()` displays a built-in loading indicator — no `onLoading` builder is needed. - -### Pattern B: StatefulWidget + Magic.find + +### Pattern B: StatefulWidget + Manual Access -Use `Magic.find()` when you need controller access inside a non-page widget (e.g., a dropdown, a card action, or a sub-widget inside an existing view): +Use a plain `StatefulWidget` with `ProjectController.instance` when you need controller access inside a non-page widget (e.g., a dropdown, a card action, or a sub-widget inside an existing view): ```dart -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:magic/magic.dart'; import 'package:magic_starter/magic_starter.dart'; @@ -579,7 +579,7 @@ class ProjectController extends MagicController ### View (`lib/src/ui/views/projects/project_list_view.dart`) ```dart -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:magic/magic.dart'; import 'package:magic_starter/magic_starter.dart';