# Magic documentation > Magic brings Laravel's facades, Eloquent-style models, service providers and routing to Flutter, so Laravel developers feel at home. # Installation Add the `magic` package, scaffold the app with `dart run magic:dowel magic:install`, and call `Magic.init()` to get the IoC container, facades, Eloquent-style models, routing and auth. - [Meet Magic](#meet-magic) - [Why Magic?](#why-magic) - [Requirements](#requirements) - [Installing Magic](#installing-magic) - [Bootstrapping Your Application](#bootstrapping-your-application) - [Wind UI Plugin](#wind-ui-plugin) - [Upgrading from 0.0.x](#upgrading-from-0-0-x) - [Related Documentation](#related-documentation) ## Meet Magic Magic is the full-stack framework for Flutter: Eloquent-style models, auth, routing, realtime and payments, wired and ready on every device. The core package provides the container, facades, models, auth, routing, validation, forms, events, cache, localisation and test fakes. Realtime, payments, SQLite, notifications, deep links, social sign-in, encryption, file picking and the starter kit are official plugin packages, so `flutter pub add magic` alone does not include them: see [Installing Magic](#installing-magic) for how to add one. Core provides: - **Eloquent-style models**: active-record models over your REST API (and over local storage with a plugin). - **Expressive routing**: the app's navigation defined with fluent route definitions. - **Service container**: dependency injection and service management. - **Facades**: static-style access to core services. - **CLI**: scaffolding, a `dowel` plugin, for controllers, models, views, actions and more. - **Wind UI**: Tailwind-style utility classes for Flutter widgets. ## Why Magic? Magic is progressive: start with the container, routing and a controller, and adopt the rest as the app grows. ### Familiar to Laravel Developers Magic's structure and vocabulary follow Laravel, so the names carry over: ```dart // Routing MagicRoute.page('/users', () => UserController.instance.index()); MagicRoute.page('/users/:id', (String id) => UserController.instance.show(id)); // Eloquent final users = await User.all(); final user = await User.find(1); if (user != null) await user.delete(); // HTTP final response = await Http.get('/api/users'); if (response.successful) { // Handle data } ``` ### No BuildContext Needed Dialogs and navigation work from controllers, services and plain Dart classes: ```dart // Show dialogs from anywhere Magic.success('Done!', 'Profile updated successfully'); Magic.dialog(ConfirmationDialog()); Magic.loading(); // Navigate without context MagicRoute.to('/dashboard'); MagicRoute.back(); ``` ### Wind UI Build UIs with utility-first classes instead of nested widgets: ```dart WDiv( className: 'flex flex-col p-4 bg-white shadow-lg rounded-xl', children: [ WText('Hello World', className: 'text-xl font-bold text-primary'), WButton( onTap: () => MagicRoute.to('/next'), className: 'bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg', child: WText('Get Started', className: 'text-white'), ), ], ) ``` ## Requirements Magic requires: - **Dart SDK**: 3.13.0 or higher - **Flutter**: 3.47.0 or higher ## Installing Magic ### 1. Add the Package Add `magic` to your Flutter project: ```bash flutter pub add magic ``` This pulls in Magic and its dependencies, including **Wind UI** (`wind_kit`) and the CLI. Layers beyond the core are separate plugin packages. Add one with `flutter pub add ` then `dart run magic:dowel plugin:install `: | Package | Provides | |:--------|:---------| | [`magic_local_storage`](https://anilcancakir.com/magic/docs/local_storage/getting-started/installation) | SQLite, query builder, migrations, seeders, factories, sync | | [`magic_encryption`](https://anilcancakir.com/magic/docs/encryption/getting-started/installation) | AES encryption (`Crypt`) | | [`magic_picker`](https://anilcancakir.com/magic/docs/picker/getting-started/installation) | File, image and video picking, share sheet | | [`magic_socket`](https://anilcancakir.com/magic/docs/socket/getting-started/installation) | Real-time broadcasting (`Echo`) | ### 2. Scaffold Your Application The Magic CLI ships as a `dowel` executable bundled with the `magic` package (declared in its `pubspec.yaml` `executables:`). Run it from any project that depends on magic: ```bash dart run magic:dowel magic:install ``` No global activation or package-name substitution is needed: `dart run magic:dowel` resolves magic's bundled executable directly. The installer adds `wind_kit`, `material_ui` and `dowel` to your `pubspec.yaml` and scaffolds `bin/dispatcher.dart` (plus the fast wrapper `bin/dowel`) through dowel's `install`. This command creates everything you need: - `lib/config/`: Configuration files (app, auth, cache, network, logging, routing, view) - `lib/app/`: `providers/app_service_provider.dart`, `providers/route_service_provider.dart` and `kernel.dart` (controllers, models and the rest come from the `make:*` generators) - `lib/routes/`: Route definitions - `lib/resources/views/`: UI view classes - `lib/main.dart`: Bootstrapped entry point with `Magic.init()` - `.env` / `.env.example`: Environment configuration You can exclude features you don't need: ```bash dart run magic:dowel magic:install --without-auth --without-cache ``` Available flags: `--without-auth`, `--without-network`, `--without-cache`, `--without-localization`, `--without-logging`, `--preserve` (inject the Magic bootstrap into an existing `lib/main.dart` instead of overwriting it; it rejects a synchronous `main()`, so make the entry point `async` first) and `--with-devtools` (wire `magic_devtools`, tapir and crumbs into `lib/main.dart` under `!kReleaseMode`). ## Bootstrapping Your Application If you used `dart run magic:dowel magic:install`, your application is already bootstrapped. The install command generates a ready-to-run `main.dart` and all configuration files. Here's what was created: ### Generated Entry Point The generated `lib/main.dart` initialises Magic and runs your app: ```dart import 'package:material_ui/material_ui.dart'; import 'package:magic/magic.dart'; import 'config/app.dart'; import 'config/view.dart'; import 'config/auth.dart'; import 'config/network.dart'; import 'config/cache.dart'; import 'config/logging.dart'; import 'config/routing.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Magic.init( configFactories: [ () => appConfig, () => viewConfig, () => authConfig, () => networkConfig, () => cacheConfig, () => loggingConfig, () => routingConfig, ], ); runApp( MagicApplication(title: 'My App'), ); } ``` The `Magic.init()` method accepts: | Parameter | Type | Description | |:---|:---|:---| | `envFileName` | `String` | Environment file name (default: `.env`) | | `configFactories` | `List` | Configuration factory functions | | `configs` | `List` | Direct configuration maps | | `providers` | `List` | Additional service providers | ### Manual Setup (Without CLI) If you prefer to set up manually without the CLI, create a `lib/config/app.dart`: ```dart import 'package:magic/magic.dart'; final appConfig = { 'app': { 'name': Env.get('APP_NAME', 'Magic App'), 'debug': Env.get('APP_DEBUG', true), 'url': Env.get('APP_URL', 'http://localhost'), 'providers': [ (app) => RouteServiceProvider(app), (app) => AppServiceProvider(app), ], } }; ``` Then initialise in `lib/main.dart`: ```dart import 'package:material_ui/material_ui.dart'; import 'package:magic/magic.dart'; import 'config/app.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Magic.init( configFactories: [() => appConfig], ); runApp(MagicApplication(title: 'My App')); } ``` ### The Application Widget `MagicApplication` is your root widget. It handles routing, themes, localisation, and overlays automatically: ```dart runApp( MagicApplication( title: 'My App', debugShowCheckedModeBanner: false, ), ); ``` To keep a widget on screen across every page, such as a floating player, pass a `builder`; see [A Layer Above Every Page](https://anilcancakir.com/magic/docs/basics/routing.md#a-layer-above-every-page). ## Wind UI Plugin Magic includes **Wind UI** (`wind_kit`), a utility-first styling engine with Tailwind-style syntax. Instead of nesting widgets, you compose UIs with className strings like `flex flex-col p-4 bg-white rounded-xl shadow-md`. > [!TIP] > For the complete widget reference, utility classes, and advanced patterns, see the [Wind UI Documentation](https://anilcancakir.com/wind_kit/docs/getting-started/installation). ## Upgrading from 0.0.x The CLI, the styling package, the SDK floor and four layers changed in 1.0.0. See the changelog for the full list; the steps an app needs are: 1. Raise the SDK floor to Dart 3.13 and Flutter 3.47, then swap the packages: `dart pub remove fluttersdk_artisan fluttersdk_wind` and `dart pub add dowel wind_kit material_ui`. Run `flutter pub add magic` so the `magic` constraint moves to the 1.x line (a `^0.0.x` constraint does not admit it). 2. `fluttersdk_wind` is now `wind_kit`: change `package:fluttersdk_wind/fluttersdk_wind.dart` to `package:wind_kit/wind_kit.dart`. `MaterialApp`, `ThemeData` and `Colors` come from `package:material_ui/material_ui.dart`, because wind_kit builds on it and the two sets of types do not mix: run `dart fix --apply --code=deprecated_member_use` to swap the imports. 3. Move the project state from `.artisan/` to `.dowel/`, so the install records and their file hashes survive. Delete `.dowel/plugins.json` and the records under `.dowel/installed/` that belong to renamed packages (`fluttersdk_*`). Delete the generated `bin/dispatcher.dart`, `bin/fsa`, a leftover `bin/artisan.dart`, `lib/app/_plugins.g.dart` and `lib/app/commands/_index.g.dart`. In your own commands and providers replace `package:fluttersdk_artisan/artisan.dart` with `package:dowel/dowel.dart` and `Artisan*` classes with `Dowel*`. 4. Run `dart run dowel install`, which scaffolds `bin/dispatcher.dart` and `bin/dowel`, then `dart run dowel commands:refresh`. Register magic and each plugin again so they land in `.dowel/plugins.json`: `dart run dowel plugin:install magic`, then `dart run dowel plugin:install `. A file a renamed plugin published earlier is reported as an `unmanaged-file` conflict: review it, then re-run with `--force`. 5. From then on run the CLI as `dart run magic:dowel `. It hands over to your `bin/dispatcher.dart`, so plugin commands resolve. If `dart run dowel install` ran before `magic` was in the project, the dispatcher has no magic provider: run `dart run dowel plugin:install magic`. 6. Add the plugin for each layer your app used that left core, with `flutter pub add ` then `dart run magic:dowel plugin:install `: `Crypt` is in `magic_encryption`; `Pick` and the `MagicFile.toXFile()` and `XFile.toMagicFile()` conversions are in `magic_picker`; `Echo`, the broadcasting manager and `ListensToBroadcasts` are in `magic_socket`; `DB`, `Schema`, `Model.query()`, migrations, seeders, factories, `SyncFeed` and `SyncLedger` are in `magic_local_storage`. An app installed on 0.0.41 has `lib/config/database.dart` and `lib/config/broadcasting.dart`: back up any customised settings in them and move those settings into the matching plugin configuration, then delete them before `plugin:install magic_local_storage` or `plugin:install magic_socket`, because the plugins export `databaseConfig` and `broadcastingConfig` and the import becomes ambiguous otherwise. 7. Replace what no longer exists in core: `Magic.seed` becomes `Seeding.run(List)` from `magic_local_storage`; `make:controller --broadcasts` is gone, so mix `ListensToBroadcasts` into the controller by hand; `--without-database` and `--without-broadcasting` are gone from `magic:install`. A model with `useLocal => true` throws a `StateError` naming `magic_local_storage` until that plugin is installed, and `Storage.download` on IO throws `UnsupportedError` until `magic_picker` is installed. The dowel changelog for 0.0.21 carries the full recipe, including the `.mcp.json` cleanup. ## Related Documentation - [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md): how Magic's configuration system works. - [Directory Structure](https://anilcancakir.com/magic/docs/getting-started/directory-structure.md): the recommended project layout. - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md): registering and booting services. - [Routing](https://anilcancakir.com/magic/docs/basics/routing.md): defining your application's navigation. - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md): handling user interactions and business logic. - [Eloquent ORM](https://anilcancakir.com/magic/docs/eloquent/getting-started.md): working with your models. - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `magic:install` and the `make:*` generators. --- # Configuration Magic's configuration system lets you load, read, write, and inspect application settings at runtime using the `Config` facade with dot-notation keys. - [Introduction](#introduction) - [Environment Configuration](#environment-configuration) - [Environment Variable Types](#environment-variable-types) - [Determining the Current Environment](#determining-the-current-environment) - [Accessing Configuration Values](#accessing-configuration-values) - [Retrieving All Values](#retrieving-all-values) - [Array Configuration](#array-configuration) - [Removing Configuration](#removing-configuration) - [Repository Access](#repository-access) - [Configuration Files](#configuration-files) - [Theme Persistence](#theme-persistence) - [Configuration Caching](#configuration-caching) - [Guarding Against a Blank Value](#guarding-against-a-blank-value) - [Related Documentation](#related-documentation) ## Introduction All of the configuration files for Magic are stored in the `lib/config` directory. Each option is documented, so feel free to look through the files and get familiar with the options available to you. Magic uses a modular configuration system. The framework loads sensible defaults automatically for all services. You only need to create config files for values you wish to override. These configuration files allow you to configure things like your API connection information, your cache stores, as well as various other core configuration values such as your application URL and log channels. ## Environment Configuration It is often helpful to have different configuration values based on the environment the application is running in. For example, you may wish to use a different API endpoint locally than you do on your production server. Magic uses `.env` files located in your project root. To set up environment-based configuration: ### 1. Create Your Environment File Create a `.env` file in your project root: ```bash APP_NAME="My Magic App" APP_ENV=local APP_DEBUG=true APP_URL=http://localhost APP_KEY=base64:your-generated-key API_BASE_URL=https://api.example.com ``` ### 2. Register as Flutter Asset Add the `.env` file to your `pubspec.yaml`: ```yaml flutter: assets: - .env ``` ### 3. Access in Configuration Use the `env()` helper or `Env.get()` in your configuration files: ```dart Map get appConfig => { 'app': { 'name': env('APP_NAME', 'Magic App'), 'debug': env('APP_DEBUG', false), 'url': env('APP_URL', 'http://localhost'), } }; ``` > [!NOTE] > The `.env` file is a Flutter asset, so its values are bundled at build time. `Magic.init()` reads only that file: process environment variables do not override it at runtime. Ship a different value by building with a different `.env`, or pass `envFileName` to `Magic.init()`. > [!NOTE] > `APP_KEY` is not read by core. The `magic_encryption` plugin reads it as `app.key`, and `dart run magic:dowel key:generate` (from that plugin) writes it. See [magic_encryption](https://anilcancakir.com/magic/docs/encryption/getting-started/installation). ### Environment Variable Types All variables in your `.env` files are read as strings. The type argument `T` alone picks the conversion, whether or not you pass a default: `env()` and `Env.get()` convert only when `T` is exactly `bool`, `int` or `double`. When you omit `T`, it is inferred from the default, so `env('APP_DEBUG', false)` reads a `bool` and `env('PORT', 3000)` reads an `int`, while `env('APP_URL')` with no default reads `dynamic` and returns the raw string: | `T` | `.env` value | Result | |:---|:---|:---| | `bool` | `true`, `1` or `yes` (any case) | `true` | | `bool` | any other value, including `false`, `(true)` and `on` | `false` | | `int` | a string `int.tryParse` accepts | the parsed `int` | | `int` | anything else | the default | | `double` | a string `double.tryParse` accepts | the parsed `double` | | `double` | anything else | the default | | `String`, `dynamic`, `Object` | any value, including `null` and `empty` | the raw string (`'null'`, `'empty'`) | | any `T` | key absent, or `.env` not loaded | the default | "The default" is cast to `T`, so a call that can fall back needs a default of that type: `env('PORT')` with `PORT` absent or unparsable throws a `TypeError`, since `null` is not an `int`; write `env('PORT', 3000)`. Any other `T` gets the raw string cast to it, so it has to accept a `String`: `env('PORT', 3000)` and `env('PORT')` throw when `PORT` is present (neither is exactly `int`), and so does `env('DEBUG')`. `Env.getBool(key)` and `Env.getInt(key)` default to `false` and `0`; `Env.getString(key)` defaults to `''`. `KEY=""` and `KEY=''` resolve to `''`, not to the `env()` default: an explicitly present but empty value is not the same as an absent key, matching Laravel's `Env::get`. A blank value reaching a browser tab title or a link as `Monitor | ""` or as a path with no origin is the failure mode this distinguishes; see [Guarding Against a Blank Value](#guarding-against-a-blank-value) for the helper that treats blank as absent instead. ### Determining the Current Environment The current application environment is determined via the `APP_ENV` variable. You may access this value via the `Config` facade: ```dart final environment = Config.get('app.env'); if (environment == 'local') { // Enable debug features } ``` ## Accessing Configuration Values You may easily access configuration values using the `Config` facade from anywhere in your application. Configuration values may be accessed using "dot" notation, which includes the name of the file and option you wish to access: ```dart // Get a value with a default fallback final appName = Config.get('app.name', 'Magic App'); // Get a typed value final timeout = Config.get('network.drivers.api.timeout', 5000); // Get a nested configuration final guard = Config.get('auth.defaults.guard'); ``` ### Checking If Configuration Exists ```dart if (Config.has('services.stripe')) { initializeStripe(); } ``` ### Getting Required Values If a configuration value is required and must exist, use `getOrFail`: ```dart final apiKey = Config.getOrFail('services.stripe.secret'); // Throws if not found ``` ### Runtime Configuration You may set configuration values at runtime: ```dart Config.set('app.locale', 'fr'); Config.set('app.timezone', 'Europe/Istanbul'); ``` > [!WARNING] > Runtime configuration values are ephemeral and will not persist across application restarts. ### Merging Configuration You can deep-merge additional configuration at runtime: ```dart Config.merge({ 'app': { 'name': 'My App (staging)', 'debug': false, } }); ``` ## Retrieving All Values Use `Config.all()` to retrieve the entire configuration as a `Map`. This is useful for debugging or passing the full config snapshot to another service: ```dart final everything = Config.all(); print(everything['app']['name']); // 'My App' ``` ## Array Configuration When a configuration value holds a list, you can prepend or append items at runtime without reading and rewriting the whole list. ### Prepending a Value `Config.prepend()` inserts a value at the beginning of an array key: ```dart // services.middleware is currently ['AuthMiddleware', 'LogMiddleware'] Config.prepend('services.middleware', 'TraceMiddleware'); // now: ['TraceMiddleware', 'AuthMiddleware', 'LogMiddleware'] ``` ### Pushing a Value `Config.push()` appends a value to the end of an array key: ```dart Config.push('app.providers', (app) => AnalyticsServiceProvider(app)); ``` ## Removing Configuration ### Forgetting a Key `Config.forget()` removes a single configuration key (and its nested values) at runtime: ```dart Config.forget('services.stripe'); // Config.has('services.stripe') is now false ``` ### Flushing All Configuration `Config.flush()` clears the entire in-memory configuration store. Use with care: any subsequent `Config.get()` call will return `null` (or the provided default) until configuration is reloaded: ```dart Config.flush(); ``` > [!WARNING] > `Config.flush()` is destructive. It is intended for test isolation and controlled restarts, not routine use in production code. ## Repository Access For advanced use cases you can obtain the underlying `ConfigRepository` instance directly via the `repository` getter. This is useful when passing the repository to a service that needs direct access without going through the facade: ```dart final repo = Config.repository; final value = repo.get('app.name'); ``` > [!NOTE] > Prefer the `Config` facade methods for everyday access. Direct repository use is an escape hatch for integration scenarios. ## Configuration Files Magic recommends organising your configuration into logical files: | File | Purpose | Config Key | |:---|:---|:---| | `app.dart` | General settings, service providers | `app` | | `auth.dart` | Authentication guards and behaviour | `auth` | | `network.dart` | API endpoints, timeouts, headers | `network` | | `cache.dart` | Cache stores and TTL settings | `cache` | | `logging.dart` | Log levels and channels | `logging` | | `localization.dart` | Supported locales and defaults | `localization` | ### Example: app.dart ```dart import 'package:magic/magic.dart'; Map get appConfig => { 'app': { 'name': env('APP_NAME', 'My App'), 'env': env('APP_ENV', 'production'), 'debug': env('APP_DEBUG', false), 'url': env('APP_URL', 'http://localhost'), 'key': env('APP_KEY'), 'providers': [ (app) => CacheServiceProvider(app), (app) => RouteServiceProvider(app), (app) => NetworkServiceProvider(app), (app) => AuthServiceProvider(app), ], }, }; ``` ### Example: network.dart ```dart Map get networkConfig => { 'network': { 'default': 'api', 'drivers': { 'api': { 'base_url': env('API_BASE_URL', 'http://localhost:8000/api/v1'), 'timeout': 10000, 'headers': { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }, }, }, }; ``` ## Theme Persistence MagicApplication automatically persists the user's theme preference (dark or light) to secure storage via the Vault. This allows the application to remember the user's theme choice across restarts. ### How It Works When the application starts, MagicApplication: 1. Loads the saved theme preference from Vault (stored under the `theme_mode` key) 2. Restores the saved preference (`'dark'` or `'light'`) if available 3. Falls back to the system brightness if no preference was previously saved 4. Gracefully defaults to system theme if `VaultServiceProvider` is not registered When the user manually toggles the theme, the preference is automatically persisted to Vault. The `onThemeChanged` callback fires during theme toggles, allowing you to add extra side-effects like analytics tracking. ### Example Usage ```dart void main() async { await Magic.init( configFactories: [ () => appConfig, () => authConfig, ], ); runApp( MagicApplication( themeMode: ThemeMode.system, // Follows system by default, or restores saved preference onThemeChanged: (brightness) { // Called when user manually toggles theme // Preference is auto-saved to Vault; use this for extra side-effects analytics.track('theme_changed', { 'mode': brightness.name, }); }, ), ); } ``` ### Storage Details - **Storage Key**: `theme_mode` - **Storage Values**: `'dark'` or `'light'` - **Provider**: Requires `VaultServiceProvider` to be registered in your app config - **Fallback**: If Vault is unavailable, the application gracefully uses system brightness > [!NOTE] > Theme persistence is automatic and requires no additional setup beyond including `VaultServiceProvider` in your application providers. The `onThemeChanged` callback is optional and is useful for tracking theme changes or triggering related UI updates. > [!WARNING] > If `VaultServiceProvider` is not registered, theme persistence will not work, and the application will always follow system brightness on startup. ## Configuration Caching Configuration is loaded during `Magic.init()` and cached in memory for the duration of the application lifecycle. To update configuration: 1. Modify your config files 2. Perform a hot restart (or restart the application) so `Magic.init()` runs again > [!NOTE] > `Magic.reload()` and hot reload do not reload configuration. `Magic.reload()` only rebuilds the widget tree through `MagicAppWidget.restart()`, and hot reload does not run initialisation again, so the cached values stay as they were. > [!TIP] > For production apps, consider using `Config.getOrFail()` for critical values to catch missing configuration early during startup. ## Guarding Against a Blank Value `Env.get`/`env()` only fall back to the given default when the key is entirely missing; a key that is present but blank resolves to `''`. `Env.filled(key, fallback)` treats an absent, blank, or quote-only value the same way, all resolving to `fallback`, and strips one wrapping pair of quotes plus surrounding whitespace from a present value (an inner apostrophe survives; an unbalanced quote is left alone): ```dart Env.filled('WEB_URL', 'https://app.example.com'); // falls back on '', '""', "''", or a missing key Env.filled('APP_NAME', 'My App'); ``` Use it for any value that becomes a URL, a title, or anything else a blank string silently corrupts rather than visibly breaks. `Env.getOrFail(key)` mirrors Laravel's `Env::getOrFail`: it throws a `StateError` when `key` is absent entirely, and still returns `''` for a key that is present but empty, matching `Env.get`'s own Laravel-parity handling of an explicit empty value. ```dart final apiKey = Env.getOrFail('API_KEY'); // throws StateError when API_KEY is not set at all ``` ## Related Documentation - [Installation](https://anilcancakir.com/magic/docs/getting-started/installation.md): the files `magic:install` generates and the `Magic.init()` parameters. - [Directory Structure](https://anilcancakir.com/magic/docs/getting-started/directory-structure.md): where `lib/config` sits in the project. - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md): the `app.providers` list. - [Request Lifecycle](https://anilcancakir.com/magic/docs/architecture/request-lifecycle.md): when `configFactories` are evaluated. - [Facades](https://anilcancakir.com/magic/docs/architecture/facades.md): the `Config` facade among the others. --- # Directory Structure The default Magic project layout organises your app into folders for controllers, models, routes, config, and resources, so each is easy to locate at a glance, and it mirrors Laravel's conventions. - [Introduction](#introduction) - [The Root Directory](#the-root-directory) - [The App Directory](#the-app-directory) - [The Config Directory](#the-config-directory) - [The Resources Directory](#the-resources-directory) - [The Routes Directory](#the-routes-directory) - [The Database Directory](#the-database-directory) - [Related Documentation](#related-documentation) ## Introduction The default Magic application structure is intended to provide a great starting point for both large and small applications. But you are free to organise your application however you like. Magic imposes almost no restrictions on where any given class is located, as long as the classes can be imported. ## The Root Directory A fresh Magic project contains the following directories: ``` my_app/ ├── lib/ │ ├── app/ # Application logic │ ├── config/ # Configuration files │ ├── resources/ # Views and assets │ ├── routes/ # Route definitions │ └── main.dart # Application entry point ├── assets/ │ └── lang/ # Localization JSON files ├── .env # Environment variables └── pubspec.yaml # Dependencies ``` ## The App Directory The `app` directory contains the core code of your application. Almost all of the classes in your application will be in this directory. ``` lib/app/ ├── actions/ # MagicAction writes ├── controllers/ # Request handlers ├── forms/ # MagicFormObject form state ├── middleware/ # Route middleware ├── models/ # Eloquent models ├── policies/ # Authorization policies ├── providers/ # Service providers ├── repositories/ # Repository row caches └── kernel.dart # Application kernel ``` ### The Controllers Directory The `controllers` directory contains all of your application's controller classes. Controllers are responsible for handling incoming requests and returning responses. ```dart // lib/app/controllers/user_controller.dart class UserController extends MagicController with MagicStateMixin> { static UserController get instance => Magic.findOrPut(UserController.new); Widget index() => UserListView(); Widget show(String id) => UserShowView(id: id); } ``` ### The Middleware Directory The `middleware` directory contains your application's route middleware. Middleware provide a convenient mechanism for filtering requests entering your application. ```dart // lib/app/middleware/auth_middleware.dart class AuthMiddleware extends MagicMiddleware { @override String? redirectTarget(String location) { if (!Auth.check() && location != '/login') return '/login'; return null; } } ``` A redirect belongs in `redirectTarget`, which runs before the route builds, so the destination mounts once. Redirecting from `handle()` with `MagicRoute.to()` runs after the view has mounted and remounts the destination. ### The Models Directory The `models` directory contains all of your Eloquent model classes. Each model maps to a REST resource and is used to interact with it. ```dart // lib/app/models/user.dart class User extends Model with HasTimestamps, InteractsWithPersistence { @override String get table => 'users'; @override String get resource => 'users'; String? get name => getAttribute('name') as String?; String? get email => getAttribute('email') as String?; } ``` ### The Policies Directory The `policies` directory contains the authorisation policy classes for your application. Policies are used to determine if a user can perform a given action against a resource. ### The Providers Directory The `providers` directory contains all of the service providers for your application. Service providers bootstrap your application by binding services in the service container and registering events. ```dart // lib/app/providers/route_service_provider.dart class RouteServiceProvider extends ServiceProvider { RouteServiceProvider(super.app); @override void register() { registerKernel(); } @override Future boot() async { registerAppRoutes(); } } ``` ## The Config Directory The `config` directory contains all of your application's configuration files. Each file returns a Map that is merged into Magic's configuration. ``` lib/config/ ├── app.dart # App name, env, providers ├── auth.dart # Guards, session config ├── network.dart # API endpoints, timeouts ├── cache.dart # Cache drivers ├── logging.dart # Log channels ├── routing.dart # Router options └── view.dart # Snackbar, dialog and loading styles ``` [`magic:install`](https://anilcancakir.com/magic/docs/packages/magic-cli.md) writes no localisation config; add a `localization` key (for example `supported_locales`) to a config file yourself when you need one. ## The Resources Directory The `resources` directory contains your views and other UI resources. ``` lib/resources/ └── views/ ├── auth/ # Authentication views │ ├── login_view.dart │ └── register_view.dart ├── layouts/ # Layout components │ ├── app_layout.dart │ └── guest_layout.dart ├── components/ # Reusable UI components │ ├── app_sidebar.dart │ └── app_header.dart └── dashboard_view.dart ``` Views in Magic are Dart classes that extend `MagicView` or `MagicStatefulView`: ```dart // lib/resources/views/dashboard_view.dart class DashboardView extends MagicView { @override Widget build(BuildContext context) { return WDiv( className: 'p-4 flex flex-col gap-4', children: [ WText('Dashboard', className: 'text-2xl font-bold'), ], ); } } ``` ## The Routes Directory The `routes` directory contains all of the route definitions for your application. ``` lib/routes/ └── app.dart # Application routes ``` Routes are defined using the `MagicRoute` facade: ```dart // lib/routes/app.dart void registerAppRoutes() { MagicRoute.group( prefix: '/', middleware: ['auth'], layoutId: 'app', layout: (child) => AppLayout(child: child), routes: () { MagicRoute.page('/', () => DashboardView()); MagicRoute.page('/users', () => UserController.instance.index()); MagicRoute.page('/users/:id', (id) => UserController.instance.show(id)); }, ); } ``` ## The Database Directory A fresh project has no `database` directory. Migrations, seeders and model factories belong to the `magic_local_storage` plugin, which creates `lib/database/` when you install it: `flutter pub add magic_local_storage`, then `dart run magic:dowel plugin:install magic_local_storage`. See [Eloquent: Local Storage](https://anilcancakir.com/magic/docs/eloquent/getting-started.md#local-storage) and the [plugin's installation page](https://anilcancakir.com/magic/docs/local_storage/getting-started/installation). ## Related Documentation - [Installation](https://anilcancakir.com/magic/docs/getting-started/installation.md): the `magic:install` command that generates this layout. - [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md): the files under `lib/config`. - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md): the files under `lib/app/providers`. - [Actions](https://anilcancakir.com/magic/docs/basics/actions.md), [Forms](https://anilcancakir.com/magic/docs/basics/forms.md) and [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md): the `lib/app` classes. - [Middleware](https://anilcancakir.com/magic/docs/basics/middleware.md): the files under `lib/app/middleware`. - [Views](https://anilcancakir.com/magic/docs/basics/views.md): the files under `lib/resources/views`. - [Routing](https://anilcancakir.com/magic/docs/basics/routing.md): the files under `lib/routes`. - [Repositories](https://anilcancakir.com/magic/docs/eloquent/repositories.md): the files under `lib/app/repositories`. - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): the `make:*` generators that create these files. --- # Service Providers Service providers are the central bootstrapping point for Magic applications: each provider's `register` method binds services into the IoC container, and `boot` runs async setup after all providers have registered. - [Introduction](#introduction) - [Generating Providers](#generating-providers) - [Writing Service Providers](#writing-service-providers) - [The Register Method](#the-register-method) - [The Boot Method](#the-boot-method) - [Registering Providers](#registering-providers) - [Providers Without Boot Logic](#providers-without-boot-logic) - [Built-in Providers](#built-in-providers) - [Related Documentation](#related-documentation) ## Introduction Service providers are the central place of all Magic application bootstrapping. Your own application, as well as all of Magic's core services, are bootstrapped via service providers. But, what do we mean by "bootstrapped"? In general, we mean **registering** things, including registering service container bindings, event listeners, middleware, and even routes. Service providers are the central place to configure your application. If you open the `config/app.dart` file included with Magic, you will see a `providers` array. These are all of the service provider classes that will be loaded for your application. ## Generating Providers Use the Magic CLI to generate a new service provider class: ```bash dart run magic:dowel make:provider Payment ``` This creates `lib/app/providers/payment_service_provider.dart` with a `ServiceProvider` stub containing empty `register()` and `boot()` methods. ## Writing Service Providers All service providers extend the `ServiceProvider` class. Most service providers contain a `register` and a `boot` method. Within the `register` method, you should **only bind things into the service container and register event listeners**. Within the `boot` method, you may do anything else: register routes, or any other functionality. ```dart import 'package:magic/magic.dart'; class PaymentServiceProvider extends ServiceProvider { PaymentServiceProvider(super.app); @override void register() { // Bind services into the container app.singleton('payment.gateway', () => StripeGateway()); app.bind('payment.processor', () => PaymentProcessor()); } @override Future boot() async { // Called after all providers are registered final gateway = app.make('payment.gateway'); await gateway.initialize(); } } ``` ### The Register Method Within the `register` method, you should only bind things into the service container and register event listeners. You should never attempt to register routes, or any other piece of functionality within the `register` method. This is because you may accidentally use a service from another provider that has not been loaded yet. Listeners are the exception: `BaseEventServiceProvider` registers them here, so they are in place before any provider's `boot` dispatches an event. ```dart @override void register() { // ✅ Good: Only bind services app.singleton('analytics', () => AnalyticsService()); app.bind('report', () => ReportGenerator()); // ❌ Bad: Don't use other services here // final auth = Auth.manager; // May not be ready! } ``` #### Binding Types The service container supports two types of bindings: | Method | Description | |:---|:---| | `app.bind(key, closure)` | Creates a new instance each time | | `app.singleton(key, closure)` | Creates a single shared instance | ```dart // New instance every time app.bind('report', () => ReportGenerator()); // Same instance every time (singleton) app.singleton('reports', () => ReportService()); ``` ### The Boot Method The `boot` method is called after **all** other service providers have been registered, meaning you have access to all services that have been registered by the framework: ```dart @override Future boot() async { // ✅ Safe to access any registered service final config = Config.get('payment'); final auth = Auth.manager; // Event listeners belong in register(), not here: the auth guard can // dispatch AuthLogin during AuthServiceProvider.boot, before this runs. // Perform async initialization await initializePaymentGateway(); } ``` > [!NOTE] > The `boot` method supports `async` operations. You may perform asynchronous initialisation here. ## Registering Providers All service providers are registered in your `config/app.dart` configuration file using the `providers` key. Each provider is a factory function that receives the application instance: ```dart import 'package:magic/magic.dart'; Map get appConfig => { 'app': { 'name': 'My App', 'providers': [ // Framework Providers (app) => CacheServiceProvider(app), (app) => NetworkServiceProvider(app), // Application Providers (app) => AppServiceProvider(app), (app) => PaymentServiceProvider(app), // Auth boots after AppServiceProvider has set the user factory (app) => AuthServiceProvider(app), ], }, }; ``` ### Provider Loading Order Providers are loaded in the order they are listed: 1. **Register Phase**: `register()` is called on each provider in order 2. **Boot Phase**: `boot()` is called on each provider in order > [!TIP] > Place framework providers first, then your custom providers. If Provider B depends on Provider A, ensure A comes before B in the list. `AuthServiceProvider` is the exception to framework-first: its `boot()` restores the stored session with the user factory that `AppServiceProvider.boot()` sets, so it goes after `AppServiceProvider`; in the other order the restore is skipped. ## Providers Without Boot Logic Magic has no deferred providers: every registered provider is registered and booted at startup. `boot()` is optional, though. Its default implementation does nothing, so a provider that only binds services overrides `register()` alone: ```dart class SimpleServiceProvider extends ServiceProvider { SimpleServiceProvider(super.app); @override void register() { app.singleton('simple', () => SimpleService()); } } ``` ## Built-in Providers Magic includes several built-in service providers for core functionality: | Provider | Purpose | |:---|:---| | `CacheServiceProvider` | Cache stores and drivers | | `NetworkServiceProvider` | HTTP client configuration | | `AuthServiceProvider` | Authentication guards and state | | `LocalizationServiceProvider` | Language and locale support | | `VaultServiceProvider` | Secure storage | | `BaseEventServiceProvider` | Event listeners; subclass it to map events to listeners | | `GateServiceProvider` | Authorization gates and policies; subclass it to define abilities | | `LaunchServiceProvider` | Opening URLs, email, phone and SMS; not registered by default | These providers are registered in your `config/app.dart` to enable framework features. Providers from plugin packages, such as `magic_encryption` or `magic_socket`, are added to the same list by `dart run magic:dowel plugin:install `. ## Related Documentation - [Service Container](https://anilcancakir.com/magic/docs/architecture/service-container.md): `bind`, `singleton` and `make`. - [Request Lifecycle](https://anilcancakir.com/magic/docs/architecture/request-lifecycle.md): when `register()` and `boot()` run. - [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md): the `app.providers` list. - [Events](https://anilcancakir.com/magic/docs/digging-deeper/events.md): listeners and `BaseEventServiceProvider`. - [Authorization](https://anilcancakir.com/magic/docs/security/authorization.md): `GateServiceProvider`. - [Installation](https://anilcancakir.com/magic/docs/getting-started/installation.md): the plugins that add their own providers. --- # Application Structure Six primitives divide a magic app's write-heavy screens the way Laravel divides a controller-heavy one: each owns exactly one kind of state, and none of them reaches into another's. - [Introduction](#introduction) - [Controllers Own Read State](#controllers-own-read-state) - [Actions Own Writes](#actions-own-writes) - [Form Objects Own Form State](#form-objects-own-form-state) - [Repositories Own Rows](#repositories-own-rows) - [SessionScope Resets Every Tenant Holder](#sessionscope-resets-every-tenant-holder) - [Listeners Replace a Central Realtime Dispatcher](#listeners-replace-a-central-realtime-dispatcher) - [How the Six Compose](#how-the-six-compose) - [Related Documentation](#related-documentation) ## Introduction A controller that also validates, also writes, also owns a paginated collection, and also resubscribes a broadcast channel is not one class doing six jobs well; it is six responsibilities racing each other on one `notifyListeners()`. This page names the six pieces a magic app now has for that: `MagicController` (read state), `MagicAction` (a write), `MagicFormObject` (a screen's form), `Repository`/`RepositoryQuery` (a resource's rows), `SessionScope` (the tenant boundary), and `BroadcastListeners` (the shared channel registry, in the `magic_socket` plugin). None of them is new architecture on top of magic; each is the existing primitive from [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md), [Forms](https://anilcancakir.com/magic/docs/basics/forms.md), [Repositories](https://anilcancakir.com/magic/docs/eloquent/repositories.md), [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md), and the [magic_socket](https://anilcancakir.com/magic/docs/socket/basics/listeners) docs, read together as one map. ## Controllers Own Read State A `MagicController` answers "what does this screen show right now": `RxStatus`, a `MagicFormObject`'s field values, a `RepositoryQuery`'s pages. It does not persist a write itself; it runs a `MagicAction` through `RunsActions.runAction` and reacts to the result. ```dart class MonitorController extends MagicController with MagicStateMixin>, RunsActions { static MonitorController get instance => Magic.findOrPut(MonitorController.new); Future pause(String id) async { final outcome = await runAction(MagicAction.resolve(PauseMonitor.new), id, key: id); if (!outcome.succeeded) return; } } ``` See [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) for `MagicStateMixin`, fetch helpers, and lifecycle. ## Actions Own Writes A `MagicAction` is one stateless write, Fortify's `Actions\*` ported: no `MagicController` base, no `notifyListeners`, nothing to dispose. It throws on failure (a `ValidationException` for a 422 or a failed client rule, anything else for a transport fault) rather than answering a bool, because `RunsActions.runAction` is the one place both are already handled. ```dart class PauseMonitor extends MagicAction { const PauseMonitor(); @override Future handle(String monitorId) async { final response = await Http.update('monitors', monitorId, {'status': 'paused'}); if (!response.successful) { throw Exception(response.errorMessage ?? 'Failed to pause monitor'); } } } ``` A test swaps the real action for a fake via `MagicAction.bind(() => _FakePauseMonitor())` and clears every override with `MagicAction.flush()` in `tearDown`. See [Actions](https://anilcancakir.com/magic/docs/basics/actions.md). ## Form Objects Own Form State A `MagicFormObject` is a Livewire-style Form object: one field-bound, validated, persistable unit for a single screen's write, created once per `State` and never registered in the container (`onClose` asserts against it). It composes `MagicFormData`, `ValidatesRequests`, and `RunsActions` rather than hand-wiring all three per screen. ```dart class MonitorFormObject extends MagicFormObject { MonitorFormObject({this.editing}); final Monitor? editing; @override Map get initial => {'name': editing?.name ?? '', 'url': editing?.url ?? ''}; @override FormRequest get request => editing == null ? const StoreMonitorRequest() : const UpdateMonitorRequest(); @override Future persist(Map validated) async { final monitor = editing ?? Monitor(); monitor.fill(validated, strict: true); return monitor.save(); } } ``` See [Forms](https://anilcancakir.com/magic/docs/basics/forms.md). ## Repositories Own Rows A `Repository` is an id-keyed cache of one remote resource's rows (Eloquent's identity map); a `RepositoryQuery` is one ordered, filtered, paginated view over it, so a list screen and a show screen reading the same row see the same cached copy without a refetch. Neither owns navigation or form state. ```dart class MonitorRepository extends Repository { static MonitorRepository instance = MonitorRepository(); @override String get resource => 'monitors'; @override Monitor Function(Map) get fromMap => Monitor.fromMap; @override Set get showOnlyKeys => const {'uptime_24h'}; } ``` See [Repositories](https://anilcancakir.com/magic/docs/eloquent/repositories.md). ## SessionScope Resets Every Tenant Holder `SessionScope` is the seam that keeps a login or a team switch from ever showing one tenant's cached rows to another: it resets every `SessionScoped` controller (found via `Magic.controllers`) and every registered `SessionScoped` holder (a `Repository` registers itself in its constructor) on an identity change, in place, never by deleting and recreating the instance. ```dart @override Future boot() async { SessionScope.identity = () => Auth.check() ? '${Auth.id()}:$teamId' : null; SessionScope.attach(); // explicit, and LAST: realtime and polling must already point at the new session } ``` See [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md). ## Listeners Replace a Central Realtime Dispatcher Real-time broadcasting moved out of core into the `magic_socket` plugin: the `Echo` facade, the broadcasting manager and drivers, `ListensToBroadcasts` and `BroadcastListeners`. The plugin's `BroadcastListeners` owns exactly one channel subscription per alias, no matter how many controllers listen on it, and a controller declares what it wants through the `ListensToBroadcasts` mixin instead of talking to a socket directly. Install it with `flutter pub add magic_socket`, then `dart run magic:dowel plugin:install magic_socket`; the [plugin's listeners page](https://anilcancakir.com/magic/docs/socket/basics/listeners) covers the listener map and the auth-scoped subscriptions. Core keeps the `BroadcastReceived` perf event the registry emits. ## How the Six Compose A monitor list screen, put together: `MonitorController` (reads) holds a `RepositoryQuery` over `MonitorRepository.instance` (rows), runs `PauseMonitor` (a write) through `RunsActions`, edits through `MonitorFormObject` (form state), stays correct across a team switch because the repository is `SessionScoped`, and stays live because `MonitorController` mixes in `ListensToBroadcasts` (from `magic_socket`) instead of opening its own channel. No primitive here is optional scaffolding: each closes a failure mode the others do not (a write racing itself, a tenant's rows outliving its session, two controllers fighting over one socket) that a single fat controller used to absorb by accident. ## Related Documentation - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md): `MagicController`, `MagicStateMixin` and the fetch helpers. - [Actions](https://anilcancakir.com/magic/docs/basics/actions.md): `MagicAction`, `RunsActions` and `ActionOutcome`. - [Forms](https://anilcancakir.com/magic/docs/basics/forms.md): `MagicFormObject` and `MagicFormData`. - [Repositories](https://anilcancakir.com/magic/docs/eloquent/repositories.md): `Repository` and `RepositoryQuery`. - [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md): resetting every tenant holder on an identity change. - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md): where `SessionScope.attach()` belongs. - [Listeners](https://anilcancakir.com/magic/docs/socket/basics/listeners): `BroadcastListeners` and `ListensToBroadcasts` in `magic_socket`. --- # Request Lifecycle Understanding the Magic request lifecycle helps you know exactly where your service providers, middleware, and controllers run relative to each bootstrap step. - [Introduction](#introduction) - [Lifecycle Overview](#lifecycle-overview) - [Application Bootstrap](#application-bootstrap) - [Service Providers](#service-providers) - [Routing](#routing) - [Middleware](#middleware) - [Controller Dispatch](#controller-dispatch) - [View Rendering](#view-rendering) - [Related Documentation](#related-documentation) ## Introduction Understanding the Magic request lifecycle will help you build better applications. This document covers how a Magic application starts up, handles navigation requests, and renders views. ## Lifecycle Overview ``` ┌─────────────────────────────────────────────────────────┐ │ main.dart │ │ │ │ │ Magic.init() │ │ │ │ │ Env.load() │ │ │ │ │ configFactories evaluated │ │ │ │ │ URL strategy applied (web) │ │ │ │ │ ┌────────────────┴────────────────┐ │ │ ▼ ▼ │ │ providers register() (sync) (all providers) │ │ │ │ │ providers boot() (async, all registered) │ │ │ │ │ router pre-build │ │ │ │ │ runApp(MagicApplication) │ │ │ │ │ Route Matched (GoRouter) │ │ │ │ │ ┌─────────────┼─────────────┐ │ │ ▼ ▼ ▼ │ │ Run Middleware Resolve Layout Get Controller │ │ │ │ │ │ │ Controller.method() │ │ │ │ │ Render View │ └─────────────────────────────────────────────────────────┘ ``` ## Application Bootstrap The application starts in `main.dart`: ```dart void main() async { WidgetsFlutterBinding.ensureInitialized(); // 1. Initialize Magic await Magic.init( envFileName: '.env', configFactories: [ () => appConfig, () => networkConfig, () => authConfig, ], ); // 2. Restore authentication (optional) await Auth.restore(); // 3. Start the application runApp(MagicApplication( title: 'My App', initialRoute: Auth.check() ? '/dashboard' : '/login', )); } ``` ### Magic.init() Steps 1. **Load Environment** - Reads `.env` file into memory via `Env.load()` 2. **Evaluate Config Factories** - Runs all `configFactories` now that `Env` is populated 3. **Apply URL Strategy** - Configures path vs hash URL strategy for web (before any widget tree exists) 4. **Register Service Providers** - Calls `register()` on each provider synchronously 5. **Boot Service Providers** - Calls `boot()` on each provider asynchronously 6. **Pre-build Router** - Resolves `MagicRouter.instance.routerConfig` so GoRouter is ready before `runApp` ## Service Providers Providers are registered in `config/app.dart`: ```dart 'providers': [ (app) => NetworkServiceProvider(app), (app) => CacheServiceProvider(app), (app) => LocalizationServiceProvider(app), (app) => EventServiceProvider(app), (app) => AppServiceProvider(app), (app) => AuthServiceProvider(app), ], ``` `AppServiceProvider` comes before `AuthServiceProvider` so the session restore finds the user factory; see [Provider Loading Order](https://anilcancakir.com/magic/docs/getting-started/service-providers.md#registering-providers). ### Provider Lifecycle 1. **register()** - Bind services to container (no dependencies) 2. **boot()** - Initialise services (can use other services) ```dart class AppServiceProvider extends ServiceProvider { @override void register() { // Runs first - just bind services app.bind('api', () => ApiService()); } @override Future boot() async { // Runs after ALL providers register // Safe to use other services here Gate.before((user, ability) { if ((user as User).isAdmin) return true; return null; }); } } ``` ### Registering a provider after boot A provider registered once the app has already booted is booted on the spot, rather than waiting for a boot phase that has been and gone. `register()` returns a `Future` so a caller that needs the provider wired before its next line can wait for it: ```dart // A plugin installing itself lazily, after the app is running. await Magic.app.register(NotificationServiceProvider(Magic.app)); Notify.startPolling(); // the provider has finished booting ``` Ignoring the future is fine when you do not need that guarantee. Before the boot phase it completes immediately, since nothing asynchronous has happened yet. Registering the same provider **instance** twice is a no-op the second time, so its `register()` and `boot()` each run once. Two different instances of the same provider class both run, which is deliberate: a provider parameterised per plugin is a legitimate shape. ## Routing Routes are defined in `lib/routes/`: ```dart // lib/routes/web.dart void registerRoutes() { // Guest routes MagicRoute.group( layoutId: 'guest', layout: (child) => GuestLayout(child: child), routes: () { MagicRoute.page('/login', () => AuthController.instance.login()); MagicRoute.page('/register', () => AuthController.instance.register()); }, ); // Authenticated routes MagicRoute.group( layoutId: 'app', middleware: ['auth'], layout: (child) => AppLayout(child: child), routes: () { MagicRoute.page('/dashboard', () => DashboardView()); MagicRoute.page('/settings', () => SettingsView()); }, ); } ``` Each group with a `layout:` is its own go_router `ShellRoute`, so the guest and authenticated groups are separate shells. Groups that share a `layoutId` share one persistent shell, and shells are emitted in registration order. See [Layouts](https://anilcancakir.com/magic/docs/basics/routing.md#layouts-shell-routes). ### Route Resolution 1. URL is matched against defined routes 2. Layout is wrapped around the view 3. Middleware is executed in order 4. Controller/View is resolved ## Middleware Middleware intercepts navigation before the view renders: ```dart class EnsureAuthenticated extends MagicMiddleware { @override String? redirectTarget(String location) { if (!Auth.check() && location != '/login') return '/login'; return null; // Allow navigation } } ``` A redirect guard overrides `redirectTarget`, which runs in the router's `redirect` callback before the route builds, so the destination mounts once. `handle(next)` is for async, non-redirecting work; a `MagicRoute.to()` from inside it runs after the route has mounted and remounts the destination. See [Middleware](https://anilcancakir.com/magic/docs/basics/middleware.md). ### Middleware Execution Order 1. Global middleware (registered in provider) 2. Route group middleware 3. Route-specific middleware ## Controller Dispatch Controllers are resolved using the `findOrPut` pattern: ```dart // Route definition MagicRoute.page('/tasks', () => TaskController.instance.index()); // Controller class TaskController extends MagicController with MagicStateMixin> { static TaskController get instance => Magic.findOrPut(TaskController.new); Widget index() { if (isEmpty) _loadTasks(); return const TaskListView(); } Future _loadTasks() async { setLoading(); try { setSuccess(await Task.all()); } on Exception catch (e) { setError(e.toString()); // the view's onError branch renders it } } } ``` `isEmpty`, `setLoading()`, `setSuccess()` and `setError()` come from `MagicStateMixin`, so a controller that tracks loading state mixes it in. ### Controller Lifecycle For a `MagicController` behind a route: 1. **findOrPut** - Get the existing controller or create one; creating it does not run `onInit()` 2. **Method execution** - The route handler calls the method (`index()` above), which returns the view widget 3. **onInit()** - Runs once per controller, when the first `MagicStatefulView` backed by it mounts; a stateless `MagicView` never runs it. Nothing the method reads in step 2 can depend on it 4. **State updates** - `refreshUI()` (or a `MagicStateMixin` setter) triggers rebuilds 5. **onClose()** - Called when controller is disposed A `SimpleMagicController` differs only in step 3: its constructor calls `onInit()`, so it has run before step 2. ## View Rendering Views render using controller state: ```dart class TaskListView extends MagicView { const TaskListView({super.key}); @override Widget build(BuildContext context) { return controller.renderState( (tasks) => _buildList(tasks), onLoading: WText('Loading...'), onError: (msg) => WText('Error: $msg'), onEmpty: WText('No tasks'), ); } } ``` ### State Flow 1. Controller calls `setLoading()` → View shows loading 2. Controller calls `setSuccess(data)` → View renders data 3. Controller calls `setError(msg)` → View shows error 4. Controller calls `refreshUI()` → View rebuilds > [!TIP] > Understanding this lifecycle helps you place logic in the right location: configuration in providers, authorisation in middleware, business logic in controllers, and UI in views. ## Related Documentation - [Service Container](https://anilcancakir.com/magic/docs/architecture/service-container.md): the bindings `Magic.init()` builds. - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md): `register()` and `boot()` in depth. - [Routing](https://anilcancakir.com/magic/docs/basics/routing.md): groups, layouts and `MagicRoute.page()`. - [Middleware](https://anilcancakir.com/magic/docs/basics/middleware.md): `MagicMiddleware` and the `Kernel`. - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md): `MagicController` and `renderState`. - [Views](https://anilcancakir.com/magic/docs/basics/views.md): `MagicView` and its controller. --- # Service Container The Magic service container is a string-keyed IoC container for registering factory closures and resolving singletons across your application without relying on reflection. - [Introduction](#introduction) - [Binding Services](#binding-services) - [Simple Bindings](#simple-bindings) - [Singletons](#singletons) - [Resolving Services](#resolving-services) - [Automatic Resolution](#automatic-resolution) - [Using Controllers](#using-controllers) - [Related Documentation](#related-documentation) ## Introduction The Magic service container is a powerful tool for managing class dependencies and performing dependency injection. Like Laravel's container, it allows you to register bindings and resolve them throughout your application. ```dart // Register a service Magic.bind('mailer', () => MailService()); // Resolve it anywhere final mailer = Magic.make('mailer'); ``` ## Binding Services ### Simple Bindings Use `Magic.bind()` to register a service. Each resolve creates a new instance: ```dart Magic.bind('api', () => ApiClient()); // Each call creates a new instance final api1 = Magic.make('api'); final api2 = Magic.make('api'); // api1 != api2 ``` ### Singletons Use `Magic.singleton()` to register a service that's instantiated only once: ```dart Magic.singleton('mailer', () => MailService()); // Always returns the same instance final mailer1 = Magic.make('mailer'); final mailer2 = Magic.make('mailer'); // mailer1 == mailer2 ``` ### Rebinding, and where fakes go Binding a key that has already been resolved discards the instance the old factory produced, so the new binding takes effect immediately. That is what makes an override work: a starter package binds a key in its provider, your app binds the same key to its own implementation, and the next `make()` gets yours even if something resolved the key in between. ```dart Magic.singleton('svc', () => StarterService()); Magic.make('svc'); // StarterService Magic.singleton('svc', () => AppService()); Magic.make('svc'); // AppService ``` > [!WARNING] > The same rule applies to a value placed with `setInstance`, which is how > `Auth.fake()`, `Cache.fake()`, `Http.fake()`, `Log.fake()` and > `Vault.fake()` install a fake. A later `bind` or `singleton` on that key > evicts it. Install fakes AFTER `Magic.init()` and after your providers have > registered, never before. ### Binding in Service Providers The recommended way to register services is in a service provider: ```dart class AppServiceProvider extends ServiceProvider { @override void register() { // Register services app.bind('api', () => ApiClient(Config.get('api.url'))); app.singleton('mailer', () => MailService()); } @override Future boot() async { // Perform initialization after all services are registered final api = app.make('api'); await api.warmUp(); } } ``` ## Resolving Services ### Using Magic.make() ```dart // With type inference final service = Magic.make('myservice'); // Without type (returns dynamic) final service = Magic.make('myservice'); ``` ### Checking Existence ```dart if (Magic.bound('api')) { final api = Magic.make('api'); } ``` ## Automatic Resolution Magic facades automatically resolve their underlying services. You don't need to manually resolve common services: ```dart // These facades auto-resolve from the container: Cache.get('key'); // Resolves CacheManager Auth.check(); // Resolves AuthManager Http.get('/users'); // Resolves the NetworkDriver ``` ## Using Controllers Magic provides a convenient way to manage controller instances: ### findOrPut Pattern The recommended pattern for controllers is using `Magic.findOrPut()`: ```dart class UserController extends MagicController with MagicStateMixin> { // Singleton accessor static UserController get instance => Magic.findOrPut(UserController.new); // Controller logic... } // Usage anywhere in your app final controller = UserController.instance; ``` ### How It Works 1. **First call**: Creates a new controller instance and stores it 2. **Subsequent calls**: Returns the existing instance 3. **Automatic cleanup**: Controller's `onClose()` is called when disposed ### Manual Controller Management ```dart // Register a controller Magic.put(UserController()); // Find an existing controller final controller = Magic.find(); // Delete a controller Magic.delete(); ``` `Magic.delete` disposes what it removes when it is a `ChangeNotifier`: every `MagicController`, and a plain `ChangeNotifier` or `ValueNotifier` registered with `Magic.put` too. Do not dispose such a notifier yourself as well; a second `dispose()` throws in a debug build. `Magic.flush()` clears the registry without disposing. ### Controller Lifecycle ```dart class UserController extends MagicController { @override void onInit() { // Called once, the first time a stateful view backs the controller. // super.onInit() marks the controller initialised; without it a remount // runs onInit (and loadUsers) again. super.onInit(); loadUsers(); } @override void onClose() { // Called when controller is disposed // Clean up subscriptions, streams, etc. } } ``` > [!TIP] > Use the `findOrPut` pattern for controllers to ensure a single instance is used throughout your application while maintaining proper lifecycle management. ## Related Documentation - [Facades](https://anilcancakir.com/magic/docs/architecture/facades.md): the static stubs that resolve these bindings. - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md): where bindings are registered and booted. - [Request Lifecycle](https://anilcancakir.com/magic/docs/architecture/request-lifecycle.md): when the container is built during `Magic.init()`. - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md): `MagicController` and its lifecycle. --- # Facades Facades provide a clean, static interface to the services bound in Magic's IoC container, giving you expressive one-liners without sacrificing testability or container transparency. - [Introduction](#introduction) - [When To Use Facades](#when-to-use-facades) - [Available Facades](#available-facades) - [How Facades Work](#how-facades-work) - [Facade Class Reference](#facade-class-reference) - [Related Documentation](#related-documentation) ## Introduction Facades provide a "static" interface to classes that are available in the application's service container. Magic ships with many facades which provide access to almost all of Magic's features. Magic facades serve as "static proxies" to underlying classes in the service container, providing the benefit of a terse, expressive syntax. ```dart // Using a facade - clean and expressive await Cache.put('key', 'value'); final user = Auth.user(); MagicRoute.to('/dashboard'); // Instead of resolving from container final cache = app.make('cache'); await cache.put('key', 'value'); ``` ## When To Use Facades Facades have many benefits. They provide a terse, memorable syntax that allows you to use Magic's features without remembering long class names or understanding dependency injection. Use facades when: - You need quick access to a service from anywhere - You want clean, readable code - The service is stateless or globally applicable - You're in a controller, service, or helper class ## Available Facades Core ships 13 facades, one file each under `lib/src/facades/`: `Auth`, `Cache`, `Config`, `Event`, `Gate`, `Http`, `Lang`, `Launch`, `Log`, `MagicRoute`, `Session`, `Storage` and `Vault`. ### Core Facades | Facade | Description | |:---|:---| | `Config` | Configuration values access | ### Authentication & Security | Facade | Description | |:---|:---| | `Auth` | Authentication state and user | | `Gate` | Authorization abilities and policies | | `Vault` | Secure storage (Keychain/Keystore) | ### HTTP & Network | Facade | Description | |:---|:---| | `Http` | HTTP client for API requests | ### Routing | Facade | Description | |:---|:---| | `MagicRoute` | Navigation and route definitions | ### Session Management | Facade | Description | |:---|:---| | `Session` | Form flash data for validation errors and old input | ### Data & Storage | Facade | Description | |:---|:---| | `Cache` | Caching system | | `Storage` | File storage | ### Utilities | Facade | Description | |:---|:---| | `Event` | Event dispatching | | `Lang` | Localization and translations | | `Launch` | Open URLs, email, phone, and SMS via url_launcher | | `Log` | Logging | ### Related Classes These are static entry points too, but they are classes in their own right, not facades over a container binding. | Class | Description | |:---|:---| | `Magic` | Application container, UI helpers (snackbar, dialog) | | `Env` | Environment variables | | `Carbon` | Date and time manipulation | ### Plugin Facades These facades live in optional plugin packages, not in core. Each plugin is installed with `flutter pub add ` then `dart run magic:dowel plugin:install `, and its own documentation covers the facade. | Facade | Package | Provides | |:-------|:--------|:---------| | `Crypt` | [`magic_encryption`](https://anilcancakir.com/magic/docs/encryption/getting-started/installation) | AES-256-CBC encryption | | `Echo` | [`magic_socket`](https://anilcancakir.com/magic/docs/socket/getting-started/installation) | Real-time broadcasting client | | `DB`, `Schema` | [`magic_local_storage`](https://anilcancakir.com/magic/docs/local_storage/getting-started/installation) | SQLite, query builder, migrations | | `Pick` | [`magic_picker`](https://anilcancakir.com/magic/docs/picker/getting-started/installation) | File and image picking | ## How Facades Work Behind the scenes, facades access the service container and resolve the relevant service. For example: ```dart // When you call: Cache.put('key', 'value'); // Magic actually does: Magic.make('cache').put('key', 'value'); ``` Each facade has a static method that proxies to the underlying service instance. ## Facade Class Reference ### Magic ```dart // Service container Magic.bind('key', () => Service()); Magic.make('key'); Magic.findOrPut(Controller.new); // UI Helpers Magic.success('Title', 'Message'); Magic.error('Title', 'Message'); Magic.toast('Message'); Magic.loading(message: 'Please wait...'); Magic.closeLoading(); Magic.dialog(Widget()); Magic.closeDialog([result]); await Magic.confirm(title: 'Delete?', message: '...'); ``` ### Config ```dart Config.get('app.name', 'Default'); Config.set('app.locale', 'tr'); Config.has('services.stripe'); Config.all(); Config.merge({'key': 'value'}); ``` ### Auth ```dart Auth.check(); // Is authenticated? Auth.guest; // Is guest? Auth.user(); // Get typed user Auth.id(); // Get user ID await Auth.login(data, user); // Login await Auth.logout(); // Logout await Auth.restore(); // Restore from cache await Auth.refreshToken(); // Refresh token ``` ### MagicRoute ```dart // Navigation MagicRoute.to('/path'); MagicRoute.to('/search', query: {'q': 'flutter'}); MagicRoute.push('/path'); MagicRoute.back(); MagicRoute.replace('/path'); MagicRoute.toNamed('route.name', params: {}, query: {}); // Route definition MagicRoute.page('/path', () => Widget()); MagicRoute.group(prefix: '/admin', routes: () {}); ``` ### Http ```dart await Http.get('/users', query: {'page': 1}); await Http.post('/users', data: {...}); await Http.put('/users/1', data: {...}); await Http.delete('/users/1'); // RESTful shortcuts await Http.index('users'); await Http.show('users', '1'); await Http.store('users', data); await Http.update('users', '1', data); await Http.destroy('users', '1'); ``` ### Cache ```dart Cache.get('key', defaultValue: 'default'); await Cache.put('key', 'value', ttl: Duration(minutes: 5)); await Cache.remember('key', duration, () async => compute()); await Cache.forget('key'); await Cache.flush(); ``` ### Gate ```dart Gate.define('ability', (user, model) => bool); Gate.before((user, ability) => true/false/null); Gate.allows('ability', model); Gate.denies('ability', model); ``` ### Lang ```dart trans('key', {'param': 'value'}); // Global helper Lang.current; // Current locale await Lang.setLocale(Locale('tr')); Lang.supportedLocales; // Locales the app declares ``` ### Launch ```dart await Launch.url('https://flutter.dev'); await Launch.url('https://flutter.dev', mode: LaunchMode.inAppWebView); await Launch.email('support@example.com', subject: 'Hello', body: 'Hi!'); await Launch.phone('+1234567890'); await Launch.sms('+1234567890', body: 'On my way!'); // Check before launch if (await Launch.canLaunch('tel:+1234567890')) { await Launch.phone('+1234567890'); } ``` ### Carbon ```dart Carbon.now(); Carbon.parse('2024-01-15'); carbon.addDays(5); carbon.subMonths(2); carbon.diffForHumans(); // "2 days ago" carbon.format('yyyy-MM-dd'); carbon.isToday(); carbon.isFuture(); ``` ### Vault ```dart await Vault.put('api_key', 'secret'); await Vault.get('api_key'); await Vault.delete('api_key'); await Vault.flush(); ``` > [!TIP] > When in doubt, check the facade source file in `/lib/src/facades/` for all available methods. ## Related Documentation - [Service Container](https://anilcancakir.com/magic/docs/architecture/service-container.md): the bindings a facade resolves. - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md): where those bindings are registered. - [Testing Facades](https://anilcancakir.com/magic/docs/testing/facades.md): swapping a facade for a fake in a test. --- # Routing Magic's routing wraps `go_router` with a Laravel-style fluent API: define routes with `MagicRoute.page()`, group them with middleware and layouts, and navigate without `BuildContext`. - [Introduction](#introduction) - [Basic Routing](#basic-routing) - [Route Parameters](#route-parameters) - [Query Parameters](#query-parameters) - [Named Routes](#named-routes) - [Resource Routes](#resource-routes) - [Route Groups](#route-groups) - [Middleware](#middleware) - [Prefixes](#prefixes) - [Layouts (Shell Routes)](#layouts-shell-routes) - [Standalone Layouts](#standalone-layouts) - [A Layer Above Every Page](#a-layer-above-every-page) - [Context-Free Navigation](#context-free-navigation) - [Back Gestures and the Stack](#back-gestures-and-the-stack) - [Transitions](#transitions) - [Turning the Gesture Off](#turning-the-gesture-off) - [Changing What `platform` Looks Like](#changing-what-platform-looks-like) - [Bottom Sheet Routes](#bottom-sheet-routes) - [Route Middleware](#route-middleware) - [URL Strategy](#url-strategy) - [URL Strategy (Path vs Hash)](#url-strategy-path-vs-hash) - [Navigator Observers](#navigator-observers) - [Page Titles](#page-titles) - [Router Config](#router-config) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Introduction The most basic Magic routes accept a URI and a closure, providing a very simple and expressive method of defining routes and behaviour without complicated routing configuration files. All routes for your application are defined in the `lib/routes` directory. These files are loaded by the `RouteServiceProvider`, which is included in your `config/app.dart` by default. ## Basic Routing The most basic route definitions involve calling a method on the `MagicRoute` facade: ```dart MagicRoute.page('/', () => HomePage()); ``` ### Route Methods Use the `page` method to define full-screen page routes: ```dart // Simple page MagicRoute.page('/greeting', () => Text('Hello World')); // Controller action MagicRoute.page('/dashboard', () => DashboardController.instance.index()); // Inline widget MagicRoute.page('/about', () => AboutPage()); ``` ### The Initial Route Configure your application's initial route via `MagicApplication`: ```dart runApp( MagicApplication( initialRoute: '/dashboard', // ... ), ); ``` ## Route Parameters ### Required Parameters Sometimes you need to capture segments of the URI. For example, to capture a user's ID: ```dart MagicRoute.page('/user/:id', (id) { return UserProfileView(userId: id); }); ``` You may define as many route parameters as required: ```dart MagicRoute.page('/posts/:postId/comments/:commentId', (postId, commentId) { return CommentView(postId: postId, commentId: commentId); }); ``` > [!NOTE] > Magic uses `:param` syntax instead of Laravel's `{param}` syntax. ### Reading Parameters From a Widget `MagicRouter.instance.pathParameter()` answers from the page whose builder ran last, which is not always the page on screen. When a page is pushed across layout shells, go_router builds it before the shell underneath, so the static reader hands the pushed page the parameters of the page beneath it. A widget that has a `BuildContext` should ask the page it sits in instead: ```dart class _ThreadViewState extends State { String? _id; @override void didChangeDependencies() { super.didChangeDependencies(); _id = MagicRouter.instance.pathParameterOf(context, 'id'); } } ``` `queryParameterOf(context, key)` is the query counterpart. Call both from `didChangeDependencies` or `build`, never `initState`: they subscribe the context to the router, which a context still being created cannot do. Both throw a `GoError` when no route page sits above the context (a widget pumped on its own), and neither falls back to the static reader, because that fallback is the wrong page's value. Keep `pathParameter()`, `queryParameter()` and the `Request` facade for code with no `BuildContext`, such as a controller or a middleware. ## Query Parameters Query parameters are the key-value pairs that appear after the `?` in a URL (e.g., `/search?q=flutter&page=2`). Magic provides the `Request` facade to read them from the current route. ### Reading Query Parameters Use `Request.query()` to retrieve a single query parameter by key. It returns `null` when the key is absent: ```dart // URL: /search?q=flutter&page=2 final term = Request.query('q'); // 'flutter' final page = Request.query('page'); // '2' final sort = Request.query('sort'); // null ``` > [!IMPORTANT] > Read the query where a REBUILD can see it, which means `build()` and not `initState()`. Navigating from `/search?q=a` to `/search?q=b` does not remount the screen: go_router keys a page on the matched path and the query is not part of it, so the same `State` is reused and `initState` never runs a second time. A screen that reads its query once at mount renders the first one forever while the address bar shows the second. > > The same applies to a `const` page widget. `MagicRoute.page('/search', () => const SearchPage())` hands back one identical instance every time, so the element never updates and nothing rebuilds at all. Take the path parameter (`(id) => SearchPage(id: id)`) or read the query in `build()`, through `MagicRouter.instance.queryParameterOf(context, 'q')` where the page may be pushed across layout shells. Use `Request.queryParams` to retrieve all query parameters as a `Map`: ```dart // URL: /search?q=flutter&sort=desc final params = Request.queryParams; // {'q': 'flutter', 'sort': 'desc'} ``` ### Navigating With Query Parameters Pass a `query` map to `MagicRoute.to()` or `MagicRoute.toNamed()` to append query parameters to the URL: ```dart // By path MagicRoute.to('/search', query: {'q': 'flutter'}); // By name MagicRoute.toNamed('search', query: {'q': 'flutter', 'page': '2'}); ``` > [!NOTE] > Query parameters are always `String` values. Convert to other types after reading (e.g., `int.tryParse(Request.query('page') ?? '')`). ## Named Routes Named routes allow convenient generation of URLs or redirects for specific routes. Specify a name by chaining the `name` method: ```dart MagicRoute.page('/user/profile', () => ProfileView()) .name('profile'); MagicRoute.page('/user/:id', (id) => UserView(id: id)) .name('user.show'); ``` ### Navigating To Named Routes Once you have assigned a name to a route, you may use it when navigating: ```dart // Navigate to named route MagicRoute.toNamed('profile'); // With path parameters MagicRoute.toNamed('user.show', params: {'id': '42'}); // With query parameters MagicRoute.toNamed('search', query: {'q': 'flutter'}); ``` ## Resource Routes `MagicRoute.resource()` wires the four canonical GET routes for a resource in a single line. The target controller must mix in `ResourceController` and implement the view-building methods it supports. | Path | Method | |:---|:---| | `/{name}` | `index()` | | `/{name}/create` | `create()` | | `/{name}/:id` | `show(id)` | | `/{name}/:id/edit` | `edit(id)` | ```dart class MonitorController extends MagicController with ResourceController { @override Widget index() => const MonitorsIndexView(); @override Widget create() => const MonitorCreateView(); @override Widget show(String id) => MonitorShowView(id: id); @override Widget edit(String id) => MonitorEditView(id: id); } // Register all four routes at once MagicRoute.resource('monitors', MonitorController.instance); // Only a subset MagicRoute.resource( 'status-pages', StatusPagesController.instance, only: ['index', 'show'], ); // All except a few MagicRoute.resource( 'metrics-library', MetricsLibraryController.instance, except: ['create', 'edit'], ); ``` Controllers that only expose a subset can override `resourceMethods`: ```dart class DocsController extends MagicController with ResourceController { @override Set get resourceMethods => const {'index', 'show'}; @override Widget index() => const DocsIndexView(); @override Widget show(String id) => DocsShowView(slug: id); } ``` Each registered route receives the name and title key `{slug}.{method}` (for example `monitors.index`, `monitors.show`). Override with the usual fluent API when needed. Mutating actions (`store`, `update`, `destroy`) stay as regular controller methods invoked via `Http.post` / `put` / `delete`. They are not routes. ## Route Groups Route groups allow you to share route attributes, such as middleware or prefixes, across multiple routes. ### Middleware Assign middleware to all routes within a group: ```dart MagicRoute.group( middleware: ['auth'], routes: () { MagicRoute.page('/dashboard', () => DashboardView()); MagicRoute.page('/profile', () => ProfileView()); }, ); ``` ### Prefixes Add a path prefix to all routes in a group: ```dart MagicRoute.group( prefix: '/admin', middleware: ['auth', 'admin'], routes: () { MagicRoute.page('/', () => AdminDashboard()); // /admin MagicRoute.page('/users', () => AdminUsers()); // /admin/users MagicRoute.page('/settings', () => AdminSettings()); // /admin/settings }, ); ``` ### Nested Groups Groups can be nested. Child groups inherit parent attributes: ```dart MagicRoute.group( prefix: '/admin', middleware: ['auth'], routes: () { MagicRoute.group( prefix: '/users', routes: () { MagicRoute.page('/', () => UserList()); // /admin/users MagicRoute.page('/:id', (id) => UserShow(id: id)); // /admin/users/:id }, ); }, ); ``` ### Layouts (Shell Routes) Assign a persistent layout to all routes within a group. The layout persists while child pages change, which suits tab bars, navigation rails and sidebars: ```dart MagicRoute.group( layoutId: 'app', layout: (child) => AppLayout(child: child), middleware: ['auth'], routes: () { MagicRoute.page('/', () => DashboardView()); MagicRoute.page('/monitors', () => MonitorsView()); MagicRoute.page('/settings', () => SettingsView()); }, ); ``` Your layout widget should accept and render the `child` parameter: ```dart class AppLayout extends StatelessWidget { final Widget child; const AppLayout({required this.child, super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Row( children: [ AppSidebar(), Expanded(child: child), // Child pages render here ], ), ); } } ``` #### One Group, One Shell Every group with a `layout:` compiles to its own go_router `ShellRoute`. Groups that share a `layoutId` share one persistent shell: their routes are concatenated in registration order under the first builder. A group without a `layoutId`, or with a different one, is a separate shell, even when it builds the same layout widget: ```dart MagicRoute.group( layoutId: 'app', layout: (child) => AppLayout(child: child), middleware: ['auth'], routes: () { MagicRoute.page('/', () => DashboardView()); }, ); MagicRoute.group( layoutId: 'app', // Same id, same shell as the group above. layout: (child) => AppLayout(child: child), prefix: '/billing', middleware: ['auth'], routes: () { MagicRoute.page('/', () => BillingView()); }, ); ``` Moving between two shells rebuilds the layout and plays its entrance animation again, so the sidebar flashes and loses its state. Put every route that shows the same layout in groups with one `layoutId`. Shells are emitted in registration order of their first group, after every top-level route, and go_router tries them in that order: a top-level catch-all shadows every layout page. In debug builds, `MagicRouter` prints a note once per layout widget type when a group without a `layoutId` builds the same layout widget as another group (two different explicit ids are taken as deliberate), and suggests the same `layoutId` to share one shell or a unique id to keep them apart on purpose. > [!TIP] > Use layouts for any UI that should persist across page navigation, such as sidebars, bottom navigation bars, or headers. ## Standalone Layouts When you need to define a persistent layout outside of a `group()` call, use `MagicRoute.layout()`. This is useful when you want to build layouts programmatically or pass a list of already-registered route definitions: ```dart final dashboardRoutes = [ MagicRoute.page('/dashboard', () => DashboardView()).name('dashboard'), MagicRoute.page('/monitors', () => MonitorsView()).name('monitors'), MagicRoute.page('/settings', () => SettingsView()).name('settings'), ]; MagicRoute.layout( builder: (child) => AppLayout(child: child), routes: dashboardRoutes, ); ``` Pass an optional `id` to identify the layout. Layouts that share an `id` merge into one shell, exactly like groups sharing a `layoutId`; a layout without one is its own shell: ```dart MagicRoute.layout( id: 'admin-shell', builder: (child) => AdminLayout(child: child), routes: adminRoutes, ); ``` The routes you pass leave the top-level table and live under the layout only, as a group's routes do; an overlay route in the list is the exception and opens on the root navigator, above the shell (see [Bottom Sheet Routes](#bottom-sheet-routes)). go_router takes the first match, trying every top-level route first and then each shell in registration order. A catch-all such as `MagicRoute.page('/:slug', ...)` therefore captures every path it can match that is tried after it: at the top level it shadows every layout page, and last in an app shell it still captures `/login` from a guest shell registered later. Register a catch-all after every route it could shadow, in the last shell tried. > [!TIP] > For most cases, the `layout:` parameter on `MagicRoute.group()` is more convenient. Use `MagicRoute.layout()` when you need to reference a list of routes that are built separately. ## A Layer Above Every Page A layout belongs to its routes: a `to()` to an unstacked route outside the group disposes it with them. When something has to survive every navigation, whatever the route, wrap the router itself with `MagicApplication`'s `builder`: ```dart MagicApplication( builder: (context, child) => Stack( children: [ child!, const FloatingPlayer(), ], ), ) ``` It is passed straight to `MaterialApp.builder`, so the builder runs inside the app's `Theme`, localisations, `Directionality` and `MediaQuery`, and `child` is the `Router`, which builds the root `Navigator`. Whatever you put around `child` sits above the Navigator and inside no route, so its State survives every `to()`, push and `back()`. That is the property a floating video player needs, since remounting its platform view would restart the stream. The same position means `Navigator.of(context)` and `Overlay.of(context)` find nothing from the layer's own context. Navigate with `MagicRoute` and open a dialog with `Magic.dialog()`, which shows it on the root Navigator. A widget inside the layer that needs an `Overlay` of its own, such as a `Tooltip`, a `WPopover` or a `WSelect`, throws unless the layer puts an `Overlay` around it. > [!WARNING] > A soft restart is not a navigation. `Magic.reload()` rebuilds the whole `MaterialApp`, so the layer is remounted with a new State, and `Lang.setLocale()` calls it unless you pass `reload: false`. Only the running app is wrapped: the loading and failure screens shown before `MagicApplication` finishes initialising are not. Leaving `builder` null changes nothing. ## Context-Free Navigation You may navigate from anywhere in your application (controllers, services or pure Dart classes) without needing `BuildContext`: ```dart // Replace current route MagicRoute.to('/dashboard'); // Push onto navigation stack (back button works) MagicRoute.push('/details'); // Go back MagicRoute.back(); // Go back with an explicit fallback path MagicRoute.back(fallback: '/home'); // Replace current route (no new history entry) MagicRoute.replace('/home'); // With query parameters MagicRoute.to('/search', query: {'q': 'flutter'}); ``` ### Cross-Shell Back Navigation `MagicRoute.back()` works reliably even when navigating across shell routes (layouts). Magic maintains a lightweight history stack automatically, with no setup. When the standard pop is not possible, it falls back to the last tracked history entry. Pass an optional `fallback` path to control where navigation lands when the history stack is empty: ```dart // Falls back to '/dashboard' if there is no navigation history MagicRoute.back(fallback: '/dashboard'); ``` > [!NOTE] > The history stack is populated automatically by `MagicRoute.to()` and `MagicRoute.toNamed()` on an UNSTACKED route. A stacked one records nothing, deliberately: the push itself is the record, and `back()` prefers the native pop, so an entry there would leave a string naming the location the pop just landed on and make the next press look like a press that did nothing. `replace()` swaps the last entry without growing the stack, so back navigation after a replace lands at the entry before the replace. ### From Controllers ```dart class AuthController extends MagicController { Future logout() async { await Auth.logout(); MagicRoute.to('/login'); // No context needed! } } ``` ## Back Gestures and the Stack `MagicRoute.to()` calls `go()`, which REPLACES the Navigator's page list. That is the right default for a tabbed app and the reason two platform behaviours are off until you ask for them. On iOS there is no left-edge swipe back, because a swipe pops a page and there is never more than one. On Android it is worse than a missing gesture: Flutter tells the platform whether the app handles back, with one page it answers no, and the embedder then unregisters its callback so the system back button LEAVES THE APP instead of going back. Mark the routes a reader drills INTO with `stacked()`: ```dart // Switched between: leave these alone, or every tap grows the stack. MagicRoute.page('/monitors', () => MonitorsPage()); MagicRoute.page('/incidents', () => IncidentsPage()); // Drilled into: pushed, poppable, and back now means back. MagicRoute.page('/monitors/:id', (id) => MonitorPage(id)) .stacked() .transition(RouteTransition.platform); ``` `back()` is unchanged and still prefers the native pop, so the history fallback keeps covering every route you do not stack. On the web a stacked route owns the address bar: opening `/monitors/42` shows `/monitors/42`, browser back returns to `/monitors`, and a reload reopens the monitor. go_router reports a push under the address of the page beneath it by default, so the router turns on `GoRouter.optionURLReflectsImperativeAPIs` when it is built. go_router discourages that flag because a pushed page's URL is not always deep-linkable; every Magic route is a full path the router matches on its own, so the address always matches a route. It applies to every push, not only stacked routes: `MagicRoute.push()`, `replace()` over a pushed page and a go_router `context.push` report their own address as well. A reload arrives with only the path, so a page pushed with go_router's `extra` gets no `extra` back, and the page that was underneath is gone, so `back()` needs a `fallback` there. The flag is global to go_router, so an app that sets it back to `false` after `Magic.init()` gets the list's address again. On the web, `MagicRoute.back()` closes a pushed page through the browser's own Back instead of adding an entry. go_router reports every change of the page stack to the browser as a new entry, so `back()` from `/monitors/42` used to leave `[/monitors, /monitors/42, /monitors]` behind and the next browser Back reopened the monitor. Now the history stays `[/monitors, /monitors/42]` with `/monitors` current, and Forward reopens the monitor. A second `back()` before the browser has answered does nothing, and a `to()`, `push()` or `replace()` issued meanwhile runs once it has, so `MagicRoute.back(); MagicRoute.to('/monitors/7')` lands on `/monitors/7` with the history `[/monitors, /monitors/7]`. If the browser does not answer within a second the step is given up and the page stays open (a sheet a drag slid shut slides back up); nothing closes in the app then, so an answer the browser sends late closes the page like its own Back instead of stepping past an entry the app just added. The router steps back only when it can vouch for the entry before: the page was pushed by `to()` or `push()` while the browser's current entry held exactly the stack beneath it, and the browser is still on the entry that push created (compared by its full encoded stack, page keys included, not only its address). A page whose top was swapped by `replace()`, including the query swap `to()` does on the screen already showing, gives up that claim: go_router keeps the page key and reports the swap as a new entry, so the entry before is the page as it was. Every other close happens in the app and adds an entry the old way, so the closed page can sit behind browser Back, one entry back, rather than the router stepping into an entry it cannot vouch for: - `Navigator.pop` and `Navigator.popUntil` from app code, which keep go_router's synchronous pop on every route except a guarded bottom sheet; - a page pushed by a go_router `context.push`, or two pushes in one frame (they share one entry, so neither owns it); - any page after the app closed a page in the app, since the entry before no longer holds the stack beneath. A refresh of the router (`refreshListenable`, the auth state notifier) does not re-run the redirect of a page pushed on top of the stack: go_router re-parses the browser's entry from the address of the stack's base, so a stacked page whose middleware now redirects stays on screen until the next navigation. That is go_router's behaviour on every platform, not something the web history work changed. No route gains a go_router `onExit` for this; magic intercepts only the closes it drives itself. Measured in Chrome; Safari, Firefox and mobile browsers were not measured. One exception, and it is not one you can trigger by hand. A navigation issued before the `Router` widget has parsed a location replaces rather than pushes, whichever verb asked: `to()` on a `stacked()` route and `MagicRoute.push()` alike. go_router pushes onto `routerDelegate.currentConfiguration`, which is empty until the widget mounts, so pushing there leaves the delegate reporting an empty location that `currentLocation`, `pathParameter` and `queryParameter` all read afterwards (`pathParameterOf` and `queryParameterOf` read the page's own state and are not affected). The realistic way in is a deeplink or a tapped push notification on a cold start, and there is nothing underneath to pop back to at that point anyway: a link arriving from outside the app is where the reader arrives, not somewhere they stepped to. `toNamed()` answers the same way. It resolves the name to a location and hands it to `to()`, so one route behaves one way whichever verb reaches it: ```dart MagicRoute.page('/monitors/:id', (id) => MonitorPage(id)).name('monitors.show').stacked(); MagicRoute.toNamed('monitors.show', params: {'id': '42'}); // pushed, like to() ``` The page a stacked route builds is `MagicPlatformPage`, exported for a test or a type check that needs the name. Nothing asks you to construct one: the router builds it from the route's own `transition()` and `swipeBack()`. Navigating to the path you are already on depends on whether you name a query: | From | `to(...)` | Result | |:---|:---|:---| | `/monitors/42` | `'/monitors/42'` | nothing; a re-tapped destination does not stack a screen on itself | | `/monitors/42?tab=checks` | `'/monitors/42'` | nothing; naming no query is asking for the screen, not asking to clear its tab | | `/monitors/42?tab=overview` | `'/monitors/42', query: {'tab': 'checks'}` | the top page is swapped, so the screen rebuilds with the new tab and the pages under it survive | That swap rebuilds rather than remounts, which is what every other navigation in Magic does with a query change. See [Reading Query Parameters](#query-parameters) for where a screen has to read its query for that to be visible. "The path you are already on" is the top of the page stack, read the moment `to()` is called: over a page inside a layout, and for a second `to()` in the same frame (a double click), it is the pushed screen, so the swap and the re-tap rules above hold there too. Set the default once when a whole app wants it: ```dart // In a service provider's boot(), before the router is built. MagicRouter.instance.defaultStacked = true; MagicRouter.instance.defaultTransition = RouteTransition.platform; ``` > [!NOTE] > Leave `defaultStacked` off on web. `go()` already produces a working browser Back, and pushing adds Navigator pages on top of that. ### Transitions `RouteTransition.platform` is the one that carries gestures. It routes through Flutter's `PageTransitionsTheme`, so iOS and macOS get the Cupertino slide plus the edge swipe, Android gets predictive back, and Windows and Linux get the zoom. The other values build a custom transition on a bare page route, which carries no gesture at all: Flutter installs the back-swipe detector inside the Cupertino transition rather than beside it. `RouteTransition.none`, the default, is an instant switch with no animation. | Value | Animation | Back gesture | |:---|:---|:---| | `none` (default) | none | no | | `platform` | the running platform's | yes | | `fade`, `slideRight`, `slideUp`, `scale` | as named, every platform | no | A side menu needs no arbitration with the swipe, even though both live on the left edge. Flutter refuses the gesture on a route with nothing under it, so the detector never enters the gesture arena on a drawer's own screen; on a pushed route both are armed and the deeper one wins. ### Turning the Gesture Off ```dart MagicRoute.page('/checkout/payment', () => PaymentPage()) .stacked() .transition(RouteTransition.platform) .swipeBack(false); ``` `swipeBack(false)` refuses the GESTURE and nothing else: the route is still popped by the Android back button, by a back button in your own chrome, and by `MagicRoute.back()`. When the answer is "this route should not be left yet at all", use `PopScope` instead. It covers every one of those, and Flutter's gesture already honours it, so you do not need both. ```dart PopScope( canPop: !form.isDirty, child: EditMonitorPage(), ) ``` ### Changing What `platform` Looks Like `RouteTransition.platform` has no animation of its own. It routes through `Theme.of(context).pageTransitionsTheme`, so leaving it alone gives every platform the animation its own operating system uses, which is the reason to reach for it. Most apps should stop here. When one surface needs a different answer, `MagicApplication` takes the theme: ```dart MagicApplication( pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(), }, ), ) ``` It is applied with `copyWith`, so your `wind_kit` theme's colours, typography and component defaults are untouched, and leaving it null changes nothing at all. A per-route `RouteTransition` cannot express this, which is why the knob exists: the transition is chosen once at registration, and the case that comes up is the opposite shape, keep the mobile builds' native animation and give the desktop or web build none. > [!NOTE] > A partial map is a partial override. A platform you leave out of `builders` keeps its OWN default rather than falling through to a shared one, so omitting `TargetPlatform.iOS` leaves the Cupertino slide and its edge swipe exactly where they were. What removes the gesture is naming iOS and giving it a different builder, since Flutter installs the swipe detector inside the Cupertino transition rather than beside it. Nothing else is affected: `fade`, `slideRight`, `slideUp` and `scale` build their own animation explicitly and never consult the theme. ## Bottom Sheet Routes A route marked `overlay()` opens as a modal bottom sheet over a page instead of replacing it, and keeps its own URL: ```dart MagicRoute.page('/stock', () => StockPage()).title('Stock'); MagicRoute.page('/stock/add', () => AddItemSheet()) .title('Add item') .overlay(base: '/stock', canDismiss: () => !form.isDirty); ``` - `MagicRoute.to('/stock/add')` always PUSHES the sheet over the page on screen, whoever calls it: a deep link that `magic_deeplink` routes through `to()`, or the redirect back to an intended URL after login, opens the sheet over whatever page is current. Closing it, `MagicRoute.back()`, the Android back button and browser Back return to that page with its state intact. On the web the address bar shows `/stock/add` while the sheet is open. Calling `to()` again for a sheet that is already on top does nothing, so a double click opens one sheet; `MagicRoute.push()` has no such guard. - A location that replaces the whole stack builds `base` first and opens the sheet over it in the next frame, so closing it leaves a real page underneath. That is the initial location, route information the platform reports (a reload, a typed address, a link the platform hands to the router) and a go_router `go()`. On the web the sheet waits until the browser holds an entry for `base`, so a page loaded straight on `/stock/add` leaves the history as `[/stock, /stock/add]`: the first browser Back closes the sheet onto `/stock` and the second leaves the app. That is best effort on Chrome until the reader has interacted with the page: Chrome's history intervention lets its real Back button skip an entry a script added without a user gesture, so a cold load nobody touched may still leave the app on the first Back. The Chrome measurement drove Back through Playwright, whose `goBack` does not go through that rule, so it does not prove the untouched case. A reload with the sheet open is a cold entry too, and since the browser hands a reloaded page only its path, the entry it reloaded becomes `/stock` and the sheet gets a new one, so the history then holds `/stock` twice and one Back in between shows no change (measured in Chrome). `MagicRoute.replace('/stock/add')` is not such an entry: it swaps the top page for the sheet, and when that page is the only one, nothing is left underneath. - `base` is the full path of another registered page route (group prefix included). It may declare parameters, which a cold entry fills from the overlay's own location, so the overlay must declare each of them: `/locations/:id/count` over `/locations/:id` opens on location 42 for `/locations/42/count`. The router refuses to build when `base` names no route, names another overlay, or needs a parameter the overlay path lacks. - `canDismiss` is asked before every close: a scrim tap, a drag down, the system back button, browser Back, `MagicRoute.back()`, and a `to()` that replaces the stack. A `to()` to a `stacked()` route pushes over the sheet and asks nothing. While it answers `false` the scrim and the drag are off and every other way out leaves the sheet open. It is read again on each attempt, so a guard that turns while the sheet is open is honoured; a drag that was already allowed slides the sheet back up. Browser Back refused this way keeps the sheet and the address bar stays on the sheet's URL (measured in Chrome). - On the web, closing the sheet from inside the app steps the browser history back like the stacked pages in [Back Gestures and the Stack](#back-gestures-and-the-stack), under the same conditions, when the close is its own button calling `MagicRoute.back()`, a scrim tap or a drag. A later browser Back then leaves `/stock` instead of reopening the sheet, and Forward reopens it. A button that calls `Navigator.pop` closes in the app and adds an entry, so a later browser Back reopens the sheet: close with `MagicRoute.back()`, and to leave the sheet for another page write `MagicRoute.back(); MagicRoute.to('/stock/42')`, which lands on `/stock/42` with the sheet's entry gone. Measured in Chrome for each close, for a sheet over a stacked page, and after a refused Back once the guard let go; Safari, Firefox, mobile browsers and the Android system back button were not measured. - A panel that refuses the pop itself (`PopScope(canPop: false)`, a `Form` with `canPop: false`, a local history entry) keeps it: a scrim tap or Escape then leaves the sheet open and the browser history untouched, and the panel's own `onPopInvokedWithResult` runs once, as it would without the router. A drag is a `Navigator.pop` and is not stopped by `PopScope`, as with any Flutter bottom sheet; use `canDismiss` to stop it. - A panel built on `DraggableScrollableSheet(shouldCloseOnMinExtent: true)` that closes by shrinking to its minimum closes in the app and adds an entry the old way: `BottomSheet` closes it from the extent change, not from a drag-end fling, which is what the router recognises. - A guarded sheet (one with `canDismiss`) takes a go_router `onExit`, on every platform, so go_router finishes each pop of it in a microtask. Two consequences: a `Navigator.popUntil` over the open sheet never gets past it and must not be used, and every `to()`, `push()` or `replace()` issued while the sheet is on top runs one event later, whether or not a pop came first (and waits further for a browser step back started meanwhile), so `Navigator.pop(); MagicRoute.to('/places/5')` lands on `/places/5` instead of go_router dropping it with the closed sheet. A second pop of the same sheet in one event is refused. The sheet is a frame only: a `ModalBottomSheetRoute` on the root navigator with a transparent background, `isScrollControlled`, no drag handle, no safe-area padding and no `constraints`, so Material 3's 640dp width cap applies. That is the frame `showModalBottomSheet(backgroundColor: Colors.transparent, isScrollControlled: true, useRootNavigator: true)` builds, so a panel widget that draws its own surface, handle and safe-area padding gets the same frame opened by URL or by hand. Whatever the route builds is the panel. A route page has no caller context to capture themes from, so the sheet inherits the app-level themes, not a `Theme` or `DefaultTextStyle` that a layout sets for its own pages. An app can open its overlays as centred dialogs instead, typically on a wide window, by installing a resolver once in a service provider: ```dart MagicRoute.useOverlayPresentation( (context) => MediaQuery.sizeOf(context).width >= 1024 ? MagicOverlayPresentation.dialog : MagicOverlayPresentation.sheet, ); ``` The resolver is asked once each time an overlay opens, with the root navigator's context; the answer holds until that overlay closes, so resizing the window does not swap an open frame. Without a resolver every overlay is a sheet. Magic itself knows no breakpoint: the decision is the app's. The dialog is a frame as well: a `DialogRoute` on the root navigator with the sheet's scrim colour, no safe-area padding and no captured themes. Like the sheet's `BottomSheet`, it puts a Material above the panel, a transparent one, so a bare `Text` or `WText` in the panel gets the theme's text style instead of the debug fallback (a yellow double underline). That Material paints nothing and takes no tap of its own, so the scrim around the panel still closes the dialog; the panel still draws its own dialog surface and size. Everything above except the drag applies to it unchanged: the URL, the cold entry, `canDismiss` (a guard that says no turns the scrim and Escape off) and the browser history steps. `MagicRouter.reset()` drops the resolver with the rest of the router. An overlay declared inside a layout group keeps the group's prefix and middleware but never renders inside the layout: it opens above the shell, scrim and all. It is also matched BEFORE the layout's pages, which is what lets it win over a catch-all such as `/:path(.*)` declared last in the group, and it means an overlay path must not also match a page path: an overlay at `/items/:id` would capture a `/items/new` page. Route middleware and titles behave as on any page: `redirectTarget` runs first (a guard that redirects wins over building the base), `handle()` runs when the sheet mounts, and the title follows the sheet while it is open. `transition()`, `stacked()` and `swipeBack()` do not apply to an overlay. ## Route Middleware Assign middleware to individual routes using the `middleware` method: ```dart MagicRoute.page('/profile', () => ProfileView()) .middleware(['auth']); MagicRoute.page('/admin', () => AdminPanel()) .middleware(['auth', 'admin']); ``` See the [Middleware documentation](https://anilcancakir.com/magic/docs/basics/middleware.md) for details on creating custom middleware. ## URL Strategy Flutter web defaults to hash-based URLs (`/#/path`). Magic can enable clean path-based URLs (`/path`) via config, with no code changes needed elsewhere. ### URL Strategy (Path vs Hash) Set `url_strategy` in your `config/routing.dart`: ```dart 'routing': { 'url_strategy': 'path', // 'path' | 'hash' | null (default: null, the hash strategy) }, ``` | Value | URL shape | Notes | |:---|:---|:---| | `null` (default) | `https://example.com/#/dashboard` | No web server config required | | `'path'` | `https://example.com/dashboard` | Web server must rewrite all paths to `index.html` | | `'hash'` | `https://example.com/#/dashboard` | Explicit hash; identical to `null` | Magic applies `usePathUrlStrategy()` during bootstrap (before `runApp`) when the value is `'path'`. > [!NOTE] > This setting has no effect on iOS, Android or desktop: it is web-only. **Web server rewrite requirement:** when using path-based URLs, every path must serve your Flutter app's `index.html`. Without this, a direct visit to `https://example.com/dashboard` returns a 404 from the server. Example nginx configuration: ```nginx location / { try_files $uri $uri/ /index.html; } ``` ## Navigator Observers Register `NavigatorObserver` instances for analytics, monitoring, or performance tracking. Observers must be added before the router is built (typically in your `RouteServiceProvider`): ```dart class ScreenLogObserver extends NavigatorObserver { @override void didPush(Route route, Route? previousRoute) { Log.debug('Screen: ${route.settings.name}'); } } class RouteServiceProvider extends ServiceProvider { @override Future boot() async { // Add observers before registering routes MagicRouter.instance.addObserver(ScreenLogObserver()); registerAppRoutes(); } void registerAppRoutes() { MagicRoute.page('/', () => HomePage()); // ... } } ``` Every page carries a name in its `RouteSettings`, which is what an observer reads to tell one screen from another. It is the route's `.name()` when you set one, and the route path otherwise: ```dart MagicRoute.page('/orders', () => OrdersPage()).name('orders'); // -> 'orders' MagicRoute.page('/settings', () => SettingsPage()); // -> '/settings' ``` > [!NOTE] > `GoRoute.name` and `RouteSettings.name` are different things, and only the > second one reaches an observer. Anything that identifies screens depends on > it: analytics and breadcrumb trails. Sentry's navigator observer is registered for you by the `magic_sentry` plugin (`flutter pub add magic_sentry`, then `dart run magic:dowel plugin:install magic_sentry`); see its [Boot and Provider page](https://anilcancakir.com/magic/docs/sentry/basics/boot-and-provider). Pages inside a layout are named the same way and reach the same observers. Layouts compile to `ShellRoute`, whose children push onto the shell's own nested navigator, not the root one. go_router still forwards that navigator's events to the observers you passed to the router, so they see every page. Observers are passed directly to GoRouter and receive all navigation events (`didPush`, `didPop`, `didReplace`, `didRemove`). > [!NOTE] > Observers must be registered before `routerConfig` is accessed. Adding observers after the router is built throws a `StateError`. ## Page Titles Magic provides automatic page title management via `SystemChrome.setApplicationSwitcherDescription`, which updates the browser tab title on web and the app switcher description on mobile. On Android the switcher card also carries a colour, and on web the same value becomes the `theme-color` meta. `MagicApplication` hands its theme's `primaryColor` to `TitleManager.instance.setPrimaryColor()`, the colour `MaterialApp` uses, so once the app has built a title change keeps that colour. Chrome keeps a tab title per history entry, and the Flutter engine swaps entries with `history.pushState` or `replaceState` after a page builds, which used to leave the tab showing the previous entry's title or the bare host (`app.example.com`) while `document.title` was correct. `MagicApplication` re-sends the title after every history entry change on the web (through the Navigation API), so nothing is needed in your app. Other platforms, and browsers without the Navigation API, are unaffected. ### Title Suffix Set a global suffix via `MagicApplication`: ```dart MagicApplication( title: 'My App', titleSuffix: 'Acme', ) ``` Page titles render as `"Dashboard - Acme"`. The suffix is only applied to route-level and override titles: when no page title is set, the fallback app title is shown without suffix. ### Static Route Titles Assign titles to routes using the fluent `.title()` method: ```dart MagicRoute.page('/dashboard', () => DashboardPage()) .name('dashboard') .title('Dashboard') .middleware(['auth']); ``` The title is set automatically when the route becomes active. ### Dynamic Titles with MagicTitle Widget For data-dependent titles that resolve after the route mounts, wrap your widget with `MagicTitle`: ```dart class ProjectPage extends StatelessWidget { final String projectName; const ProjectPage({super.key, required this.projectName}); @override Widget build(BuildContext context) { return MagicTitle( title: projectName, child: Scaffold( appBar: AppBar(title: Text(projectName)), body: ProjectContent(), ), ); } } ``` `MagicTitle` sets the title on mount, updates on rebuild, and clears on dispose (falling back to the route title). ### Imperative Title API Set or read the title from anywhere (controllers, services, callbacks): ```dart // Set title imperatively MagicRoute.setTitle('User Profile: John'); // Read the current title (without suffix) final title = MagicRoute.currentTitle; ``` ### Title Resolution Priority Highest to lowest: 1. `MagicTitle` widget / `MagicRoute.setTitle()`: explicit override 2. `RouteDefinition.title()`: static route title 3. `MagicApplication.title`: app-level fallback When a higher-priority source is cleared (e.g., `MagicTitle` disposes), the next level takes over automatically. ## Router Config `MagicRoute.config` exposes the underlying `GoRouter` instance as a `RouterConfig`, suitable for passing directly to `MaterialApp.router` (`MaterialApp` comes from `package:material_ui/material_ui.dart`): ```dart @override Widget build(BuildContext context) { return MaterialApp.router( routerConfig: MagicRoute.config, title: 'My App', ); } ``` `MagicRoute.config` builds the `GoRouter` on its first read; `Magic.init()` reads it once at the end of the bootstrap, after every provider has booted. Read it only after `Magic.init()` completes: an earlier read does not throw, it freezes the router with the routes registered so far, and every later route, layout or observer registration (`addRoute`, `addLayout`, `addObserver`) throws a `StateError`. ## Gotchas | Mistake | Fix | |:---|:---| | Reading `Request.query()` once in `initState()` | A query change does not remount the screen; read it in `build()` (or through `queryParameterOf(context, key)`) | | A `const` page widget in `MagicRoute.page('/search', () => const SearchPage())` | It hands back one identical instance, so nothing rebuilds on a query change; take a path parameter or read the query in `build()` | | Using `MagicRouter.instance.pathParameter()` from a widget | It answers for the page that built last, which may sit beneath the one on screen; use `pathParameterOf(context, key)` from `didChangeDependencies` or `build` | | Expecting a swipe back or a working Android back button on a `to()` route | `to()` replaces the page list; mark drill-down routes with `.stacked()` and `.transition(RouteTransition.platform)` | | Adding a `NavigatorObserver` after the router is built | `addObserver` throws a `StateError`; register observers in `boot()`, before `routerConfig` is read | | Giving two groups the same layout widget but no shared `layoutId` | Each group compiles to its own shell, so moving between them rebuilds the layout; give the groups one `layoutId` | | Wrapping a dialog overlay panel in a full-frame `Material` to fix its text style | The dialog already puts a transparent `Material` above the panel; add a `Material` only where the panel needs its own surface, and keep it off the scrim around the panel | ## Related Documentation - [Middleware](https://anilcancakir.com/magic/docs/basics/middleware.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Views](https://anilcancakir.com/magic/docs/basics/views.md) - [Request Lifecycle](https://anilcancakir.com/magic/docs/architecture/request-lifecycle.md) - [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md) --- # Middleware Middleware lets you inspect and filter navigation requests before they reach a route, making it the right place for authentication guards, permission checks, and pre-navigation side effects. - [Introduction](#introduction) - [Generating Middleware](#generating-middleware) - [Defining Middleware](#defining-middleware) - [Registering Middleware](#registering-middleware) - [Global Middleware](#global-middleware) - [Assigning Middleware To Routes](#assigning-middleware-to-routes) - [Middleware & Responses](#middleware--responses) - [Authorization Middleware](#authorization-middleware) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Introduction Middleware provide a convenient mechanism for inspecting and filtering navigation requests entering your application. For example, an authentication middleware verifies the user is signed in. If the user is not, the middleware redirects them to your application's login screen; otherwise it lets the request proceed further into the application. You write that one yourself (see below); the core ships `AuthorizeMiddleware` for Gate abilities. Of course, additional middleware can be written to perform a variety of tasks besides authentication. Middleware are stored in the `lib/app/middleware` directory. ## Generating Middleware Use dowel to generate a new middleware class: ```bash dart run magic:dowel make:middleware EnsureAuthenticated ``` This creates `lib/app/middleware/ensure_authenticated.dart` with a `MagicMiddleware` stub ready to implement. ## Defining Middleware To create a new middleware, create a class that extends `MagicMiddleware`: ```dart import 'package:magic/magic.dart'; class EnsureAuthenticated extends MagicMiddleware { @override String? redirectTarget(String location) { if (!Auth.check() && location != '/login') return '/login'; return null; // Authenticated, or already on /login: allow. } } ``` As you can see, if the user is not authenticated, the middleware returns the login path and the user is redirected to the login screen. If the user is authenticated, it returns `null` and the request proceeds. ### Redirect guards vs blocking guards Middleware has two hooks, and you pick the one that matches the intent: - **`redirectTarget(String location)`** for redirect-style guards (auth, guest, role gates). It is evaluated synchronously in the router's `redirect` callback BEFORE the route builds, so the destination view mounts exactly once. Return a path to redirect to, or `null` to allow. Always return `null` when `location` already equals the target, otherwise the redirect loops. - **`handle(void Function() next)`** for async, non-redirecting concerns (logging, a blocked-state screen). Call `next()` to allow, skip it to block. Both hooks default to allow, so override only the one you need. > [!IMPORTANT] > Prefer `redirectTarget` for redirects. Redirecting with an imperative `MagicRoute.to()` from inside `handle()` runs after the route has already mounted, so it remounts the destination view and recreates its state. ### Async Operations All middleware is asynchronous. You may perform `await` operations before deciding whether to call `next()`: ```dart class TeamAccessMiddleware extends MagicMiddleware { @override Future handle(void Function() next) async { final response = await Http.get('/teams/${Request.route('teamId')}/access'); if (response.successful) { next(); } else { MagicRoute.to('/unauthorized'); } } } ``` ## Registering Middleware ### Global Middleware If you want middleware to run during every navigation in your application, register it globally in your `lib/app/kernel.dart`: ```dart void registerKernel() { Kernel.global([ () => LoggingMiddleware(), () => VerifyDeviceMiddleware(), ]); } ``` Global middleware runs on every route, in the order they are registered. ### Assigning Middleware To Routes To assign middleware to specific routes, first register it with a key in your Kernel: ```dart void registerKernel() { Kernel.registerAll({ 'auth': () => EnsureAuthenticated(), 'guest': () => RedirectIfAuthenticated(), 'admin': () => EnsureAdmin(), }); } ``` Then use the string key in your route definitions: ```dart // Single middleware MagicRoute.page('/dashboard', () => DashboardView()) .middleware(['auth']); // Multiple middleware (executed in order) MagicRoute.page('/admin', () => AdminPanel()) .middleware(['auth', 'admin']); ``` An alias nothing registered stops the app at `Magic.init`, rather than leaving the route ungated: ``` StateError: Unresolvable route middleware on 1 route: /admin: Route middleware alias "admin" is not registered. Register it with Kernel.register('admin', () => ...) from a service provider. ``` The router checks every registered route's middleware when it builds, which is step 7 of `Magic.init`, after every provider has booted. So an alias registered from `register()` or from `boot()` is equally early, and the usual cause of this error is a typo or an alias nobody registered at all. Every registered route includes the ones declared inside a layout. A route in `MagicRoute.group(layout: ...)` or `MagicRoute.layout(...)` is held in the layout's own child list rather than the top-level table, and a shell or tab layout is where a gated screen usually lives, so the check walks both. Bootstrap rather than navigation is deliberate. `Kernel.resolveAll` throws at navigation too, but that throw happens inside GoRouter's `redirect` callback, which routes it to `onException`: the page renders nothing and the log reports a routing failure, which names the wrong problem. ### Route Group Middleware You can apply middleware to all routes within a group: ```dart MagicRoute.group( middleware: ['auth'], routes: () { MagicRoute.page('/dashboard', () => DashboardView()); MagicRoute.page('/profile', () => ProfileView()); MagicRoute.page('/settings', () => SettingsView()); }, ); ``` ## Middleware & Responses Since middleware controls navigation flow, there are two possible outcomes: | Action | Effect | |:---|:---| | Call `next()` | Navigation proceeds to the route | | Don't call `next()` | Navigation is blocked | When blocking, you should redirect the user: ```dart @override Future handle(void Function() next) async { if (await hasValidSubscription()) { next(); } else { // Don't call next() - redirect instead MagicRoute.to('/subscription/required'); Magic.error('Subscription Required', 'Please upgrade your plan.'); } } ``` ## Authorization Middleware Magic includes `AuthorizeMiddleware` that integrates with the Gate authorisation system, the counterpart of Laravel's `can` middleware. ### Basic Usage Register authorisation middleware in your Kernel: ```dart void registerKernel() { Kernel.registerAll({ 'auth': () => EnsureAuthenticated(), 'can:manage-team': () => AuthorizeMiddleware('manage-team'), 'can:admin': () => AuthorizeMiddleware('admin-access'), }); } ``` Use in your routes: ```dart MagicRoute.page('/team/settings', () => TeamSettings()) .middleware(['auth', 'can:manage-team']); MagicRoute.page('/admin', () => AdminPanel()) .middleware(['auth', 'can:admin']); ``` ### Custom Redirect By default, unauthorised users are redirected to `/unauthorized`. Customise this: ```dart Kernel.register('can:admin', () => AuthorizeMiddleware( 'admin-access', unauthorizedRoute: '/access-denied', )); ``` ### With Model Arguments For authorisation requiring a model (like checking ownership), create a custom middleware: ```dart class CanEditPostMiddleware extends MagicMiddleware { @override Future handle(void Function() next) async { final response = await Http.show('posts', Request.route('id')!); if (response.successful && Gate.allows('edit-post', Post.fromMap(response.data))) { next(); } else { MagicRoute.to('/unauthorized'); } } } ``` Register and use it: ```dart Kernel.register('can:edit-post', () => CanEditPostMiddleware()); MagicRoute.page('/posts/:id/edit', (id) => EditPostView(id: id)) .middleware(['auth', 'can:edit-post']); ``` See the [Authorization documentation](https://anilcancakir.com/magic/docs/security/authorization.md) for more on defining abilities and policies. ## Gotchas | Mistake | Fix | |:---|:---| | A `redirectTarget` that does not return `null` when `location` already equals the target | Return `null` for the target itself, otherwise the redirect loops | | Redirecting with `MagicRoute.to()` from inside `handle()` | Use `redirectTarget`: it runs before the route builds, so the destination mounts once instead of remounting | | Using a route alias nobody registered | `Magic.init` stops with a `StateError` naming the route and the alias; register it with `Kernel.register` or `Kernel.registerAll` | | Passing an instance to `Kernel.register` | It takes a factory: `Kernel.register('auth', () => EnsureAuthenticated())` | | Skipping `next()` in `handle()` without redirecting | The route renders an empty box in place of its page; send the user somewhere or show feedback | | `AuthorizeMiddleware` with no `/unauthorized` route | A failed ability redirects to `unauthorizedRoute` (default `/unauthorized`); register that route or pass your own | ## Related Documentation - [Routing](https://anilcancakir.com/magic/docs/basics/routing.md) - [Authentication](https://anilcancakir.com/magic/docs/security/authentication.md) - [Authorization](https://anilcancakir.com/magic/docs/security/authorization.md) - [Request Lifecycle](https://anilcancakir.com/magic/docs/architecture/request-lifecycle.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `make:middleware`. --- # Controllers Controllers extend `MagicController` (a `ChangeNotifier`) to hold reactive state, run validation, and dispatch navigation, keeping business logic out of the widget tree. - [Introduction](#introduction) - [Writing Controllers](#writing-controllers) - [Basic Controllers](#basic-controllers) - [Single Action Controllers](#single-action-controllers) - [Controller State Management](#controller-state-management) - [The MagicStateMixin](#the-magicstatemixin) - [Rendering State](#rendering-state) - [Fetch Helpers](#fetch-helpers) - [Validation Handling](#validation-handling) - [Running Writes, Timers, and Broadcasts](#running-writes-timers-and-broadcasts) - [Controller Lifecycle](#controller-lifecycle) - [Generating Controllers](#generating-controllers) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Introduction Instead of defining all of your request handling logic as closures in your route files, you may wish to organise this behaviour using "controller" classes. Controllers can group related request handling logic into a single class. Controllers are stored in the `lib/app/controllers` directory. ## Writing Controllers ### Basic Controllers To generate a new controller, use the `make:controller` dowel command: ```bash dart run magic:dowel make:controller User ``` A basic controller extends `MagicController` and contains action methods that return widgets: ```dart import 'package:material_ui/material_ui.dart'; import 'package:magic/magic.dart'; import '../../resources/views/users/user_list_view.dart'; import '../../resources/views/users/user_show_view.dart'; class UserController extends MagicController { /// Singleton accessor, registered on first use. static UserController get instance => Magic.findOrPut(UserController.new); /// Display a listing of users. Widget index() => const UserListView(); /// Display the specified user. Widget show(String id) => UserShowView(userId: id); /// Show the form for creating a new user. Widget create() => const CreateUserView(); } ``` Register the controller's methods as routes: ```dart MagicRoute.page('/users', () => UserController.instance.index()); MagicRoute.page('/users/create', () => UserController.instance.create()); MagicRoute.page('/users/:id', (id) => UserController.instance.show(id)); ``` > [!NOTE] > `Magic.findOrPut()` registers the controller in the container on first use and returns that same instance afterwards. ### Single Action Controllers If a controller action is particularly complex, you may dedicate an entire controller class to that single action: ```dart class ExportReportController extends MagicController { static ExportReportController get instance => Magic.findOrPut(ExportReportController.new); Widget invoke() { return const ExportReportView(); } } ``` ## Controller State Management ### The MagicStateMixin Controllers may use `MagicStateMixin` to manage async data and loading states: ```dart class UserController extends MagicController with MagicStateMixin> { static UserController get instance => Magic.findOrPut(UserController.new); Widget index() { if (isEmpty) loadUsers(); return const UserListView(); } Future loadUsers() => fetchList('users', User.fromMap); } ``` `fetchList` drives `setLoading`, `setSuccess`, `setError` and `setEmpty` for you (see [Fetch Helpers](#fetch-helpers)); call the four methods below directly when you load by other means. #### State Methods | Method | Description | |:---|:---| | `setLoading()` | Set loading state | | `setSuccess(T data)` | Set success state with data | | `setError(String message)` | Set error state with message | | `setEmpty()` | Set empty state (no data) | #### State Properties | Property | Type | Description | |:---|:---|:---| | `isLoading` | bool | Currently loading | | `isSuccess` | bool | Successfully loaded | | `isError` | bool | Error occurred | | `isEmpty` | bool | Empty/no data | | `rxState` | T? | The current data | ### Fetch Helpers `MagicStateMixin` ships two convenience methods that handle the full loading → success/error/empty cycle without boilerplate. Both delegate to `Http.get()` internally. #### `fetchList` Fetch a paginated or collection endpoint. The response body must contain a JSON array under `dataKey` (default: `'data'`). ```dart Future fetchList( String url, E Function(Map) fromMap, { String dataKey = 'data', Map? query, Map? headers, }) ``` ```dart class ProjectController extends MagicController with MagicStateMixin> { static ProjectController get instance => Magic.findOrPut(ProjectController.new); Future loadProjects(String teamId) => fetchList('teams/$teamId/projects', Project.fromMap); // With query parameters Future search(String q) => fetchList('projects', Project.fromMap, query: {'q': q}); } ``` State transitions: | Condition | Resulting State | |:---|:---| | Request fails (4xx/5xx) | `isError` with `response.errorMessage` | | Transport failure (status `0`: no connection, timeout) | `isError` with the transport exception's message when the driver has one, otherwise `'Failed to load'` | | Body is not a JSON object | `isEmpty` | | `dataKey` list is null or empty | `isEmpty` | | List has items | `isSuccess` with `List` cast to `T` | `MagicResponse.failed` is true for a status of 400 or more and for status `0`, so a timeout or dead link reaches `setError` rather than the empty state: "no rows" and "nobody answered" are different screens. #### `fetchOne` Fetch a single resource. The response body must contain the resource object under `dataKey` (default: `'data'`). ```dart Future fetchOne( String url, T Function(Map) fromMap, { String dataKey = 'data', Map? query, Map? headers, }) ``` ```dart class ProjectDetailController extends MagicController with MagicStateMixin { static ProjectDetailController get instance => Magic.findOrPut(ProjectDetailController.new); Future loadProject(String id) => fetchOne('projects/$id', Project.fromMap); } ``` State transitions: | Condition | Resulting State | |:---|:---| | Request fails (4xx/5xx) | `isError` with `response.errorMessage` | | Transport failure (status `0`: no connection, timeout) | `isError` with the transport exception's message when the driver has one, otherwise `'Failed to load'` | | Body is not a JSON object | `isError` with `'Invalid response format'` | | `dataKey` value is null | `isError` with `'Resource not found'` | | Data present | `isSuccess` with parsed `T` | > [!TIP] > Both helpers accept optional `query` and `headers` parameters, forwarded directly to `Http.get()`. Use `query` for pagination or filtering parameters. ### Rendering State Use `renderState` in your view to declaratively handle different states, like Blade's `@if` directives: ```dart class UserListView extends MagicView { @override Widget build(BuildContext context) { final controller = UserController.instance; return controller.renderState( (users) => ListView.builder( itemCount: users.length, itemBuilder: (_, i) => UserCard(user: users[i]), ), onLoading: Center(child: CircularProgressIndicator()), onError: (msg) => WText(msg), onEmpty: EmptyState(message: 'No users yet'), ); } } ``` ## Validation Handling Controllers can handle server-side validation errors using the `ValidatesRequests` mixin: ```dart class AuthController extends MagicController with MagicStateMixin, ValidatesRequests { static AuthController get instance => Magic.findOrPut(AuthController.new); Future register(Map data) async { setLoading(); clearErrors(); // Clear previous validation errors final response = await Http.post('/register', data: data); if (response.successful) { setSuccess(true); Magic.success('Success', 'Account created!'); MagicRoute.to('/dashboard'); } else { // Automatically handles 422 validation errors handleApiError(response, fallback: 'Registration failed'); } } } ``` ### ValidatesRequests Methods | Method | Description | |:---|:---| | `handleApiError(response)` | Handle 422 and other API errors automatically | | `setErrorsFromResponse(response)` | Populate errors from 422 validation response | | `hasError('field')` | Check if a field has an error | | `getError('field')` | Get error message for a field | | `hasErrors` | Check if any validation errors exist | | `clearErrors()` | Clear all validation errors | > [!TIP] > Use `MagicFormData` in your view to collect form data. The validation errors automatically bind to form fields. ## Running Writes, Timers, and Broadcasts Two mixins close the gaps a controller doing more than read-and-render runs into: `RunsActions` for a write and `OwnsTimers` for anything on a `Timer`. A third, `ListensToBroadcasts` for a shared realtime channel, ships in the `magic_socket` plugin (`flutter pub add magic_socket`, then `dart run magic:dowel plugin:install magic_socket`); see its [listeners page](https://anilcancakir.com/magic/docs/socket/basics/listeners). The two mixins above are documented in full at their own pages; this is the controller-side shape. ```dart class MonitorController extends MagicController with RunsActions, OwnsTimers { Future pause(String id) async { final outcome = await runAction(MagicAction.resolve(PauseMonitor.new), id, key: id); // doc/basics/actions.md if (!outcome.succeeded) return; } void startCooldown(String id) => own(Countdown()).start(id, 30); // cancelled on close automatically } ``` `own(cancellable)` accepts a `PollHandle`, a `Countdown`, a `Debouncer`, a bare `Timer`, or a `StreamSubscription`, and hands it back so a call can chain (`final poll = own(Poll.until(...));`); `OwnsTimers.onClose` cancels every one of them, so a controller never hand-writes its own cancellation list. See `doc/digging-deeper/helpers.md` for `LatestRead`, `Poll`, `Countdown`, and `Debouncer` themselves. ## Controller Lifecycle Controllers have lifecycle methods you can override: ```dart class UserController extends MagicController { @override void onInit() { super.onInit(); // Called once, the first time a stateful view backs the controller // Initialize data, start listeners, etc. } @override void onClose() { // Called when the controller is disposed // Cancel subscriptions, clean up resources super.onClose(); } } ``` A controller is a Type-keyed singleton, so `onInit` runs once per instance and not on every mount of a view; a view that must refetch on each mount mixes `RefetchesOnMount` into its state. `refreshUI()` is the call that notifies listeners: it is a no-op once the controller is disposed. `SimpleMagicController` runs `onInit` from its constructor instead. ## Generating Controllers ```bash # Plain controller (the Controller suffix is appended when omitted) dart run magic:dowel make:controller User # Nested in a subfolder dart run magic:dowel make:controller Admin/Dashboard # Read controller over a repository dart run magic:dowel make:controller Monitor --resource # Read controller over another model's repository dart run magic:dowel make:controller Monitor --resource --model=Ping # Mix in the helpers, and scaffold the matching test dart run magic:dowel make:controller Monitor --actions --timers --validates --test ``` ### Command Options | Option | Alias | Description | |:---|:---|:---| | `--resource` | `-r` | Generate a read controller that owns a `RepositoryQuery` over `Repository` | | `--model` | `-m` | The model a `--resource` controller reads (default: the controller's own name) | | `--actions` | | Mix in `RunsActions` | | `--timers` | | Mix in `OwnsTimers` | | `--validates` | | Mix in `ValidatesRequests` and `CollapsesIndexedErrorKeys` | | `--test` | | Also scaffold the matching test | | `--force` | | Overwrite an existing file | Every generated controller has the `instance` accessor and implements `SessionScoped` (a plain one gets a `resetForSession` stub to fill in). ### Resource Controllers A `--resource` controller imports `lib/app/models/.dart` and `lib/app/repositories/_repository.dart` and carries the read state only: `items`, `ensureFresh()` and `reload()`, with the query started in `onInit` and disposed in `onClose`. A write belongs on a `MagicAction`, not on the controller (see [Actions](https://anilcancakir.com/magic/docs/basics/actions.md)). ## Gotchas | Mistake | Fix | |:---|:---| | Loading data in `onInit` and expecting it to run on every visit | `onInit` fires once per controller instance, because controllers are Type-keyed singletons. Mix `RefetchesOnMount` into the view state when each mount must refetch | | Omitting `super.onInit()` or `super.onClose()` | Both are `@mustCallSuper`; `OwnsTimers` cancels its timers inside `onClose`, so skipping the super call leaks them | | Calling `notifyListeners()` directly | Call `refreshUI()`: it guards against a disposed controller and feeds the perf hooks | | Caching per-user rows on a controller with no session hook | Implement `SessionScoped` so a login or team switch clears them, see [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md) | | A view built on a controller nobody registered | `MagicView.controller` is `Magic.find()`, so reach the controller through the `Magic.findOrPut(Controller.new)` accessor (or `Magic.put`) first | | Calling `own(...)` on a controller that is already disposed | It throws a `StateError`; register timers in `onInit` or while the controller is alive | ## Related Documentation - [Actions](https://anilcancakir.com/magic/docs/basics/actions.md) - [Views](https://anilcancakir.com/magic/docs/basics/views.md) - [Forms](https://anilcancakir.com/magic/docs/basics/forms.md) - [Routing](https://anilcancakir.com/magic/docs/basics/routing.md) - [Validation](https://anilcancakir.com/magic/docs/digging-deeper/validation.md) - [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md) - [Helpers](https://anilcancakir.com/magic/docs/digging-deeper/helpers.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `make:controller` and its options. --- # Views Views extend `MagicView` or `MagicStatefulView` to give each screen a typed controller reference, async-state rendering helpers, and a clean MVC separation between UI and business logic. - [Introduction](#introduction) - [Creating Views](#creating-views) - [Stateless Views](#stateless-views) - [Stateful Views](#stateful-views) - [Rebuilds While a Route Is Covered](#covered-routes) - [Form Handling](#form-handling) - [Rendering Async State](#rendering-async-state) - [Refetching Data on Mount](#refetching-on-mount) - [Guarding a Submit Against a Double Tap](#submits-once) - [Responsive Views](#responsive-views) - [Generating Views](#generating-views) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Introduction Magic Views provide a structured way to separate your UI from your business logic, following the MVC pattern familiar to Laravel developers. Instead of standard Flutter widgets, Magic views extend `MagicView` or `MagicStatefulView`. ### Why Use MagicView? - **Auto-Injection**: The controller is automatically available via `controller` property - **Type Safety**: The controller is fully typed (`MagicView`) - **Consistency**: Matches Laravel's MVC structure - **`wind_kit` Integration**: Build UIs with utility-first classes ## Creating Views To generate a new view, use dowel: ```bash dart run magic:dowel make:view Dashboard ``` ### Stateless Views Most of your pages will be stateless, relying on the controller for state management: ```dart import 'package:material_ui/material_ui.dart'; import 'package:magic/magic.dart'; class DashboardView extends MagicView { const DashboardView({super.key}); @override Widget build(BuildContext context) { return WDiv( className: 'p-6 flex flex-col gap-4', children: [ WText('Dashboard', className: 'text-2xl font-bold text-white'), WText('Welcome back!', className: 'text-gray-400'), ], ); } } ``` ### Stateful Views Use `MagicStatefulView` when you need local widget state or form handling: ```dart class LoginView extends MagicStatefulView { const LoginView({super.key}); @override State createState() => _LoginViewState(); } class _LoginViewState extends MagicStatefulViewState { late final form = MagicFormData({ 'email': '', 'password': '', 'remember_me': false, }, controller: controller); @override void onClose() => form.dispose(); @override Widget build(BuildContext context) { return ListenableBuilder( listenable: controller, builder: (context, _) => _buildForm(), ); } Widget _buildForm() { return MagicForm( formData: form, child: WDiv( className: 'max-w-md p-6 bg-slate-900 rounded-xl', children: [ WFormInput( controller: form['email'], label: trans('attributes.email'), placeholder: trans('fields.email_placeholder'), type: InputType.email, validator: rules([Required(), Email()], field: 'email'), ), WFormInput( controller: form['password'], label: trans('attributes.password'), type: InputType.password, validator: rules([Required(), Min(8)], field: 'password'), ), WButton( isLoading: controller.isLoading, onTap: _submit, className: 'w-full bg-primary p-4 rounded-lg', child: WText(trans('auth.login'), className: 'text-white text-center'), ), ], ), ); } void _submit() { final data = form.validated(); if (data.isNotEmpty) { controller.login(data); } } } ``` ### Rebuilds While a Route Is Covered A `MagicStatefulViewState` rebuilds on every controller notification, except while nobody can see it. Flutter disables the tickers of content it does not paint: when an opaque route is pushed over the view (a detail page over a list), the `Overlay` disables them for everything underneath, and go_router does the same for an inactive `StatefulShellRoute` branch. The view reads that `TickerMode`, and while it is disabled a notification only marks the view as stale. When the route is uncovered, the view rebuilds once with the controller's current state, however many notifications arrived in between, and not at all when none did. A detail page that notifies its controller six times during a "check now" therefore no longer rebuilds the fifty-row list underneath six times; it rebuilds it once, as the detail page pops. A dialog, a bottom sheet or a popover is not opaque: the page under it stays painted, keeps its tickers and rebuilds on every notification as before. So does a view outside any `Navigator`. > [!WARNING] > The signal is the ticker mode, not visibility. If you wrap a painted view in `TickerMode(enabled: false)` to freeze its animations, its controller rebuilds freeze with them until the tickers are re-enabled. > [!NOTE] > Only the view's own rebuild is deferred. A `MagicSelector`, `MagicBuilder` or `ListenableBuilder` inside the view listens for itself and still rebuilds its own subtree while covered. ## Form Handling Views use `MagicFormData` for centralized form management: ```dart late final form = MagicFormData({ 'name': '', 'email': '', 'age': 0, 'active': true, }, controller: controller); // Access values form.get('name'); // String value form.value('active'); // Typed value form['email']; // TextEditingController // Set values form.set('name', 'John'); // text field form.setValue('active', false); // any other type // Validation final data = form.validated(); // Returns {} if invalid form.validate(); // Returns bool // Cleanup @override void onClose() => form.dispose(); ``` ### Validation Rules Use the `rules()` helper within views for client-side validation: ```dart WFormInput( controller: form['email'], validator: rules([Required(), Email()], field: 'email'), ); WFormInput( controller: form['password_confirmation'], validator: rules([ Required(), Same('password', valueGetter: () => form['password'].text), ], field: 'password_confirmation'), ); ``` ## Rendering Async State Use `controller.renderState()` to handle loading, error, empty and success states: ```dart @override Widget build(BuildContext context) { return controller.renderState( (users) => UserList(users: users), onLoading: Center(child: CircularProgressIndicator()), onError: (msg) => WText(msg, className: 'text-red-500'), onEmpty: WText(trans('users.empty')), ); } ``` `onLoading`, `onError` and `onEmpty` are optional; Magic renders a default widget for any you omit. ## Refetching Data on Mount Controllers are Type-keyed singletons and fire `onInit` ONCE per controller instance, not once per view mount, so a controller that loads its data in `onInit` fetches on the first view that resolves it and never again for the lifetime of the app. Navigating away and back re-renders the same cached rows, which reads as stale or fabricated data rather than as a stale screen. Mix `RefetchesOnMount` onto a `MagicStatefulViewState` and point `refetch` at a load method your controller defines (`ensureFresh` below). That method must JOIN a load already in flight rather than start a second one: the mount that creates the controller has already started the same load from `onInit`, so a refetch that always fires a new request sends every request twice. ```dart class _ItemsListViewState extends MagicStatefulViewState with RefetchesOnMount { @override Future refetch() => controller.ensureFresh(); } ``` The refetch is fire-and-forget: `build()` renders the cached data immediately and the view rebuilds once the fresh data lands, so a mount never blocks on the network. Have the load keep its last-known-good data on failure, so a failed refetch leaves the screen as it was. Keep a separate method for a refresh after a mutation: it must not join an older in-flight request, or it returns a snapshot without the row the user just created. ## Guarding a Submit Against a Double Tap An `async` submit handler wired straight to a button (`onTap: _onSubmit`) leaves nothing disabling the button for the duration of the await, so a double tap fires the write twice. On a create path that is not idempotent, two taps create two records. Mix `SubmitsOnce` onto the form's `State`, route the handler through `submitOnce`, and feed `isSubmitting` to the button's `isLoading`: ```dart class _RegisterFormState extends State with SubmitsOnce { Future _onSubmit() async { await Http.post('/register', data: form.data); } @override Widget build(BuildContext context) { return WButton( isLoading: isSubmitting, onTap: () => submitOnce(_onSubmit), child: WText(trans('auth.register')), ); } } ``` Feeding `isSubmitting` to the button's `isLoading` is what actually blocks the second tap: a well-behaved button computes `isInteractive = !isLoading && !disabled` and passes `null` for `onTap` when that is false, so the spinner and the guard are the same switch. A throwing submit re-arms the button rather than leaving it spinning forever, since the reset runs in a `finally`. ## Responsive Views For layouts that adapt to screen size, use `LayoutBuilder` with the `wind_kit` responsive helpers: ```dart class DashboardView extends MagicView { @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final isDesktop = wScreenIs(context, 'lg'); if (isDesktop) { return _buildDesktopLayout(); } return _buildMobileLayout(); }, ); } } ``` Or use the `wind_kit` responsive prefixes: ```dart WDiv( className: ''' flex flex-col gap-4 md:flex-row md:gap-6 lg:gap-8 ''', children: [...], ) ``` ## Generating Views Dowel can generate different types of views: ```bash # Basic stateless view dart run magic:dowel make:view Dashboard # Stateful view wired to LoginController dart run magic:dowel make:view Auth/Login --stateful # Nested in subfolder dart run magic:dowel make:view Admin/Users/Index # List view that refetches on every mount, with a form object dart run magic:dowel make:view Monitors/List --controller=Monitor --list --form=MonitorFormObject ``` ### Command Options | Option | Description | |:---|:---| | `--stateful` | Create a stateful view bound to a controller (derived from the view's name) | | `--controller` | Bind the view to this controller (suffix optional); implies `--stateful` | | `--list` | Add `RefetchesOnMount`, which calls the controller's `ensureFresh()` (a controller made with `make:controller --resource` has one); implies `--stateful` | | `--form` | Add a form object field owned by the state and disposed in `onClose`; implies `--stateful` | | `--test` | Also scaffold the matching test | | `--force` | Overwrite an existing file | **Output:** Creates `lib/resources/views/_view.dart`; the `View` suffix is appended when omitted. ## Gotchas | Mistake | Fix | |:---|:---| | Keeping a `TextEditingController` or `MagicFormData` in a `MagicView` | `MagicView` is stateless and has no lifecycle; use `MagicStatefulView` with a `MagicStatefulViewState` and dispose the form from `onClose` | | A view whose controller was never registered | `controller` is `Magic.find()` and throws when nothing is registered; reach the controller through its `Magic.findOrPut` accessor first | | Loading data in `onInit` and expecting a refetch when the screen is revisited | `onInit` runs once per controller instance; mix `RefetchesOnMount` into the state | | Wrapping a visible view in `TickerMode(enabled: false)` | Its controller rebuilds freeze with the tickers; the view only defers rebuilds while a route covers it | | Passing `_onSubmit` straight to `onTap` | Route it through `submitOnce` and feed `isSubmitting` to `isLoading`, or a double tap writes twice | ## Related Documentation - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Forms](https://anilcancakir.com/magic/docs/basics/forms.md) - [UI Helpers](https://anilcancakir.com/magic/docs/basics/ui-helpers.md) - [Routing](https://anilcancakir.com/magic/docs/basics/routing.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `make:view` and its options. --- # Forms Magic provides a complete form handling system: `MagicFormData` for centralised form state, `wind_kit` form widgets for UI, `FormRequest` classes for structured validation and authorisation, and the `ValidatesRequests` mixin for controller-side error management. - [Introduction](#introduction) - [MagicFormData](#magicformdata) - [Creating Form Data](#creating-form-data) - [Accessing Values](#accessing-values) - [Setting Values](#setting-values) - [MagicForm Widget](#magicform-widget) - [Form Inputs](#form-inputs) - [WFormInput](#wforminput) - [WFormCheckbox](#wformcheckbox) - [WFormSelect](#wformselect) - [Form Validation](#form-validation) - [Custom FormRequest Classes](#custom-form-request-classes) - [Validating Requests in Controllers](#validating-requests-in-controllers) - [Submitting Forms](#submitting-forms) - [Form Processing](#form-processing) - [Error Management](#error-management) - [Form Introspection](#form-introspection) - [MagicFormObject](#magicformobject) - [Complete Example](#complete-example) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Introduction Magic provides a powerful form handling system that combines the simplicity of Laravel's request handling with Flutter's form widgets. Forms are managed through `MagicFormData`, which centralizes form state, validation, and data extraction. > [!TIP] > You can generate a validated form request stub using dowel: `dart run magic:dowel make:request StoreOrder`. The generated file at `lib/app/validation/requests/store_order_request.dart` provides a structured place for your validation rules. ## MagicFormData ### Creating Form Data Create a form data instance in your stateful view: ```dart class LoginView extends MagicStatefulView { const LoginView({super.key}); @override State createState() => _LoginViewState(); } class _LoginViewState extends MagicStatefulViewState { // Define form fields with initial values // String values create TextEditingControllers // bool values create ValueNotifiers late final form = MagicFormData({ 'email': '', 'password': '', 'remember_me': false, }, controller: controller); @override void onClose() => form.dispose(); // Always dispose } ``` ### Accessing Values ```dart // Get string value String email = form.get('email'); // Get typed value bool? rememberMe = form.value('remember_me'); int? age = form.value('age'); // Get TextEditingController for text fields TextEditingController emailController = form['email']; ``` ### Setting Values ```dart // Set text field value form.set('email', 'john@example.com'); // Set non-text field value (bool, list, etc.) form.setValue('remember_me', true); form.setValue('tags', ['flutter', 'magic']); ``` > [!NOTE] > `set(field, value)` is for text fields (backed by `TextEditingController`). > `setValue(field, value)` is for non-text fields (backed by `ValueNotifier`). ## MagicForm Widget Wrap your form fields with `MagicForm` for automatic state management: ```dart @override Widget build(BuildContext context) { return MagicForm( formData: form, child: WDiv( className: 'flex flex-col gap-4', children: [ WFormInput( controller: form['email'], label: trans('attributes.email'), validator: rules([Required(), Email()], field: 'email'), ), WFormInput( controller: form['password'], type: InputType.password, label: trans('attributes.password'), validator: rules([Required(), Min(8)], field: 'password'), ), WButton( onTap: _submit, child: WText(trans('auth.login')), ), ], ), ); } ``` `MagicForm` provides: - Form key for validation - Auto-validation mode when errors exist - Automatic error state binding - Server-side error display ## Form Inputs ### WFormInput The primary text input widget, styled with `wind_kit` class names: ```dart WFormInput( controller: form['email'], label: trans('attributes.email'), placeholder: trans('fields.email_placeholder'), type: InputType.email, // text, password, email, number, multiline validator: rules([Required(), Email()], field: 'email'), className: ''' w-full bg-slate-900 border border-gray-700 rounded-lg p-3 text-white focus:border-primary focus:ring-2 focus:ring-primary/30 error:border-red-500 error:ring-red-200 ''', labelClassName: 'text-sm font-medium text-gray-300 mb-1', placeholderClassName: 'text-gray-400', ) ``` #### Input Types | Type | Description | |:---|:---| | `InputType.text` | Standard text input (default) | | `InputType.password` | Obscured password input | | `InputType.email` | Email keyboard | | `InputType.number` | Numeric keyboard | | `InputType.multiline` | Multi-line text area | ### WFormCheckbox Checkbox with validation support: ```dart WFormCheckbox( value: form.value('accept_terms'), onChanged: (v) => form.setValue('accept_terms', v), label: WText(trans('auth.accept_terms')), validator: rules([Accepted()], field: 'accept_terms'), className: 'w-5 h-5 checked:bg-primary error:border-red-500', ) ``` ### WFormSelect Dropdown select with search support. Its value is not text, so declare it as a non-text field (`'country': null as String?`) and read it with `value`: ```dart WFormSelect( value: form.value('country'), options: [ SelectOption(value: 'us', label: 'United States'), SelectOption(value: 'gb', label: 'United Kingdom'), SelectOption(value: 'tr', label: 'Turkey'), ], onChange: (v) => form.setValue('country', v), label: trans('attributes.country'), searchable: true, placeholder: trans('fields.select_country'), validator: rules([Required()], field: 'country'), className: 'w-full bg-slate-900 border border-gray-700 rounded-lg', ) ``` ## Form Validation ### Client-Side Validation Use the `rules()` helper for client-side validation. It is a method of `MagicStatefulViewState` that wraps `FormValidator.rules` and passes the view's controller along, so a server-side error for the field wins over the client-side rules: ```dart WFormInput( controller: form['email'], validator: rules([Required(), Email()], field: 'email'), ) WFormInput( controller: form['password_confirmation'], validator: rules([ Required(), Same('password', valueGetter: () => form['password'].text), ], field: 'password_confirmation'), ) ``` To name a field in its messages, call `FormValidator.rules` with `attribute`. It wins over the global `attributes.` and matches what a `FormRequest.attributes()` entry shows after submit: ```dart WFormInput( validator: FormValidator.rules([Required()], field: 'url', attribute: 'Server address'), ) ``` ### Server-Side Errors Server-side validation errors are automatically displayed when using `ValidatesRequests` mixin in your controller: ```dart class AuthController extends MagicController with ValidatesRequests { Future register(Map data) async { clearErrors(); // Clear previous errors final response = await Http.post('/register', data: data); if (!response.successful) { handleApiError(response); // Auto-populates field errors } } } ``` ## Custom FormRequest Classes `FormRequest` collapses the authorize-prepare-validate ceremony into a single, reusable class. Subclass it, declare `rules()`, and optionally override `authorize()` for access checks and `prepared()` for input normalisation. ```dart import 'package:magic/magic.dart'; class StoreOrderRequest extends FormRequest { @override bool authorize() => Gate.allows('order.create'); @override Map prepared(Map data) => { ...data, 'status': 'pending', }; @override Map> rules() => { 'product_id': [Required()], 'quantity': [Required(), Min(1)], 'note': [Max(500)], 'status': [Required(), In(['pending'])], }; } ``` Pass the raw form data to `validate()` in the controller. It runs `authorize` first, then `prepared`, then the rules, and returns the cleaned data filtered to the keys declared in `rules()`. A key that `prepared()` adds survives only when `rules()` names it, which is why `status` has a rule above; without one, `validate()` drops it: ```dart class OrderController extends MagicController with MagicStateMixin, ValidatesRequests { Future store(Map data) async { setLoading(); clearErrors(); try { final payload = StoreOrderRequest().validate(data); final response = await Http.post('/orders', data: payload); if (!response.successful) { handleApiError(response); return; } setSuccess(Order.fromMap(response.data)); MagicRoute.to('/orders'); } on AuthorizationException { setError('You are not authorized to create orders.'); } on ValidationException catch (e) { setErrorsFromMap(e.errors.map((field, message) => MapEntry(field, [message]))); setEmpty(); } } } ``` Override `attributes()` to name a field for `:attribute` in this request's messages, ahead of the global `attributes.` translation: ```dart @override Map attributes() => {'product_id': 'Product'}; ``` **Failure modes:** - `authorize()` returns `false`: throws `AuthorizationException` - A rule in `rules()` fails: throws `ValidationException` with a field-keyed error map ### Validating Requests in Controllers The `ValidatesRequests` mixin adds Laravel-style validation directly to a controller. Import it from the public barrel `package:magic/magic.dart`. Apply the mixin with the `with` keyword on any `MagicController` subclass: ```dart import 'package:magic/magic.dart'; class ProfileController extends MagicController with ValidatesRequests { Future update(Map data) async { clearErrors(); // Clear previous field errors before re-submitting try { final validated = validate(data, { 'name': [Required(), Min(2), Max(100)], 'email': [Required(), Email()], }); final response = await Http.put('/profile', data: validated); if (!response.successful) { handleApiError(response); // Populates validationErrors from 422 response return; } // Success path } on ValidationException { // validationErrors is already populated and the view was refreshed // The MagicForm widget switches to AutovalidateMode.always automatically } } } ``` **How errors surface in `MagicFormData`:** When the controller bound to a `MagicFormData` instance implements `ValidatesRequests`, field errors flow to the form automatically: - `handleApiError(response)` calls `setErrorsFromResponse(response)`, which parses the Laravel `{"errors": {"field": ["msg"]}}` format and populates `validationErrors`. - `MagicForm` detects `hasRelevantErrors` on the form data and switches `AutovalidateMode` to `always`, so server-side errors display inline next to each field. - `MagicFormData` attaches listeners to each field controller so `clearFieldError(field)` fires as soon as the user types, removing stale errors one field at a time. **Available mixin members:** | Member | Description | |:---|:---| | `validate(data, rules)` | Run rules; returns validated data or throws `ValidationException` | | `validateRequest(request, data)` | Run a `FormRequest` (authorize, prepare, rules); returns the prepared data or throws | | `setErrorsFromResponse(response)` | Populate field errors from a 422 `MagicResponse` | | `handleApiError(response, {fallback})` | Combined handler: sets field errors for 422, generic error otherwise | | `clearErrors()` | Remove all validation errors and notify listeners | | `clearFieldError(field)` | Remove a single field's error and notify listeners | | `hasError(field)` | Returns `true` if the field has an error | | `getError(field)` | Returns the error message for a field, or `null` | | `firstError` | The first error message in `validationErrors`, or `null` | | `hasErrors` | `true` if any validation errors are present | | `validationErrors` | The raw `Map` of current errors | ## Submitting Forms ### Using validated() The `validated()` method validates the form and returns data if valid: ```dart void _submit() { final data = form.validated(); if (data.isNotEmpty) { // Form is valid, submit to controller controller.register(data); } // If empty, validation failed (errors are shown automatically) } ``` ### Using validate() For more control, use `validate()` separately: ```dart void _submit() { if (form.validate()) { // Form is valid final data = { 'email': form.get('email'), 'password': form.get('password'), 'remember_me': form.value('remember_me'), }; controller.login(data); } } ``` ## Form Processing The `process()` method wraps an async action with automatic loading state management. It sets `isProcessing` to `true` before execution and `false` after, regardless of success or failure. ### Basic Usage ```dart void _submit() async { final data = form.validated(); if (data.isEmpty) return; await form.process(() => controller.register(data)); } ``` ### Preventing Double Submissions `process()` throws a `StateError` if the form is already processing, preventing concurrent submissions: ```dart void _submit() async { final data = form.validated(); if (data.isEmpty) return; try { await form.process(() => controller.register(data)); } on StateError { // Already processing, ignore } } ``` ### Processing-Aware UI Use `isProcessing` for simple checks, or `processingListenable` for granular rebuilds: ```dart // Simple: check in build WButton( isLoading: form.isProcessing, onTap: _submit, child: WText(trans('common.save')), ) // Efficient: rebuild only the button when processing state changes MagicBuilder( listenable: form.processingListenable, builder: (isProcessing) => WButton( isLoading: isProcessing, onTap: _submit, child: WText(trans('common.save')), ), ) ``` > [!TIP] > Prefer `form.isProcessing` over `controller.isLoading` when you need form-scoped loading state. This avoids full-page rebuilds and keeps loading indicators tied to the specific form being submitted. ## Error Management ### Clearing All Errors Use `clearErrors()` on the controller to remove all validation errors at once: ```dart class AuthController extends MagicController with ValidatesRequests { Future register(Map data) async { clearErrors(); // Clear previous errors before new submission final response = await Http.post('/register', data: data); if (!response.successful) { handleApiError(response); } } } ``` ### Clearing a Single Field Error Use `clearFieldError(field)` to remove the error for a specific field. This is called automatically when the user types in a text field or changes a non-text field value, thanks to `MagicFormData`'s built-in listeners: ```dart // Automatic: MagicFormData auto-clears field errors on input change. // No manual wiring needed for fields registered in MagicFormData. // Manual: clear a specific field error explicitly controller.clearFieldError('email'); ``` > [!NOTE] > `clearErrors()` and `clearFieldError()` live on the `ValidatesRequests` mixin, not on `MagicFormData`. They are accessed through the controller. ## Form Introspection ### fieldNames The `fieldNames` getter returns a `Set` of all registered field names (both text and non-text fields): ```dart final form = MagicFormData({ 'name': '', 'email': '', 'accept_terms': false, }); print(form.fieldNames); // {'name', 'email', 'accept_terms'} ``` ### hasRelevantErrors The `hasRelevantErrors` getter checks if the controller has validation errors that match fields in this form. This prevents cross-form error leakage when multiple forms share a controller: ```dart if (form.hasRelevantErrors) { // At least one of this form's fields has a server-side error } ``` ## MagicFormObject The pattern above (`MagicFormData` in a `State`, `ValidatesRequests` on the controller) is the right shape for a form that shares its controller with the rest of the screen. When a form is its own unit of work, unrelated to the screen's read state, `MagicFormObject` collapses `MagicFormData`, `ValidatesRequests`, `CollapsesIndexedErrorKeys`, and `RunsActions` into one class: a Livewire-style Form object. ```dart class MonitorFormObject extends MagicFormObject { MonitorFormObject({this.editing}); final Monitor? editing; @override Map get initial => { 'name': editing?.name ?? '', 'url': editing?.url ?? '', }; @override FormRequest get request => editing == null ? const StoreMonitorRequest() : const UpdateMonitorRequest(); @override Future persist(Map validated) async { final monitor = editing ?? Monitor(); monitor.fill(validated, strict: true); return monitor.save(); } } ``` Create ONE instance per `State` and dispose it from that `State`'s `onClose`: ```dart class _MonitorFormViewState extends MagicStatefulViewState { late final form = MonitorFormObject(editing: widget.editing); @override void onClose() => form.dispose(); void _submit() => form.submit(); } ``` `submit()` clears stale errors, validates the current `data` against `request` (painting a failing client rule onto `data`'s fields and stopping before `persist` ever runs), then routes `persist` through `data.process` so `data.isProcessing` covers the write and a server-side `ValidationException` from `persist` is caught the same way a client-side failure is. Never register a `MagicFormObject` via `Magic.put`/`findOrPut`: it is scoped to a single screen's lifetime, and `onClose` asserts (in debug) that it never ended up in the container. See [Actions](https://anilcancakir.com/magic/docs/basics/actions.md) for the `RunsActions` half of the mix. ```bash dart run magic:dowel make:form Monitor # -> MonitorFormObject dart run magic:dowel make:form Monitor --request=StoreMonitorRequest dart run magic:dowel make:form Monitor --resource=Monitor # full create and edit contract ``` ## Complete Example ```dart class RegisterView extends MagicStatefulView { const RegisterView({super.key}); @override State createState() => _RegisterViewState(); } class _RegisterViewState extends MagicStatefulViewState { late final form = MagicFormData({ 'name': '', 'email': '', 'password': '', 'password_confirmation': '', 'accept_terms': false, }, controller: controller); @override void onClose() => form.dispose(); @override Widget build(BuildContext context) { return ListenableBuilder( listenable: controller, builder: (context, _) => _buildForm(), ); } Widget _buildForm() { return WDiv( className: 'max-w-md mx-auto p-6 bg-slate-900 rounded-xl', child: MagicForm( formData: form, child: WDiv( className: 'flex flex-col gap-4', children: [ WText(trans('auth.register_title'), className: 'text-2xl font-bold text-white text-center'), WFormInput( controller: form['name'], label: trans('attributes.name'), validator: rules([Required(), Min(2)], field: 'name'), className: _inputClass, ), WFormInput( controller: form['email'], type: InputType.email, label: trans('attributes.email'), validator: rules([Required(), Email()], field: 'email'), className: _inputClass, ), WFormInput( controller: form['password'], type: InputType.password, label: trans('attributes.password'), validator: rules([Required(), Min(8)], field: 'password'), className: _inputClass, ), WFormInput( controller: form['password_confirmation'], type: InputType.password, label: trans('attributes.password_confirmation'), validator: rules([ Required(), Same('password', valueGetter: () => form['password'].text), ], field: 'password_confirmation'), className: _inputClass, ), WFormCheckbox( value: form.value('accept_terms'), onChanged: (v) => setState(() => form.setValue('accept_terms', v)), label: WText(trans('auth.accept_terms'), className: 'text-gray-300'), validator: rules([Accepted()], field: 'accept_terms'), ), SizedBox(height: 8), WButton( isLoading: controller.isLoading, onTap: _submit, className: 'w-full bg-primary p-4 rounded-lg', child: WText(trans('auth.register'), className: 'text-white text-center'), ), ], ), ), ); } String get _inputClass => ''' w-full bg-slate-800 border border-gray-700 rounded-lg p-3 text-white focus:border-primary error:border-red-500 '''; void _submit() { final data = form.validated(); if (data.isNotEmpty) { controller.register(data); } } } ``` ## Gotchas | Mistake | Fix | |:---|:---| | Reading a `bool` or list field with `form['x']`, or a text field with `value('x')` | Strings become `TextEditingController`s (`form['x']`, `get`, `set`); every other type is a `ValueNotifier` (`value`, `setValue`). The wrong accessor fails an assert | | Not calling `form.dispose()` from the state's `onClose` | `MagicFormData` owns every controller and notifier it created; `dispose()` releases them all | | Passing a controller to `MagicFormData` that does not mix in `ValidatesRequests` | Server-side errors never reach the form and field errors are not cleared as the user types; add the mixin to the controller | | Calling `form.process(...)` again while a submit is running | It throws a `StateError`; guard the button with `isProcessing` (or `isLoading: form.isProcessing`) | | Assuming `form.validated()` always returns data | It returns an empty map when validation fails; check `isNotEmpty` before submitting | | Registering a `MagicFormObject` with `Magic.put` or `findOrPut` | Create one per `State` (`late final form = MyForm();`) and dispose it from `onClose`; it asserts in debug when it finds itself in the container | ## Related Documentation - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Actions](https://anilcancakir.com/magic/docs/basics/actions.md) - [Views](https://anilcancakir.com/magic/docs/basics/views.md) - [Validation](https://anilcancakir.com/magic/docs/digging-deeper/validation.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `make:form` and `make:request`. --- # Actions `MagicAction` is a stateless, single-purpose write unit, Fortify's `Actions\*` ported: a subclass takes one input, does one write, and throws on failure instead of answering a bool. - [Introduction](#introduction) - [Writing an Action](#writing-an-action) - [Running an Action from a Controller](#running-an-action-from-a-controller) - [Failure Modes](#failure-modes) - [Swapping an Action in Tests](#swapping-an-action-in-tests) - [Generating an Action](#generating-an-action) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Introduction A write scattered across a controller method (validate, call the endpoint, patch the local cache, show a toast, guard against a double-tap) repeats the same shape at every call site. `MagicAction` names the one piece that is genuinely single-purpose (the write itself) and `RunsActions` names the piece every call site already needed (in-flight guarding, error routing) so neither has to be re-typed per screen. ## Writing an Action Subclass `MagicAction` with the input and output types the write needs, and implement `handle`: ```dart class PauseMonitor extends MagicAction { const PauseMonitor(); @override Future handle(String monitorId) async { final response = await Http.update('monitors', monitorId, {'status': 'paused'}); if (!response.successful) { throw Exception(response.errorMessage ?? 'Failed to pause monitor'); } } } ``` An action carries no `MagicController` base and nothing to dispose: it is a plain, often `const`, class. Resolve it through `MagicAction.resolve` rather than constructing it directly, so a test can swap the implementation via `MagicAction.bind` without the call site changing: ```dart await MagicAction.resolve(PauseMonitor.new).handle(monitorId); ``` ## Running an Action from a Controller Mix `RunsActions` onto the controller and call `runAction`, checking the `ActionOutcome` it answers: ```dart class MonitorController extends MagicController with RunsActions { Future pause(String id) async { final outcome = await runAction(MagicAction.resolve(PauseMonitor.new), id, key: id); if (!outcome.succeeded) return; } } ``` `runAction` answers an `ActionOutcome`, a sealed type with three cases, so a caller can no longer mistake a refused double-tap for a success by checking a `null` result: - `ActionSucceeded`, carrying `value` (the action's own result), when `handle` ran to completion; - `ActionFailed`, carrying `error` (the exception `handle` threw), on either failure mode below; - `ActionRefused`, when `key` (or the shared unkeyed slot) was already running: the action never ran a second time. `ActionOutcome` also carries two convenience getters usable without a `switch`: `succeeded` (`true` only for `ActionSucceeded`) and `valueOrNull` (the value on success, `null` otherwise). `runAction`: - marks `key` (or a shared default slot when `key` is omitted) running for the call's duration, refusing a second call under the same key while the first is still in flight (answers `ActionRefused` without running the action again); - on a `ValidationException`, paints its errors onto the host when the controller also mixes in `ValidatesRequests`; on a host that does not, there is no error bag to paint it onto, so it falls back to the same feedback the generic-failure case gets; - on any other failure, calls `onFailure(error)` when given, so the caller owns the feedback (its own toast, a silent cooldown, nothing), and otherwise logs the exception and shows a toast titled `trans('common.error_occurred')` with `failureMessage` (or that same translated fallback, never the exception's own text, which may carry a raw transport message) as the body; - answers `ActionFailed` on either failure, and `ActionSucceeded` with the action's own result on success. `isRunning([key])` reads whether a key (or the shared unkeyed slot) is currently running, for gating a button's `isLoading`: ```dart WButton( isLoading: controller.isRunning(monitor.id), onTap: () => controller.pause(monitor.id), ) ``` ## Failure Modes | What `handle` does | What `runAction` does | |:---|:---| | Throws `ValidationException`, host is `ValidatesRequests` | Paints field errors via `setErrorsFromMap`; answers `ActionFailed` | | Throws `ValidationException`, host is not `ValidatesRequests` | Falls back to the generic-failure toast below; answers `ActionFailed` | | Throws anything else | Logs the exception, shows an error toast titled and bodied with `trans('common.error_occurred')` (or `failureMessage` as the body when given); answers `ActionFailed` | | Runs to completion | Answers `ActionSucceeded` with the action's own result | | Called again under a key already running | The second call is a no-op; answers `ActionRefused` without invoking `handle` | `common.error_occurred` ships in every fresh app's `assets/lang/en.json` (from the `magic:install` stub); override the key in your own catalogue to change the wording. `ActionRequestFailed` is the exception a resource action's `handle` throws for a refused write that is not a 422: the backend answered non-2xx, or an ORM write's `save()`/`delete()` answered `false` with no field errors to raise as a `ValidationException`. Its static `ActionRequestFailed.refusalOf(action, errors, [response])` picks the right exception for you: a `ValidationException` holding the first message per field when `errors` is non-empty, otherwise an `ActionRequestFailed` carrying `response`. ```dart class PauseMonitor extends MagicAction { const PauseMonitor(); @override Future handle(String monitorId) async { final response = await Http.update('monitors', monitorId, {'status': 'paused'}); if (!response.successful) { throw ActionRequestFailed.refusalOf('pause monitor', response.errors, response); } } } ``` An ORM write consumes its own response, so pass the model's `lastRemoteResponse` as the third argument (the `make:action --kind` stubs do): ```dart final Monitor monitor = Monitor()..fill(fields, strict: true); if (await monitor.save()) return monitor; throw ActionRequestFailed.refusalOf( 'create', monitor.validationErrors, monitor.lastRemoteResponse, ); ``` `ActionRequestFailed` exposes `statusCode` read off `response`; `message`, the JSON body's own `message` (a message given through `ActionRequestFailed.withMessage` wins); and `retryAfterSeconds`, read from a 429 body's `retry_after_seconds`, falling back to `1` when the body carries no usable value so a button waiting on it still recovers. `message` never returns the text the driver writes on the response (its "The connection errored: ..." or its paragraph about a non-JSON error page): that is not the backend's word, so it stays in `toString()` for the log line and out of a toast. `isTransportFailure` is true when the response is the driver's status 0: no readable answer, whether the connection failed, timed out, hit a cross-origin error page, or returned a 2xx the client could not decode. Nothing judged the input, so "check the form" is the wrong copy. The write may or may not have landed, so a create is worth a refresh before the operator presses again. With no response at all, `isTransportFailure` is `false`: unknown is not proof. ## Swapping an Action in Tests `MagicAction.bind(factory)` registers the implementation `MagicAction.resolve` returns for `A`; `MagicAction.flush()` clears every override and belongs in `tearDown`, because the bindings are a static map that `MagicTest.init()` does not reset: ```dart setUp(() { MagicAction.bind(() => _FakePauseMonitor()); }); tearDown(() { MagicAction.flush(); }); ``` ## Generating an Action ```bash dart run magic:dowel make:action PauseMonitor # -> lib/app/actions/pause_monitor.dart dart run magic:dowel make:action Monitors/PauseMonitor # Nested path support dart run magic:dowel make:action PauseMonitor --force # Overwrite existing file ``` ## Gotchas | Mistake | Fix | |:---|:---| | `MagicAction.bind(() => FakePauseMonitor())` with no type argument | Name the type: `MagicAction.bind(...)`. Dart infers the fake's own type from the closure, so `resolve` never finds the binding and the override silently never applies | | Constructing the action directly at the call site | Go through `MagicAction.resolve(PauseMonitor.new)`, otherwise a test cannot swap it | | Treating a non-`null` result as success | `runAction` answers `ActionRefused` for a same-key re-entry: check `outcome.succeeded` | | Two buttons calling `runAction` with no `key` | Unkeyed calls share one slot and refuse each other; pass a `key` per target | | A `ValidationException` produces a toast instead of field errors | The controller must also mix in `ValidatesRequests`; without it the error bag does not exist and the generic-failure feedback runs | | Bindings from one test leak into the next | Call `MagicAction.flush()` in `tearDown`; `MagicTest.init()` does not clear them | ## Related Documentation - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Validation](https://anilcancakir.com/magic/docs/digging-deeper/validation.md) - [Testing Getting Started](https://anilcancakir.com/magic/docs/testing/getting-started.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `make:action`. --- # HTTP Client The `Http` facade is Magic's HTTP client, built on top of Dio, with one API for making requests and handling responses. - [Introduction](#introduction) - [Configuration](#configuration) - [Network Config](#network-config) - [Register in Config](#register-in-config) - [Header Casing](#header-casing) - [Making Requests](#making-requests) - [GET Requests](#get-requests) - [POST Requests](#post-requests) - [PUT & DELETE](#put--delete) - [RESTful Resources](#restful-resources) - [Paginated Collections](#paginated-collections) - [A Source That Is Not a URL](#a-source-that-is-not-a-url) - [Cursor or Offset](#cursor-or-offset) - [Rendering It Lazily](#rendering-it-lazily) - [Handling Responses](#handling-responses) - [Response Properties](#response-properties) - [Validation Errors](#validation-errors) - [File Uploads](#file-uploads) - [Interceptors](#interceptors) - [Configuring the Underlying Driver](#configuring-the-underlying-driver) - [Testing HTTP](#testing-http) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Introduction Every request method on the `Http` facade returns a `MagicResponse` and never throws: a failed request is a value you read, including a transport failure, which arrives as status code `0`. The one exception is `Http.upload()`, which reads the files before it sends anything: a path in `files` that cannot be read throws a `FileSystemException`, and an unsupported file value throws an `ArgumentError` (see [File Uploads](#file-uploads)). ```dart final response = await Http.get('/users'); if (response.successful) { final users = response.data; } ``` ## Configuration ### Network Config Create `lib/config/network.dart`: ```dart Map get networkConfig => { 'network': { 'default': 'api', 'drivers': { 'api': { 'base_url': env('API_BASE_URL', 'https://api.example.com/v1'), 'timeout': 10000, 'headers': { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }, }, }, }; ``` #### The default User-Agent `NetworkServiceProvider` adds a `User-Agent` of ` (Flutter; )` unless `headers` already carries one, reading the name from `app.name` and the platform from `defaultTargetPlatform`. Without it Dart sends `Dart/ (dart:io)`, which says nothing about the app and is identical across every Flutter client a backend has, so anything the server derives from the agent answers wrongly rather than partially. It is skipped on WEB: `User-Agent` is a forbidden header name for `XMLHttpRequest`, so the browser drops it and sends its own, which is the right agent there anyway. Set your own in `headers` to override it; the match is case-insensitive, so writing `user-agent` replaces it rather than adding a second. **The name is folded to ASCII on the way out, so what you see on the wire may not be what you wrote.** `dart:io` refuses any header value carrying a byte above 127 and throws a `FormatException` from `HttpHeaders.set`, which Dio surfaces as a `DioException`, so an unfolded name would lose every request the app makes. Accented Latin letters fold to their base letter rather than being dropped, since dropping leaves a mangled word: | `app.name` | sent as | |:---|:---| | `Uptizm` | `Uptizm (Flutter; iOS)` | | `Şirket Takip` | `Sirket Takip (Flutter; iOS)` | | `Café Münster` | `Cafe Munster (Flutter; iOS)` | | `日本` | `Magic App (Flutter; iOS)` | The table covers every letter in Latin-1 Supplement and Latin Extended-A, which is Turkish, German, French, Spanish, Nordic, Polish, Czech and Dutch. A test walks both ranges and fails on any letter that does not survive, so the claim checks itself rather than being maintained by hand. A script with no Latin base has nothing to fold to, so it is dropped and the agent falls back to the config default rather than opening with a bare space. Set your own `User-Agent` in `headers` if you need an exact string; it is passed through untouched. ### Register in Config ```dart await Magic.init( configFactories: [ () => appConfig, () => networkConfig, ], ); ``` Don't forget to add `NetworkServiceProvider` to your app providers: ```dart 'providers': [ (app) => NetworkServiceProvider(app), // ... ], ``` ### Header Casing Header names you set, in the config map above or per request, reach the wire exactly as you wrote them. `User-Agent` goes out as `User-Agent`, not as `user-agent`. HTTP/1.1 treats header names as case-insensitive, so this rarely matters. It matters when the other end reads a header by exact key: ExoPlayer looks its request headers up case-sensitively and finds nothing when the key arrives lowercased, which costs you a user agent on a media request and gets the wrong stream served back without an error. ```dart final response = await Http.get('/stream', headers: { 'User-Agent': 'MyApp/1.0', 'X-Request-Id': requestId, }); ``` > [!NOTE] > This holds on mobile and desktop, where Dio's IO adapter runs. On the web, header names are still lowercased: `dio_web_adapter` writes them through `XMLHttpRequest.setRequestHeader`, which the browser normalises on its own. Do not build a web feature on a case-sensitive header. Responses are a separate matter. `MagicResponse.headers` keys are always lowercase, whatever casing the server sent, because `HttpHeaders` lowercases on receipt. Read them with a lowercase key. ## Making Requests ### GET Requests ```dart // Simple GET final response = await Http.get('/users'); // With query parameters final response = await Http.get('/users', query: { 'page': 1, 'per_page': 25, 'sort': 'name', }); // Access the data if (response.successful) { final users = response.data; // Parsed JSON } ``` ### POST Requests ```dart final response = await Http.post('/users', data: { 'name': 'John Doe', 'email': 'john@example.com', 'password': 'secret123', }); if (response.successful) { final user = response.data; Magic.success('Success', 'User created!'); } ``` ### PUT & DELETE ```dart // PUT - Full update await Http.put('/users/1', data: { 'name': 'Jane Doe', 'email': 'jane@example.com', }); // DELETE await Http.delete('/users/1'); ``` ## RESTful Resources For RESTful APIs, Magic provides resource helper methods: ```dart // GET /users final all = await Http.index('users'); // GET /users/1 final one = await Http.show('users', '1'); // POST /users final created = await Http.store('users', { 'name': 'New User', 'email': 'new@example.com', }); // PUT /users/1 final updated = await Http.update('users', '1', { 'name': 'Updated Name', }); // DELETE /users/1 await Http.destroy('users', '1'); ``` ## Paginated Collections `Http.index()` and `fetchList()` read a collection in one request and hand you every row. That is the right shape for a settings screen and the wrong one for a log, a check history, or a feed: a long collection rendered as a column of every row costs one build, one layout and one semantics node per row on the first frame, whether or not the reader ever scrolls that far. `MagicPaginator` reads such a collection one page at a time. It holds the rows fetched so far, knows whether the server has more, and appends rather than replaces: ```dart final checks = MagicPaginator( url: 'monitors/$id/checks', fromMap: CheckRow.fromMap, perPage: 50, ); await checks.loadFirst(); // first page await checks.loadMore(); // append the next one await checks.refresh(); // start over from page one checks.items; // every row so far, oldest page first checks.total; // how many rows MATCH on the server, or null checks.hasMore; // is there another page checks.loadedPages; // how many pages are held checks.isLoading; // a request is in flight checks.isRefreshing; // ...rebuilding page one under rows already on screen checks.isLoadingMore; // ...fetching the page after the last one checks.error; // the last failure, cleared by the next success checks.isEmpty; // a first page arrived and held nothing checks.mode; // cursor, offset or single, read from the response checks.generation; // bumps on every landed reset ``` ### Three Loading States, Not One A list renders a loading state three different ways and `isLoading` cannot tell them apart on its own: | State | What the screen does | |:---|:---| | first load, nothing on screen | render a skeleton | | `isRefreshing`, rows already held | keep the rows, say more is coming | | `isLoadingMore` | put a footer under the last row | Blanking a list the reader is already looking at on every filter change is a flash for no information: the rows are still true until the answer lands. And a footer during a refresh promises a page nothing asked for. `MagicPaginatedListView` reads `isLoadingMore` for exactly this reason. Both are false on a first load, since there is nothing on screen to preserve and no page being appended, and all three are false once the request lands. > [!NOTE] > A `refresh()` arriving while a `loadMore()` is in flight is deferred rather than started, so until that page lands the paginator still reports `isLoadingMore` and not `isRefreshing`: a footer stays up through a refresh that has been asked for and not yet begun. That is what is happening on the wire, and it is the only path where the flags follow the REQUEST rather than the caller's most recent ask. A screen that must show its refresh indicator immediately owns that state itself. ### The Total `total` reads `meta.total`, so it is the size of the whole collection rather than of the pages in hand: `items.length` answers "how much have I fetched", and a header reading "11 of 240" needs the other number. > [!NOTE] > It is null on a cursor collection. Laravel's `cursorPaginate()` deliberately does not count, and null is the honest answer there rather than a total invented from the page that happens to be loaded. A later page that omits the key leaves the last known value alone, so an endpoint that sends the count on page one only keeps it. ### The Page Count `loadedPages` counts what is HELD rather than requests made: a `refresh()` puts it back to one and a failed page counts nothing. A screen that writes its position into a URL wants this rather than the cursor, because a cursor names a position in one ordered result: shared, it drops the reader into the middle of a list with nothing above it, and points nowhere once that row is renamed or deleted. A page count re-fetches pages one to N, which is the same rows with the top intact. `generation` exists for views that throttle themselves. A list that stops asking once a page adds no rows has to tell that from a fresh first page of the same length, and a row count alone cannot; compare the generation beside it and a `refresh()` re-arms whatever the count had disarmed. It is a `ChangeNotifier`, so a widget can listen to it directly and a controller can hold several without inventing a state enum per list. > [!NOTE] > `loadMore()` is a no-op while a request is in flight and when there is nothing more to fetch, so it is safe to call from a scroll callback that fires every frame. A failed `loadMore()` keeps the rows already on screen and leaves `hasMore` alone, so the reader does not lose page one because page two timed out, and a retry still has a target. A **transport** failure counts as a failure here: a timeout or a dead link arrives as statusCode 0, and `error` is set rather than the collection reporting itself empty, because "no rows" and "nobody answered" are different screens. `refresh()` issued while the tail is auto-fetching waits for that page to land and then starts over, so a pull-to-refresh cannot retract over stale rows having done nothing. Disposing the paginator while a request is in flight is safe. ### A Source That Is Not a URL Some collections do not arrive from `Http.get`. A billing history reaches the client through a payments service whose store build refuses rather than answering; a search result needs its query assembled; a list comes from a local store. Pointing the url constructor at the endpoint such a service wraps walks around the abstraction, and the abstraction is usually there for a reason. `MagicPaginator.fetcher` pages anything: ```dart final invoices = MagicPaginator.fetcher( fetch: (MagicPageRequest request) async { final page = await Payments.getInvoices(cursor: request.cursor); return MagicPage( items: page.invoices, nextCursor: page.nextCursor, ); }, ); ``` `fetch` receives a `MagicPageRequest`: the `cursor` the previous page reported, plus `isFirst`. Report `nextCursor` when the source pages by token, or `hasMore` when it pages by something the paginator never sees: ```dart fetch: (MagicPageRequest request) async { final int page = request.isFirst ? 1 : _page + 1; ... return MagicPage(items: rows, hasMore: page < lastPage); } ``` > [!WARNING] > A source that keeps its own position MUST branch on `isFirst`. The cursor is null for every page of such a source, so without it a `refresh()` is indistinguishable from a `loadMore()` and renders whatever page it was up to as the whole list. > [!NOTE] > A fetcher reports failure by **throwing**, where an endpoint reports it with a status code. Either way the rows already in hand stay, `error` is set, and `hasMore` is untouched so a retry has a target. Only an `Exception` becomes state: an `Error` (a bad cast, a failed assertion) is the fetcher itself being wrong and propagates instead of landing in `error`. `mode` reports `PaginationMode.fetcher` on this path, because how the pages are addressed is the fetcher's business and the paginator does not know. Everything else (accumulation, the in-flight and disposal guards, the lazy list) is the same as the url mode. ### Cursor or Offset The mode is read from the response rather than configured, so one paginator serves whichever the endpoint uses: | Response `meta` | Mode | Next page | |:---|:---|:---| | `next_cursor` | `PaginationMode.cursor` | `?cursor=` | | `current_page` + `last_page` | `PaginationMode.offset` | `?page=` | | neither | `PaginationMode.single` | there is no next page | **Prefer `cursorPaginate()` on the server for anything that grows at the head**, which is most live data: checks, events, messages, notifications. ```php // Drifts: a row inserted at the top between two requests shifts // everything down, so page two repeats the last row of page one. return CheckResource::collection($query->paginate($perPage)); // Stable: the cursor names a position in the ordering. return CheckResource::collection($query->cursorPaginate($perPage)); ``` Cursor pagination also costs the database the same at any depth, because it seeks to a position instead of counting past every row it skips. What you give up is a total count and the ability to jump to page five, neither of which an infinitely scrolling list uses. ### Rendering It Lazily `MagicPaginatedListView` builds the rows the viewport can show and asks for the next page as the tail comes into view: ```dart WDiv( className: 'h-[600px]', child: MagicPaginatedListView( paginator: controller.checks, itemBuilder: (_, CheckRow row, _) => CheckHistoryRow(row: row), separatorBuilder: (_, _) => const WDiv(className: 'h-px bg-gray-200'), emptyState: const WText('No checks yet'), loadingFooter: const WDiv(className: 'p-4', child: WText('Loading...')), ), ) ``` A first page too short to fill the viewport still fetches its successor: the widget checks after the frame whether there is anything to scroll, so a small `perPage` cannot strand the reader on a truncated list. That fill stops on an error and on a page that added no rows, so a failing endpoint is asked once rather than once per frame. > [!WARNING] > It is a `ListView`, so it needs a **bounded height**. Dropping it into a page that already scrolls without a bound throws, and reaching for `shrinkWrap: true` to make that work defeats the whole thing: shrink-wrapping measures every row, so all of them get built and nothing is saved. Give it a height, or give the page a sliver-based scaffold. ## Handling Responses The `MagicResponse` object provides helpful properties and methods for handling API responses. ### Response Properties ```dart final response = await Http.get('/users'); // Status checks response.successful // true if 2xx status response.failed // true if 4xx or 5xx, or status 0 (no connection, timeout) response.unauthorized // true if 401 response.forbidden // true if 403 response.notFound // true if 404 response.isValidationError // true if 422 // Access data response.statusCode // HTTP status code response.data // Parsed response body response['key'] // Direct access to data key response.dataAs() // Typed access ``` ### Validation Errors A Laravel-style 422 response is parsed into field errors: ```dart final response = await Http.post('/register', data: formData); if (response.isValidationError) { // Get all errors as a Map final errors = response.errors; // {'email': ['Email already taken'], 'password': ['Too short']} // Get flat list of all error messages final allMessages = response.errorsList; // ['Email already taken', 'Too short'] // Get just the first error (useful for snackbars) final firstError = response.firstError; // 'Email already taken' // Get the main error message final message = response.errorMessage; // 'The given data was invalid.' } ``` ### Controller Integration Use `ValidatesRequests` mixin in your controller for automatic error handling: ```dart class AuthController extends MagicController with ValidatesRequests { Future register(Map data) async { clearErrors(); final response = await Http.post('/register', data: data); if (response.successful) { // Handle success } else { // Automatically populates controller errors from 422 response handleApiError(response, fallback: 'Registration failed'); } } } ``` ## File Uploads > [!NOTE] > Picking a file from the device (the `Pick` facade) belongs to the `magic_picker` plugin: `flutter pub add magic_picker`, then `dart run magic:dowel plugin:install magic_picker`. See its [Pick page](https://anilcancakir.com/magic/docs/picker/basics/pick). ### Using MagicFile (Recommended) ```dart // Upload a stored file final image = await Storage.getFile('avatars/1.jpg'); if (image != null) { final response = await image.upload('/upload', fieldName: 'avatar'); if (response.successful) { final url = response['url']; } } // With additional form data final response = await image.upload( '/upload', fieldName: 'photo', data: {'title': 'Profile Photo', 'public': true}, ); ``` ### Using Http.upload() ```dart final file = await Storage.getFile('documents/contract.pdf'); final response = await Http.upload( '/documents', data: {'title': 'My Document'}, files: {'document': file}, ); ``` The Dio driver catches only `DioException`, so a string path in `files` that cannot be read raises a `FileSystemException` out of the call instead of a status `0` response. A value that is not a path, a `MagicFile`, a `MultipartFile`, a `Uint8List` or a `List` raises an `ArgumentError`. Wrap an upload that takes user-chosen paths in `try`/`catch`. ## Interceptors Extend `MagicNetworkInterceptor` to modify requests or handle responses and errors globally. Each hook receives a driver-agnostic type (`MagicRequest`, `MagicResponse`, `MagicError`) and returns it, possibly changed: ```dart class RequestHeaderInterceptor extends MagicNetworkInterceptor { @override dynamic onRequest(MagicRequest request) { request.headers['X-Client'] = 'my-app'; return request; } @override dynamic onResponse(MagicResponse response) { Log.debug('Response ${response.statusCode}'); return response; } @override dynamic onError(MagicError error) { if (error.isUnauthorized) { // React to a 401 here } return error; } } ``` Register an interceptor on the network driver from a service provider's `boot()`: ```dart class HttpServiceProvider extends ServiceProvider { HttpServiceProvider(super.app); @override Future boot() async { Magic.make('network').addInterceptor(RequestHeaderInterceptor()); } } ``` `AuthServiceProvider` already registers an `AuthInterceptor` that attaches the guard's token to each request and refreshes it on a 401, so you do not write one for that. To work with Dio's own `Interceptor` type, use `configureDriver` below. ### Configuring the Underlying Driver For integrations that need direct Dio access (a Dio package that attaches its own interceptor, or certificate pinning), resolve the `DioNetworkDriver` from the IoC container and call `configureDriver()`: ```dart final driver = Magic.make('network'); driver.configureDriver((dio) { dio.interceptors.add(LogInterceptor()); }); ``` Reporting failed requests to Sentry is the job of the `magic_sentry` plugin (`flutter pub add magic_sentry`, then `dart run magic:dowel plugin:install magic_sentry`), which registers its own interceptor; see its [HTTP Reporting page](https://anilcancakir.com/magic/docs/sentry/basics/http-reporting). You can also use this hook to pin a certificate by supplying a custom `HttpClientAdapter`: ```dart final driver = Magic.make('network'); driver.configureDriver((dio) { dio.httpClientAdapter = PinnedHttpClientAdapter( trustedCertificate: certBytes, ); }); ``` > [!NOTE] > `configureDriver()` is specific to `DioNetworkDriver`. Call it after `Magic.init()` completes, typically in a service provider's `boot()` method. ## Testing HTTP Magic provides first-class HTTP faking so tests never make real network calls. For a full guide to request assertions and stubbing strategies, see [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md). ```dart import 'package:magic/testing.dart'; // Replace the real driver with a fake that returns 200 for all requests Http.fake(); // Stub specific URL patterns Http.fake({ '/users': Http.response({'data': []}, 200), '/users/1': Http.response({'id': 1, 'name': 'Alice'}, 200), }); // Restore the real driver when done Http.unfake(); ``` ## Gotchas | Mistake | Fix | |:---|:---| | Wrapping `Http` calls in `try`/`catch` for a failed request | The request methods never throw: read `response.successful`. A timeout or dead link is a `MagicResponse` with status code `0`, not an exception | | Passing a file path to `Http.upload()` without a `try`/`catch` | The files are read before the request, outside the driver's `DioException` handling, so an unreadable path throws a `FileSystemException`. Catch it around `upload()` | | Looking for `Http.addInterceptor` or passing a Dio `Interceptor` to the driver | Extend `MagicNetworkInterceptor` and call `addInterceptor` on `Magic.make('network')`; reach Dio's own types through `DioNetworkDriver.configureDriver` | | Relying on header name casing on the web | Casing is preserved on mobile and desktop only; the browser lowercases names, so do not build a web feature on a case-sensitive header | | Reading a response header with its original casing | `MagicResponse.headers` keys are always lowercase | | A fetcher-backed `MagicPaginator` that keeps its own page counter | Branch on `request.isFirst`: the cursor is null for every page of such a source, so a `refresh()` is otherwise indistinguishable from a `loadMore()` | | `MagicPaginatedListView` inside an unbounded-height parent | Give it a bounded height; `shrinkWrap` builds every row and defeats the lazy list | ## Related Documentation - [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Forms](https://anilcancakir.com/magic/docs/basics/forms.md) - [Authentication](https://anilcancakir.com/magic/docs/security/authentication.md) - [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md) --- # UI Helpers The UI helpers are context-free UI feedback utilities, reactive widget builders, declarative page-title management, responsive layouts, form-processing helpers, and data-loading shortcuts that integrate with the controller state lifecycle. - [Introduction](#introduction) - [Snackbars](#snackbars) - [Typed Snackbars](#typed-snackbars) - [Dialogs](#dialogs) - [Custom Dialogs](#custom-dialogs) - [Confirmation Dialogs](#confirmation-dialogs) - [Loading Overlay](#loading-overlay) - [Toast Messages](#toast-messages) - [Configuration](#configuration) - [MagicBuilder](#magic-builder) - [MagicSelector](#magic-selector) - [MagicTitle](#magic-title) - [MagicResponsiveView](#magic-responsive-view) - [Extended Breakpoints](#extended-breakpoints) - [Context Helpers](#context-helpers) - [Advanced MagicFormData](#advanced-magic-form-data) - [Processing State](#processing-state) - [Granular Rebuilds with processingListenable](#processing-listenable) - [Data Loading Helpers](#data-loading-helpers) - [fetchList](#fetch-list) - [fetchOne](#fetch-one) - [Rendering State with renderState](#render-state) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Introduction The `Magic` facade shows snackbars, dialogs, confirmations, loading overlays and toasts from **anywhere** (controllers, services or callbacks) without passing `BuildContext`. ```dart // No context needed Magic.success('Done', 'User created successfully'); Magic.confirm(title: 'Delete?', message: 'This cannot be undone'); Magic.loading(); ``` ## Snackbars Show notification messages at the bottom of the screen. ### Basic Snackbar ```dart Magic.snackbar('Title', 'Message'); ``` ### Typed Snackbars Use typed helpers for semantic styling: ```dart Magic.success('Success', 'Operation completed'); // Green Magic.error('Error', 'Something went wrong'); // Red Magic.snackbar('Info', 'New update available', type: 'info'); // Blue Magic.snackbar('Warning', 'Low storage space', type: 'warning'); // Amber ``` `success` and `error` are shortcuts; `type` takes `'success'`, `'error'`, `'info'` (the default) or `'warning'`, the keys of `view.snackbar.style`. ### With Custom Duration ```dart Magic.snackbar( 'Custom', 'Message', type: 'info', duration: Duration(seconds: 5), ); ``` ## Dialogs ### Custom Dialogs Display any widget in a centred dialog: ```dart Magic.dialog( WDiv( className: 'p-6 bg-white rounded-xl max-w-md', children: [ WText('Custom Dialog', className: 'text-xl font-bold'), WText('Any content goes here.', className: 'text-gray-600 mt-2'), WDiv( className: 'flex justify-end gap-4 mt-6', children: [ WButton( onTap: () => Magic.closeDialog(), className: 'px-4 py-2 text-gray-600', child: WText('Cancel'), ), WButton( onTap: () { // Handle action Magic.closeDialog(); }, className: 'px-4 py-2 bg-primary text-white rounded-lg', child: WText('Confirm'), ), ], ), ], ), ); ``` ### Close Dialog ```dart Magic.closeDialog(); ``` Pass a value to answer the future `Magic.dialog` returned; without one it completes with `null`, as a barrier tap does: ```dart final String? name = await Magic.dialog(RenameForm()); // Inside RenameForm: Magic.closeDialog('Living room'); ``` ### Confirmation Dialogs Ask the user to confirm an action: ```dart final confirmed = await Magic.confirm( title: 'Delete Item', message: 'Are you sure you want to delete this item?', confirmText: 'Delete', cancelText: 'Cancel', ); if (confirmed == true) { await deleteItem(); } ``` ### Dangerous Actions Use `isDangerous: true` for destructive confirmations (styled in red): ```dart final confirmed = await Magic.confirm( title: 'Delete Account', message: 'This action cannot be undone. All your data will be permanently removed.', confirmText: 'Delete Forever', isDangerous: true, ); if (confirmed == true) { await deleteAccount(); MagicRoute.to('/goodbye'); } ``` ## Loading Overlay Show a blocking loading overlay during async operations. ### Show Loading ```dart Magic.loading(); // With message Magic.loading(message: 'Please wait...'); ``` ### Close Loading ```dart Magic.closeLoading(); ``` ### Pattern: Wrap Async Operations ```dart Future submitForm() async { Magic.loading(message: 'Saving...'); try { await api.save(data); Magic.success('Saved', 'Your changes have been saved'); } catch (e) { Magic.error('Error', e.toString()); } finally { Magic.closeLoading(); } } ``` ### Controller Pattern ```dart class OrderController extends MagicController with MagicStateMixin { Future placeOrder(Map data) async { Magic.loading(message: trans('orders.processing')); try { final response = await Http.post('/orders', data: data); if (response.successful) { setSuccess(Order.fromMap(response.data)); Magic.success( trans('common.success'), trans('orders.placed'), ); MagicRoute.to('/orders/${response['id']}'); } else { Magic.error(trans('common.error'), response.errorMessage ?? ''); } } finally { Magic.closeLoading(); } } } ``` ## Toast Messages Show brief, non-intrusive messages (centred, pill-shaped): ```dart Magic.toast('Item added to cart'); // Custom duration Magic.toast('Copied!', duration: Duration(seconds: 1)); ``` Toasts are ideal for quick confirmations that don't require user interaction. ## Configuration Customise appearance via `lib/config/view.dart`; any key you leave out keeps its default from `defaultViewConfig`: ```dart Map get viewConfig => { 'view': { // Snackbar styling 'snackbar': { 'duration': 4000, 'style': { 'success': 'bg-green-500 text-white p-4 rounded-lg', 'error': 'bg-red-500 text-white p-4 rounded-lg', 'info': 'bg-blue-500 text-white p-4 rounded-lg', 'warning': 'bg-amber-500 text-white p-4 rounded-lg', }, }, // Dialog container 'dialog': { 'class': 'bg-white rounded-xl p-6 shadow-2xl w-80 max-w-md', }, // Confirmation dialog 'confirm': { 'container_class': 'bg-white rounded-xl p-6 shadow-2xl w-80', 'title_class': 'text-lg font-bold text-gray-900', 'message_class': 'text-gray-600 mt-2', 'button_cancel_class': 'px-4 py-2 text-gray-600', 'button_confirm_class': 'px-4 py-2 bg-primary text-white rounded-lg', 'button_danger_class': 'px-4 py-2 bg-red-500 text-white rounded-lg', }, // Loading overlay 'loading': { 'container_class': 'bg-white rounded-xl p-6 shadow-2xl', 'spinner_class': 'text-primary', 'text_class': 'text-gray-600 text-sm mt-4', }, // Toast 'toast': { 'duration': 2000, 'class': 'bg-gray-800 text-white px-6 py-3 rounded-full shadow-lg', }, }, }; ``` > [!TIP] > Use `wind_kit` utility classes in your config for consistent styling across all UI helpers. ## MagicBuilder `MagicBuilder` is a concise reactive widget that rebuilds a subtree whenever a `ValueListenable` changes. It wraps `ValueListenableBuilder` but drops the unused `context` and `child` parameters so the builder closure stays focused on the value. ```dart MagicBuilder( listenable: controller.counterNotifier, builder: (count) => WText('$count'), ) ``` The two required parameters are: | Parameter | Type | Purpose | |:---|:---|:---| | `listenable` | `ValueListenable` | The notifier to observe | | `builder` | `Widget Function(T value)` | Called on every change with the new value | When you also need `BuildContext` inside the builder, wrap the `MagicBuilder` in a `Builder`: ```dart Builder( builder: (context) { final theme = Theme.of(context); return MagicBuilder( listenable: controller.enabledNotifier, builder: (enabled) => Switch( value: enabled, activeThumbColor: theme.colorScheme.primary, onChanged: (_) => controller.toggle(), ), ); }, ) ``` The classic use case is a multi-section view where only one section depends on a notifier: ```dart class MonitorShowView extends MagicView { const MonitorShowView({super.key}); @override Widget build(BuildContext context) { return Column( children: [ // Rebuilds only when checksNotifier fires MagicBuilder>( listenable: controller.checksNotifier, builder: (checks) => _buildStatsSection(checks), ), // Rebuilds only when the toggle changes MagicBuilder( listenable: controller.realTimeEnabledNotifier, builder: (enabled) => Switch( value: enabled, onChanged: (_) => controller.toggleRealTime(), ), ), ], ); } } ``` > [!TIP] > For E2E drivability, prefer `MagicBuilder` over `setState` on the parent widget. Targeted subtree rebuilds keep interactive element identity stable so E2E agents (tapir) do not lose their references mid-action. ## MagicSelector `MagicSelector` rebuilds one subtree when one part of a controller changes, and leaves it alone the rest of the time. Reach for it when `MagicBuilder` cannot help, which is whenever the thing you want to watch is a plain field on a `MagicController` rather than a `ValueListenable`. ```dart MagicSelector( controller: controller, selector: (GuideController c) => c.countLabel, builder: (String label) => Text(label), ) ``` ### What it is for `refreshUI()` notifies every listener, and `MagicStatefulViewState` answers by calling `setState` on the whole view. That is the right default: a controller does not know which of its fields a screen reads, and a view that rebuilds is always correct. It stops being cheap on a screen where one field changes often and most of the screen does not care. A search field is the worked example. Every keystroke is a notification, and one keystroke on a real screen was measured rebuilding 220 styled containers, almost none of which could have looked different. ### How it avoids the rebuild It caches the widget the builder returned and, while the selected value compares equal, returns that same **instance**. `Element.updateChild` short circuits when the new widget is `==` to the mounted one, so an identical instance ends the descent there and the subtree is never visited. That is what makes it work under a parent that rebuilds anyway. A widget that merely skipped its own `setState` would still be rebuilt from above, which is the situation inside every `MagicStatefulView`. ### The contract `builder` must be a pure function of the value it is handed. A cached child cannot see anything else the closure captured: ```dart // WRONG: `total` is captured and nothing here watches it, so the line reads // a stale total for as long as `count` happens not to move. MagicSelector( controller: c, selector: (C c) => c.count, builder: (int count) => Text('$count of $total'), ) ``` Select both instead. A Dart record has value equality, so it compares by content and the cache still holds: ```dart MagicSelector( controller: c, selector: (C c) => (c.count, c.total), builder: ((int, int) v) => Text('${v.$1} of ${v.$2}'), ) ``` Reading an `InheritedWidget` inside the cached subtree needs no selection. `Theme.of`, `MediaQuery.of` and `WindTheme.of` register their own dependency, and the framework rebuilds a dependent element directly rather than through its parent. The word doing the work there is **inside**. A lookup written in the enclosing `build` and captured by the closure is the captured-`total` hole wearing different clothes, and a dark-mode toggle is a likelier way to meet it: ```dart // WRONG: `context` belongs to the view's build, so a theme change rebuilds the // view, the cache is served, and this subtree keeps the old theme. MagicSelector( controller: c, selector: (C c) => c.count, builder: (int n) => WText( '$n', className: Theme.of(context).brightness == Brightness.dark ? 'text-white' : 'text-gray-900', ), ) // Right: the lookup happens inside the built subtree, which registers its own // dependency. MagicSelector( controller: c, selector: (C c) => c.count, builder: (int n) => Builder( builder: (BuildContext inner) => WText( '$n', className: Theme.of(inner).brightness == Brightness.dark ? 'text-white' : 'text-gray-900', ), ), ) ``` ### Equality Plain `==`, deliberately. A selector that returns a freshly built `List` or `Map` never matches its own cache, because Dart gives collections identity equality, and the subtree then rebuilds on every notification exactly as it would have without the widget. Deep comparison was the alternative and is worse where it matters: walking a ten thousand element list on every keystroke costs more than the rebuild it prevents. Select a scalar, a record, or an object whose identity is stable across notifications. > [!NOTE] > `MagicSelector` does not replace `MagicBuilder`. Use `MagicBuilder` when the source already is a `ValueListenable`, such as `MagicFormData.processingListenable`; use `MagicSelector` when the source is the controller itself. ## MagicTitle `MagicTitle` is a declarative title-override widget. Wrap any part of the widget tree with it to push a page title through `TitleManager` while the widget is mounted. The configured app-name suffix is applied automatically. ```dart class UserProfileView extends MagicView { @override Widget build(BuildContext context) { return MagicTitle( title: 'Edit Profile', child: Column( children: [ // page content ], ), ); } } ``` The constructor accepts two required parameters: | Parameter | Type | Purpose | |:---|:---|:---| | `title` | `String` | The page title to display | | `child` | `Widget` | The subtree to render | `MagicTitle` calls `TitleManager.instance.setOverride(title)` in `initState`, updates it in `didUpdateWidget` when `title` changes, and clears the override in `dispose`. This makes it safe for data-dependent titles that resolve after the route is already mounted: ```dart MagicTitle( title: controller.isSuccess ? controller.rxState!.name : 'Loading...', child: _buildContent(), ) ``` > [!NOTE] > `MagicTitle` updates the browser tab title on web and integrates with route-level title observers. It does not replace the `.title()` method on a route definition. Use `.title()` for static route titles and `MagicTitle` for dynamic or data-dependent ones. ## MagicResponsiveView `MagicResponsiveView` is an abstract view base class that dispatches to a different widget method depending on the current screen width. Breakpoints are read from the active `WindThemeData.screens` so they stay consistent with your `wind_kit` utility classes. Subclass it and override the layout methods you need: ```dart class DashboardView extends MagicResponsiveView { const DashboardView({super.key}); @override Widget phone(BuildContext context) => MobileDashboard(); @override Widget tablet(BuildContext context) => TabletDashboard(); @override Widget desktop(BuildContext context) => DesktopDashboard(); } ``` The dispatch order is: | Condition | Method called | |:---|:---| | `width < 320 px` | `watch(context)` | | `width < sm` (default 640 px) | `phone(context)` | | `width < lg` (default 1024 px) | `tablet(context)` | | `width >= lg` | `desktop(context)` | Only `phone` is abstract; the others fall back up the chain by default (`watch` calls `phone`, `tablet` calls `phone`, `desktop` calls `tablet`). Override only the breakpoints where your layout actually differs. ### Extended Breakpoints For fine-grained control over every Wind breakpoint, extend `MagicResponsiveViewExtended` instead: ```dart class AdminView extends MagicResponsiveViewExtended { const AdminView({super.key}); @override Widget xs(BuildContext context) => MobileAdminView(); // < 320 px @override Widget sm(BuildContext context) => PhoneAdminView(); // >= 320, < sm @override Widget lg(BuildContext context) => TabletAdminView(); // >= md, < lg @override Widget xxl(BuildContext context) => WideAdminView(); // >= xl } ``` Methods that you do not override cascade to the next smaller breakpoint, so you only implement the layouts where behaviour changes. ### Context Helpers The `MagicResponsiveContext` extension on `BuildContext` exposes breakpoint checks for use inside any `build` method: ```dart Widget build(BuildContext context) { if (context.isDesktop) { return Row(children: [_sidebar(), _content()]); } return _content(); } ``` Available getters: | Getter | Type | Meaning | |:---|:---|:---| | `screenWidth` | `double` | `MediaQuery.of(context).size.width` | | `screenHeight` | `double` | `MediaQuery.of(context).size.height` | | `isPhone` | `bool` | Width is below the `md` breakpoint | | `isTablet` | `bool` | Width is from `md` up to, but not including, `lg` | | `isDesktop` | `bool` | Width is at or above `lg` | | `activeBreakpoint` | `String` | Current Wind breakpoint name (e.g. `'md'`) | | `isAtLeast(String)` | `bool` | True if screen is at or above the given breakpoint | ## Advanced MagicFormData The basics of `MagicFormData` are covered in the [Forms](https://anilcancakir.com/magic/docs/basics/forms.md) page. This section documents the processing-state API that powers submit flows. ### Processing State `process()` wraps an async action with automatic processing-state management. It sets `isProcessing` to `true` before the action and back to `false` when it completes (whether it succeeds or throws). If `process()` is called again while already running, it throws a `StateError` to prevent duplicate submissions. ```dart Future _submit() async { if (!form.validate()) return; await form.process(() => controller.updateProfile( name: form.get('name'), email: form.get('email'), )); } ``` | Member | Type | Description | |:---|:---|:---| | `process(Future Function() action)` | `Future` | Runs `action` with automatic `isProcessing` tracking | | `isProcessing` | `bool` | `true` while `process()` is executing | | `processingListenable` | `ValueListenable` | Notifier backing `isProcessing`; use with `MagicBuilder` | ### Granular Rebuilds with processingListenable Use `form.processingListenable` together with `MagicBuilder` to rebuild only the submit button while the rest of the form stays static. This keeps the widget tree stable during the async operation: ```dart MagicBuilder( listenable: form.processingListenable, builder: (isProcessing) => WButton( semanticLabel: 'Save profile', isLoading: isProcessing, onTap: isProcessing ? null : _submit, child: WText(trans('common.save')), ), ) ``` Passing `null` to `onTap` while `isProcessing` is `true` also prevents double-tap submissions because `form.process()` would throw a `StateError` on a concurrent call regardless. ## Data Loading Helpers Controllers that mix in `MagicStateMixin` gain two convenience methods for the most common data-fetch patterns. Both methods handle the full loading/success/error/empty lifecycle automatically. ### fetchList `fetchList()` fetches a paginated or plain list from a URL and maps each item through a `fromMap` factory. It is generic over the element type `E` and expects the controller's state type `T` to be `List`. ```dart class UserController extends MagicController with MagicStateMixin> { @override void onInit() { super.onInit(); fetchList('/api/users', User.fromMap); } } ``` Signature: ```dart Future fetchList( String url, E Function(Map) fromMap, { String dataKey = 'data', Map? query, Map? headers, }) ``` | Parameter | Default | Description | |:---|:---|:---| | `url` | required | The endpoint to GET | | `fromMap` | required | Factory that maps a JSON object to type `E` | | `dataKey` | `'data'` | Key in the response payload that holds the list | | `query` | `null` | Optional query-string parameters | | `headers` | `null` | Optional request headers | The helper sets `setLoading()` before the request. On success it calls `setSuccess(items)`. If the list is absent or empty it calls `setEmpty()`. On a failed response it calls `setError(message)`. ### fetchOne `fetchOne()` fetches a single resource and maps it through a `fromMap` factory. ```dart class UserController extends MagicController with MagicStateMixin { @override void onInit() { super.onInit(); fetchOne('/api/users/1', User.fromMap); } } ``` Signature: ```dart Future fetchOne( String url, T Function(Map) fromMap, { String dataKey = 'data', Map? query, Map? headers, }) ``` The parameters are identical to `fetchList`. The helper sets `setLoading()`, then on a successful response calls `setSuccess(fromMap(data[dataKey]))`. On failure or a malformed payload it calls `setError(message)`. ### Rendering State with renderState After either fetch helper resolves, use `renderState` to build the UI declaratively against `rxStatus`: ```dart class UserProfileView extends MagicView { @override Widget build(BuildContext context) { return controller.renderState( (user) => UserCard(user: user), onLoading: const Center(child: CircularProgressIndicator()), onError: (msg) => WText('Error: $msg', className: 'text-red-500'), onEmpty: WText('No user found.', className: 'text-gray-400'), ); } } ``` `renderState` uses `AnimatedBuilder` on the controller so the view rebuilds automatically whenever status or state changes. `onLoading`, `onError` and `onEmpty` are optional; omitted states fall back to built-in default widgets. ## Gotchas | Mistake | Fix | |:---|:---| | Calling `Magic.info()` or `Magic.warning()` | Only `success` and `error` have shortcuts; use `Magic.snackbar(title, message, type: 'info')` or `type: 'warning'` | | Calling `Magic.loading()` and not closing it when the work throws | Close it in a `finally` block with `Magic.closeLoading()`, or the blocking overlay stays on screen | | A `MagicSelector` builder that reads a value from the enclosing `build` | The cached subtree never sees it change; select every value the builder needs (a record compares by content) and do inherited lookups inside a `Builder` | | A `MagicSelector` selector that returns a fresh `List` or `Map` | Collections compare by identity, so it rebuilds on every notification; select a scalar, a record or an object with a stable identity | | Mixing `context.isPhone` with `MagicResponsiveView.phone` | They use different breakpoints: `isPhone` is below `md`, while `phone` runs below `sm`; pick one source of truth per screen | | Calling `form.process()` again while it is running | It throws a `StateError`; disable the button with `isProcessing` from `processingListenable` | ## Related Documentation - [Views](https://anilcancakir.com/magic/docs/basics/views.md) - [Forms](https://anilcancakir.com/magic/docs/basics/forms.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Routing](https://anilcancakir.com/magic/docs/basics/routing.md) --- # Eloquent: Getting Started Eloquent is Magic's active-record ORM: define models as Dart classes and persist them to a remote REST backend, or to local storage with the `magic_local_storage` plugin, through the same model interface. - [Introduction](#introduction) - [Generating Models](#generating-models) - [Defining Models](#defining-models) - [Table Names](#table-names) - [Primary Keys](#primary-keys) - [Fillable Attributes](#fillable-attributes) - [Attribute Casting](#attribute-casting) - [Retrieving Models](#retrieving-models) - [Inserting & Updating](#inserting--updating) - [Deleting Models](#deleting-models) - [Hybrid Persistence](#hybrid-persistence) - [Local Storage](#local-storage) - [Related Documentation](#related-documentation) ## Introduction Magic's Eloquent ORM provides an Active Record implementation for working with your data. Each model maps to a REST resource and, optionally, to a local table. Models allow you to retrieve records, insert new ones, and update existing ones. Models are API-first: they persist to a remote REST API by default. Magic's Eloquent also supports **Hybrid Persistence**, where a model persists to local storage, a remote REST API, or both. The local leg ships in the `magic_local_storage` plugin (see [Local Storage](#local-storage)). ## Generating Models To generate a new model, use the `make:model` Magic CLI command: ```bash dart run magic:dowel make:model Post ``` ### Available Options | Option | Shortcut | Description | |:---|:---|:---| | `--migration` | `-m` | Create a database migration (needs `magic_local_storage`) | | `--seeder` | `-s` | Create a database seeder (needs `magic_local_storage`) | | `--factory` | `-f` | Create a model factory (needs `magic_local_storage`) | | `--controller` | `-c` | Create a controller | | `--policy` | `-p` | Create a policy | | `--all` | `-a` | Create all related files | ### The `--all` Flag The `-a` flag is the most convenient way to scaffold a complete feature: ```bash dart run magic:dowel make:model Product --all ``` This single command creates: - `lib/app/models/product.dart` - the migration, seeder and factory (when `magic_local_storage` is installed; otherwise a hint is printed) - `lib/app/policies/product_policy.dart` - `lib/app/repositories/product_repository.dart` - `lib/app/controllers/product_controller.dart`, a resource controller reading the repository ## Defining Models Models typically live in the `lib/app/models` directory: ```dart import 'package:magic/magic.dart'; class User extends Model with HasTimestamps, InteractsWithPersistence { @override String get table => 'users'; @override String get resource => 'users'; @override List get fillable => ['name', 'email', 'avatar']; @override Map get casts => const { 'email_verified_at': 'datetime', 'is_active': 'bool', 'settings': 'json', }; // Typed accessors String? get name => getAttribute('name') as String?; set name(String? value) => setAttribute('name', value); String? get email => getAttribute('email') as String?; Carbon? get emailVerifiedAt => getAttribute('email_verified_at') as Carbon?; bool get isActive => getAttribute('is_active') as bool? ?? false; // Static helpers (required for type-safe queries) static Future find(dynamic id) => InteractsWithPersistence.findById(id, User.new); static Future> all() => InteractsWithPersistence.allModels(User.new); } ``` ### Table Names The `table` property specifies which local table corresponds to your model when `useLocal` is on: ```dart @override String get table => 'users'; ``` ### Primary Keys By default, Eloquent assumes your primary key is `id`. You may override this: ```dart @override String get primaryKey => 'user_id'; @override bool get incrementing => false; // For UUID keys ``` ### Fillable Attributes Define which attributes can be mass-assigned: ```dart @override List get fillable => ['name', 'email', 'avatar']; ``` Only these attributes will be set when using `fill()`: ```dart user.fill({ 'name': 'John', 'email': 'john@example.com', 'is_admin': true, // Ignored - not in fillable }); ``` Pass `strict: true` to turn silent drops into a `MassAssignmentException`. Useful when filling from validated request payloads, where an unknown key signals a client/schema mismatch you want to surface loudly: ```dart user.fill(validated, strict: true); // throws if validated contains a non-fillable key ``` ## Attribute Casting The `casts` property converts attributes to common data types. Return a `const` literal: `getAttribute` reads `casts` on every call, so a non-const map literal allocates on every attribute read. ```dart @override Map get casts => const { 'created_at': 'datetime', // Returns Carbon 'updated_at': 'datetime', 'is_active': 'bool', // Returns bool 'settings': 'json', // Returns Map 'age': 'int', // Returns int 'price': 'double', // Returns double }; ``` ### Available Cast Types | Cast | Returns | |:---|:---| | `datetime` | `Carbon` (Magic's date/time wrapper) | | `bool` | `bool` | | `int` | `int` | | `double` | `double` | | `json` | `Map` or `List` | ### Accessing Cast Attributes ```dart final user = await User.find(1); Carbon? createdAt = user?.createdAt; // Carbon instance (from HasTimestamps) bool? isActive = user?.isActive; // bool final settings = user?.getAttribute('settings') as Map?; // Map ``` ## Retrieving Models ### Retrieving All Models ```dart final users = await User.all(); for (final user in users) { print(user.name); } ``` ### Retrieving A Single Model ```dart final user = await User.find(1); if (user != null) { print(user.name); } ``` ### Refreshing Models Reload a model's attributes from its persistence layer: ```dart await user.refresh(); ``` ## Inserting & Updating ### Inserting Models To create a new record, instantiate a model, set attributes, and call `save()`: ```dart final user = User() ..fill({ 'name': 'John Doe', 'email': 'john@example.com', }); await user.save(); print(user.id); // The new auto-generated ID ``` ### Updating Models To update an existing model, retrieve it, modify attributes, and call `save()`: ```dart final user = await User.find(1); if (user != null) { user.name = 'Updated Name'; await user.save(); } ``` ### Validation Errors `save()` returns a `bool`, which tells you *that* a remote save failed but not *why*. When the backend answers with Laravel's validation shape (`{"message": ..., "errors": {"field": ["message"]}}`, typically a 422), the per-field messages are captured on the model so a form can render them under the matching fields: ```dart final monitor = Monitor()..fill({'name': '', 'url': 'not-a-url'}); if (!await monitor.save()) { monitor.validationErrors; // {'name': ['The name field is required.'], ...} monitor.validationError('name'); // 'The name field is required.' (first message) } ``` `validationErrors` is cleared at the start of every remote save, so it stays empty after a save that succeeded or returned no field errors. An empty map plus a `false` return means "not a field failure", and that covers two different things: the backend refused (a 500, a 403) or the client got no readable answer at all. `lastRemoteResponse` tells them apart. It holds the response the latest remote `save()` or `delete()` received, with the driver's `statusCode` 0 when the client got no readable answer, and is `null` before any remote write, when the driver threw, and after a `delete()` on a model that does not exist: ```dart if (!await monitor.save()) { final int? status = monitor.lastRemoteResponse?.statusCode; // 0: no readable answer, so not the form's fault; 500: the server failed. } ``` In an action, hand it to `ActionRequestFailed.refusalOf(action, model.validationErrors, model.lastRemoteResponse)` so the refusal carries it; see [Actions](https://anilcancakir.com/magic/docs/basics/actions.md). The map is deeply unmodifiable, and it tracks the remote leg rather than the return value. A [hybrid](#hybrid-persistence) model whose remote save 422s while its local write succeeds returns `true` and still carries the errors, so check the map even after a successful save when local persistence is on. ### Dirty Checking Track which attributes have changed: ```dart final user = await User.find(1); print(user.isDirty()); // false user.name = 'New Name'; print(user.isDirty()); // true print(user.isDirty('name')); // true print(user.getDirty()); // {'name': 'New Name'} ``` ## Deleting Models ```dart final user = await User.find(1); if (user != null) { await user.delete(); print(user.exists); // false } ``` ## Hybrid Persistence Hybrid persistence lets one model sync between local storage and a remote REST API. ### Configuration Control where your model persists: ```dart // Local storage only (offline mode, needs magic_local_storage) @override bool get useLocal => true; @override bool get useRemote => false; // Remote API only (online mode, the default) @override bool get useLocal => false; @override bool get useRemote => true; // Both (hybrid mode, needs magic_local_storage) @override bool get useLocal => true; @override bool get useRemote => true; ``` ### API Resource Endpoint The `resource` property maps to your REST API: ```dart @override String get resource => 'users'; // Maps to: // GET /users -> all() // GET /users/{id} -> find(id) // POST /users -> save() (insert) // PUT /users/{id} -> save() (update) // DELETE /users/{id} -> delete() ``` ### How Hybrid Mode Works When using hybrid mode: 1. **Read operations** try local storage first. A single-record read (`find`) falls back to the API when the local read finds no record or fails; a collection read (`all`) falls back only when the local read fails, so an empty local table returns an empty list without an API call. Rows fetched from the API are written back to local storage on a best-effort basis: a failed write-back is ignored and does not fail the read, so the next read goes to the API again 2. **Write operations** persist to both local and remote > [!TIP] > Use hybrid mode for offline-first apps that sync to a backend API. ## Local Storage `useLocal` defaults to `false`, so a model talks to your REST API unless you opt in. The local leg is provided by the `magic_local_storage` plugin (SQLite database, query builder, migrations, seeders, factories and sync). Core only ships the `LocalModelStore` contract (container key `'local_model_store'`): a model with `useLocal => true` throws a `StateError` naming `magic_local_storage` when nothing is bound. Install it with `flutter pub add magic_local_storage`, then `dart run magic:dowel plugin:install magic_local_storage`. Its [models page](https://anilcancakir.com/magic/docs/local_storage/basics/models) covers the local leg. ## Related Documentation - [Mutators & Casting](https://anilcancakir.com/magic/docs/eloquent/mutators.md) - [Serialization](https://anilcancakir.com/magic/docs/eloquent/serialization.md) - [Repositories](https://anilcancakir.com/magic/docs/eloquent/repositories.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md) --- # Eloquent: Mutators Accessors, mutators, and attribute casts let you transform Eloquent model values automatically as they are read from or written to the database, without changing the calling code. - [Introduction](#introduction) - [Accessors](#accessors) - [Mutators](#mutators) - [Attribute Casting](#attribute-casting) - [Date Casting](#date-casting) - [JSON Casting](#json-casting) - [Related Documentation](#related-documentation) ## Introduction Accessors and mutators allow you to transform Eloquent attribute values when you retrieve or set them on model instances. For example, you may want to lower-case an email address as it is set and format a phone number as it is read. ## Accessors Accessors transform attribute values when you retrieve them. Define typed getter methods in your model: ```dart class User extends Model { // Raw attribute access String? get rawName => getAttribute('name') as String?; // Accessor with transformation String get fullName { final firstName = getAttribute('first_name') as String? ?? ''; final lastName = getAttribute('last_name') as String? ?? ''; return '$firstName $lastName'.trim(); } // Accessor with formatting String get formattedPhone { final phone = getAttribute('phone') as String?; if (phone == null) return ''; return '+1 (${phone.substring(0, 3)}) ${phone.substring(3, 6)}-${phone.substring(6)}'; } } ``` ### Using Accessors ```dart final user = await User.find(1); print(user?.fullName); // "John Doe" print(user?.formattedPhone); // "+1 (555) 123-4567" ``` ## Mutators Mutators transform attribute values when you set them. Define setter methods: ```dart class User extends Model { // Raw setter set name(String? value) => setAttribute('name', value); // Mutator with transformation set email(String? value) => setAttribute('email', value?.toLowerCase().trim()); // Mutator with hashing (example) set password(String? value) => setAttribute('password', value != null ? hashPassword(value) : null); } ``` ### Using Mutators ```dart final user = User(); user.email = ' JOHN@EXAMPLE.COM '; print(user.getAttribute('email')); // "john@example.com" user.password = 'secret123'; // Stored as hashed value ``` ## Attribute Casting The `casts` property provides automatic type conversion for attributes: ```dart class Task extends Model { @override Map get casts => const { 'is_completed': 'bool', 'priority': 'int', 'progress': 'double', 'due_date': 'datetime', 'settings': 'json', }; // Typed accessors use the cast values bool get isCompleted => getAttribute('is_completed') as bool? ?? false; int get priority => getAttribute('priority') as int? ?? 0; double get progress => getAttribute('progress') as double? ?? 0.0; Carbon? get dueDate => getAttribute('due_date') as Carbon?; Map? get settings => getAttribute('settings') as Map?; } ``` ### Available Cast Types | Cast | Returns | |:---|:---| | `bool` | `bool` | | `int` | `int` | | `double` | `double` | | `datetime` | `Carbon` | | `json` | `Map` or `List` | ### Custom Casts (`CastsAttributes`) For domain types like enums or value objects, implement `CastsAttributes` and register the instance in `casts`: ```dart enum MonitorStatus { active, paused, failed } class Monitor extends Model { @override Map get casts => const { 'status': EnumCast(MonitorStatus.values), // enum round-trip 'statuses': ListCast(EnumCast(MonitorStatus.values)), // list of enums 'created_at': 'datetime', // built-ins still work }; MonitorStatus? get status => getAttribute('status') as MonitorStatus?; set status(MonitorStatus? value) => setAttribute('status', value); } ``` Two casts ship with Magic: - **`EnumCast(values, {strict = false})`**: resolves a raw name to its enum value. Set `strict: true` to throw `ArgumentError` on unknown names instead of returning `null`. - **`ListCast(inner)`**: applies `inner` to every list element. Stored as a JSON-encoded string. Roll your own by implementing `CastsAttributes`: ```dart class MoneyCast implements CastsAttributes { const MoneyCast(); @override Money? get(Model model, String key, Object? raw) => raw == null ? null : Money.fromCents(raw as int); @override Object? set(Model model, String key, Object? value) => value is Money ? value.cents : value; } ``` ## Date Casting Date attributes are automatically converted to Carbon instances: ```dart class Event extends Model { @override Map get casts => const { 'starts_at': 'datetime', 'ends_at': 'datetime', 'published_at': 'datetime', }; Carbon? get startsAt => getAttribute('starts_at') as Carbon?; Carbon? get endsAt => getAttribute('ends_at') as Carbon?; Carbon? get publishedAt => getAttribute('published_at') as Carbon?; // Duration helper int? get durationInMinutes { if (startsAt == null || endsAt == null) return null; return endsAt!.diffInMinutes(startsAt!); } // Check if event is happening now bool get isHappeningNow { final now = Carbon.now(); return startsAt?.isBefore(now) == true && endsAt?.isAfter(now) == true; } } ``` ### Working with Dates ```dart final event = await Event.find(1); // Format dates print(event?.startsAt?.format('MMMM d, yyyy')); // "January 15, 2024" // Human readable print(event?.startsAt?.diffForHumans()); // "in 2 days" // Comparison if (event?.startsAt?.isFuture() == true) { print('Upcoming event'); } ``` ## JSON Casting JSON attributes are serialised/deserialised automatically: ```dart class Monitor extends Model { @override Map get casts => const { 'settings': 'json', 'tags': 'json', }; // Access as Map Map get settings => (getAttribute('settings') as Map?) ?? {}; // Access as List List get tags => (getAttribute('tags') as List?)?.cast() ?? []; // Setters set settings(Map value) => setAttribute('settings', value); set tags(List value) => setAttribute('tags', value); } ``` ### Using JSON Attributes ```dart final monitor = Monitor(); // Set complex data monitor.settings = { 'timeout': 30, 'retries': 3, 'headers': {'Authorization': 'Bearer token'}, }; monitor.tags = ['production', 'critical', 'api']; await monitor.save(); // Retrieve print(monitor.settings['timeout']); // 30 print(monitor.tags.first); // "production" ``` > [!TIP] > Always define typed getters for your attributes to get IDE autocompletion and type safety throughout your application. ## Related Documentation - [Getting Started](https://anilcancakir.com/magic/docs/eloquent/getting-started.md) - [Serialization](https://anilcancakir.com/magic/docs/eloquent/serialization.md) - [Carbon](https://anilcancakir.com/magic/docs/digging-deeper/carbon.md) --- # Repositories `Repository` is an id-keyed cache of one remote resource's rows, Eloquent's identity map for a client with no query builder; `RepositoryQuery` is one ordered, filtered, paginated view over it. - [Introduction](#introduction) - [Writing a Repository](#writing-a-repository) - [RepositoryQuery](#repositoryquery) - [showOnlyKeys](#showonlykeys) - [Reading and Writing a Single Row](#reading-and-writing-a-single-row) - [Session Resets](#session-resets) - [Failure Modes](#failure-modes) - [Generating a Repository](#generating-a-repository) - [Related Documentation](#related-documentation) ## Introduction A list screen and a show screen for the same resource used to keep two separate copies of the same row, one per `MagicStateMixin`; a patch on one never reached the other. `Repository` holds one row per id, and every `RepositoryQuery` built over it reads that row live, so a `patch` or an `evict` shows up everywhere at once, with no refetch. ## Writing a Repository Subclass `Repository` with the three pieces that describe the resource: ```dart class MonitorRepository extends Repository { static MonitorRepository instance = MonitorRepository(); @override String get resource => 'monitors'; @override Monitor Function(Map) get fromMap => Monitor.fromMap; } ``` Never register a `Repository` via `Magic.put`/`Magic.findOrPut`: its constructor registers it with `SessionScope` on its own, which is how a session reset reaches it (see [Session Resets](#session-resets)). A plain static singleton, as above, is the right shape. ## RepositoryQuery `RepositoryQuery` owns the ordering, the cursor, and the filters over a repository; the rows themselves live in the repository. It reuses `MagicPaginator.fetcher` to walk pages, and writes every page it reads into the repository via `Repository.upsertFromList`. ```dart final query = RepositoryQuery( repository: MonitorRepository.instance, perPage: 50, filters: {'status': statusFilter}, ); await query.ensureFresh(); // joins the first reload if one is already in flight query.items; // live rows, in server order query.hasMore; query.isLoadingMore; await query.loadMore(); await query.setFilters({'status': 'down'}); // reloads from the first page ``` Built by the controller that owns it, one per screen; dispose it from that controller's `onClose`: ```dart class MonitorListController extends MagicController { late final query = RepositoryQuery(repository: MonitorRepository.instance); @override void onClose() { query.dispose(); super.onClose(); } } ``` ## showOnlyKeys A list endpoint often does not measure every field a show endpoint does (`uptime_24h`, a computed aggregate). Override `showOnlyKeys` to name those fields: a `null` the list sends for one of them keeps whatever the cache already holds from a prior `upsertFromShow`, instead of erasing it. ```dart class MonitorRepository extends Repository { @override Set get showOnlyKeys => const {'uptime_24h'}; } ``` A field outside `showOnlyKeys` that the list sends as `null` DOES become `null`: the list is the source of truth for everything it was not told to leave alone. `upsertFromShow` is authoritative for every field regardless, since the show endpoint measures them all. ## Reading and Writing a Single Row | Method | What it does | |:---|:---| | `find(id)` | The cached row for `id`, or `null` | | `all` | Every cached row, in no particular order | | `refresh(id)` | Re-reads `GET $resource/$id`; a 404 evicts the row and answers `null`, any other failure hands back the cached copy | | `patch(id, attributes)` | Merges `attributes` onto the cached row; a no-op when nothing is cached for `id` | | `evict(id)` | Drops the cached row, if any | `refresh`'s 404-evicts, other-failure-keeps split matters: a fault of ours (a timeout, a 500) is never a verdict about the row, so only a confirmed "this row is gone" clears the cache. Every write path notifies its listeners (and so every `RepositoryQuery` over it) only when the cache actually changed: a row that was not cached, an evicted row, or an attribute whose stored value differs, compared deeply and with map keys in any order. A broadcast `patch` that restates what is cached, or a `refresh` that answers the row already held, rebuilds nothing. `upsertFromShow` still stores the answered instance, so `find(id)` is the row `refresh` returned. A controller that needs to repaint once a read settles (to clear its own loading flag) calls its own `refreshUI()` after the `await`; it cannot count on the repository's notify, which a failed read never sent either. A `RepositoryQuery` reload is unaffected: its page landing notifies through the paginator whether or not the rows changed. ## Session Resets A `Repository` implements `SessionScoped` and registers itself with `SessionScope` in its own constructor, so a login or a team switch drops every cached row before the owning controller refetches (see [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md)). A `RepositoryQuery` built over it registers its own reset hook through `Repository.addResetHook`, clearing its cursor, its filters' resolved ids, and its `isFirstLoad`/`hasMore` flags alongside the rows, so a screen mid-load when the identity changes shows its first-load skeleton for the new session rather than a stale page. ## Failure Modes | Situation | What happens | |:---|:---| | A list page throws mid-fetch | `RepositoryQuery.items` and `.meta` keep what they already held; `loadFailed` is `true` only when `items` is also empty | | A page lands after a session reset (`repository.epoch` moved) | The page's rows never reach the repository or the query's ids | | `refresh(id)` gets a 404 | The row is evicted; answers `null` | | `refresh(id)` gets a 500 or times out | The cached row stands; answers the cached copy | | `patch(id, ...)` called for an id nothing has fetched | No-op: a partial patch never fabricates a row nobody fetched | ## Generating a Repository ```bash dart run magic:dowel make:repository Monitor # -> MonitorRepository dart run magic:dowel make:repository MonitorRepository # Suffix already present dart run magic:dowel make:repository Monitor --force # Overwrite existing file ``` ## Related Documentation - [Getting Started](https://anilcancakir.com/magic/docs/eloquent/getting-started.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md) --- # Eloquent: Serialization Eloquent models can be serialised to maps and JSON strings with a single call, with support for hiding sensitive attributes and appending computed values to every serialised output. - [Introduction](#introduction) - [Serializing to Arrays](#serializing-to-arrays) - [Serializing to JSON](#serializing-to-json) - [Hiding Attributes](#hiding-attributes) - [Appending Attributes](#appending-attributes) - [API Response Example](#api-response-example) - [Related Documentation](#related-documentation) ## Introduction When building APIs or passing data to JavaScript frontends, you will often need to convert your models to arrays or JSON. Magic includes convenient methods for these conversions while controlling which attributes are included. ## Serializing to Arrays Convert a model to an array using `toMap()`: ```dart final user = await User.find(1); final array = user?.toMap(); // { // 'id': 1, // 'name': 'John Doe', // 'email': 'john@example.com', // 'created_at': '2024-01-15T10:30:00.000Z', // 'updated_at': '2024-01-15T10:30:00.000Z', // } ``` ### Converting Collections ```dart final users = await User.all(); final array = users.map((u) => u.toMap()).toList(); ``` ## Serializing to JSON Convert a model to JSON using `toJson()`: ```dart final user = await User.find(1); final json = user?.toJson(); // '{"id":1,"name":"John Doe","email":"john@example.com",...}' ``` ## Hiding Attributes ### Model-Level Hidden Attributes Define attributes that should never be serialised: ```dart class User extends Model { @override List get hidden => ['password', 'remember_token', 'api_key']; } ``` Now when you call `toMap()` or `toJson()`, these attributes are excluded: ```dart final user = await User.find(1); print(user?.toMap()); // { // 'id': 1, // 'name': 'John Doe', // 'email': 'john@example.com', // // password, remember_token, api_key are NOT included // } ``` ### Runtime Hidden Attributes Hide additional attributes on one model instance: ```dart final user = await User.find(1); final array = user?.makeHidden(['email', 'phone']).toMap(); // email and phone are hidden for this instance ``` `makeHidden`, `makeVisible` and `append` return the same model and change it: every later `toMap()` or `toJson()` on that instance keeps the change, while other instances are untouched. > [!WARNING] > A name in the model's own `visible` list wins over every hidden name. `toMap()` removes each model-level `visible` name from the hidden set before it serialises, and `makeHidden` only edits the runtime sets, so `makeHidden(['password'])` does not hide `password` on a model whose `visible` lists it. The same removal applies to a model-level `hidden` entry that is also in `visible`. Take the name out of `visible` to stop serialising it. ### Making Hidden Attributes Visible Show attributes the model hides by default: ```dart final user = await User.find(1); final array = user?.makeVisible(['password']).toMap(); // password is included for this instance ``` ## Appending Attributes Name attributes that `toMap()` must include even when the model's `visible` whitelist leaves them out: ```dart class User extends Model { @override List get visible => ['id', 'name']; @override List get appends => ['is_admin']; } ``` An appended name is read with `getAttribute`, so it is included only when the attribute exists and is not `null`, and a hidden attribute is never appended. A Dart getter such as `bool get isAdmin` is not called: Dart has no reflection, so to serialise a computed value, store it with `setAttribute` first. The stored value is a real attribute of the model, not an output-only field: `toArray()` is `toMap()`, so a later `save()` includes it in the payload it sends to the API and the local store. Build output-only values on a separate map when the backend must not receive them. ```dart final user = await User.find(1); user?.setAttribute('is_admin', true); print(user?.toMap()); // { // 'id': 1, // 'name': 'John Doe', // 'is_admin': true, // Appended // } ``` ### Runtime Appends Append additional attributes on one model instance: ```dart final user = await User.find(1); final array = user?.append(['avatar_url']).toMap(); ``` ## API Response Example Combine serialisation options for a clean API payload: ```dart final users = await User.all(); final payload = users .map((user) => user.makeHidden(['password', 'remember_token']).toMap()) .toList(); ``` `makeHidden` only hides these names when the model's `visible` list does not name them (see [Hiding Attributes](#hiding-attributes)). With no `visible` list on `User`, this returns: ```json [ { "id": 1, "email": "john@example.com", "first_name": "John", "last_name": "Doe" } ] ``` > [!TIP] > Always hide sensitive attributes like passwords and API keys at the model level using the `hidden` property. ## Related Documentation - [Getting Started](https://anilcancakir.com/magic/docs/eloquent/getting-started.md) - [Mutators & Casting](https://anilcancakir.com/magic/docs/eloquent/mutators.md) --- # Authentication Magic provides a frontend-focused authentication system with secure token storage, user caching, automatic token refresh, and support for multiple configurable guards. - [Introduction](#introduction) - [Quick Start](#quick-start) - [Configuration](#configuration) - [The Auth Facade](#the-auth-facade) - [Guards](#guards) - [Built-in Guards](#built-in-guards) - [Custom Guards](#custom-guards) - [Reactive Auth State](#reactive-auth-state) - [Auto Token Refresh](#auto-token-refresh) - [Protecting Routes](#protecting-routes) - [Login & Logout](#login--logout) - [Auth Events](#auth-events) - [Testing](#testing) - [Related Documentation](#related-documentation) ## Introduction Magic provides a simple, frontend-focused authentication system with user caching and automatic token refresh. Like Laravel's Auth system, it's built around the concept of "guards" that define how users are authenticated. ### Key Features | Feature | Description | |:--------|:------------| | **User Caching** | Instant restore from secure cache, then sync from API | | **Auto Token Refresh** | 401 response → refresh token → retry original request (for guards built with a refresh endpoint) | | **Multiple Guards** | Support for Bearer, Basic, API Key, or custom guards | | **Secure Storage** | Tokens stored in platform secure storage (Keychain/Keystore) | ## Quick Start ```dart // 1. Register user factory (tells Magic how to create User from API data) Auth.registerModel(User.fromMap); // 2. Login after API call final response = await Http.post('/login', data: { 'email': email, 'password': password, }); if (response.successful) { final user = User.fromMap(response['data']['user']); await Auth.login({ 'token': response['data']['token'], }, user); MagicRoute.to('/dashboard'); } // 3. Check authentication anywhere if (Auth.check()) { final user = Auth.user(); print('Welcome, ${user?.name}'); } // 4. Logout await Auth.logout(); MagicRoute.to('/login'); ``` > [!WARNING] > `logout()` can throw. Secure storage is a platform service and a delete can > fail (a locked keychain, a lost entitlement). When it does, every step is > still attempted and the in-memory session is still cleared and announced, so > your UI is correct either way, but the throw reaches you because a credential > may have survived on the device. Navigate away and surface it; do not swallow > it: > > ```dart > try { > await Auth.logout(); > } catch (e) { > Log.warning('Logout could not clear stored credentials: $e'); > } finally { > MagicRoute.to('/login'); > } > ``` ## Configuration Create `lib/config/auth.dart`: ```dart Map get authConfig => { 'auth': { 'defaults': { 'guard': 'api', }, 'guards': { 'api': { 'driver': 'bearer', }, }, 'endpoints': { 'user': '/api/user', }, 'token': { 'key': 'auth_token', 'header': 'Authorization', 'prefix': 'Bearer', }, 'auto_refresh': true, }, }; ``` Register in your config and add `AuthServiceProvider`: ```dart 'providers': [ (app) => AuthServiceProvider(app), // ... ], ``` ## The Auth Facade The `Auth` facade provides convenient access to authentication functionality: ```dart // Check if user is authenticated Auth.check() // bool // Check if user is a guest (not authenticated) Auth.guest // bool (a getter) // Get the authenticated user Auth.user() // User? // Get user ID Auth.id() // dynamic // Login await Auth.login(tokenData, user) // Logout await Auth.logout() // Restore session from cache await Auth.restore() // Manually refresh token await Auth.refreshToken() // Token management await Auth.hasToken() // bool await Auth.getToken() // String? // Resolve a guard by name (default guard when name is omitted) Auth.guard() // Guard Auth.guard('api') // Guard (named) // Notifier that bumps on every auth state change Auth.stateNotifier // ValueNotifier ``` ## Guards Guards define how users are authenticated. Each guard implements the `Guard` contract: ```dart abstract class Guard { Future login(Map data, Authenticatable user); Future logout(); bool check(); bool get guest => !check(); T? user(); dynamic id(); void setUser(Authenticatable user); Future hasToken(); Future getToken(); Future refreshToken(); Future restore(); /// Bumped on every auth state change (login, logout, restore). ValueNotifier get stateNotifier; } ``` ### Built-in Guards | Guard | `driver` | Login Data | Use Case | |:------|:---------|:----------|:---------| | `BearerTokenGuard` | `bearer` (or `sanctum`) | `token` | JWT/OAuth APIs | | `BasicAuthGuard` | `basic` | `username`, `password` | Basic HTTP Auth | | `ApiKeyGuard` | `api_key` | `api_key` | API Key authentication | ### Custom Guards Create custom guards by extending `BaseGuard`: ```dart class MyGuard extends BaseGuard { MyGuard() : super( userEndpoint: '/api/me', refreshEndpoint: '/api/refresh', refreshTokenKey: 'refresh_token', userFactory: (data) => User.fromMap(data), ); @override Future login(Map data, Authenticatable user) async { await startSession( user, token: data['token'] as String?, refreshToken: data['refresh_token'] as String?, ); } } // Register the driver, then name it in a guard's `driver` config key Auth.manager.extend('myguard', (config) => MyGuard()); ``` `startSession` marks the session as opened and moves the in-memory token before its first await, then persists the tokens, sets the user and caches it. Prefer it to `storeToken` followed by `setUser`: both mark a new session, so a boot-time user sync or a refresh still in the air ignores its answer, and both delete a stored refresh token the new session does not carry, but only `startSession` also deletes the stored access token when the login carries none and leaves the user alone when a newer sign-in began during its writes. ### Firebase Guard Example ```dart class FirebaseGuard extends BaseGuard { final _auth = firebase.FirebaseAuth.instance; FirebaseGuard() : super(userFactory: (data) => User.fromMap(data)); @override Future login(Map data, Authenticatable user) async { final idToken = await _auth.currentUser?.getIdToken(); await startSession(user, token: idToken); } @override Future restore() async { // Try cached user first (instant UI) final cached = await loadCachedUser(); if (cached != null) setUser(cached); // Then verify with Firebase final fbUser = _auth.currentUser; if (fbUser == null) { await logout(); return; } final user = userFactory!({ 'id': fbUser.uid, 'email': fbUser.email, 'name': fbUser.displayName, }); await startSession(user, token: await fbUser.getIdToken()); } @override Future logout() async { await _auth.signOut(); await super.logout(); } } ``` ## Reactive Auth State Every guard exposes a `ValueNotifier stateNotifier` that increments on every auth state change (`setUser()`, `logout()`, and session restore). Use it to reactively rebuild UI without manual state management. ```dart // Using ListenableBuilder (Flutter built-in) ListenableBuilder( listenable: Auth.stateNotifier, builder: (context, _) { if (Auth.check()) { return Text('Hello, ${Auth.user()?.name}'); } return const Text('Not logged in'); }, ) ``` With `MagicBuilder` the pattern is identical; pass `stateNotifier` as the listenable: ```dart MagicBuilder( listenable: Auth.stateNotifier, builder: (context, _) => Auth.check() ? const DashboardView() : const LoginView(), ) ``` `setUser()` is called internally by `login()`, `restore()`, and any custom guard that calls `setUser(user)` directly. Each call bumps `stateNotifier.value` by 1, triggering all registered listeners. ## Auto Token Refresh `AuthServiceProvider` registers an `AuthInterceptor` on the network driver, which handles 401 responses: 1. Original request fails with 401, **having presented the token** 2. Interceptor calls `Auth.refreshToken()` 3. If refresh succeeds, original request is retried with new token 4. If refresh fails, user is logged out (unless the session changed meanwhile, see below) `BaseGuard.refreshToken()` returns `false` unless the guard was built with both `refreshEndpoint` and `refreshTokenKey`. The built-in guards resolved from the `auth` config are built with neither, so for them step 4 follows any 401 on a presented credential. To refresh tokens, register a custom guard that passes both (see [Custom Guards](#custom-guards)). The refresh posts `{'refresh_token': ...}` to `refreshEndpoint` and reads `token` or `access_token` (at the top level or under `data`) and an optional `refresh_token` from the response. **A 401 on a request that carried no auth header, or an older token, is ignored.** The ladder above runs only when the refused request actually presented the header `auth.token.header` names, matched without regard to case, carrying the token the guard holds now. A request dispatched before a sign-in completed goes out anonymous, and its refusal arrives once the session exists; treating that as a rejection would end a session the server was never shown. Only a credential the server actually saw and refused ends a session. **A request that raced a token change is handed back refused, not replayed.** When the refused request carried an older token and the guard already holds a newer one, the interceptor cannot tell a refresh from a different account signing in, and a replay with the newer token could answer one account's screen with another account's data. The caller receives the 401 and decides whether its request still stands. **A refresh belongs to the session it started in.** When a sign-in, a sign-out or a restore of another account's token lands while a refresh is in the air, the refreshed token is discarded and the refused request is handed back with no retry and no logout, so the new session keeps its own token and stays signed in. A retry already on its way is sent with the refreshed token, never the new session's, and its answer is handed back as the 401. A sign-in without a `token` or a `refresh_token` deletes the stored one, so it never inherits the previous account's. `storeToken` counts as a sign-in too, and drops a stored refresh token it is not given; a custom guard that rotates its own token through it gets no retry and loses an in-flight boot sync, so rotate through `refreshToken()` with a `refreshEndpoint` instead. **A guard that does not extend `BaseGuard`** keeps no token the interceptor can compare against, so it is judged on presence alone: a 401 ends its session only when the request carried the header `auth.token.header` names. A cookie-based guard, or one that sends its credential under another header, stays signed in while calls return 401; such a guard has to end its own session. `Auth.refreshToken()` also returns `false` when the session changed while the refresh was in the air, so the example below signs out only when the stored token is still the one it refreshed. Comparing the stored token with the one that was refreshed does not detect a sign-in that stores the same token, so the example below can sign out a replacement session in that case. It can also sign out a sign-in that is still writing its tokens, because the second read can still find the previous token on disk. No public session-identity check is available. ```dart // Manual token refresh final refreshed = await Auth.getToken(); final success = await Auth.refreshToken(); if (!success && await Auth.getToken() == refreshed) { await Auth.logout(); MagicRoute.to('/login'); } ``` ## Protecting Routes Core ships no `auth` middleware: write one that checks `Auth.check()`, register it in the `Kernel` under an alias (see [Middleware](https://anilcancakir.com/magic/docs/basics/middleware.md)), and name the alias on the route: ```dart class EnsureAuthenticated extends MagicMiddleware { @override String? redirectTarget(String location) { if (!Auth.check() && location != '/login') return '/login'; return null; } } Kernel.register('auth', () => EnsureAuthenticated()); // Single route MagicRoute.page('/dashboard', () => DashboardView()) .middleware(['auth']); // Route group MagicRoute.group( middleware: ['auth'], routes: () { MagicRoute.page('/dashboard', () => DashboardView()); MagicRoute.page('/profile', () => ProfileView()); MagicRoute.page('/settings', () => SettingsView()); }, ); ``` Write a `guest` middleware the same way to redirect authenticated users: ```dart class RedirectIfAuthenticated extends MagicMiddleware { @override String? redirectTarget(String location) { if (Auth.check() && location != '/dashboard') return '/dashboard'; return null; } } ``` ## Login & Logout ### Login Flow ```dart class AuthController extends MagicController with ValidatesRequests { Future login(Map data) async { clearErrors(); final response = await Http.post('/login', data: data); if (response.successful) { final user = User.fromMap(response['data']['user']); await Auth.login({ 'token': response['data']['token'], }, user); Magic.success('Success', 'Welcome back!'); MagicRoute.to('/dashboard'); } else { handleApiError(response, fallback: 'Invalid credentials'); } } } ``` ### Logout Flow ```dart Future logout() async { // Optionally notify backend await Http.post('/logout'); // Clear local auth state await Auth.logout(); Magic.snackbar('Logged Out', 'See you next time!'); MagicRoute.to('/login'); } ``` ### Restoring Session on App Start `AuthServiceProvider.boot()` restores the session during `Magic.init`: when a user factory is registered (`Auth.registerModel` or `Auth.manager.setUserFactory`) and `auto_refresh` is not `false`, it calls `Auth.restore()`. Register the factory in a provider listed before `AuthServiceProvider`; a stored session with no factory is not restored and logs a warning. With `auto_refresh` set to `false` the provider skips the restore, so call it yourself in `main.dart`: ```dart void main() async { await Magic.init(...); // Restore auth session from cache await Auth.restore(); runApp(MagicApplication(...)); } ``` A restore loads the cached user for a fast startup, then syncs with the API in the background. A sign-in or a sign-out that completes while the sync is in the air wins: a late 401 about the restored token does not log out the new session, and a late 200 does not put the previous account back. A token refresh is not a new session, so a 200 that arrives under a refreshed token is still applied, and a 401 or 403 about a token that has since been refreshed is re-checked once under the current token, whose answer decides. If `userFactory` is not set on the guard, the cache load and API sync steps are skipped gracefully (no error is thrown). Set `userFactory` via `Auth.manager.setUserFactory()` (or pass it to `BaseGuard`'s constructor) during the boot phase to enable full session restore. ## Auth Events `BaseGuard` (and `FakeAuthManager`'s fake guard) dispatch `MagicEvent`s through the `Event` facade; see [Events](https://anilcancakir.com/magic/docs/digging-deeper/events.md#framework-events) for `Event.listen`'s registration shape. - **`AuthLogin`** fires at the end of `startSession`, once the token (when given) is persisted and the user is set and cached. Not fired on a restore; a restored user announces itself through `AuthRestored` instead. Skipped when the session ended or was replaced while the user was being cached, so a sign-out racing a sign-in never gets a stray `AuthLogin` for the session it just ended. - **`AuthLogout`** fires from `logout()`, after the `stateNotifier` bump and before a rethrown Vault failure. It carries the user held when the logout began (`null` for a guest), and a guest logout still dispatches. It means "the in-memory session ended", not "the credentials are gone": it fires even when a Vault delete failed, so a listener releasing server-side state must still gate on `Auth.hasToken()`. - **`AuthRestored`** fires only after an API-confirmed sync (`BaseGuard`'s background `/user` fetch inside `restore()`), never for the cache-only step that runs first. A sign-in or sign-out racing that sync suppresses it the same way `AuthLogin` is suppressed. Its `changed` field is `false` when the synced user serialises (`toMap()`, compared deeply) the same as the user the guard held before the sync, the usual warm cold boot, and `true` when an attribute moved or no user was held; a listener that remounts or refetches on it should skip `changed: false`. It defaults to `true` for an event built by hand. ```dart class LogAuthEvents extends MagicListener { @override Future handle(AuthLogout event) async { Log.info('Session ended for ${event.user?.authIdentifier ?? 'guest'}'); } } Event.listen(() => LogAuthEvents()); ``` ## Testing Replace the real auth manager with a `FakeAuthManager` using `Auth.fake()`. The fake routes all guard operations through an in-memory guard so tests run without platform channels or a real backend. Its `login()` and `logout()` dispatch `AuthLogin`/`AuthLogout` through the real `Event` facade too, same as the real guard, so a listener under test observes a faked session exactly as it would a real one. ```dart import 'package:magic/testing.dart'; void main() { setUp(() { MagicApp.reset(); Magic.flush(); }); tearDown(() => Auth.unfake()); test('login sets authenticated state', () async { final fake = Auth.fake(); await Auth.login({'token': 'abc'}, myUser); fake.assertLoggedIn(); fake.assertLoginCount(1); expect(Auth.check(), isTrue); expect(Auth.user(), same(myUser)); }); test('logout clears auth state', () async { final fake = Auth.fake(user: myUser); await Auth.logout(); fake.assertLoggedOut(); }); } ``` ### FakeAuthManager Assertions | Method | Description | |:-------|:------------| | `fake.assertLoggedIn()` | Fails if no user is currently set. | | `fake.assertLoggedOut()` | Fails if a user is currently set. | | `fake.assertLoginAttempted()` | Fails if `Auth.login()` was never called. | | `fake.assertLoginCount(n)` | Fails if `Auth.login()` was not called exactly `n` times. | | `fake.reset()` | Clears user, token, and login-attempt history. | Call `Auth.unfake()` in `tearDown()` to restore normal auth resolution after each test. ## Related Documentation - [Authorization](https://anilcancakir.com/magic/docs/security/authorization.md) - [Vault](https://anilcancakir.com/magic/docs/security/vault.md) - [Middleware](https://anilcancakir.com/magic/docs/basics/middleware.md) - [HTTP Client](https://anilcancakir.com/magic/docs/basics/http-client.md) - [Events](https://anilcancakir.com/magic/docs/digging-deeper/events.md) --- # Authorization Magic's authorisation system lets you define and check user abilities via the `Gate` facade, with support for policies, super-admin bypasses, and declarative UI widgets. - [Introduction](#introduction) - [Defining Abilities](#defining-abilities) - [Checking Abilities](#checking-abilities) - [Super Admin Bypass](#super-admin-bypass) - [Policies](#policies) - [Gate Utility Methods](#gate-utility-methods) - [UI Integration](#ui-integration) - [MagicCan Widget](#magiccan-widget) - [MagicCannot Widget](#magiccannot-widget) - [GateServiceProvider](#gateserviceprovider) - [Generating Policies](#generating-policies) - [Related Documentation](#related-documentation) ## Introduction In addition to authentication, Magic provides a simple way to authorise user actions against a given resource. Like Laravel, authorisation logic is defined using the `Gate` facade and may be checked anywhere in your application. ```dart // Define abilities (in provider) Gate.define('update-post', (user, post) => user.id == post.userId); // Check abilities (in code) if (Gate.allows('update-post', post)) { showEditButton(); } // Declarative (in UI) MagicCan( ability: 'update-post', arguments: post, child: EditButton(), ) ``` ## Defining Abilities Register abilities using `Gate.define()`. The callback receives the authenticated user as the first argument and optional model/data as the second: ```dart // Simple ability (no model) Gate.define('view-dashboard', (user, _) => user.isActive); // With model Gate.define('update-post', (user, post) => user.id == post.userId); // Complex logic Gate.define('delete-post', (user, post) { return user.isAdmin || user.id == post.userId; }); // Multiple conditions Gate.define('manage-team', (user, team) { return team.ownerId == user.id || team.admins.contains(user.id); }); ``` ## Checking Abilities ### In Controllers ```dart class PostController extends MagicController { Future update(String id, Map data) async { final post = await Post.find(id); if (post == null) return; if (Gate.denies('update-post', post)) { Magic.error('Forbidden', 'You cannot edit this post.'); return; } post.fill(data); await post.save(); Magic.success('Success', 'Post updated!'); } } ``` ### Available Methods ```dart // Check if allowed if (Gate.allows('update-post', post)) { // User can update } // Check if denied if (Gate.denies('delete-post', post)) { // User cannot delete } // Alias for allows if (Gate.check('view-admin')) { // Same as allows } // Any ability passes (short-circuits on first match) if (Gate.allowsAny(['owner', 'admin'], project)) { // At least one ability allows access } // All abilities must pass (short-circuits on first failure) if (Gate.allowsAll(['update', 'publish'], post)) { // Every ability allows access } ``` A check is denied, never thrown, when nobody is signed in, when the ability was never defined, and when its callback throws. ### Inside Controllers `MagicController` exposes an `authorize()` helper that delegates to the Gate and throws `AuthorizationException` when access is denied. This mirrors Laravel's `$this->authorize()` inside controller actions: ```dart class MonitorController extends MagicController with MagicStateMixin { Future update(String id, MonitorFormValues values) async { final monitor = await Monitor.find(id); authorize('update', monitor); // ... proceed with update, exception already thrown on denial } } ``` Catch `AuthorizationException` at the boundary (route handler, form submit) to surface a 403 view or feedback toast. ## Super Admin Bypass Use `Gate.before()` to register a global check that runs before all ability checks: ```dart Gate.before((user, ability) { // Super admins bypass all checks if ((user as User).role == 'super_admin') return true; // Continue with normal ability check return null; }); ``` The callback receives the user as a `Model`, so cast it to your user class to read its own getters. Return values: - `true` → Allow immediately, skip ability check - `false` → Deny immediately, skip ability check - `null` → Continue with normal ability check ## Policies Policies organise related authorisation logic into classes. This is the recommended approach for complex applications: ```dart import 'package:magic/magic.dart'; import '../models/post.dart'; class PostPolicy extends Policy { @override void register() { Gate.define('view-post', view); Gate.define('create-post', create); Gate.define('update-post', update); Gate.define('delete-post', delete); } bool view(Authenticatable user, Post post) => post.isPublished || user.id == post.userId; bool create(Authenticatable user, Post? post) => (user as User).isActive; bool update(Authenticatable user, Post post) => user.id == post.userId; bool delete(Authenticatable user, Post post) => (user as User).isAdmin || user.id == post.userId; } ``` ### Registering Policies Register policies in a service provider: ```dart class AppGateServiceProvider extends GateServiceProvider { AppGateServiceProvider(super.app); @override Future boot() async { await super.boot(); // Register policies PostPolicy().register(); CommentPolicy().register(); TeamPolicy().register(); } } ``` ## Gate Utility Methods The Gate facade exposes several utility methods for inspecting registered abilities and resetting state. ```dart // Check whether an ability has been defined if (Gate.has('update-post')) { // Ability is registered } // Get a list of all defined ability names final List defined = Gate.abilities; // Flush all abilities (useful in tests to start clean) Gate.flush(); ``` | Method | Returns | Description | |:---|:---|:---| | `Gate.has(ability)` | `bool` | Returns `true` if the named ability has been registered. | | `Gate.abilities` | `List` | All currently registered ability names. | | `Gate.flush()` | `void` | Removes all registered abilities and before-callbacks. | > [!TIP] > `Gate.flush()` is intended for test teardown. Call it in `tearDown()` alongside `MagicApp.reset()` to ensure a clean gate state between tests. ## UI Integration ### MagicCan Widget Conditionally render UI based on authorisation: ```dart // Basic usage MagicCan( ability: 'update-post', arguments: post, child: WButton( onTap: () => controller.edit(post), child: WText('Edit Post'), ), ) // With placeholder for denied access MagicCan( ability: 'view-admin-panel', child: AdminPanel(), placeholder: WText('Access Denied', className: 'text-red-500'), ) // Without arguments MagicCan( ability: 'view-dashboard', child: DashboardStats(), ) ``` ### MagicCannot Widget Show content only when user **lacks** an ability: ```dart // Show upgrade prompt to non-premium users MagicCannot( ability: 'access-premium', child: WDiv( className: 'p-4 bg-amber-500/10 rounded-lg', children: [ WText('Upgrade to Premium', className: 'font-bold text-amber-500'), WText('Unlock all features with our premium plan.'), ], ), ) // Show read-only indicator MagicCannot( ability: 'edit-post', arguments: post, child: WText('Read Only', className: 'text-gray-500 italic'), ) ``` ## GateServiceProvider Create a dedicated provider for all authorisation logic: ```dart import 'package:magic/magic.dart'; class AppGateServiceProvider extends GateServiceProvider { AppGateServiceProvider(super.app); @override Future boot() async { await super.boot(); // Super admin bypass Gate.before((user, ability) { if ((user as User).role == 'admin') return true; return null; }); // Simple abilities Gate.define('view-dashboard', (user, _) => true); Gate.define('manage-settings', (user, _) => (user as User).isAdmin); // Register policies PostPolicy().register(); TeamPolicy().register(); MonitorPolicy().register(); } } ``` Register in `config/app.dart`: ```dart 'providers': [ (app) => AppGateServiceProvider(app), // ... other providers ], ``` ## Generating Policies Use the [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md) to generate policy classes: ```bash dart run magic:dowel make:policy Post dart run magic:dowel make:policy PostPolicy # Explicit naming dart run magic:dowel make:policy Comment --model=Comment ``` ### Options | Option | Shortcut | Description | |:---|:---|:---| | `--model` | `-m` | The model that the policy applies to | **Output:** Creates `lib/app/policies/_policy.dart` with `view`, `create`, `update` and `delete` stubs, each returning `true` until you fill it in. ### Generated Policy Template ```dart import 'package:magic/magic.dart'; // TODO: Import your model // import '../models/post.dart'; /// PostPolicy /// /// Defines authorization rules for [Post] resources. class PostPolicy extends Policy { @override void register() { Gate.define('view-post', _view); Gate.define('create-post', _create); Gate.define('update-post', _update); Gate.define('delete-post', _delete); } /// Determine if the user can view the [Post]. bool _view(Authenticatable user, dynamic arguments) { // TODO: Implement your authorization logic. return true; } // _create, _update and _delete follow the same shape. } ``` > [!TIP] > Use policies for model-specific authorisation and simple `Gate.define()` calls for general abilities like "view-dashboard" or "access-admin". ## Related Documentation - [Authentication](https://anilcancakir.com/magic/docs/security/authentication.md) - [Middleware](https://anilcancakir.com/magic/docs/basics/middleware.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md) --- # Security: Vault The `Vault` facade provides a simple interface for reading and writing sensitive data to the platform's native secure storage, with a fake implementation for testing. - [Introduction](#introduction) - [macOS: Which Keychain](#macos-which-keychain) - [Storing Items](#storing-items) - [Retrieving Items](#retrieving-items) - [Removing Items](#removing-items) - [Testing](#testing) - [Encrypting Values](#encrypting-values) - [Related Documentation](#related-documentation) ## Introduction The `Vault` facade provides a simple interface for securely storing sensitive data on the device. It wraps `flutter_secure_storage`, so each platform keeps the value in its own native secure store: - **iOS**: Keychain - **macOS**: Keychain, the data protection one wherever the build may use it and the legacy login keychain where it may not. The vault chooses on first use; see below. - **Other platforms**: the store `flutter_secure_storage` provides ## macOS: Which Keychain macOS has two keychains and the vault writes to one of them. The data protection keychain is `flutter_secure_storage`'s own default and what every signed build already uses. The legacy login keychain needs no entitlement. The data protection keychain requires the `keychain-access-groups` entitlement. That entitlement is restricted, so the build has to be signed with an App ID and carry a provisioning profile. On an ad-hoc build, or one signed without a profile (a debug build from a machine with no certificate, or a release build you made to try locally), **every write to it fails** with `errSecMissingEntitlement` (-34018). So the vault probes, and you configure nothing. On its first operation it writes and deletes a throwaway item in the data protection keychain: - If that works, the vault uses the data protection keychain. - If the keychain answers -34018, the vault uses the legacy keychain instead and logs one warning. - Any other failure of the probe (a locked keychain, say) says nothing about the entitlement. It is raised as a `MagicVaultException` and the next operation probes again. The probe writes because a read cannot tell the two cases apart: on a build with no entitlement a read of the data protection keychain does not fail, it finds nothing, which is exactly what an item never stored looks like. Once the first operation has run, the choice is readable: ```dart final vault = Magic.make('vault'); vault.usesDataProtectionKeychain; // true, false, or null before the first operation ``` A failed write names its cause. `MagicVaultException.statusCode` carries the `OSStatus` the keychain returned, and `isMissingEntitlement` is true for -34018, so a screen can say "this build cannot store a sign-in" instead of a generic failure. To skip the probe and force one keychain, use one config key: ```dart // lib/config/security.dart final securityConfig = { // The domain key, like every config map in this framework: `appConfig` is // `{'app': {...}}` and `defaultCacheConfig` is `{'cache': {...}}`. // `MagicApp.init` merges each entry verbatim and derives no name from // anywhere, so a map without it lands at the top level and the provider, // which reads `security.vault.macos_data_protection_keychain`, never sees it. 'security': { 'vault': { // Only consulted on macOS. Leave it out to let the vault probe. A bare // `false` literal, not the string `'false'`: `Config.get` returns // its default on a type mismatch as well as on a missing key, so a // quoted value reads exactly like no value at all. 'macos_data_protection_keychain': false, }, }, }; ``` The file is not loaded by being there. Hand it to `Magic.init` like every other config domain, or the key is absent and the vault probes with nothing to say the setting was ignored: ```dart await Magic.init( configFactories: [ () => appConfig, () => securityConfig, ], ); ``` Or, when you construct the service yourself: ```dart app.singleton('vault', () => MagicVaultService(macOsUsesDataProtectionKeychain: false)); ``` **There is no migration between the two keychains, in either direction.** An item written to one is invisible from the other, and every caller reads that as "never stored" rather than as an error. Two consequences, both silent: - The `magic_encryption` plugin's device-key encryption generates a **new** device key on a null read, so anything encrypted under the old one becomes permanently unreadable while the old key sits unreachable in the other keychain. - `BaseGuard` loses the stored token the same way, which logs the user out. The probe can open the same split without anyone changing a setting. An app that fell back to the legacy keychain and is later signed with a provisioning profile picks the data protection keychain, and the items it stored before are not there. Pin `false` for an app that already holds items in the legacy keychain and must keep reading them, and move them across deliberately: read them under the old setting, switch, and write them back. Two more consequences of the fallback: - A release build with no provisioning profile now stores in the legacy keychain, with one warning in the log, where it used to fail every write. If that is not what you ship, give the build its profile, or set `macos_data_protection_keychain` to `true` so that a missing entitlement fails loudly. - The legacy keychain ties an item's access rule to the code signature that wrote it. An ad-hoc signature differs on every build, so after a rebuild each stored item can raise a system keychain prompt on the next launch, and the app waits behind it until someone answers. Sign local builds with a stable identity (an Apple Development certificate) and rebuilds read their items without a prompt. The vault also passes `first_unlock_this_device` accessibility on macOS: readable after the first unlock, so a refresh on launch works before anyone has touched the machine, and never restored onto a different Mac from a backup. That attribute belongs to the data protection keychain, so it has no effect on the legacy one. ## Storing Items To store a value in the vault: ```dart await Vault.put('api_token', 'super-secret-token'); ``` ## Retrieving Items To retrieve a value: ```dart final token = await Vault.get('api_token'); if (token != null) { // Use token... } ``` ## Removing Items To remove a specific item: ```dart await Vault.delete('api_token'); ``` To wipe all data from the vault (use with caution): ```dart await Vault.flush(); ``` ## Testing Replace the real vault service with a `FakeVaultService` using `Vault.fake()`. The fake stores values in memory so tests run without platform channels. ```dart import 'package:magic/testing.dart'; void main() { tearDown(() => Vault.unfake()); test('stores and reads a token', () async { final fake = Vault.fake({'existing': 'seed'}); await Vault.put('token', 'abc123'); fake.assertWritten('token'); fake.assertContains('token'); expect(await Vault.get('token'), 'abc123'); }); test('delete removes the key', () async { final fake = Vault.fake({'token': 'abc123'}); await Vault.delete('token'); fake.assertDeleted('token'); fake.assertMissing('token'); }); } ``` Pass an optional map of initial values to `Vault.fake()` to pre-seed the store. ### FakeVaultService Assertions | Method | Description | |:---|:---| | `fake.assertWritten(key)` | Fails if `Vault.put(key, ...)` was never called. | | `fake.assertDeleted(key)` | Fails if `Vault.delete(key)` was never called. | | `fake.assertContains(key)` | Fails if `key` is not currently in the store. | | `fake.assertMissing(key)` | Fails if `key` is currently in the store. | | `fake.reset()` | Clears the in-memory store, the operation history, and any configured throw below. | Call `Vault.unfake()` in `tearDown()` to restore the real vault binding after each test. ### Simulating a vault failure `fake.throwOnGet([error])` and `fake.throwOnPut([error])` make the fake throw instead of completing normally, for testing a vault-failure branch a consumer's own code has for `Vault.get` or `Vault.put`. Each defaults to a `MagicVaultException` and only affects its own operation: ```dart test('a get failure surfaces as MagicVaultException', () async { final fake = Vault.fake(); fake.throwOnGet(); await expectLater(Vault.get('token'), throwsA(isA())); }); ``` Pass a custom error to `throwOnGet`/`throwOnPut` to assert on a specific message. `fake.reset()` clears a configured throw along with the store. ## Encrypting Values Encrypting strings (the `Crypt` facade) is provided by the `magic_encryption` plugin, not by core. Install it with `flutter pub add magic_encryption`, then `dart run magic:dowel plugin:install magic_encryption`. Its [installation page](https://anilcancakir.com/magic/docs/encryption/getting-started/installation) covers setup and its [device key page](https://anilcancakir.com/magic/docs/encryption/basics/device-key) covers keys held in the vault. ## Related Documentation - [Authentication](https://anilcancakir.com/magic/docs/security/authentication.md) - [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md) --- # Helpers Static helpers for locale-aware strings and numbers, dot-path map access, defensive type reading, and small async utilities such as polling, debouncing and stale-read guards. - [Str](#str) - [Number](#number) - [Arr](#arr) - [Cast](#cast) - [Composing Arr and Cast](#composing-arr-and-cast) - [AppLifecycle](#applifecycle) - [LatestRead](#latestread) - [Poll](#poll) - [Countdown](#countdown) - [Debouncer](#debouncer) - [UrlGenerator](#urlgenerator) - [Related Documentation](#related-documentation) ## Str `Str.upper` and `Str.lower` are locale-aware replacements for `String.toUpperCase()`/`toLowerCase()`, which get Turkish and Azerbaijani wrong: those alphabets distinguish a dotted `i` from a dotless `ı`, a pair Dart's own mapping conflates. Every method defaults its `locale` to `Lang.current.languageCode`, and accepts a full tag (`tr_TR`, `tr-TR`) as well as a bare language code. ```dart Str.upper('çalışan izleyiciler', locale: 'tr'); // 'ÇALIŞAN İZLEYİCİLER' Str.upper('istanbul', locale: 'en'); // 'ISTANBUL' Str.lower('İSTANBUL', locale: 'tr'); // 'istanbul' Str.lower('IŞIK', locale: 'tr'); // 'ışık' ``` `Str.initials(value, {limit, capitalize, locale})` takes the first letter of each whitespace-separated word. `limit` (greater than 0) keeps only the first N words; `capitalize` routes the result through `Str.upper` under `locale`. A blank or null `value` returns `''`. ```dart Str.initials('ismail kaya', limit: 2, capitalize: true, locale: 'tr'); // 'İK' ``` `Str.unwrap(value, before, [after])` strips `before` from the start and `after` (default `before`) from the end, each checked and stripped independently, mirroring Laravel's `Str::unwrap`. A prefix-only match (`'"x'`) loses the leading quote and is left unbalanced rather than untouched. ```dart Str.unwrap('"quoted"', '"'); // 'quoted' Str.unwrap('"x', '"'); // 'x', prefix-only match still strips Str.unwrap('[value]', '[', ']'); // 'value' ``` `Str.ascii(value)` folds Latin-1 Supplement and Latin Extended-A letters, the Romanian comma-below letters `Ș ș Ț ț`, `ẞ`, and the Angstrom sign U+212B to their plain ASCII base, for building a search key. Combining marks (U+0300-U+036F) are always dropped, whatever letter they decorate. A Latin letter outside that coverage (Vietnamese, the rest of Latin Extended-B/Additional) and every other script pass through untouched; magic folds Latin only, unlike Laravel's `Str::ascii`, which transliterates every script it has a table for and would otherwise collapse a non-Latin word to `?` or a phonetic guess. ```dart Str.ascii('çalışan izleyiciler'); // 'calisan izleyiciler' Str.ascii('Ångström'); // 'Angstrom' ``` `Str.squish(value)` trims `value` and collapses every run of whitespace to one space, mirroring Laravel's `Str::squish`. The whitespace class is Dart's `\s` plus the two Hangul filler code points (U+3164, U+1160) a rendered blank can carry without registering as `\s`; both ends of `value` are checked against the same class, so a boundary and a middle occurrence of the same code point fold the same way. ```dart Str.squish(' hello world '); // 'hello world' ``` ## Number `Number` formats numeric values with a locale's own grouping, decimal, and currency conventions, resolving `locale` (or `Lang.current` when omitted) through `Intl.verifiedLocale`; an unknown locale falls back to `'en'` instead of throwing inside a `build` method. ```dart Number.format(1234567.891, maxPrecision: 3, locale: 'tr'); // '1.234.567,891' Number.format(1234567.891, maxPrecision: 3, locale: 'en'); // '1,234,567.891' ``` `Number.currency(amount, {code, precision, locale})` is built on `NumberFormat.simpleCurrency`, not `NumberFormat.currency`: the latter renders the bare ISO code with no symbol (`'TRY1.234,50'`), while `simpleCurrency` resolves the locale's own symbol. ```dart Number.currency(1234.5, code: 'TRY', locale: 'tr'); // '₺1.234,50' ``` `Number.percentage(value, {precision, maxPrecision, locale})` takes a 0-100 input like Laravel's `Number::percentage()`, not intl's native 0-1 fraction, so a caller never has to remember to divide by 100 first. ```dart Number.percentage(99.95, precision: 2, locale: 'tr'); // '%99,95' Number.percentage(99.95, precision: 2, locale: 'en'); // '99.95%' ``` `Number.fileSize(bytes, {precision, maxPrecision, locale})` steps by 1024 (B, KB, MB, GB, TB, PB) and formats the final number through `Number.format`. ```dart Number.fileSize(1536, maxPrecision: 1, locale: 'en'); // '1.5 KB' ``` `Number.abbreviate(value, {precision, maxPrecision, locale})` compacts a value with the locale's own unit letters (`Mn`, `B` for Turkish; `M`, `K` for English). ```dart Number.abbreviate(1500000, maxPrecision: 1, locale: 'tr'); // '1,5 Mn' ``` ## Arr `Arr` carries dot-path access into a nested `Map`, mirroring Laravel's `Arr::get` / `has` / `set` / `dot`. ```dart Arr.get({'a': {'b': [10, 20]}}, 'a.b.1'); // 20 Arr.has({'a': {'b': 1}}, 'a.b'); // true final map = {}; Arr.set(map, 'a.b.c', 1); // {'a': {'b': {'c': 1}}} Arr.dot({'a': {'b': 1}}); // {'a.b': 1} ``` An exact key wins over walking the path: a map that happens to hold a literal `'a.b'` key reads that value rather than descending into `map['a']['b']`. A numeric path segment indexes into a `List` at that position. `Arr.get` answers the given fallback (`null` by default) when the path is unreachable. **`Arr` carries no typed accessors.** There is no `Arr.getString`, `Arr.getInt`, or similar; typing a value read off a path is [Cast](#cast)'s job, composed at the call site. ## Cast `Cast` reads a loosely-typed wire value (a field out of a nested map or a pivot row that has not gone through the ORM's own coercion) as a specific Dart type, degrading to a fallback instead of throwing. Use it on a value pulled out of a decoded JSON map, not on a model attribute, since the ORM already coerces those through `get`. ```dart Cast.stringOr('hello', 'fallback'); // 'hello' Cast.stringOr(42, 'fallback'); // 'fallback' Cast.stringOrNull(42); // null Cast.intOr(3.9, 0); // 3 Cast.intOr('3', 0); // 3, the one reader that parses a numeric string Cast.intOr('abc', 0); // 0 Cast.intOrNull('3'); // null, unlike intOr Cast.numOrNull(3.5); // 3.5 Cast.doubleOrNull(3); // 3.0 Cast.boolOr('yes', false); // false, only a real bool passes Cast.boolOrNull(false); // false Cast.idOrNull(42); // '42', a num stringifies rather than degrading Cast.idOrNull('abc-123'); // 'abc-123' Cast.idOrNull(true); // null ``` `Cast.intOr` is the one reader that parses a numeric string, because the fields it serves are orders and durations, and silently falling back on `"3"` would sort a list wrongly or shorten a delay. Every other reader (`numOrNull`, `doubleOrNull`, `intOrNull`) leaves a numeric string as unreadable, so the caller's own `?? fallback` decides what it means rather than a number appearing on a chart from a value the backend was not supposed to send. `Cast.idOrNull` stringifies a number rather than answering null, because a primary key can be a uuid or a bigint depending on backend configuration, and reading an int id as null would corrupt a save-diff that branches on a null id to mean "create this row". ## Composing Arr and Cast `Arr.get` performs no type check of its own; compose it with `Cast` at the call site for a typed read off a nested payload: ```dart final priority = Cast.intOr(Arr.get(payload, 'meta.priority'), 0); final label = Cast.stringOrNull(Arr.get(payload, 'meta.label')); ``` ## AppLifecycle `AppLifecycle.states()` exposes the app lifecycle as a `Stream`, for a reader constructed before a `WidgetsBinding` necessarily exists. A dependency built inside a service provider's `register()` runs before the app has bound anything, so reaching for `WidgetsBinding.instance` at construction time throws; `AppLifecycle.states()` defers that lookup to the moment a listener actually subscribes. ```dart final subscription = AppLifecycle.states().listen((state) { if (state == AppLifecycleState.paused) Log.info('app paused'); }); // Later, when done: await subscription.cancel(); ``` Each subscription owns its own observer: it is added to the binding on `listen` and removed on `cancel`, so nothing outlives its reader and nothing before the first `listen` touches the binding at all. Prefer Flutter's own `AppLifecycleListener` when the reader is a widget-lifetime object; reach for `AppLifecycle.states()` only when construction has to happen before a binding is guaranteed to exist. ## LatestRead `LatestRead` drops a stale answer that lands after a newer read for the same key already started, in place of a hand-rolled read counter. `begin([key])` starts a new read and hands back a token; `isCurrent(token, [key])` answers whether that token is still the newest one issued. ```dart final _reads = LatestRead(); Future loadChecks(String monitorId) async { final token = _reads.begin(monitorId); final checks = await Http.get('monitors/$monitorId/checks'); if (!_reads.isCurrent(token, monitorId)) return; // a newer read for this key already started setChecks(checks.data); } ``` `invalidate([key])` bumps the counter without starting a new read, dropping whatever read is currently in flight for `key` when it lands. ## Poll `Poll.until` re-reads on a fixed interval until a value is accepted or an attempt budget runs out, replacing a `Timer`-per-field polling loop hand-rolled once per screen. ```dart final handle = Poll.until( read: () => Http.get('checks/$checkId').then((r) => r.successful ? CheckResult.fromMap(r.data) : null), done: (result) => result.status != 'pending', every: const Duration(seconds: 2), maxAttempts: 15, ); final outcome = await handle.result; switch (outcome) { case PollSettled(value: final result): setSuccess(result); case PollExhausted(): setError('Check did not finish in time'); case PollCancelled(): break; // the caller cancelled; nothing to show } ``` The first read runs only after one `every` interval, never immediately, since the caller already has whatever state it polled before starting. A `null` read result is treated as "not landed yet" and never reaches `done`; a throwing read spends its attempt like a read that answered nothing, logged rather than propagated. `handle.cancel()` stops the pending read early and settles `PollCancelled`. Cancel a `Poll.until` handle from `OwnsTimers` (`own(Poll.until(...))`) so a controller never has to track it by hand. ## Countdown `Countdown` is a per-key clock ticking down to zero once a second, for something like a "check now" cooldown per monitor id, where several independent countdowns run without one key's clock interfering with another's. ```dart final cooldowns = Countdown()..onTick = (key, remaining) => refreshUI(); cooldowns.start(monitorId, 30); cooldowns.remaining(monitorId); // seconds left, or null once finished cooldowns.isRunning(monitorId); cooldowns.cancel(monitorId); // or cancelAll() ``` A key's countdown stops and forgets its own state the moment it reaches zero; it never ticks into negative numbers or keeps a finished timer running. ## Debouncer `Debouncer` coalesces repeated calls under the same key into one delayed run of the LAST callback given, generalising a single-purpose reload debounce. ```dart final debouncer = Debouncer(); void onSearchChanged(String query) { debouncer.run('search', const Duration(milliseconds: 300), () => search(query)); } ``` Each call to `run` for a given key cancels that key's still-pending timer and arms a fresh one. `cancelAll()` cancels every pending run without firing any of them. ## UrlGenerator `UrlGenerator` (and the top-level `url()` helper) builds an absolute URL against the app's configured origin, Laravel's `url()`/`URL::to`. It is not named `Url`: that name is already taken by the validation rule at `lib/src/validation/rules/url.dart`. ```dart url('/terms'); // 'https://example.com/terms' UrlGenerator.to('/terms', query: {'ref': 'app'}); UrlGenerator.localized('/pricing'); // '/tr/pricing' under an active non-default locale ``` The origin is read from `Config.get(UrlGenerator.originKey, ...)` (`'app.url'` by default), falling back to `Env.filled('APP_URL', '')`. Override `UrlGenerator.originKey` once, before any call, for an app whose origin config lives under a different key (a marketing-site origin distinct from the API base URL). `localized()` prefixes the active language unless it is the configured default one, or degrades to the bare path when the active language is not in `localization.supported_locales`. ## Related Documentation - [Localization](https://anilcancakir.com/magic/docs/digging-deeper/localization.md) - [Carbon](https://anilcancakir.com/magic/docs/digging-deeper/carbon.md) - [Validation](https://anilcancakir.com/magic/docs/digging-deeper/validation.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) --- # Validation Magic provides a client-side validation system that integrates with Flutter forms, resolves error messages through the Lang facade, and extends to async rules for uniqueness or remote existence checks. - [Introduction](#introduction) - [Quick Start](#quick-start) - [Defining Validation Rules](#defining-validation-rules) - [Available Rules](#available-rules) - [Sizing a Numeric String](#sizing-a-numeric-string) - [The Url Rule](#the-url-rule) - [Custom Messages](#custom-messages) - [Form Requests](#form-request) - [Validating a Form Request Through a Controller](#validate-request) - [Server-Side Validation](#server-side-validation) - [Async Validation](#async-rules) - [Custom Rules](#custom-rules) - [Error Styling](#error-styling) - [Localization](#localization) - [Related Documentation](#related-documentation) ## Introduction Magic provides a validation system that integrates with Flutter forms. Define your validation rules in the View using the `rules()` helper, and your Controller receives only clean, pre-validated data. ### Key Features | Feature | Description | |:---|:---| | **MagicForm Widget** | Automatic form state management and error handling | | **rules() Helper** | Concise rule definition with auto-injected controller | | **Server-Side Errors** | Automatic display of API 422 validation errors | | **Error Styling** | Wind UI's `error:` state prefix for styling | | **Localization** | Error messages resolved via `trans()` helper | ## Quick Start Use `MagicFormData` for Laravel-style form handling: ```dart class RegisterView extends MagicStatefulView { const RegisterView({super.key}); @override State createState() => _RegisterViewState(); } class _RegisterViewState extends MagicStatefulViewState { // Define form fields - types inferred from initial values late final form = MagicFormData({ 'name': '', 'email': '', 'password': '', 'password_confirmation': '', 'accept_terms': false, }, controller: controller); @override void onClose() => form.dispose(); @override Widget build(BuildContext context) { return MagicForm( formData: form, child: WDiv( className: 'flex flex-col gap-4', children: [ WFormInput( controller: form['name'], label: trans('attributes.name'), validator: rules([Required(), Min(2)], field: 'name'), ), WFormInput( controller: form['email'], label: trans('attributes.email'), type: InputType.email, validator: rules([Required(), Email()], field: 'email'), ), WFormInput( controller: form['password'], label: trans('attributes.password'), type: InputType.password, validator: rules([Required(), Min(8)], field: 'password'), ), WFormInput( controller: form['password_confirmation'], label: trans('attributes.password_confirmation'), type: InputType.password, validator: rules([ Required(), Same('password', valueGetter: () => form['password'].text), ], field: 'password_confirmation'), ), WFormCheckbox( value: form.value('accept_terms'), onChanged: (v) => form.setValue('accept_terms', v), label: WText(trans('auth.accept_terms')), validator: rules([Accepted()], field: 'accept_terms'), ), WButton( isLoading: controller.isLoading, onTap: () { final data = form.validated(); if (data.isNotEmpty) { controller.register(data); } }, className: 'w-full bg-primary p-4 rounded-lg', child: WText(trans('auth.register'), className: 'text-white text-center'), ), ], ), ); } } ``` ## Defining Validation Rules ### The rules() Helper The `rules()` helper is available in all `MagicStatefulViewState` subclasses: ```dart // Full syntax validator: FormValidator.rules([Required()], field: 'email', controller: controller) // Shorthand (controller auto-injected) validator: rules([Required(), Email()], field: 'email') ``` ### Multiple Rules Combine multiple rules in an array: ```dart validator: rules([ Required(), Email(), Max(255), ], field: 'email') ``` Rules are evaluated in order. If any rule fails, validation stops and the error is displayed. ## Available Rules | Rule | Description | Example | |:---|:---|:---| | `Required()` | Field must not be empty | `[Required()]` | | `Email()` | Valid email format | `[Email()]` | | `Url()` | Valid http(s) address with a host | `[Url()]`, `[Url(schemes: ['https'])]` | | `Min(n)` | Minimum length/value | `[Min(8)]` | | `Max(n)` | Maximum length/value | `[Max(255)]` | | `Confirmed()` | Must match `{field}_confirmation` | `[Confirmed()]` | | `Same('field')` | Must match another field | `[Same('password', valueGetter: ...)]` | | `Accepted()` | Must be true/1/"yes"/"on" | `[Accepted()]` | | `In(values)` | Value must appear in whitelist | `[In(['public', 'private'])]` | | `InList(values)` | Value must match an enum (name or instance) | `[InList(Severity.values)]` | | `Uuid()` | Valid RFC 4122 UUID, any version | `[Uuid()]` | | `Boolean()` | `true`/`false`/`0`/`1`/`'0'`/`'1'` | `[Boolean()]` | | `Numeric()` | A `num` or a numeric string | `[Numeric()]` | | `Integer()` | An integer, an integer-looking string, or a whole-number double | `[Integer()]` | | `Gt(n)` / `Gte(n)` / `Lt(n)` / `Lte(n)` | Size strictly greater/at least/strictly less/at most `n` (numeric, string length, or list length) | `[Gt(0)]` | | `Between(min, max)` | Size within an inclusive range | `[Between(8, 64)]` | | `Regex(pattern)` | Matches a plain Dart `RegExp` pattern (no delimiters) | `[Regex(r'^[A-Z]{3}-\d{4}$')]` | | `Date()` | A `DateTime` or a string `DateTime.tryParse` accepts | `[Date()]` | | `Nullable()` | Marker rule; always passes | `[Nullable(), Numeric()]` | | `RequiredIf(other, value)` | Required only when `other` equals `value` | `[RequiredIf('type', 'company')]` | | `ArrayRule()` | Value must be a `List` | `[ArrayRule()]` | ### Sizing a Numeric String `Min`, `Max`, `Between`, `Gt`, `Gte`, `Lt` and `Lte` measure a number by its value, a list by its item count, and a string by its length, except that a string is read as a number when the same field also carries `Numeric()` or `Integer()`. That is Laravel's `getSize`, and it matters because form input is always a string: ```dart 'quantity': [Required(), Numeric(), Between(1, 100)], // "500" fails: 500 > 100 'password': [Required(), Between(8, 64)], // "12345678" passes: 8 characters ``` The rules cannot see each other, so `Validator` and `FormValidator` pass the answer in through `SizeRule.passesSized`. A custom rule that sizes a value extends `SizeRule` to be read the same way; calling `passes()` on one directly, with no rule list around it, always sizes a string by its length. ### The Url Rule `Url()` accepts an address a request could actually be built from: a scheme from its allowlist and a non-empty host. ```dart 'website': [Required(), Url()], // http or https 'webhook': [Required(), Url(schemes: ['https'])], // https only 'socket': [Url(schemes: ['ws', 'wss'])], ``` It is not an RFC 3986 parser and does not try to be. It reaches no network, resolves no host and takes no view on whether the path exists, because a rule answering a form field synchronously cannot know any of that. **The scheme allowlist is the part that matters.** `Uri.parse` accepts `javascript:alert(1)` and `file:///etc/passwd` without complaint; both have a scheme and parse cleanly. Nothing but the allowlist keeps them out of a field whose value becomes a request or a link. Narrow it to `['https']` for anything carrying a credential. Whitespace is rejected before parsing, because `Uri` does not treat it as an error: `Uri.tryParse('http://exa mple.com')` succeeds and percent-encodes the space into the host as `exa%20mple.com`, which no DNS lookup can resolve. Laravel's `url` rejects it too. ### Custom Messages A rule's message comes from its own catalogue key, which is generic by design: `validation.required` is `The :attribute field is required.` for every field in the app. When one screen wants its own wording, pass `messages`, keyed by rule name: ```dart WFormInput( validator: FormValidator.rules( [Required(), Url()], field: 'address', messages: { 'required': 'provider.error.address_required', 'url': 'provider.error.address_scheme', }, ), ) ``` Pass `attribute` to name the field in its messages, the field-level twin of `FormRequest.attributes()`. It is used as written, ahead of the global `attributes.`, so the field reads the same while typing as after a `FormRequest` submit: ```dart FormValidator.rules([Required()], field: 'url', attribute: 'Server address') // "The Server address field is required." ``` This is Laravel's third `Validator::make` argument. Without it, a screen wanting specific copy had to abandon the rules and hand-roll a closure, which is exactly what the rules exist to prevent. The value is a **key**, not a finished sentence. An override taking a sentence would make every consumer using it monolingual. A key with no sentence renders as itself, which is `trans`'s own contract, and the rule's parameters still reach it, so `:attribute` and `:schemes` work in an override too. The map key is `Rule.name`, derived from the rule's message key (`validation.required` gives `required`) rather than from `runtimeType`, which is not a dependable identifier in a release build: dart2js minifies class names, and Flutter's own `objectRuntimeType` declines to call `toString` on a runtime type outside asserts. ### Whitelist Rules (`In` / `InList`) `In` validates against a primitive whitelist. The generic `T` is the element type, so type mismatches fail explicitly rather than silently coercing: ```dart 'visibility': [In(['public', 'private'])], 'priority': [In([1, 2, 3, 5, 8])], ``` `InList` is the enum-aware variant. By default it compares against `Enum.name` and also accepts the enum instance itself. Use `caseInsensitive: true` for loose matching, or `wire:` to map enums onto a custom wire representation (snake_case, kebab-case, or bespoke codes): ```dart enum Severity { low, medium, high, critical } InList(Severity.values); // matches 'low', 'medium', ... InList(Severity.values, caseInsensitive: true); // also 'HIGH', 'Critical' enum ThresholdDirection { highBad, lowBad } InList( ThresholdDirection.values, wire: (d) => switch (d) { ThresholdDirection.highBad => 'high_bad', ThresholdDirection.lowBad => 'low_bad', }, ); ``` Both rules pass on `null` so you pair them with `Required()` when presence matters. The failure message resolves `validation.in` with a comma-joined `:values` placeholder (`"The severity must be one of low, medium, high, critical."`). ### Same Rule with ValueGetter For password confirmation, use the `valueGetter` parameter: ```dart WFormInput( controller: form['password_confirmation'], validator: rules([ Required(), Same('password', valueGetter: () => form['password'].text), ], field: 'password_confirmation'), ) ``` ## Form Requests A `FormRequest` collapses "authorize, normalise, validate" into a single class so controllers stop hand-rolling that ceremony. Subclass `FormRequest`, implement `rules()`, optionally override `authorize()` and `prepared()`, then call `.validate(rawInput)`: ```dart class StoreMonitorRequest extends FormRequest { const StoreMonitorRequest(); @override bool authorize() => Gate.allows('monitor.create'); @override Map prepared(Map data) => { ...data, 'slug': (data['name'] as String? ?? '') .trim() .toLowerCase() .replaceAll(RegExp(r'\s+'), '-'), }; @override Map> rules() => { 'name': [Required(), Max(120)], 'slug': [Required()], }; } // In a controller with the ValidatesRequests mixin: try { final payload = const StoreMonitorRequest().validate(form.data); await Http.post('/monitors', data: payload); } on AuthorizationException { Magic.error('Error', 'You do not have permission to create monitors.'); } on ValidationException catch (e) { // e.errors maps each failing field to its message; put them on the form setErrorsFromMap( e.errors.map((field, message) => MapEntry(field, [message])), ); } ``` - `authorize()` runs first and throws `AuthorizationException` on `false`. `Gate.allows('monitor.create')` answers `true` only once the app has defined the ability, typically in a provider's `boot()` with `Gate.define('monitor.create', (User user, [dynamic _]) => user.isAdmin)` (see [Authorization](https://anilcancakir.com/magic/docs/security/authorization.md)). Until then every request is denied, unless a `Gate.before` callback allows it; a signed-out user is always denied. - `prepared()` normalises the payload before rules see it (trim, slugify, merge defaults). - `validate()` returns the prepared payload filtered to the keys declared in `rules()`, the same contract as `Validator.validate`. - `attributes()` names fields for `:attribute` in this request's messages (see below). Default is an empty map. ### Naming Fields Per Request By default `:attribute` resolves to the catalogue's global `attributes.`, then to the humanized key. Override `attributes()` when one request calls a field something else; an entry wins over the global name, and a field it omits keeps the global lookup: ```dart class UpdatePanelRequest extends FormRequest { @override Map> rules() => { 'url': [Required()], }; @override Map attributes() => {'url': 'Server address'}; } // "The Server address field is required." ``` The value is used as written, not looked up as a translation key. `Validator.make(data, rules, attributes: {...})` takes the same map for ad hoc checks. `ValidatesRequests.validate` takes the same map, and `validateRequest` and `validateRequestAsync` pass the request's `attributes()` on, so a controller with the mixin and `MagicFormObject.submit` honour it. Pairs cleanly with `Model.fill(payload, strict: true)` so mass-assignment catches schema drift at the boundary. ## Validating a Form Request Through a Controller `FormRequest.validate()` filters its return value to the keys declared in `rules()` and never touches a controller's error bag, so calling it directly skips clearing stale errors before a resubmit, populating per-field errors on failure, and repainting the form. A controller with the `ValidatesRequests` mixin calls `validateRequest` instead, which runs the same authorize/prepare sequence but routes through the mixin's own error bag and returns the FULL prepared map, not the rule-filtered one, so a write can still send fields the backend needs but no rule constrains: ```dart class MonitorController extends MagicController with ValidatesRequests { Future store(Map data) async { try { final payload = validateRequest(const StoreMonitorRequest(), data); await Http.post('/monitors', data: payload); } on AuthorizationException { Magic.error('Error', 'You do not have permission to create monitors.'); } on ValidationException catch (e) { // validationErrors is already populated; UI has already rebuilt. } } } ``` `validateRequest` throws `AuthorizationException` when `FormRequest.authorize()` returns `false`, before any field is touched, and runs only synchronous rules (an `AsyncRule` in the request's `rules()` is skipped). Use `validateRequestAsync` when a rule needs to `await`; it has the same contract, validated through `Validator.validateAsync()` so an `AsyncRule` actually runs: ```dart final payload = await validateRequestAsync(SlugUniqueRequest(), data); ``` ### CollapsesIndexedErrorKeys A backend that validates a list field returns one wire key per element (`items.0.name`, `items.1.name`), but a form with a single error slot per field, not per element, has nowhere to put a per-index message. `ValidatesRequests.errorFieldFor` keeps a wire key as-is by default, which is what an existing controller already depends on; mix in `CollapsesIndexedErrorKeys` on top to collapse an indexed key down to its field name instead: ```dart class ItemsController extends MagicController with ValidatesRequests, CollapsesIndexedErrorKeys {} // A 422 response carrying {"errors": {"items.0.name": ["The items.0.name field is required."]}} // populates controller.validationErrors as {"name": "The items.0.name field is required."} ``` Two wire keys that collapse onto the same field (two failing elements of the same list) keep the FIRST message; the later one is dropped rather than overwriting it. A key addressing a distinct sub-key rather than a list element (`credentials.token`) is left whole, since a form with a separate error slot per sub-key needs each one kept. A controller that already extends a different base and cannot mix in `CollapsesIndexedErrorKeys` still reaches the same collapse through the mixin's static helper, `CollapsesIndexedErrorKeys.collapse(wireKey)`: ```dart final field = CollapsesIndexedErrorKeys.collapse('items.0.name'); // 'name' ``` ## Server-Side Validation Magic automatically handles Laravel-style 422 validation errors from your API. ### Controller Setup Add the `ValidatesRequests` mixin to your controller: ```dart class AuthController extends MagicController with MagicStateMixin, ValidatesRequests { Future register(Map data) async { setLoading(); clearErrors(); // Clear previous validation errors final response = await Http.post('/register', data: data); if (response.successful) { setSuccess(true); MagicRoute.to('/dashboard'); } else { // Automatically populates field errors from 422 response handleApiError(response, fallback: 'Registration failed'); } } } ``` ### handleApiError Method The `handleApiError()` method handles different error types: | Error Type | Behaviour | |:---|:---| | 422 Validation | Sets field-level errors; with `MagicStateMixin`, sets the response's error message, or empty when it has none | | Any other failure | With `MagicStateMixin`, sets the first error from the response, else `fallback`, else `'An error occurred'` | ### ValidatesRequests Methods | Method | Description | |:---|:---| | `handleApiError(response)` | Handle any API error automatically | | `setErrorsFromResponse(response)` | Populate errors from 422 response | | `hasError('field')` | Check if a field has an error | | `getError('field')` | Get error message for a field | | `hasErrors` | Check if any errors exist | | `clearErrors()` | Clear all validation errors | ### Displaying Server Errors Server-side errors appear automatically under form fields. For manual display: ```dart if (controller.hasError('email')) WText(controller.getError('email')!, className: 'text-red-500 text-sm'), ``` ## Async Validation Some validations cannot decide locally: uniqueness, remote existence, captcha checks. Extend `AsyncRule` instead of `Rule` and implement `passesAsync`. Run the validator with `validateAsync()` to await async rules; sync failures short-circuit per field so no network call is made for a field that already has a sync error. ### The AsyncRule Contract `AsyncRule` is an abstract class that extends `Rule`. Implement `passesAsync` for the async check. The synchronous `passes` method always returns `true` so the rule is invisible to `Validator.validate()` (sync-only flows); the real outcome comes from `validateAsync()`. ```dart class Exists extends AsyncRule { Exists(this.endpoint); final String endpoint; @override Future passesAsync( String attribute, dynamic value, Map data, ) async { if (value == null) return true; final response = await Http.get( endpoint, query: {attribute: value.toString()}, ); return response.successful; } @override String message() => 'validation.exists'; } ``` Use `validateAsync()` whenever the rule set contains async rules: ```dart final validator = Validator.make(data, { 'slug': [Required(), Unique('/validate/unique', field: 'slug')], }); try { final validated = await validator.validateAsync(); } on ValidationException catch (e) { // e.errors maps each failing field to its message } ``` ### The Unique Rule `Unique(endpoint, field: ...)` issues a `GET` to the endpoint with `?{field}={value}` and treats `{"unique": true}` (or `{"available": true}`) as a pass. Network errors are logged and pass so a flaky connection never blocks the form; the server remains the source of truth on submit. ```dart // Default HTTP resolver (GET with query param) Unique('/validate/unique', field: 'slug'); // Custom resolver (e.g. POST to a different shape) Unique('/validate/unique', field: 'slug').via((endpoint, field, value) async { final response = await Http.post(endpoint, data: {field: value}); return response.data['unique'] == true; }); ``` ### Debounce `Unique` debounces rapid-fire calls with a default window of 400 ms. Only the last call within the window reaches the resolver; earlier calls resolve to `true` as stale and never record errors. Pass `debounce: Duration.zero` to disable debouncing entirely. ```dart // Custom debounce window Unique('/validate/unique', field: 'email', debounce: Duration(milliseconds: 600)); // No debounce Unique('/validate/unique', field: 'email', debounce: Duration.zero); ``` ## Custom Rules Create custom rules by extending `Rule`: ```dart class StrongPassword extends Rule { @override bool passes(String attribute, dynamic value, Map data) { if (value is! String || value.isEmpty) return true; final hasUppercase = value.contains(RegExp(r'[A-Z]')); final hasLowercase = value.contains(RegExp(r'[a-z]')); final hasNumber = value.contains(RegExp(r'[0-9]')); final hasSpecial = value.contains(RegExp(r'[!@#$%^&*]')); return hasUppercase && hasLowercase && hasNumber && hasSpecial; } @override String message() => 'validation.strong_password'; } // Usage validator: rules([Required(), StrongPassword()], field: 'password') ``` `message()` returns the translation key, not the translated sentence: the validator translates it, and `Rule.name` (here `strong_password`, the key for a `messages` override) is the part after the last dot. A rule that returns a raw sentence has no usable name and overrides `name` itself. ## Error Styling Wind UI's `WFormInput` automatically adds the `error` state when validation fails: ```dart WFormInput( controller: form['email'], className: ''' p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 error:border-red-500 error:ring-red-200 ''', validator: rules([Required(), Email()], field: 'email'), ) ``` The `error:` prefix applies styles when the field has validation errors. ## Localization ### Validation Messages Define validation messages in your language files: ```json { "validation": { "required": "The :attribute field is required.", "email": "The :attribute must be a valid email address.", "min": { "string": "The :attribute must be at least :min characters." }, "confirmed": "The :attribute confirmation does not match.", "accepted": "The :attribute must be accepted.", "uuid": "The :attribute must be a valid UUID.", "gt": { "numeric": "The :attribute must be greater than :value." }, "between": { "numeric": "The :attribute must be between :min and :max." }, "strong_password": "The :attribute must contain uppercase, lowercase, number, and special character." } } ``` ### Attribute Names Define user-friendly field names: ```json { "attributes": { "email": "email address", "password": "password", "password_confirmation": "password confirmation", "accept_terms": "terms and conditions" } } ``` The `:attribute` placeholder is replaced with the localised field name. ## Related Documentation - [Forms](https://anilcancakir.com/magic/docs/basics/forms.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Localization](https://anilcancakir.com/magic/docs/digging-deeper/localization.md) - [Session](https://anilcancakir.com/magic/docs/digging-deeper/session.md) - [HTTP Client](https://anilcancakir.com/magic/docs/basics/http-client.md) --- # Localization Magic provides a Laravel-style localisation system that lets you retrieve and switch translation strings at runtime without needing BuildContext. - [Introduction](#introduction) - [Configuration](#configuration) - [Defining Translation Strings](#defining-translation-strings) - [Retrieving Translations](#retrieving-translations) - [The trans() Helper](#the-trans-helper) - [Pluralization](#pluralization) - [Checking Key Existence](#checking-key-existence) - [Locale State](#locale-state) - [Current Locale](#current-locale) - [Loaded State](#loaded-state) - [Supported Locales](#supported-locales) - [Changing Locale](#changing-locale) - [Setting the Locale](#setting-the-locale) - [Auto-Detecting Locale](#auto-detecting-locale) - [Setting Supported Locales at Runtime](#setting-supported-locales-at-runtime) - [Reacting to Locale Changes](#reacting-to-locale-changes) - [Flutter Integration (Delegate)](#flutter-integration-delegate) - [Automatic HTTP Headers](#automatic-http-headers) - [CLI Commands](#cli-commands) - [Development: Hot Restart](#development-hot-restart) - [Related Documentation](#related-documentation) ## Introduction Magic provides a Laravel-style localisation system that allows you to retrieve strings in various languages **without needing BuildContext**. ```dart // Anywhere in your code, no context needed Text(trans('welcome', {'name': 'Magic'})) // "Welcome, Magic!" Text(trans('auth.failed')) // "Authentication failed." ``` ## Configuration ### Enabling Localization Add `LocalizationServiceProvider` to your providers in `config/app.dart`: ```dart 'providers': [ (app) => LocalizationServiceProvider(app), // ... other providers ], ``` ### Localization Configuration Create `lib/config/localization.dart`: ```dart Map get localizationConfig => { 'localization': { 'locale': 'en', 'fallback_locale': 'en', 'supported_locales': ['en', 'tr', 'es', 'de'], 'auto_detect_locale': true, 'path': 'assets/lang', }, }; ``` ### Configuration Options | Key | Default | Description | |:---|:---|:---| | `locale` | `'en'` | Default locale | | `fallback_locale` | `'en'` | Fallback when translation is missing | | `supported_locales` | `['en']` | Supported locale codes | | `auto_detect_locale` | `false` | Auto-detect from device on boot | | `path` | `'assets/lang'` | Translation JSON files path | ### Register Assets Add translation files to your `pubspec.yaml`: ```yaml flutter: assets: - assets/lang/en.json - assets/lang/tr.json - assets/lang/es.json ``` > [!NOTE] > A catalogue that does not load is not an error: every key then renders as > itself, because `Lang.get` answers the key when it has no sentence. That is > deliberate, so a broken file never blanks an interface, but it means the > symptom of a missing asset and the symptom of a missing key look identical. > `JsonAssetLoader` logs a warning naming the path when it gives up, which is > the difference between the two; the warning is skipped when no `log` service > is bound, so it is silent in a widget test that never calls `Magic.init`. ## Defining Translation Strings Translation strings are stored as JSON files in `assets/lang/`: ```json { "welcome": "Welcome, :name!", "auth": { "login": "Login", "register": "Register", "logout": "Logout", "failed": "These credentials do not match our records.", "throttle": "Too many login attempts. Please try again in :seconds seconds." }, "validation": { "required": "The :attribute field is required.", "email": "The :attribute must be a valid email address.", "min": { "string": "The :attribute must be at least :min characters." } }, "attributes": { "email": "email address", "password": "password", "password_confirmation": "password confirmation" } } ``` ### Nested Keys Use dot notation to access nested translations: ```dart trans('auth.failed') // "These credentials do not match..." trans('validation.required') // "The :attribute field is required." ``` ## Retrieving Translations ### Basic Usage ```dart // Simple translation WText(trans('auth.login')) // With parameters WText(trans('welcome', {'name': user.name})) // Output: "Welcome, John!" // Nested keys WText(trans('auth.throttle', {'seconds': '60'})) // Output: "Too many login attempts. Please try again in 60 seconds." ``` ### The trans() Helper The `trans()` function is a globally available helper that delegates to `Lang.get`: ```dart String trans(String key, [Map? replace]) ``` - Returns the translation for the current locale - Falls back to `fallback_locale` **per key**, so a key missing from the current locale's catalogue is served from the fallback's rather than rendering raw - Returns the key itself if neither catalogue defines it - Replaces `:param` placeholders with provided values The fallback catalogue is loaded once, alongside the locale's own, and the locale's strings are layered over it. A fallback that will not load is a logged warning rather than a failure: the locale's own strings still render. You can also call `Lang.get` directly, which is equivalent: ```dart final message = Lang.get('welcome', {'name': 'Magic'}); ``` ### Pluralization A sentence carrying a number needs more than one wording, and `trans()` cannot give it one: it is a lookup plus a replacement, so the same line renders at every count. Use `transChoice()`, Laravel's `trans_choice`, which takes the count and picks a segment from a pipe-separated line. ```dart String transChoice(String key, int number, [Map? replace]) ``` ```json { "apples": "There is one apple|There are :count apples" } ``` ```dart transChoice('apples', 1); // "There is one apple" transChoice('apples', 4); // "There are 4 apples" ``` `:count` is substituted from the number you pass, so you never pass it twice. An explicit `count` in `replace` still wins, which is how you render a spelled-out number. #### Inline conditions Segments may carry an explicit count or range, and the first that matches wins. A range accepts `*` on either bound. ```json { "inbox": "{0} Nothing here|[1,19] :count messages|[20,*] Lots of messages" } ``` ```dart transChoice('inbox', 0); // "Nothing here" transChoice('inbox', 7); // "7 messages" transChoice('inbox', 99); // "Lots of messages" ``` Write a line that is **all** conditions or none. A half-conditional line shifts its own positional segments, because a condition that matched nothing is stripped and what remains is read by index. #### The plural rules are per language This is the part that surprises people, and it is why the count is not simply compared to one. | Language | Forms | Rule | |:---|:---|:---| | `tr`, `ja`, `ko`, `zh`, `id`, `vi` and others | 1 | every count takes segment 0 | | `en`, `de`, `es`, `nl`, `pt`, `sv` and others | 2 | `n == 1` takes segment 0 | | `fr`, `hi`, `fil`, `am` and others | 2 | `n == 0 \|\| n == 1` takes segment 0 | | `ru`, `uk`, `hr`, `sr`, `bs`, `be` | 3 | by the last digit | | `ar` | 6 | zero, one, two, few, many, other | Other languages (for example `cs`, `pl` and `ro`) carry rules of their own. Turkish has no plural agreement after a number, so a Turkish line's second segment is never reached. If your app's first locale is Turkish, Japanese or Chinese, a two-segment English line will look correct throughout development and be wrong the first time the second locale renders. An unlisted language answers segment 0, which is Laravel's own default: it reads as "this language does not agree with its number" rather than as English. The full table lives in `MessageSelector`, ported from Laravel's `getPluralIndex`. `Lang.choice` is the facade form and is equivalent: ```dart Lang.choice('apples', 4); ``` ### Checking Key Existence Use `Lang.has` to check whether a translation key exists for the current locale before retrieving it: ```dart if (Lang.has('errors.payment_declined')) { return trans('errors.payment_declined'); } ``` ## Locale State ### Current Locale `Lang.current` returns the active `Locale`: ```dart final locale = Lang.current; // Locale('tr') print(locale.languageCode); // "tr" ``` ### Loaded State `Lang.isLoaded` returns `true` once translations have been loaded for the current locale. Use this to guard against calling `trans()` before the localisation service has finished booting: ```dart if (Lang.isLoaded) { print(trans('welcome')); } ``` ### Supported Locales `Lang.supportedLocales` returns the list of `Locale` objects your app supports, as configured in `localization.supported_locales`: ```dart final locales = Lang.supportedLocales; // [Locale('en'), Locale('tr'), Locale('es')] ``` ## Changing Locale ### Setting the Locale Call `Lang.setLocale` to switch the active locale at runtime. By default it also triggers `Magic.reload()` so all widgets rebuild with the new strings: ```dart // Change locale and rebuild the app await Lang.setLocale(Locale('tr')); // Load translations without triggering app reload await Lang.setLocale(Locale('tr'), reload: false); ``` ### Language Picker Example ```dart WFormSelect( value: Lang.current.languageCode, options: [ SelectOption(value: 'en', label: 'English'), SelectOption(value: 'tr', label: 'Türkçe'), SelectOption(value: 'es', label: 'Español'), ], onChange: (code) async { await Lang.setLocale(Locale(code!)); Magic.toast(trans('common.language_changed')); }, label: trans('settings.language'), ) ``` ### Auto-Detecting Locale `Lang.detectLocale` returns the best matching `Locale` from the device or browser without changing the current locale. It compares device preferences against `supported_locales` and returns the closest match: ```dart final detected = Lang.detectLocale(); print(detected); // e.g., Locale('tr') ``` `Lang.detectAndSetLocale` combines detection and activation in one call. It returns the locale that was set: ```dart // On app start, pick the user's device language automatically final locale = await Lang.detectAndSetLocale(); print(locale); // e.g., Locale('tr') ``` You can also use `auto_detect_locale: true` in the localisation config to have `LocalizationServiceProvider` call this automatically during boot. ### Setting Supported Locales at Runtime If you need to change the supported locale list after boot (for example, after loading user preferences from the server), use `Lang.setSupportedLocales`: ```dart Lang.setSupportedLocales([Locale('en'), Locale('fr'), Locale('de')]); ``` ## Reacting to Locale Changes The `Translator` underlying `Lang` extends Flutter's `ChangeNotifier`. Use `Lang.addListener` and `Lang.removeListener` to subscribe to locale changes without a `BuildContext`: ```dart class LocaleAwareService { LocaleAwareService() { Lang.addListener(_onLocaleChanged); } void _onLocaleChanged() { // Re-format any locale-sensitive values print('Locale changed to: ${Lang.current}'); } void dispose() { Lang.removeListener(_onLocaleChanged); } } ``` Listeners fire after every successful `setLocale` or `detectAndSetLocale` call, so they are a reliable hook for updating any state that depends on the current locale outside the widget tree. ## Flutter Integration (Delegate) When you use a plain `MaterialApp` from `package:material_ui/material_ui.dart` (rather than `MagicApp`), you must pass `Lang.delegate` to `localizationsDelegates` so Flutter's localisation lifecycle triggers translation reloads when the device locale changes. `GlobalMaterialLocalizations.delegates` already carries the Material, Cupertino and Widgets delegates: ```dart MaterialApp( localizationsDelegates: [ Lang.delegate, ...GlobalMaterialLocalizations.delegates, ], supportedLocales: Lang.supportedLocales, home: const HomePage(), ) ``` `Lang.delegate` is a `const LocalizationsDelegate`. It implements `isSupported` by checking against `localization.supported_locales` from config and delegates `load` to the `Translator` singleton. > [!NOTE] > When you use `MagicApp`, the delegate is wired automatically by the framework when `LocalizationServiceProvider` is registered. You only need to add it manually when bypassing `MagicApp`. ## Automatic HTTP Headers When `LocalizationServiceProvider` is registered, Magic automatically attaches two headers to every outgoing HTTP request via `LocalizationInterceptor`. No manual setup is required. | Header | Value | Example | |:---|:---|:---| | `Accept-Language` | Current locale language code | `en`, `tr`, `es` | | `X-Timezone` | Current IANA timezone identifier | `Europe/Istanbul`, `America/New_York` | Both values are resolved at request-time, so they always reflect the locale and timezone that are active when the request is dispatched. ```dart // When the current locale is 'tr' and timezone is 'Europe/Istanbul', // every Http.get/post/put/delete call automatically includes: // // Accept-Language: tr // X-Timezone: Europe/Istanbul // // This works transparently, no configuration needed. final response = await Http.get('/api/profile'); ``` > [!NOTE] > `LocalizationInterceptor` is registered automatically by `LocalizationServiceProvider`. You do not need to add it to your provider list or network configuration manually. ### Timezone Detection Set `auto_detect_timezone` to `true` in `config/localization.dart` (it defaults to `false`) and Magic resolves the device's real IANA timezone identifier on boot, reading it from the platform's native APIs via the `flutter_timezone` package. The resolved value is sent as the `X-Timezone` header on every outgoing request, so the backend can interpret the client's timestamps. ```dart // config/localization.dart 'auto_detect_timezone': true, 'timezone': 'UTC', // the fallback when detection cannot resolve a zone ``` You do not need any timezone detection code of your own. When detection cannot resolve a valid identifier, the configured `timezone` stays in place; Magic does not guess a zone from the device's UTC offset, because an offset does not identify a zone (many zones share an offset and differ in DST rules). ## CLI Commands ### Create Translation File ```bash dart run magic:dowel make:lang fr dart run magic:dowel make:lang de dart run magic:dowel make:lang tr ``` This command creates `assets/lang/.json`. When `assets/lang/en.json` exists, the new file gets a copy of its key tree with every value copied verbatim, ready for a translator; otherwise the file is an empty `{}`. Use `--from=` to copy another catalogue and `--force` to overwrite an existing file: ```bash dart run magic:dowel make:lang tr --from=en ``` The command does not edit `pubspec.yaml`: add the new file under `flutter: assets:` yourself (see Register Assets under [Configuration](#configuration)). > [!TIP] > Keep your translation keys organised by feature (auth, validation, users, etc.) for easier maintenance. ## Development: Hot Restart During development, Magic attempts to bypass Flutter's asset bundle cache so that translation JSON changes can be picked up on **hot restart** (`Shift+R`) without a full rebuild. | Platform | Mechanism | Reliability | |:---|:---|:---| | Web (Chrome) | Fetches JSON via HTTP with cache-busting query parameter | Verified | | macOS / Linux / Windows | Reads JSON from disk via `dart:io` | Best-effort (works when `flutter run` sets the working directory to the project root) | | iOS / Android | Attempts disk read via `dart:io`, falls back to `rootBundle` | Limited (asset files are typically not on disk) | This behaviour is **debug-mode only** (`kDebugMode`). Release builds use Flutter's standard `rootBundle` with full caching for optimal performance. > [!NOTE] > Hot **reload** (lowercase `r`) only reloads Dart code and cannot pick up asset changes. Use hot **restart** (`Shift+R`) to see translation updates. ## Related Documentation - [Validation](https://anilcancakir.com/magic/docs/digging-deeper/validation.md) - [Carbon](https://anilcancakir.com/magic/docs/digging-deeper/carbon.md) - [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md) - [HTTP Client](https://anilcancakir.com/magic/docs/basics/http-client.md) --- # Logging The `Log` facade provides a Laravel-style logging API with configurable channels, minimum log levels, and a stack channel for fanning output to multiple drivers simultaneously. - [Introduction](#introduction) - [Configuration](#configuration) - [Available Channels](#available-channels) - [Minimum Log Level](#minimum-log-level) - [Stack Channel](#stack-channel) - [Writing Log Messages](#writing-log-messages) - [Log Levels](#log-levels) - [Contextual Information](#contextual-information) - [Creating Custom Channels](#creating-custom-channels) - [Implementing a Custom Driver](#implementing-a-custom-driver) - [Registering the Custom Driver](#registering-the-custom-driver) - [Error Reporting](#error-reporting) - [Testing](#testing) - [Related Documentation](#related-documentation) ## Introduction Magic provides a Laravel-style logging system based on RFC 5424 severity levels. The `Log` facade provides a simple interface for writing log messages to the configured channels. The logging system is: - **Channel-Based**: Route logs to specific destinations using named channels. - **Extensible**: Create custom drivers to send logs anywhere (a webhook, a file, a remote service). - **Level-Aware**: Filter messages based on severity threshold. The `log` binding is registered by the framework itself, so `Log` works without a service provider. ## Configuration The defaults log to the console through a `stack` channel. To customise them, create `lib/config/logging.dart`: ```dart // lib/config/logging.dart Map get loggingConfig => { 'logging': { // Default channel used by the Log facade 'default': 'stack', // Channel configurations 'channels': { 'console': { 'driver': 'console', 'level': 'debug', }, 'production': { 'driver': 'console', 'level': 'warning', // Only warning and above }, 'stack': { 'driver': 'stack', 'channels': ['console'], }, }, }, }; ``` ### Available Channels Magic includes these built-in drivers: | Driver | Description | |:---|:---| | `console` | Pretty-prints to the debug console with colours and emojis | | `stack` | Broadcasts to multiple channels simultaneously | A channel with an unknown `driver` name falls back to `console`, unless a custom driver is registered under that name (see [Creating Custom Channels](#creating-custom-channels)). ### Minimum Log Level The `console` channel can specify a minimum `level`. Messages below this threshold are ignored: ```dart 'channels': { 'production': { 'driver': 'console', 'level': 'warning', // Ignores debug, info, notice }, } ``` **Levels by priority** (RFC 5424): | Level | Priority | Description | |:---|:---|:---| | `emergency` | 0 | System is unusable | | `alert` | 1 | Action must be taken immediately | | `critical` | 2 | Critical conditions | | `error` | 3 | Runtime errors | | `warning` | 4 | Warning conditions | | `notice` | 5 | Normal but significant | | `info` | 6 | Informational | | `debug` | 7 | Detailed debug information | ### Stack Channel Use the `stack` driver to send logs to multiple channels at once: ```dart 'stack': { 'driver': 'stack', 'channels': ['console', 'slack'], } ``` When you log to the `stack` channel, the message is forwarded to every channel listed in `channels`. ## Writing Log Messages ### Log Levels Use the `Log` facade to write messages at any RFC 5424 level: ```dart Log.emergency('System is unusable'); Log.alert('Action must be taken immediately'); Log.critical('Critical condition'); Log.error('Runtime error'); Log.warning('Warning condition'); Log.notice('Normal but significant condition'); Log.info('Informational message'); Log.debug('Debug-level message'); ``` For dynamic level selection: ```dart Log.log('info', 'User logged in'); ``` ### Contextual Information Pass additional data as the second argument: ```dart Log.error('Payment failed', { 'user_id': user.id, 'amount': 50.00, 'error': e.toString(), 'stack_trace': stackTrace.toString(), }); ``` The console driver prints the context below the message. ## Creating Custom Channels Magic's logging system is extensible. You can create custom drivers to send logs to any destination. ### Implementing a Custom Driver Create a class that extends `LoggerDriver` and implements `log()`. The level helpers (`error()`, `info()` and so on) are inherited and call `log()`: ```dart // lib/app/logging/slack_logger_driver.dart import 'package:magic/magic.dart'; class SlackLoggerDriver extends LoggerDriver { final String webhookUrl; final String channel; final String minLevel; SlackLoggerDriver({ required this.webhookUrl, this.channel = '#alerts', this.minLevel = 'error', }); static const _levels = [ 'emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug', ]; @override void log(String level, String message, [dynamic context]) { // Only send when the level is at or above minLevel. if (_levels.indexOf(level) > _levels.indexOf(minLevel)) return; Http.post(webhookUrl, data: { 'channel': channel, 'text': '[$level] $message', 'attachments': context != null ? [{'text': context.toString()}] : null, }); } } ``` ### Registering the Custom Driver Register your driver via `LogManager.extend()` in a Service Provider's `register()` phase, not `boot()`. `LogManager` caches the default driver on the first `Log` call that names no channel, a channel whose driver name is not yet registered resolves to `console` at that moment, and `extend()` does not clear the cache. A `Log` call that runs before your `boot()` therefore leaves a default `stack` containing `slack` writing those logs to the console for the rest of the process: ```dart // lib/app/providers/logging_service_provider.dart class LoggingServiceProvider extends ServiceProvider { LoggingServiceProvider(super.app); @override void register() { LogManager.extend('slack', (config) => SlackLoggerDriver( webhookUrl: config['webhook_url'], channel: config['channel'] ?? '#alerts', minLevel: config['level'] ?? 'error', )); } } ``` The factory receives the channel's config map and returns a `LoggerDriver` instance. Then use it in your config: ```dart 'channels': { 'slack': { 'driver': 'slack', 'webhook_url': 'https://hooks.example.com/your-webhook', 'level': 'error', }, } ``` And log to it: ```dart Log.channel('slack').error('Something went wrong', { 'user_id': user.id, }); ``` ## Error Reporting Sentry error and performance monitoring is not part of core. It ships as the `magic_sentry` plugin: ```bash flutter pub add magic_sentry dart run magic:dowel plugin:install magic_sentry ``` See the [installation guide](https://anilcancakir.com/magic/docs/sentry/getting-started/installation) and [HTTP failure reporting](https://anilcancakir.com/magic/docs/sentry/basics/http-reporting). ## Testing Replace the real `LogManager` with `FakeLogManager` during tests using `Log.fake()`. The fake captures all log calls in memory and provides assertion helpers. ```dart import 'package:magic/testing.dart'; test('logs a payment error', () { final fake = Log.fake(); Log.error('Payment failed', {'amount': 50.00}); fake.assertLoggedError('Payment failed'); fake.assertLoggedCount(1); Log.unfake(); }); ``` The `FakeLogManager` exposes: | Method | Description | |:---|:---| | `assertLogged(level, message)` | Assert at least one entry matches the given level and message | | `assertLoggedError(message)` | Shorthand for `assertLogged('error', message)` | | `assertNothingLogged([level])` | Assert the log is empty, or empty at a specific level | | `assertLoggedCount(n)` | Assert exactly `n` entries were recorded | | `reset()` | Clear all captured entries | | `entries` | Read-only list of all captured `FakeLogEntry` records | Call `Log.unfake()` at the end of the test (or in `tearDown`) to restore the real binding. ## Related Documentation - [Events](https://anilcancakir.com/magic/docs/digging-deeper/events.md) - [Cache](https://anilcancakir.com/magic/docs/digging-deeper/cache.md) - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md) - [Testing Facades](https://anilcancakir.com/magic/docs/testing/facades.md) --- # Cache Magic provides an expressive, unified API for various caching backends, with built-in event dispatching so you can react to cache reads, writes, and removals. - [Introduction](#introduction) - [Configuration](#configuration) - [Retrieving Items](#retrieving-items) - [Storing Items](#storing-items) - [The Remember Method](#the-remember-method) - [Removing Items](#removing-items) - [Custom Cache Drivers](#custom-cache-drivers) - [Cache Events and Listeners](#cache-events-and-listeners) - [Testing](#testing) - [Related Documentation](#related-documentation) ## Introduction Magic provides an expressive, unified API for various caching backends. The framework ships with a native `FileStore` driver that supports both Mobile/Desktop (File System) and Web (LocalStorage). Caching is essential for reducing API calls, storing computed values, and improving application performance. ## Configuration ### Enabling Cache Support Add `CacheServiceProvider` to your providers in `config/app.dart`: ```dart 'providers': [ (app) => CacheServiceProvider(app), // ... other providers ], ``` ### Cache Configuration Create `lib/config/cache.dart`: ```dart import 'package:magic/magic.dart'; Map get cacheConfig => { 'cache': { 'driver': FileStore(fileName: 'magic_cache'), 'ttl': 3600, // Default TTL in seconds }, }; ``` ## Retrieving Items Use the `Cache` facade to retrieve items from the cache: ```dart // Basic retrieval final value = Cache.get('key'); // With default value final username = Cache.get('username', defaultValue: 'Guest'); // Check existence if (Cache.has('user_settings')) { final settings = Cache.get('user_settings'); } ``` `Cache.get()` and `Cache.has()` are synchronous. The write methods (`put`, `forget`, `flush`) and `remember` return a `Future`. ## Storing Items Store items with optional TTL (time-to-live): ```dart // With custom TTL await Cache.put('key', 'value', ttl: Duration(minutes: 10)); // With default TTL from config await Cache.put('key', 'value'); // Store any serializable data await Cache.put('user', { 'id': 1, 'name': 'John', 'email': 'john@example.com', }); ``` ## The Remember Method `Cache.remember()` retrieves an item from the cache, or computes and stores it: ```dart final users = await Cache.remember( 'users', Duration(minutes: 5), () async { final response = await Http.get('/users'); return response.data; }, ); ``` This method: 1. Checks if `users` exists in cache 2. If yes, returns the cached value 3. If no, calls the closure, stores the result, and returns it ### API Response Caching ```dart class UserController extends MagicController { Future> getUsers() async { return await Cache.remember('all_users', Duration(minutes: 10), () async { final response = await Http.get('/users'); return (response.data as List).map((u) => User.fromMap(u)).toList(); }); } } ``` ### Computed Values ```dart final dashboardStats = await Cache.remember('dashboard_stats', Duration(hours: 1), () async { final response = await Http.get('/dashboard/stats'); return response.data; }); ``` ## Removing Items ```dart // Remove a single item await Cache.forget('users'); // Clear entire cache await Cache.flush(); ``` ### Invalidating Related Cache ```dart class UserController extends MagicController { Future updateUser(Map data) async { final response = await Http.put('/users/${data['id']}', data: data); if (response.successful) { // Invalidate related caches await Cache.forget('all_users'); await Cache.forget('user_${data['id']}'); await Cache.forget('dashboard_stats'); } } } ``` ## Custom Cache Drivers Implement `CacheStore` for custom backends. `get()` and `has()` are synchronous in the contract, so a driver that talks to a remote backend keeps a local copy and loads it in `init()`, as `FileStore` does: ```dart class MemoryStore implements CacheStore { final Map _items = {}; final Map _expiresAt = {}; @override Future init() async {} @override dynamic get(String key, {dynamic defaultValue}) { return has(key) ? _items[key] : defaultValue; } @override Future put(String key, dynamic value, {Duration? ttl}) async { _items[key] = value; // A null ttl means the item never expires. if (ttl == null) { _expiresAt.remove(key); } else { _expiresAt[key] = DateTime.now().add(ttl); } } @override bool has(String key) { final expiresAt = _expiresAt[key]; if (expiresAt != null && !DateTime.now().isBefore(expiresAt)) { _items.remove(key); _expiresAt.remove(key); } return _items.containsKey(key); } @override Future forget(String key) async { _items.remove(key); _expiresAt.remove(key); } @override Future flush() async { _items.clear(); _expiresAt.clear(); } } ``` Use your custom driver in config: ```dart 'cache': { 'driver': MemoryStore(), }, ``` > [!TIP] > Use caching for expensive API calls, complex computations, and data that doesn't change frequently. ## Cache Events and Listeners Magic dispatches events on every cache operation. You can listen to these events via your `AppEventServiceProvider` to add logging, metrics, or invalidation side-effects without coupling that logic to call sites. ### Event Classes All cache events extend `MagicEvent` and live in `package:magic/magic.dart`: | Event | Payload | Fired when | |:---|:---|:---| | `CacheHit` | `key`, `value` | A `Cache.get()` returns a stored value. | | `CacheMiss` | `key` | A `Cache.get()` finds no stored value. | | `CachePut` | `key`, `value`, `ttl?` | A value is written via `Cache.put()` or `Cache.remember()`. | | `CacheForget` | `key` | A single key is removed via `Cache.forget()`. | | `CacheFlush` | _(none)_ | The entire cache is cleared via `Cache.flush()`. | ### Listening to Cache Events Register listeners in your `AppEventServiceProvider`: ```dart import 'package:magic/magic.dart'; class LogCacheHit extends MagicListener { @override Future handle(CacheHit event) async { Log.debug('Cache hit: ${event.key}'); } } class LogCacheMiss extends MagicListener { @override Future handle(CacheMiss event) async { Log.debug('Cache miss: ${event.key}'); } } ``` Then wire them in your event service provider: ```dart import 'package:magic/magic.dart'; class AppEventServiceProvider extends BaseEventServiceProvider { AppEventServiceProvider(super.app); @override Map> get listen => { CacheHit: [() => LogCacheHit()], CacheMiss: [() => LogCacheMiss()], CachePut: [() => RecordCacheWrite()], CacheForget: [() => RecordCacheRemoval()], CacheFlush: [() => RecordCacheFlush()], }; } ``` The separate `magic_devtools` package can observe these events during development; core does not depend on it. ## Testing Use `Cache.fake()` to replace the real cache manager with an in-memory `FakeCacheManager` during tests. The fake supports assertions and records every operation. ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic/testing.dart'; void main() { setUp(() { MagicApp.reset(); Magic.flush(); }); test('caches users after first fetch', () async { final fake = Cache.fake(); await Cache.put('users', ['alice', 'bob']); fake.assertHas('users'); fake.assertPut('users'); }); test('cleans up after test', () async { final fake = Cache.fake(); fake.reset(); // clears store and recorded operations Cache.unfake(); }); } ``` `FakeCacheManager` exposes: - `assertHas(key)`: asserts the key currently exists in the store. - `assertMissing(key)`: asserts the key is absent. - `assertPut(key)`: asserts the key was stored at least once via `put`. - `recorded`: the full chronological list of cache operations for manual inspection. - `reset()`: clears the store and the recorded list between assertions. ## Related Documentation - [Events](https://anilcancakir.com/magic/docs/digging-deeper/events.md) - [Logging](https://anilcancakir.com/magic/docs/digging-deeper/logging.md) - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md) --- # Session & Flash Data The `Session` facade provides flash data that survives exactly one navigation hop, letting you pass form input and validation errors across a back-navigation without temporary controller state. - [Introduction](#introduction) - [Flashing Input](#flashing-input) - [Flashing Errors](#flashing-errors) - [Reading Old Input](#reading-old-input) - [Automatic Flash in MagicFormData](#automatic-flash-in-magicformdata) - [Advancing the Flash Bucket](#advancing-the-flash-bucket) - [Testing](#testing) - [Related Documentation](#related-documentation) ## Introduction The `Session` facade provides Laravel-style flash data that survives exactly one navigation hop. Use it to repopulate a form after a failed submit and a back navigation without wiring temporary state through controllers, the router, or global singletons. The store has two buckets: the **current** bucket (readable by the view being built right now) and the **next** bucket (being collected by the currently-active handler). A call to `Session.tick()` promotes `next` into `current`, so flashed data is visible on exactly one frame. ## Flashing Input Flash a map of values before navigating away: ```dart Session.flash({ 'name': 'John', 'email': 'john@test.com', }); MagicRoute.back(); ``` ## Flashing Errors Flash per-field error messages: ```dart Session.flashErrors({ 'email': ['The email has already been taken.'], }); ``` ## Reading Old Input In the form view, repopulate via the `old()` helper: ```dart WFormInput( initialValue: old('email') ?? '', ); if (Session.hasError('email')) WText(Session.error('email')!, className: 'text-red-500'); ``` `Session.oldRaw(field)` returns the original typed value (booleans, numbers, custom objects) instead of stringifying it. ## Automatic Flash in MagicFormData `MagicFormData.validate()` automatically flashes the current form data when validation fails, so you never need to wire it manually: ```dart void _submit() { if (!form.validate()) return; // form data is auto-flashed on failure controller.register(form.data); } ``` After a back navigation and a `Session.tick()`, `old('email')` returns the last-submitted value. ## Advancing the Flash Bucket Flash data survives exactly one navigation. Advance the bucket only when the route stack actually changes. Do **not** wire `Session.tick` directly to the router delegate listener: the delegate can notify for more than real navigation (redirect re-evaluation, notifier rebuilds) and would expire flash data prematurely. ```dart Future main() async { WidgetsFlutterBinding.ensureInitialized(); await Magic.init(configFactories: [() => appConfig]); final delegate = MagicRouter.instance.routerConfig.routerDelegate; var lastConfiguration = delegate.currentConfiguration; delegate.addListener(() { final currentConfiguration = delegate.currentConfiguration; if (currentConfiguration == lastConfiguration) return; lastConfiguration = currentConfiguration; Session.tick(); }); runApp(const MagicApplication(title: 'My App')); } ``` Wire it in `main()` after `await Magic.init()`, not in a `ServiceProvider.boot()`. Every `boot()` runs inside `Magic.init()`, and the first read of `routerConfig` builds the router. A `boot()` that reads it before `RouteServiceProvider.boot()` has registered the routes freezes the router early, so the next `addRoute` throws a `StateError`. Once `Magic.init()` returns, the router is built with every route in it. Compare the delegate's `currentConfiguration`, not `MagicRouter.instance.currentLocation`. The delegate sets its configuration and then notifies, while `currentLocation` changes only when the destination page builds, after the listeners have run: a listener reading `currentLocation` sees the previous page and ticks one navigation late. Two configurations are equal when their URI and route matches are, so a re-evaluation that lands on the same stack does not tick, and a push or a pop does. A query-only change (`MagicRoute.to('/search?q=flutter')` from `/search`) is a navigation too and ticks, the way a new GET request ages Laravel's flash data even when it renders the same page. So does re-tapping a `.stacked()` route with the query it already shows: `to()` replaces the top page with an identical one, and go_router counts the replacement's new completer in the configuration's equality. ## Testing In tests, always reset the store in `setUp()`: ```dart setUp(() { MagicApp.reset(); Magic.flush(); Session.reset(); }); ``` Swap the backing store with a custom `SessionStore` via `Session.setStore(store)` if you need isolated buckets per test group. ## Related Documentation - [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md) - [Validation](https://anilcancakir.com/magic/docs/digging-deeper/validation.md) - [Forms](https://anilcancakir.com/magic/docs/basics/forms.md) - [Routing](https://anilcancakir.com/magic/docs/basics/routing.md) --- # Session Scope `SessionScope` keeps every cached controller and repository pointed at the identity that is authenticated right now, so a login after a logout, or a team switch by the same user, never leaves a previous tenant's rows on screen. - [Introduction](#introduction) - [Why It Exists](#why-it-exists) - [Attaching](#attaching) - [The Identity Resolver](#the-identity-resolver) - [SessionScoped](#sessionscoped) - [Registering a Non-Controller Holder](#registering-a-non-controller-holder) - [Failure Modes](#failure-modes) - [Related Documentation](#related-documentation) ## Introduction Magic resolves controllers as Type-keyed singletons and runs `onInit` exactly once per instance lifetime. On a single-tenant app that is the whole point: a controller's first fetch happens once and stays put. On a team-scoped product it is a defect waiting to happen: `MonitorController` boots for team A, caches team A's rows, and a switch to team B (or a logout followed by a different user's login) leaves those rows on screen with nothing that would ever clear them. ## Why It Exists `SessionScope` is the one seam every scoped controller and repository routes through, rather than each one hand-rolling its own `Auth.stateNotifier` listener. It calls `resetForSession()` on every registered holder whenever the resolved identity changes, and does so **in place**: it never deletes and recreates a controller. A mounted `MagicStatefulViewState` holds its own controller instance directly, so a delete-then-recreate would freeze that view rather than refresh it. ## Attaching `SessionScope.attach()` is explicit: no core provider calls it, because it must be the LAST `Auth.stateNotifier` listener so realtime, polling, and locale already point at the new session before any scoped holder's data is refetched. ```dart @override Future boot() async { SessionScope.identity = () => Auth.check() ? '${Auth.id()}:$teamId' : null; SessionScope.attach(); } ``` `attach()` is idempotent while already attached, and records the boot identity before subscribing, so the FIRST real change (not the app's own startup) is the first one that resets anything. `detach()` unsubscribes and forgets the recorded identity; registered holders and the `identity` resolver survive it, since they belong to their owners, not to the subscription. ## The Identity Resolver `SessionScope.identity` defaults to the authenticated user id. A team-scoped app overrides it to fold the active team in, so a team switch by the same user counts as a change too: ```dart SessionScope.identity = () => Auth.check() ? '${Auth.id()}:$teamId' : null; ``` A `null` identity means unauthenticated and nothing else. A change TO `null` (a logout) is recorded without triggering a reset: from the login screen a refetch can only 401. The next login resets before any authenticated view renders. ## SessionScoped Implement `SessionScoped` on a controller or a holder that caches per-tenant rows: ```dart class MonitorController extends MagicController with MagicStateMixin> implements SessionScoped { @override Future resetForSession() async { setEmpty(); await loadMonitors(); } } ``` `resetForSession` must **clear before it refetches**. Ordinary reload paths are deliberately non-destructive (a transport failure keeps the last-known-good rows on screen), and that is the wrong behaviour across an identity change: a failed refetch here must leave the screen empty, never populated with the previous tenant's data. A controller implementing `SessionScoped` needs no explicit registration: `SessionScope.sync()` finds it by walking `Magic.controllers.whereType()` on every identity change. ## Registering a Non-Controller Holder A `Repository` is not a controller, so it registers itself explicitly. This already happens in `Repository`'s own constructor; the pattern below is what a hand-rolled `SessionScoped` holder (something other than a `Repository`) does the same way: ```dart class SearchIndex implements SessionScoped { SearchIndex() { SessionScope.register(this); } @override Future resetForSession() async { _entries.clear(); } void dispose() { SessionScope.unregister(this); } } ``` `SessionScope.unregister` must run when the holder is disposed; a registered-but-dead holder would otherwise be reset forever, even after nothing references it. ## Failure Modes | Situation | What happens | |:---|:---| | Two logins under the same identity in a row | The second `sync()` is a no-op: the identity did not change | | A `resetForSession()` throws | Logged via `Log.error`, isolated per holder: one holder's throw never stops another's reset | | A reset resolves another controller mid-iteration | Safe: the scoped set is snapshotted before iterating | | A controller is also a registered holder (both a `SessionScoped` controller and separately `register`ed) | Resets exactly once: the snapshot is a `Set` | | `attach()` called twice | The second call is a no-op; only one subscription is ever active | | A logout | Recorded, never triggers a reset (would only 401) | ## Related Documentation - [Session](https://anilcancakir.com/magic/docs/digging-deeper/session.md) - [Authentication](https://anilcancakir.com/magic/docs/security/authentication.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Repositories](https://anilcancakir.com/magic/docs/eloquent/repositories.md) --- # Events Magic's event system provides a simple observer implementation, letting you decouple side effects from application actions by dispatching named events and registering typed listeners. - [Introduction](#introduction) - [Generating Events & Listeners](#generating-events--listeners) - [Configuration](#configuration) - [Defining Events](#defining-events) - [Defining Listeners](#defining-listeners) - [Registering Events & Listeners](#registering-events--listeners) - [Dispatching Events](#dispatching-events) - [Inline Listeners](#inline-listeners) - [Wildcard Listeners and ReportsBreadcrumb](#wildcard-listeners) - [Framework Events](#framework-events) - [Related Documentation](#related-documentation) ## Introduction Magic provides a simple observer implementation, allowing you to subscribe and listen for various events that occur in your application. Events serve as a great way to decouple various aspects of your application, since a single event can have multiple listeners that do not depend on each other. ```dart // Dispatch an event when an order ships await Event.dispatch(OrderShipped(order)); // Listeners react to the event (send email, update analytics, etc.) ``` ## Generating Events & Listeners Use the Magic CLI to scaffold event and listener classes: ```bash dart run magic:dowel make:event OrderShipped ``` This creates `lib/app/events/order_shipped.dart` with a `MagicEvent` stub. ```bash dart run magic:dowel make:listener SendEmail --event=OrderShipped ``` This creates `lib/app/listeners/send_email.dart` with a `MagicListener` stub, pre-wired to the specified event. ## Configuration ### Enabling Event Support Create an event service provider that extends `BaseEventServiceProvider` (see [Registering Events & Listeners](#registering-events--listeners)) and add it to your providers in `config/app.dart`: ```dart 'providers': [ (app) => AppEventServiceProvider(app), // Your events and listeners // ... other providers ], ``` Create the event directories: ``` lib/app/ ├── events/ │ └── order_shipped.dart └── listeners/ └── send_shipment_notification.dart ``` ## Defining Events An event class is a simple data container holding information related to the event: ```dart import 'package:magic/magic.dart'; import '../models/order.dart'; class OrderShipped extends MagicEvent { final Order order; final String trackingNumber; OrderShipped(this.order, {required this.trackingNumber}); } ``` Events are simple data classes with no logic of their own. The listener is responsible for processing the event. ## Defining Listeners Event listeners receive the event instance in their `handle` method: ```dart import 'package:magic/magic.dart'; import '../events/order_shipped.dart'; class SendShipmentNotification extends MagicListener { @override Future handle(OrderShipped event) async { // Send push notification await NotificationService.send( to: event.order.user, title: 'Order Shipped!', body: 'Your order #${event.order.id} is on its way.', ); } } class UpdateAnalytics extends MagicListener { @override Future handle(OrderShipped event) async { await Analytics.track('order_shipped', { 'order_id': event.order.id, 'value': event.order.total, }); } } ``` ## Registering Events & Listeners Register mappings in your `AppEventServiceProvider`: ```dart import 'package:magic/magic.dart'; import '../events/order_shipped.dart'; import '../events/user_registered.dart'; import '../listeners/send_shipment_notification.dart'; import '../listeners/update_analytics.dart'; import '../listeners/send_welcome_email.dart'; class AppEventServiceProvider extends BaseEventServiceProvider { AppEventServiceProvider(super.app); @override Map> get listen => { // An event can have multiple listeners OrderShipped: [ () => SendShipmentNotification(), () => UpdateAnalytics(), ], UserRegistered: [ () => SendWelcomeEmail(), ], }; } ``` ## Dispatching Events Use the `Event` facade to dispatch events: ```dart import 'package:magic/magic.dart'; import '../events/order_shipped.dart'; class OrderController extends MagicController { Future shipOrder(Order order) async { // Update order status order.status = 'shipped'; await order.save(); // Dispatch event - listeners handle the rest await Event.dispatch(OrderShipped( order, trackingNumber: generateTrackingNumber(), )); Magic.success('Shipped', 'Order has been shipped!'); } } ``` ## Inline Listeners `Event.listen(factory)` registers a listener without adding a mapping to `AppEventServiceProvider.listen`. It takes a factory, `MagicListener Function()`, the same shape `listen` registers under the hood, not a bare closure: ```dart class LogOrderShipped extends MagicListener { @override Future handle(OrderShipped event) async { Log.info('Order shipped: ${event.order.id}'); } } Event.listen(() => LogOrderShipped()); ``` `T` is the registration key and must be named explicitly: leave it off and Dart infers `MagicEvent`, which no dispatched event matches exactly. > [!TIP] > Register `Event.listen` calls in your event service provider's `register()` method, not `boot()`: a guard can dispatch `AuthLogin` during `AuthServiceProvider.boot`, before a later provider's `boot()` runs. Registrations last until `Magic.flush()`. ## Wildcard Listeners and ReportsBreadcrumb `Event.listenAny(callback)` registers a callback that runs on EVERY dispatched event, regardless of type, after every typed listener has already run (Laravel's `Event::listen('*')`). It answers a remover: ```dart final remove = Event.listenAny((event) => Log.debug('dispatched: ${event.runtimeType}')); remove(); // stop listening ``` `ReportsBreadcrumb` is a contract an event implements to opt into a crash reporter's breadcrumb trail without the reporter needing to know the event exists: a reporter (the `magic_sentry` plugin, for one) registers a wildcard listener that checks `event is ReportsBreadcrumb` on every dispatch and translates a match into a breadcrumb. ```dart class ScreenOpened extends MagicEvent implements ReportsBreadcrumb { ScreenOpened(this.path); final String path; @override String get breadcrumbCategory => 'navigation'; @override String get breadcrumbMessage => 'Screen opened'; @override Map get breadcrumbData => {'path': Uri.parse(path).path}; } ``` `breadcrumbData` is a WHITELIST, not a dump of the event: it never carries a URL with a query string, an auth token, or a raw payload value, since a breadcrumb trail is uploaded to a third party. The event author keeps that rule, because a reporter copies the map as it is: the sample sends `Uri.parse(path).path` so a query such as `?token=...` never leaves the device. A wildcard listener's own throw is caught and logged the same way a typed listener's is; it never stops the caller or another wildcard callback. ## Framework Events Magic fires several system events automatically. ### Authentication Events | Event | Fired When | |:---|:---| | `AuthLogin` | A guard's `startSession` finishes: the token (when given) is persisted, the user is set and cached. Not fired on a restore. | | `AuthLogout` | A guard's `logout()` ends the in-memory session, guest logout included. Not a promise the credentials are gone: it fires even when a Vault delete failed, so a listener releasing server-side state must gate on `Auth.hasToken()`. | | `AuthRestored` | An API-confirmed sync (`BaseGuard`'s background `/user` fetch) sets the user; not fired for the cache-only step of `Auth.restore()`. `changed` is `false` when the sync only confirmed the user already held. | > [!NOTE] > `AuthFailed` is defined, not dispatched by the guards: no code path in `lib/` fires it automatically. Dispatch it yourself from a failed login flow, e.g. `Event.dispatch(AuthFailed(credentials, guard: 'web'));` in the `catch` branch around your `Auth.login()` call. ```dart class LogAuthLogin extends MagicListener { @override Future handle(AuthLogin event) async { Log.info('User logged in: ${event.user.authIdentifier}'); } } Event.listen(() => LogAuthLogin()); ``` ### Model Lifecycle Events | Event | Fired When | |:---|:---| | `ModelSaving` | Before model is saved | | `ModelSaved` | After model is saved | | `ModelCreating` | Before new model is created | | `ModelCreated` | After new model is created | | `ModelUpdating` | Before existing model is updated | | `ModelUpdated` | After existing model is updated | | `ModelDeleted` | After model is deleted | ```dart class LogNewUser extends MagicListener { @override Future handle(ModelCreated event) async { if (event.model is User) { final user = event.model as User; Log.info('New user registered: ${user.email}'); } } } Event.listen(() => LogNewUser()); ``` ### Gate Events | Event | Fired When | |:---|:---| | `GateAbilityDefined` | Ability registered with `Gate.define()` | | `GateAccessChecked` | After any ability check | | `GateAccessDenied` | When access is denied | ```dart // Log denied access attempts class LogDeniedAccess extends MagicListener { @override Future handle(GateAccessDenied event) async { Log.warning('Access denied: ${event.ability} for user ${event.user?.id}'); } } Event.listen(() => LogDeniedAccess()); ``` ### Database Events Core defines `DatabaseConnected` and `QueryExecuted`, but fires neither itself: the `magic_local_storage` plugin, which owns the database layer, is the source of them. See [Installation](https://anilcancakir.com/magic/docs/local_storage/getting-started/installation). | Event | Fired When | |:---|:---| | `DatabaseConnected` | Database connection established | | `QueryExecuted` | After query execution | > [!TIP] > Use events to decouple your application logic. Instead of calling multiple services directly, dispatch an event and let listeners handle it independently. ## Related Documentation - [Cache](https://anilcancakir.com/magic/docs/digging-deeper/cache.md) - [Logging](https://anilcancakir.com/magic/docs/digging-deeper/logging.md) - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md) - [Eloquent Getting Started](https://anilcancakir.com/magic/docs/eloquent/getting-started.md) --- # File Storage The `Storage` facade provides a unified API for reading, writing, and deleting files across multiple named disks, with built-in support for remote downloads and URL generation. - [Introduction](#introduction) - [Configuration](#configuration) - [Storing Files](#storing-files) - [Retrieving Files](#retrieving-files) - [File URLs & Downloads](#file-urls-and-downloads) - [Deleting Files](#deleting-files) - [Cached Network Images](#cached-network-images) - [Multiple Disks](#multiple-disks) - [MagicFile Reference](#magicfile-reference) - [Testing](#testing) - [Related Documentation](#related-documentation) ## Introduction Magic provides a powerful filesystem abstraction thanks to the `Storage` facade. It provides a unified API for interacting with local filesystems across all platforms (iOS, Android, macOS, Windows, Web). Key features: - **Cross-Platform**: Same API on mobile, desktop, and web. - **Disk System**: Configure multiple storage locations. - **MagicFile Integration**: One file wrapper for stored, downloaded and picked files. Picking files (gallery, camera, file and save dialogs) and the share sheet behind `Storage.download` on mobile and desktop live in the `magic_picker` plugin: `flutter pub add magic_picker`, then `dart run magic:dowel plugin:install magic_picker`. Its `Pick` facade returns a `MagicFile` you can store with the methods below, and its service provider sets the `LocalAdapterContract.downloadHandler` seam. Without it, `Storage.download` throws `UnsupportedError` on mobile and desktop; the web download is unchanged. See the [installation guide](https://anilcancakir.com/magic/docs/picker/getting-started/installation) and the [download page](https://anilcancakir.com/magic/docs/picker/basics/download). ## Configuration Configure storage disks in `lib/config/filesystems.dart`: ```dart // lib/config/filesystems.dart Map get filesystemsConfig => { 'filesystems': { 'default': 'local', 'disks': { 'local': { 'driver': 'local', 'root': 'storage/app', }, 'public': { 'driver': 'local', 'root': 'storage/app/public', }, 'temp': { 'driver': 'local', 'root': 'storage/temp', }, 'downloads': { 'driver': 'local', 'root': 'downloads', 'exclude_from_backup': true, }, }, }, }; ``` Pass it to `Magic.init(configFactories: [...])` with your other config files. `local` is the only driver; a disk with any other `driver` throws when it is first resolved. A local disk writes under the app's documents directory, which iOS backs up to iCloud and macOS to Time Machine. Set `exclude_from_backup` to `true` on a disk holding large files the app can fetch again, such as downloaded media. On iOS and macOS the disk's root directory is then marked with `kCFURLIsExcludedFromBackupKey` the first time the disk resolves a path, and everything stored under it is left out of the backup. If the platform refuses the mark, that first call throws a `FileSystemException` and the next one tries again. The key defaults to `false` and has no effect on Android, Windows, Linux or the web. ## Storing Files ### From Bytes ```dart await Storage.put('avatars/1.jpg', imageBytes); // With MIME type (important for Web blob URLs) await Storage.put('docs/resume.pdf', bytes, mimeType: 'application/pdf'); ``` ### From String ```dart await Storage.put('logs/app.log', 'Log entry...'); ``` ### From MagicFile Any `MagicFile` stores itself, whether it came from a picker, a download or `MagicFile(...)`: ```dart // Manual path await file.store('avatars/user.jpg'); // Auto-generated timestamped name await file.storeAs('avatars'); // Result: 'avatars/1704067200000_photo.jpg' ``` ## Retrieving Files ### As Bytes ```dart final Uint8List? bytes = await Storage.get('avatars/1.jpg'); ``` ### As MagicFile ```dart final MagicFile? file = await Storage.getFile('avatars/1.jpg'); if (file != null) { print(file.name); // '1.jpg' print(file.size); // 102400 print(file.mimeType); // 'image/jpeg' print(file.extension); // 'jpg' } ``` ### Check Existence ```dart if (await Storage.exists('avatars/1.jpg')) { // File exists } ``` ## File URLs & Downloads ### Display URLs Get a URL for use in `Image.network()` or similar widgets: ```dart final url = await Storage.url('avatars/1.jpg'); ``` | Platform | URL Format | |:---|:---| | Mobile/Desktop | `file:///path/to/file.jpg` | | Web | `blob:http://localhost/...` | ### Downloading Trigger a file download (Web) or share sheet (Mobile): ```dart await Storage.download('reports/2024.pdf', name: 'Annual Report.pdf'); ``` On mobile and desktop the share sheet comes from the `magic_picker` plugin. When nothing sets `LocalAdapterContract.downloadHandler`, `download` throws `UnsupportedError`. ## Deleting Files ```dart await Storage.delete('avatars/1.jpg'); ``` ## Cached Network Images `MagicImage` draws a network image from a copy kept on the device. The first draw of an address fetches it and stores the bytes; every draw after that, in this launch or a later one, reads the file and makes no request. Flutter's own `NetworkImage` keeps images in memory only, so a poster grid asks the host again on every launch. The copies live on a local disk named `images`. Declare it in your `filesystems` config (`defaultFilesystemsConfig` carries the entry, but `Magic.init` does not merge that map by itself, so spread it or copy it), and keep it out of backup, since every file in it can be fetched again: ```dart 'images': { 'driver': 'local', 'root': 'images', 'exclude_from_backup': true, }, ``` Draw through `MagicImage.of`, which answers an `ImageProvider` or `null` when there is no address: ```dart final ImageProvider? poster = MagicImage.of(item.posterUrl, cacheWidth: 300); if (poster != null) Image(image: poster); ``` ### A Stand-In Address An item often has two pictures: one a third party sent with a listing, and a better one from your own server once it knows the item. Pass the better one as the address and the other as `fallback`: ```dart MagicImage.of(item.catalogueUrl, fallback: item.listedUrl); ``` The fallback draws at once when it is already on the device and the address is not; the address replaces it when it lands. When the address cannot be fetched, the fallback is fetched instead and stays, and the next resolve after `retryAfter` (five minutes) asks the address again. ### Size, Failures and Cleaning Up - **Size.** The folder is capped at 300 MiB. It is trimmed, least recently used first, on the first use of each launch and again after a quarter of the cap has been written. Pass `maxBytes` to a `MagicImageCache` of your own to change the cap. - **Failures.** A URL that failed is not asked again for `retryAfter`, so a grid scrolled past a dead picture does not hit the host on every frame. A body that is not a JPEG, PNG, GIF or WebP, or is larger than 8 MiB, is refused. An error message never carries the address. - **Decoding.** `cacheWidth` decodes no wider than that many pixels and never scales up. Only the first frame of an animated GIF is drawn. - **A full disk.** An image the disk will not keep is still drawn, from memory, and the host is not blamed: `MagicImageCache.file` throws a `MagicImageNotStored` that carries the bytes. - **A disk that is not there.** When the disk is not declared, is not a local disk, or the platform gives no folder for it, images are drawn as `NetworkImage` would draw them, with no copy kept. One warning is logged, and the disk is opened again on the next call. - **Cleaning up.** `await MagicImageCache.instance.clear()` removes every stored image and keeps the folder, for a sign-out or a "clear cache" action. It exists on the web too, as a no-op. - **Another disk.** `MagicImageCache(disk: 'pictures')` keeps the files on another local disk. Assign it to `MagicImageCache.instance` before the first draw. - **Web.** There is no file store in a browser, so `MagicImage.of` answers a plain `NetworkImage` and the stand-in is not drawn. In a test, assign `MagicImageCache.instance` a cache built with a `store` over a temporary folder and a `fetch` that answers bytes, and restore the previous one in `tearDown`. ## Multiple Disks Switch between configured disks: ```dart // Use the 'public' disk await Storage.disk('public').put('file.txt', 'contents'); // Get from 'temp' disk final bytes = await Storage.disk('temp').get('cache.dat'); ``` ## MagicFile Reference `MagicFile` is the unified file wrapper used throughout Magic. It's what `Storage.getFile` returns, what the `magic_picker` plugin's `Pick` methods return, and it can be used with `Storage`. ### Properties | Property | Type | Description | |:---|:---|:---| | `path` | `String?` | Original file path (null on Web) | | `name` | `String` | File name with extension | | `size` | `int?` | File size in bytes | | `mimeType` | `String?` | MIME type (e.g., 'image/jpeg') | | `extension` | `String` | Extension without dot (e.g., 'jpg') | | `isImage` | `bool` | True for jpg, png, gif, webp, etc. | | `isVideo` | `bool` | True for mp4, mov, avi, etc. | ### Methods #### readAsBytes() Read file contents as `Uint8List`: ```dart final bytes = await file.readAsBytes(); ``` #### store(path, {disk}) Store to Magic Storage: ```dart final storedPath = await file.store('uploads/photo.jpg'); // Returns: 'uploads/photo.jpg' ``` #### storeAs(directory, {disk}) Store with auto-generated unique name: ```dart final storedPath = await file.storeAs('uploads'); // Returns: 'uploads/1704067200000_photo.jpg' ``` #### upload(url, {fieldName, data, headers}) Upload directly to server: ```dart final response = await file.upload( '/api/upload', fieldName: 'avatar', data: {'user_id': 1}, ); if (response.successful) { print(response['url']); } ``` ### Creating MagicFile Manually ```dart final file = MagicFile( name: 'document.pdf', size: 1024, mimeType: 'application/pdf', bytes: pdfBytes, ); ``` ## Testing ### Replacing the manager Inject a custom `StorageManager` subclass with `Storage.setManager()` to control disk behaviour in tests: ```dart class FakeStorageManager extends StorageManager { // Override disk() to return a test double } setUp(() { Storage.setManager(FakeStorageManager()); }); ``` ### Resetting state between tests Call `Storage.flush()` to clear the cached manager and all cached disk instances. This ensures no state leaks between tests: ```dart tearDown(() { Storage.flush(); }); ``` `flush()` sets the internal manager to `null` and calls `flush()` on it first (clearing any cached disk instances), so the next call to any `Storage` method initialises a fresh manager from config. ## Related Documentation - [Configuration](https://anilcancakir.com/magic/docs/getting-started/configuration.md) - [HTTP Client](https://anilcancakir.com/magic/docs/basics/http-client.md) - [Helpers](https://anilcancakir.com/magic/docs/digging-deeper/helpers.md) - [Pick, XFile and download (magic_picker)](https://anilcancakir.com/magic/docs/picker/basics/pick) --- # Carbon (Date/Time) Carbon is Magic's date and time utility, inspired by PHP's Carbon library, offering a fluent API for creating, formatting, manipulating, and comparing date values with human-readable output. - [Introduction](#introduction) - [Creating Carbon Instances](#creating-carbon-instances) - [Formatting](#formatting) - [Manipulation](#manipulation) - [Comparison](#comparison) - [Human Readable](#human-readable) - [Short Human Readable](#short-human-readable) - [Timezone Support](#timezone-support) - [Testing](#testing) - [Model Integration](#model-integration) - [Related Documentation](#related-documentation) ## Introduction Magic includes Carbon, a date/time utility inspired by PHP's Carbon library. Carbon provides a fluent API for creating, formatting, and manipulating dates and times. ```dart // Create, manipulate, and format dates fluently Carbon.now().addDays(5).format('MMMM d, yyyy'); // "January 20, 2024" // Human-readable differences carbon.diffForHumans(); // "2 days ago" ``` ## Creating Carbon Instances ```dart // Current date/time final now = Carbon.now(); // Parse from string final date = Carbon.parse('2024-01-15'); final datetime = Carbon.parse('2024-01-15 14:30:00'); final iso = Carbon.parse('2024-01-15T14:30:00Z'); // From DateTime final fromDart = Carbon.fromDateTime(DateTime.now()); // From explicit parts (unset parts default to 1970-01-01 00:00:00) final created = Carbon.create(year: 2024, month: 1, day: 15, hour: 14, minute: 30); // Relative dates final today = Carbon.now().startOfDay(); // Today at 00:00:00 final tomorrow = Carbon.now().addDay(); final yesterday = Carbon.now().subDay(); // From a DateTime, through the extension final converted = DateTime.now().toCarbon(); ``` ## Formatting ```dart final date = Carbon.now(); // Standard formats date.format('yyyy-MM-dd'); // "2024-01-15" date.format('dd/MM/yyyy'); // "15/01/2024" date.format('MMMM d, yyyy'); // "January 15, 2024" date.format('EEEE'); // "Monday" date.format('h:mm a'); // "2:30 PM" // ISO 8601 date.toIso8601String(); // "2024-01-15T14:30:00.000Z" // Convenience methods date.toDateString(); // "2024-01-15" date.toTimeString(); // "14:30:00" date.toDateTimeString(); // "2024-01-15 14:30:00" ``` ### Format Tokens | Token | Output | Description | |:---|:---|:---| | `yyyy` | 2024 | 4-digit year | | `yy` | 24 | 2-digit year | | `MMMM` | January | Full month name | | `MMM` | Jan | Abbreviated month | | `MM` | 01 | 2-digit month | | `dd` | 15 | 2-digit day | | `d` | 15 | Day of month | | `EEEE` | Monday | Full weekday name | | `EEE` | Mon | Abbreviated weekday | | `HH` | 14 | 24-hour hour | | `hh` | 02 | 12-hour hour | | `mm` | 30 | Minutes | | `ss` | 00 | Seconds | | `a` | PM | AM/PM | ## Manipulation ### Adding Time ```dart final date = Carbon.now(); date.addSeconds(30); date.addMinutes(15); date.addHours(2); date.addDays(5); date.addWeeks(2); date.addMonths(3); date.addYears(1); ``` ### Subtracting Time ```dart date.subSeconds(30); date.subMinutes(15); date.subHours(2); date.subDays(5); date.subWeeks(2); date.subMonths(3); date.subYears(1); ``` ### Start/End of Period ```dart date.startOfDay(); // 00:00:00 date.endOfDay(); // 23:59:59 date.startOfWeek(); // Monday 00:00:00 date.endOfWeek(); // Sunday 23:59:59 date.startOfMonth(); // First day 00:00:00 date.endOfMonth(); // Last day 23:59:59 ``` ## Comparison ```dart final date1 = Carbon.parse('2024-01-15'); final date2 = Carbon.parse('2024-01-20'); // Boolean comparisons date1.isBefore(date2); // true date1.isAfter(date2); // false date1.isSame(date2); // false // Special checks date.isToday(); date.isTomorrow(); date.isYesterday(); date.isWeekend(); date.isWeekday(); date.isFuture(); date.isPast(); // Between check date.isBetween(startDate, endDate); ``` ### Difference ```dart final start = Carbon.parse('2024-01-15'); final end = Carbon.parse('2024-01-20'); start.diffInDays(end); // -5 end.diffInDays(start); // 5 start.diffInHours(end); // -120 start.diffInMinutes(end); // -7200 ``` `diffInSeconds`, `diffInMonths` and `diffInYears` follow the same shape. Each answers `this` minus `other`, truncated to whole units, so the result is negative when `this` is the earlier date. ## Human Readable The `diffForHumans()` method returns a human-readable string: ```dart Carbon.now().subMinutes(5).diffForHumans(); // "5 minutes ago" Carbon.now().subHours(2).diffForHumans(); // "2 hours ago" Carbon.now().subDays(1).diffForHumans(); // "a day ago" Carbon.now().subMonths(3).diffForHumans(); // "3 months ago" // Future dates Carbon.now().addDays(3).diffForHumans(); // "in 3 days" Carbon.now().addHours(1).diffForHumans(); // "in an hour" ``` The wording and the singular forms ("a day", "an hour") come from the `jiffy` package's English locale. ### Relative To Another Date ```dart final date1 = Carbon.parse('2024-01-15'); final date2 = Carbon.parse('2024-01-20'); date1.diffForHumans(date2); // "5 days ago" date2.diffForHumans(date1); // "in 5 days" ``` ## Short Human Readable `shortDiffForHumans([other])` is a compact ladder (seconds, minutes, hours, days, weeks, months, years, each threshold exclusive of the next) for dense tables and list rows where `diffForHumans()` reads too wide. It measures against Carbon's clock (frozen while `Carbon.setTestNow` is set), or against `other` when given: ```dart createdAt.shortDiffForHumans(); // "14m ago" dueAt.shortDiffForHumans(); // "5m from now" ``` A gap under one second reads `"Just now"`; a future instant reads `":time from now"` instead of `":time ago"`. Months are truncated 30-day buckets, so a 45-day gap reads `"1mo ago"`, not `"1 month, 15 days ago"`. Every unit and wrapper resolves through the `Lang` catalogue when present (`time.units_short.`, `time.ago`, `time.from_now`, `time.just_now`), falling back to the English literal (`:counts`, `:countm`, `:counth`, `:countd`, `:countw`, `:countmo`, `:county`, `:time ago`, `:time from now`, `Just now`) when no catalogue is loaded or the key is missing: ```json { "time": { "units_short": { "minute": ":count dk" }, "ago": ":time önce" } } ``` ```dart // With the catalogue above loaded for 'tr': event.shortDiffForHumans(); // "14 dk önce" ``` ## Timezone Support When `localization.auto_detect_timezone` is enabled (it defaults to `false`), Magic resolves the device's real IANA timezone identifier on boot through the platform's native APIs, via the `flutter_timezone` package. The resolved identifier is what `DateManager.instance.timezoneName` returns, and the `LocalizationInterceptor` sends it as the `X-Timezone` header on every outgoing request. When detection cannot resolve a valid zone, the configured `localization.timezone` value stays in place; Magic does not guess a zone from the UTC offset, because an offset does not identify a zone (Istanbul and Kyiv share a winter offset but differ in DST rules). Carbon instances do NOT pick this zone up implicitly. A `Carbon` built without an explicit zone uses the system local time, so the detected identifier is there for the backend to interpret timestamps with, not a global conversion applied to every instance. To work in a specific zone, pass it explicitly: ```dart Carbon.now('Europe/Istanbul'); // build in a zone Carbon.now().setTimezone('America/New_York'); // convert an existing instance ``` ## Testing `Carbon.setTestNow([testNow])` freezes the clock `Carbon` reads, mirroring Laravel's `Carbon::setTestNow()`. Call it with no argument (or `null`) to clear the freeze; `Carbon.hasTestNow()` reports whether one is currently set. The freeze is static state, so clear it in `tearDown` or it leaks into the next test. ```dart setUp(() { Carbon.setTestNow(Carbon.create(year: 2024, month: 1, day: 15, hour: 10)); }); tearDown(() { Carbon.setTestNow(); // never leak a frozen clock into the next test }); test('a rule that reads "now"', () { expect(Carbon.now().toDateString(), '2024-01-15'); }); ``` Every read of "now" honours the freeze: `Carbon.now()`, `isToday()`, `isYesterday()`, `isTomorrow()`, `isFuture()`, `isPast()`, and `diffForHumans()` without an argument. `Carbon.now(timezone)` still converts the frozen instant into the requested zone. Constructors that take an explicit moment (`Carbon.parse()`, `Carbon.fromDateTime()`, `Carbon.create()`) are unaffected. ## Model Integration Carbon integrates with Eloquent models through attribute casting: ```dart class Post extends Model with HasTimestamps { @override Map get casts => const { 'published_at': 'datetime', 'expires_at': 'datetime', }; // Typed getter Carbon? get publishedAt => getAttribute('published_at') as Carbon?; Carbon? get expiresAt => getAttribute('expires_at') as Carbon?; } ``` ### Using in Views ```dart // Display formatted date WText(post.publishedAt?.format('MMMM d, yyyy') ?? 'Draft') // Human-readable WText(post.publishedAt?.diffForHumans() ?? '') // "2 days ago" // Conditional display if (post.expiresAt?.isFuture() == true) { WText('Expires ${post.expiresAt!.diffForHumans()}'); } ``` ### HasTimestamps Mixin Models using `HasTimestamps` automatically get `createdAt` and `updatedAt` as Carbon instances: ```dart class User extends Model with HasTimestamps { // Automatically available: // Carbon? get createdAt // Carbon? get updatedAt } // Usage WText('Member since ${user.createdAt?.format('MMM yyyy')}'); ``` > [!TIP] > Use `diffForHumans()` in your UI for relative timestamps that are easier for users to understand. ## Related Documentation - [Localization](https://anilcancakir.com/magic/docs/digging-deeper/localization.md) - [Helpers](https://anilcancakir.com/magic/docs/digging-deeper/helpers.md) - [Eloquent Getting Started](https://anilcancakir.com/magic/docs/eloquent/getting-started.md) - [Mutators and Casts](https://anilcancakir.com/magic/docs/eloquent/mutators.md) --- # Launch The `Launch` facade provides a context-free static API for opening URLs, composing emails, initiating phone calls, and sending SMS messages, returning false on failure rather than throwing. - [Introduction](#introduction) - [Configuration](#configuration) - [URL Launching](#url-launching) - [Email](#email) - [Phone](#phone) - [SMS](#sms) - [Checking Availability](#checking-availability) - [Related Documentation](#related-documentation) ## Introduction The `Launch` facade provides a context-free, static API for opening URLs, triggering email clients, initiating phone calls, and composing SMS messages. It wraps the `url_launcher` package and adds consistent error handling: all methods return `false` on failure and log errors via `Log`. They never throw. ## Configuration ### Registering LaunchServiceProvider `LaunchServiceProvider` is not auto-registered. Add it explicitly to your providers in `config/app.dart`: ```dart 'providers': [ (app) => LaunchServiceProvider(app), // ... other providers ], ``` This registers a `LaunchService` singleton in the Magic IoC container under the key `'launch'`. The `Launch` facade resolves this singleton on every call. ## URL Launching Open a URL using the `Launch.url()` method: ```dart final opened = await Launch.url('https://flutter.dev'); ``` ### LaunchMode Options The optional `mode` parameter controls how the URL is opened. It maps directly to `url_launcher`'s `LaunchMode` enum: | Mode | Behaviour | |:---|:---| | `LaunchMode.externalApplication` | Opens in the default external app (browser, app, etc.), **default** | | `LaunchMode.inAppWebView` | Opens inside a WebView within the app | | `LaunchMode.inAppBrowserView` | Opens in an in-app browser with browser chrome | | `LaunchMode.externalNonBrowserApplication` | Opens in an external non-browser app | | `LaunchMode.platformDefault` | Uses the platform's default behaviour | ```dart // Open in the default external browser or app await Launch.url('https://flutter.dev'); // Open inside an in-app WebView await Launch.url( 'https://flutter.dev', mode: LaunchMode.inAppWebView, ); // Open in an in-app browser with navigation controls await Launch.url( 'https://flutter.dev', mode: LaunchMode.inAppBrowserView, ); ``` > [!TIP] > Prefer `LaunchMode.externalApplication` for links that users expect to open in their browser. Use `LaunchMode.inAppWebView` only when you want to keep the user inside your app. ### The Web Window On the web, `webOnlyWindowName` names the browsing context the URL opens in (`_self`, `_blank`, or a named window); every other platform ignores it. Leave it unset for a new tab. Pass `_self` for a download whose URL you fetch first. Safari blocks a new window that is no longer opened inside the tap, and the web launcher still answers `true`, so nothing happens and nothing says so. Navigating the current tab is not a popup, and a response served with `Content-Disposition: attachment` downloads without leaving the page: ```dart final String link = await controller.downloadLink(fileId); await Launch.url(link, webOnlyWindowName: '_self'); ``` ### Return Value `Launch.url()` returns `Future`. It returns `false` immediately for empty strings and `false` on any URI parse error or launch failure (errors are logged via `Log.error`). ## Email Open the device's email client pre-addressed to a recipient: ```dart // Address only await Launch.email('hello@example.com'); // With subject and body await Launch.email( 'support@example.com', subject: 'Bug Report', body: 'I found an issue with the login screen.', ); ``` Special characters in `subject` and `body` are automatically URI-encoded. The method returns `false` immediately for an empty address. ## Phone Open the device's phone dialer pre-filled with a number: ```dart await Launch.phone('+1234567890'); ``` The method returns `false` immediately for an empty number string. > [!TIP] > Always use the full international format (e.g. `+1234567890`) for phone numbers to ensure portability across regions. ## SMS Open the device's SMS app addressed to a number, with an optional pre-filled message body: ```dart // Number only await Launch.sms('+1234567890'); // With pre-filled body await Launch.sms('+1234567890', body: 'On my way!'); ``` Special characters in `body` are automatically URI-encoded. The method returns `false` immediately for an empty number string. ## Checking Availability Before launching, you can check whether the device can handle a given URL scheme using `Launch.canLaunch()`: ```dart if (await Launch.canLaunch('tel:+1234567890')) { await Launch.phone('+1234567890'); } else { // Phone calls not supported on this device } ``` This works for any URL scheme: `https://`, `mailto:`, `tel:`, `sms:`, or custom deep-link schemes: ```dart // Check before opening a deep link if (await Launch.canLaunch('myapp://profile/42')) { await Launch.url('myapp://profile/42'); } ``` `Launch.canLaunch()` returns `false` for empty strings and malformed URLs. > [!TIP] > On **iOS 9+** and **Android 11+**, querying URL scheme availability requires declaring the relevant schemes in the native app manifest. See the [`url_launcher` documentation](https://pub.dev/packages/url_launcher) for platform-specific setup instructions. ## Related Documentation - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md) - [Logging](https://anilcancakir.com/magic/docs/digging-deeper/logging.md) - [Deep links (magic_deeplink)](https://anilcancakir.com/magic/docs/deeplink/getting-started/installation) --- # Perf Hooks `MagicPerfHooks.sink` is the one seam a diagnostic reads magic's runtime activity through: which controller repainted and why, what a repository merged, how long a query reload, an action or an event dispatch took, which casts ran, which timers fired and which realtime messages arrived. - [Introduction](#introduction) - [Installing a Sink](#installing-a-sink) - [Event Kinds](#event-kinds) - [Notification Causes](#notification-causes) - [Pairing HTTP Requests](#pairing-http-requests) - [Cost and Failure Modes](#cost-and-failure-modes) - [Related Documentation](#related-documentation) ## Introduction A frame profile says a frame was slow; it does not say that a `Countdown` tick made three controllers repaint, or that a broadcast storm re-cast the same `json` attribute two hundred times. The sink reports those facts as typed `MagicPerfEvent` values so a tool can attribute a frame to the magic activity inside it. Core depends on no tooling package; the tool assigns the sink. In development that is `MagicPerfIntegration` from the separate `magic_devtools` package. ## Installing a Sink ```dart MagicPerfHooks.sink = (MagicPerfEvent event) { switch (event) { case ControllerNotified(:final controller, :final cause): record('${controller.runtimeType}', cause.name); case QueryReloaded(:final type, :final startUs, :final endUs): span('reload $type', startUs, endUs); default: break; } }; // Uninstall. MagicPerfHooks.sink = null; ``` `MagicPerfEvent` is sealed, so a `switch` over it is exhaustive. Every timestamp is `FlutterTimeline.now`, in microseconds on the clock the engine stamps frame timings with. ## Event Kinds | Event | Fields | Reported when | |:---|:---|:---| | `ControllerNotified` | `controller`, `cause` | `MagicController.refreshUI()` notifies (every `setState` helper and `ValidatesRequests` go through it). | | `RepositoryUpserted` | `type`, `count` | `Repository.upsertFromList` or `upsertFromShow` merged rows; `type` is the model type. | | `QueryReloaded` | `type`, `startUs`, `endUs`, `fromCache` | A `RepositoryQuery.reload()` settled; `fromCache` is `true` when `ensureFresh()` joined a first load already in flight instead of sending its own request. | | `ActionRan` | `type`, `startUs`, `endUs`, `outcome` | `RunsActions.runAction` ran an action to `ActionSucceeded` or `ActionFailed`. A refused re-entry never runs and is not reported. | | `EventDispatched` | `type`, `listenerCount`, `startUs`, `endUs` | The event dispatcher delivered an event to its typed and wildcard listeners. | | `AttributeCast` | `castType` | `Model.getAttribute` computed a cast. A memoised `datetime`/`bool`/`int`/`double` read is not reported; `json` is cast, and reported, on every read. A `CastsAttributes` instance reports its runtime type name. | | `TimerTicked` | `ownerType` | A `Countdown` tick, a `Debouncer` fire or a `Poll` read. | | `BroadcastReceived` | `event` | `BroadcastListeners` (from `magic_socket`) fanned out one realtime message. | ## Notification Causes `ControllerNotified.cause` is a `MagicNotifyCause`, carried by a zone value each instrumented path sets around the callback it invokes, so it follows that callback across its `await`s: | Cause | Set around | Kind | |:---|:---|:---| | `setState` | `MagicStateMixin.setState`, so every `setSuccess`, `setError`, `setLoading`, `setEmpty` | derived | | `repositoryQuery` | a `RepositoryQuery` notifying its listeners | derived | | `timerTick` | a `Countdown` `onTick`, a `Debouncer` callback, a `Poll` read | root | | `broadcast` | each `BroadcastListeners` handler for one realtime message | root | | `direct` | nothing: the app called `refreshUI()` on its own | | A root cause replaces whatever it inherits, because a timer firing or a message arriving is where new work starts: a `Debouncer` armed inside a broadcast handler reports `timerTick` when it fires. A derived cause keeps an inherited one and applies only when there is none, so a `setSuccess` after an `await` inside a `Debouncer` callback still reports `timerTick`. A cause lasts until its callback returns, or until the `Future` it returns completes. A timer or stream subscription registered inside it runs in the same zone later, but reports `direct`: it is no longer part of the work that set the cause. A callback that starts async work without returning its `Future` (`() { reload(); }` rather than `reload`) closes the cause at once, and a `setSuccess` after its first `await` reports `setState`. ## Pairing HTTP Requests `DioNetworkDriver` stamps every request with a process-unique id before any interceptor runs. `MagicRequest.id`, `MagicResponse.id` and `MagicError.id` carry the same value for the same request, so an interceptor can pair concurrent requests with their answers even when a slow first request completes last: ```dart class TimingInterceptor extends MagicNetworkInterceptor { final Map _startedUs = {}; @override dynamic onRequest(MagicRequest request) { final int? id = request.id; if (id != null) _startedUs[id] = FlutterTimeline.now; return request; } @override dynamic onResponse(MagicResponse response) { final int? startUs = _startedUs.remove(response.id); // ... return response; } } ``` A request built by hand, and every response `Http.fake` answers, carries a `null` id. ## Cost and Failure Modes - With no sink installed, each instrumented site costs one null check and constructs no event object. - A sink that throws is reported through `debugPrint` and ignored. It runs before the notification it observes, so an unguarded throw would stop the screen repainting; a broken observer costs its own numbers, never the app's frames. - The sink is process-wide. A test that installs one resets it to `null` in `tearDown`. ## Related Documentation - [Helpers](https://anilcancakir.com/magic/docs/digging-deeper/helpers.md) - [Events](https://anilcancakir.com/magic/docs/digging-deeper/events.md) - [Session Scope](https://anilcancakir.com/magic/docs/digging-deeper/session-scope.md) - [HTTP Client](https://anilcancakir.com/magic/docs/basics/http-client.md) --- # Testing: Getting Started `MagicTest` and the fakes in `package:magic/testing.dart` reset the IoC container between tests, fake facades and HTTP, and keep controller and model behaviour assertable with `flutter_test`. - [Introduction](#introduction) - [Setting Up Tests](#setting-up-tests) - [Writing Tests](#writing-tests) - [Controller Testing](#controller-testing) - [Model Testing](#model-testing) - [Mock HTTP Responses](#mock-http-responses) - [Running Tests](#running-tests) - [Related Documentation](#related-documentation) ## Introduction Magic tests run on `flutter_test`. `MagicTest.init()` clears the container and facade caches around every test, `Http.fake()` and the facade fakes replace the real network and storage, and mocks are written by implementing the contract (an abstract class) directly, never with code generation. ## Setting Up Tests ### Directory Structure The `test/` tree mirrors where the `make:*` generators write their classes, and `make:test` scaffolds a matching test there: ``` test/ ├── app/ │ ├── actions/ │ ├── controllers/ │ ├── forms/ │ ├── repositories/ │ └── validation/requests/ ├── resources/views/ └── unit/ ``` ```bash dart run magic:dowel make:test Monitor --kind=controller # -> test/app/controllers/monitor_controller_test.dart ``` `--kind` is one of `controller`, `action`, `form`, `repository`, `request`, `view` or `unit`. ### Test Helper Magic provides a `MagicTest` bootstrap helper via `package:magic/testing.dart`. Call `MagicTest.init()` once at the top of `main()`; it registers `setUpAll`, `setUp` and `tearDown` for you: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/testing.dart'; void main() { MagicTest.init(); // Registers setUpAll + setUp + tearDown automatically test('my test', () { // Magic container is clean, facades are reset }); } ``` `MagicTest.init()` handles: - `setUpAll`: `TestWidgetsFlutterBinding.ensureInitialized()` - `setUp`: `MagicApp.reset()` + `Magic.flush()` + `Gate.flush()` - `tearDown`: `Magic.flush()` + `Gate.flush()` + `DateManager.reset()` + `Translator.reset()`, so a loaded catalogue or locale does not carry into the next test `Gate` is a process static that `Magic.flush()` does not clear on its own, so without the extra call an ability defined in one test leaks into every later test in the same file. For integration tests that need a full `Magic.init()` lifecycle, use `MagicTest.boot()` instead: ```dart import 'package:magic/testing.dart'; void main() { setUpAll(() async { await MagicTest.boot( configs: [testAppConfig], envFileName: '.env.testing', ); }); test('full lifecycle test', () async { // Magic is fully initialised with providers booted }); } Map get testAppConfig => { 'app': { 'name': 'Test App', 'debug': true, }, }; ``` ### Booting a Handful of Providers A widget test that needs real providers but no env file or config boots them with `MagicTest.bootProviders(providers)`. It reproduces `Magic.init`'s order: every `register()` first, then every `boot()`, both in list order. Pair it with `MagicTest.disposeBooted()` in `tearDown`: ```dart setUp(() async { await MagicTest.bootProviders([ AppServiceProvider(Magic.app), AuthServiceProvider(Magic.app), ]); }); tearDown(MagicTest.disposeBooted); ``` `disposeBooted()` undoes what `Magic.flush()` does not reach: it disposes the registered controllers (newest first), detaches `SessionScope` from auth state, resets `MagicRouter`, then flushes the container. Anything an app keeps in its own statics, such as a timer a provider arms in `boot()`, stays the app's to reset. ### Loading a Translation Catalogue A widget test that renders `trans()` output needs a real catalogue loaded first; without one, `trans()` renders the raw dotted key. `MagicTest.loadTranslations(locale, {directory})` reads `/.json` off disk (default `assets/lang`, the app's own catalogue location), flattens it the same way the app's own loader does, and installs it before awaiting the translator's load: ```dart await MagicTest.loadTranslations('tr'); expect(trans('auth.login'), 'Giriş yap'); ``` Point `directory` at a temp directory with a small fixture json to test one key in isolation without touching the app's real catalogue. Loading never requires a fallback-locale file on disk: the translator only warns and falls back to an empty map when the fallback catalogue fails to load. ## Writing Tests ### Basic Test Structure ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic/testing.dart'; void main() { MagicTest.init(); group('Validator', () { test('rejects an invalid email', () { final validator = Validator.make( {'email': 'invalid-email'}, {'email': [Required(), Email()]}, ); expect(validator.fails(), isTrue); expect(validator.errors().containsKey('email'), isTrue); }); test('accepts a valid email', () { final validator = Validator.make( {'email': 'john@example.com'}, {'email': [Required(), Email()]}, ); expect(validator.passes(), isTrue); }); }); } ``` ## Controller Testing Register the controller with `Magic.put`, fake the network, call a method, and assert the state transitions: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic/testing.dart'; void main() { MagicTest.init(); late TaskController controller; setUp(() { Http.fake({ 'tasks': Http.response({ 'data': [ {'id': 1, 'name': 'Write docs'}, ], }), }); controller = TaskController(); Magic.put(controller); }); tearDown(() { controller.dispose(); Http.unfake(); }); test('loadTasks moves from loading to success', () async { expect(controller.isLoading, isFalse); final loading = controller.loadTasks(); expect(controller.isLoading, isTrue); await loading; expect(controller.isSuccess, isTrue); expect(controller.rxState, hasLength(1)); }); } ``` ### Testing State Changes ```dart test('setSuccess updates state correctly', () { final controller = TaskController(); expect(controller.isLoading, isFalse); expect(controller.isSuccess, isFalse); controller.setLoading(); expect(controller.isLoading, isTrue); controller.setSuccess([Task(name: 'Test')]); expect(controller.isSuccess, isTrue); expect(controller.rxState, isNotEmpty); }); ``` ### Testing Validation Errors A controller that mixes in `ValidatesRequests` keeps field errors you can assert on directly: ```dart test('clearErrors removes all validation errors', () { final controller = AuthController(); controller.setErrorsFromMap({'email': ['Invalid email']}); expect(controller.hasError('email'), isTrue); controller.clearErrors(); expect(controller.hasError('email'), isFalse); }); ``` ## Model Testing Attribute behaviour (mass assignment, casts, dirty tracking) needs no fake; a model that talks to the backend is tested against `Http.fake()` (see [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md)): ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic/testing.dart'; void main() { MagicTest.init(); group('User Model', () { test('fills attributes and exports them', () { final user = User()..fill({'name': 'John', 'email': 'john@example.com'}); expect(user.toMap()['email'], equals('john@example.com')); }); test('tracks dirty attributes', () { final user = User()..fill({'name': 'John'}); user.syncOriginal(); user.name = 'Updated Name'; expect(user.isDirty('name'), isTrue); }); }); } ``` A model with `useLocal => true` also needs the `magic_local_storage` plugin; its docs cover testing against the local store. ## Mock HTTP Responses `Http.fake()` swaps the network driver for an in-memory one that answers from the stubs you give it and records every request: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic/testing.dart'; void main() { MagicTest.init(); late AuthController controller; setUp(() { controller = AuthController(); }); tearDown(() { controller.dispose(); Http.unfake(); }); test('login failure sets the error state', () async { final fake = Http.fake({ 'auth/login': Http.response({'message': 'Invalid credentials'}, 401), }); await controller.login({ 'email': 'john@example.com', 'password': 'wrong', }); expect(controller.isError, isTrue); fake.assertSent((r) => r.method == 'POST' && r.url.contains('auth/login')); }); } ``` [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md) covers URL patterns, callback stubs, request assertions and stray-request protection; [Facade Testing](https://anilcancakir.com/magic/docs/testing/facades.md) covers the `Auth`, `Cache`, `Vault` and `Log` fakes. ## Running Tests ### Run All Tests ```bash flutter test ``` ### Run Specific Test File ```bash flutter test test/app/controllers/task_controller_test.dart ``` ### Run With Coverage ```bash flutter test --coverage ``` ### Test Output ```bash flutter test --reporter=expanded ``` ## Related Documentation - [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md) - [Facade Testing](https://anilcancakir.com/magic/docs/testing/facades.md) - [Controllers](https://anilcancakir.com/magic/docs/basics/controllers.md) - [Actions](https://anilcancakir.com/magic/docs/basics/actions.md) - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `make:test`. --- # HTTP Tests `Http.fake()` lets you stub HTTP responses by URL or callback so controller and service tests run without a live server, with assertions to verify which requests were actually made. - [Introduction](#introduction) - [Faking Responses](#faking-responses) - [URL Pattern Stubs](#url-pattern-stubs) - [Callback Stubs](#callback-stubs) - [Making Assertions](#making-assertions) - [Preventing Stray Requests](#preventing-stray-requests) - [Response Factory](#response-factory) - [Unfaking](#unfaking) - [Full Example](#full-example) - [Testing fetchList and fetchOne](#testing-fetchlist-and-fetchone) - [Related Documentation](#related-documentation) ## Introduction Magic provides a first-class HTTP faking API that lets you swap the real network driver with a `FakeNetworkDriver` during tests. All requests are recorded and no real network traffic is made. This approach is inspired by Laravel's `Http::fake()` and requires no third-party mocking libraries. ```dart setUp(() { MagicApp.reset(); Magic.flush(); Http.fake(); // Intercept all HTTP requests }); tearDown(() { Http.unfake(); // Restore real driver }); ``` ## Faking Responses Call `Http.fake()` before the code under test runs. When called with no arguments, every request returns a 200 response with empty data. ```dart final fake = Http.fake(); final response = await Http.get('/users'); expect(response.successful, isTrue); expect(response.data, equals({})); ``` ### URL Pattern Stubs Pass a `Map` to stub specific URL patterns. Patterns support `*` as a wildcard. ```dart final fake = Http.fake({ 'users/*': Http.response({'id': 1, 'name': 'Alice'}, 200), 'posts': Http.response([], 200), 'auth/login': Http.response({'token': 'test-token'}, 200), }); final user = await Http.get('/users/42'); expect(user['name'], 'Alice'); final posts = await Http.get('/posts'); expect(posts.data, isEmpty); ``` Pattern matching is done after stripping the leading `/` so both `/users/42` and `users/42` match the pattern `users/*`. ### Callback Stubs Pass a `FakeRequestHandler` (a function that receives a `MagicRequest` and returns a `MagicResponse`, or a `Future`) for dynamic stubbing logic. ```dart final fake = Http.fake((request) { if (request.url.contains('admin')) { return Http.response({'error': 'Forbidden'}, 403); } return Http.response({'ok': true}, 200); }); final adminResponse = await Http.get('/admin/users'); expect(adminResponse.forbidden, isTrue); final publicResponse = await Http.get('/users'); expect(publicResponse.successful, isTrue); ``` #### Holding a request in flight An `async` handler lets a test keep a request OUTSTANDING while it does something else, which is the only way to script a concurrency case: what happens when a sign-out, a navigation or a second call arrives before the first response lands. ```dart final gate = Completer(); Http.fake((request) async { await gate.future; return Http.response({'ok': true}, 200); }); final inFlight = controller.submit(); // outstanding, not finished await controller.signOut(); // arrives mid-request gate.complete(); await inFlight; ``` ## Making Assertions `Http.fake()` returns the `FakeNetworkDriver` instance. Use it to assert on recorded requests after the code under test runs. ### `assertSent` Assert that at least one recorded request matches the predicate. ```dart final fake = Http.fake(); await Http.post('/users', data: {'name': 'Bob'}); fake.assertSent((request) => request.url.contains('users')); fake.assertSent( (request) => request.method == 'POST' && request.url == '/users', ); ``` ### `assertNotSent` Assert that no recorded request matches the predicate. ```dart fake.assertNotSent((request) => request.url.contains('payments')); ``` ### `assertNothingSent` Assert that no requests were made at all. ```dart final fake = Http.fake(); // Code that should not touch the network doSomethingLocal(); fake.assertNothingSent(); ``` ### `assertSentCount` Assert an exact number of requests were recorded. ```dart final fake = Http.fake(); await Http.get('/users'); await Http.get('/users/1'); fake.assertSentCount(2); ``` ### `recorded` Access the full list of recorded `(MagicRequest, MagicResponse)` pairs for custom assertions. ```dart final fake = Http.fake(); await Http.post('/orders', data: {'item': 'Widget'}); final pair = fake.recorded.first; expect(pair.$1.method, 'POST'); expect(pair.$2.statusCode, 200); ``` ## Preventing Stray Requests Call `preventStrayRequests()` on the fake driver to throw a `StrayRequestException` whenever an unmatched request is made. This ensures every HTTP call in your test has an explicit stub. ```dart final fake = Http.fake({ 'users': Http.response([], 200), })..preventStrayRequests(); // This is fine: matched by stub await Http.get('/users'); // This throws StrayRequestException: no matching stub await Http.get('/notifications'); // throws! ``` > [!TIP] > Enable `preventStrayRequests()` in tests that should be fully isolated from external services to catch accidental network calls early. ## Response Factory `Http.response()` is a convenience factory for building `MagicResponse` objects to use as stub responses. ```dart // 200 with empty data (default) Http.response() // 200 with a Map body Http.response({'id': 1, 'name': 'Alice'}) // Custom status code Http.response({'message': 'Not found'}, 404) // List body Http.response([{'id': 1}, {'id': 2}], 200) // 422 validation errors Http.response({ 'message': 'The given data was invalid.', 'errors': { 'email': ['The email field is required.'], }, }, 422) ``` ## Unfaking Call `Http.unfake()` in `tearDown` to remove the fake driver from the IoC container and restore the real network driver for subsequent tests. ```dart tearDown(() { Http.unfake(); }); ``` Alternatively, you can call `fake.reset()` on the driver instance to clear recorded requests and stubs without restoring the real driver. This is useful when reusing the same fake across multiple test cases. ```dart setUp(() { MagicApp.reset(); Magic.flush(); }); // In a group where all tests share one fake: final fake = Http.fake(); test('first test', () async { await Http.get('/a'); fake.assertSentCount(1); fake.reset(); // Clear for next test }); test('second test', () async { await Http.get('/b'); fake.assertSentCount(1); }); ``` ## Full Example A controller that fetches a user profile and the test verifying it: ```dart // lib/app/controllers/profile_controller.dart class ProfileController extends MagicController with MagicStateMixin>, ValidatesRequests { Future load(String userId) async { setLoading(); final response = await Http.show('users', userId); if (response.successful) { setSuccess(response.data as Map); } else { setError(response.firstError ?? 'Failed to load profile'); } } } // test/app/controllers/profile_controller_test.dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; void main() { group('ProfileController', () { late ProfileController controller; late FakeNetworkDriver fake; setUp(() { MagicApp.reset(); Magic.flush(); controller = ProfileController(); Magic.put(controller); fake = Http.fake(); }); tearDown(() { Http.unfake(); }); test('sets success state on 200 response', () async { fake.stub( 'users/*', Http.response({'id': '42', 'name': 'Alice'}, 200), ); await controller.load('42'); expect(controller.isSuccess, isTrue); expect(controller.rxState?['name'], 'Alice'); fake.assertSent((r) => r.url.contains('users/42')); }); test('sets error state on failure', () async { fake.stub( 'users/*', Http.response({'message': 'User not found'}, 404), ); await controller.load('999'); expect(controller.isError, isTrue); }); }); } ``` ## Testing fetchList and fetchOne Controllers that use `fetchList()` or `fetchOne()` from `MagicStateMixin` can be tested with `Http.fake()` the same way as any other HTTP call. Stub the URL the helper will request, then assert on the resulting controller state. ### fetchList ```dart // lib/app/controllers/project_controller.dart class ProjectController extends MagicController with MagicStateMixin> { Future loadProjects(String teamId) => fetchList('teams/$teamId/projects', Project.fromMap); } // test/app/controllers/project_controller_test.dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:my_app/app/controllers/project_controller.dart'; void main() { group('ProjectController.fetchList', () { late ProjectController controller; late FakeNetworkDriver fake; setUp(() { MagicApp.reset(); Magic.flush(); controller = ProjectController(); Magic.put(controller); fake = Http.fake({ 'teams/*/projects': Http.response({ 'data': [ {'id': 1, 'name': 'Project A'}, {'id': 2, 'name': 'Project B'}, ], }, 200), }); }); tearDown(() => Http.unfake()); test('sets success state with parsed list', () async { await controller.loadProjects('team-1'); expect(controller.isSuccess, isTrue); expect(controller.rxState?.length, 2); expect(controller.rxState?.first.name, 'Project A'); fake.assertSent((r) => r.url.contains('teams/team-1/projects')); }); test('sets empty state when data list is empty', () async { fake.stub('teams/*/projects', Http.response({'data': []}, 200)); await controller.loadProjects('team-1'); expect(controller.isEmpty, isTrue); }); test('sets error state on failed response', () async { fake.stub( 'teams/*/projects', Http.response({'message': 'Unauthorized'}, 401), ); await controller.loadProjects('team-1'); expect(controller.isError, isTrue); }); }); } ``` ### fetchOne ```dart // lib/app/controllers/project_detail_controller.dart class ProjectDetailController extends MagicController with MagicStateMixin { Future loadProject(String id) => fetchOne('projects/$id', Project.fromMap); } // test/app/controllers/project_detail_controller_test.dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:my_app/app/controllers/project_detail_controller.dart'; void main() { group('ProjectDetailController.fetchOne', () { late ProjectDetailController controller; late FakeNetworkDriver fake; setUp(() { MagicApp.reset(); Magic.flush(); controller = ProjectDetailController(); Magic.put(controller); fake = Http.fake({ 'projects/*': Http.response({ 'data': {'id': 1, 'name': 'Project A'}, }, 200), }); }); tearDown(() => Http.unfake()); test('sets success state with parsed model', () async { await controller.loadProject('1'); expect(controller.isSuccess, isTrue); expect(controller.rxState?.name, 'Project A'); fake.assertSent((r) => r.url.contains('projects/1')); }); test('sets error state when data key is absent', () async { fake.stub('projects/*', Http.response({'meta': {}}, 200)); await controller.loadProject('1'); expect(controller.isError, isTrue); }); }); } ``` > [!TIP] > Use `fake.stub()` inside individual tests to override the default stub registered in `setUp`. This keeps the happy path in `setUp` and edge cases inline. ## Related Documentation - [Getting Started](https://anilcancakir.com/magic/docs/testing/getting-started.md) - [Facade Testing](https://anilcancakir.com/magic/docs/testing/facades.md): `Auth.fake()`, `Cache.fake()`, `Vault.fake()`, `Log.fake()` - [HTTP Client](https://anilcancakir.com/magic/docs/basics/http-client.md) --- # Facade Testing The `Auth`, `Cache`, `Vault`, `Log` and `Http` facades each ship an in-memory fake so tests never touch real storage, network, or platform channels. - [Introduction](#introduction) - [Fake Catalog](#fake-catalog) - [Auth.fake()](#auth-fake) - [Cache.fake()](#cache-fake) - [Vault.fake()](#vault-fake) - [Log.fake()](#log-fake) - [Broadcasting Fakes](#broadcasting-fakes) - [Full Example](#full-example) - [Unfaking](#unfaking) - [Related Documentation](#related-documentation) ## Introduction Magic provides a first-class facade faking API for Auth, Cache, Vault, Log, and Http. Each facade exposes a `fake()` static method that swaps the IoC-bound service with an in-memory implementation for the duration of a test. No real credentials, storage, or network traffic are touched. Plugin facades ship their own fakes (see [Broadcasting Fakes](#broadcasting-fakes)). Call `fake()` in `setUp` and `unfake()` in `tearDown`: ```dart setUp(() { MagicApp.reset(); Magic.flush(); final authFake = Auth.fake(); final cacheFake = Cache.fake(); final vaultFake = Vault.fake(); final logFake = Log.fake(); }); tearDown(() { Auth.unfake(); Cache.unfake(); Vault.unfake(); Log.unfake(); }); ``` Each `fake()` call returns its fake instance so you can run assertions after the code under test executes. ## Fake Catalog | Facade | `fake()` return type | Unfake | Docs | |:---|:---|:---|:---| | `Auth` | `FakeAuthManager` | `Auth.unfake()` | [Auth.fake()](#auth-fake) | | `Cache` | `FakeCacheManager` | `Cache.unfake()` | [Cache.fake()](#cache-fake) | | `Vault` | `FakeVaultService` | `Vault.unfake()` | [Vault.fake()](#vault-fake) | | `Log` | `FakeLogManager` | `Log.unfake()` | [Log.fake()](#log-fake) | | `Http` | `FakeNetworkDriver` | `Http.unfake()` | [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md) | All fake types are exported from `package:magic/testing.dart`. Import them alongside `package:magic/magic.dart` in test files. ## Auth.fake() `Auth.fake()` replaces the real `AuthManager` with a `FakeAuthManager` that routes all guard operations through an in-memory `_FakeGuard`. No platform channels, no secure storage, no token refresh calls. `login()` and `logout()` still dispatch `AuthLogin`/`AuthLogout` through the real `Event` facade, so a listener registered with `Event.listen` observes a faked session the same way it would a real one. **Signature:** ```dart static FakeAuthManager fake({Authenticatable? user}) ``` Pass `user` to pre-authenticate before the test body runs. ### Basic Usage ```dart test('dashboard redirects guests to login', () { Auth.fake(); // No user: guest state expect(Auth.check(), isFalse); expect(Auth.guest, isTrue); }); test('user is pre-authenticated', () { final user = User()..fill({'id': 1, 'name': 'Alice'}); Auth.fake(user: user); expect(Auth.check(), isTrue); expect(Auth.user()?.name, 'Alice'); }); ``` ### Assertions | Method | Description | |:---|:---| | `fake.assertLoggedIn()` | Assert a user is currently authenticated. | | `fake.assertLoggedOut()` | Assert no user is currently authenticated. | | `fake.assertLoginAttempted()` | Assert at least one login call was made. | | `fake.assertLoginCount(int expected)` | Assert an exact number of login calls. | ```dart test('controller logs in user on success', () async { final user = User()..fill({'id': 1, 'name': 'Alice'}); final fake = Auth.fake(); await Auth.login({'token': 'test-token'}, user); fake.assertLoggedIn(); fake.assertLoginAttempted(); fake.assertLoginCount(1); }); test('controller logs out user', () async { final user = User()..fill({'id': 1, 'name': 'Alice'}); final fake = Auth.fake(user: user); await Auth.logout(); fake.assertLoggedOut(); }); ``` ### Resetting State Call `fake.reset()` to clear the current user, token, and login attempt records without restoring the real driver: ```dart fake.reset(); // Clears user, token, and login attempt history ``` ## Cache.fake() `Cache.fake()` replaces the real `CacheManager` with a `FakeCacheManager` backed by a plain `Map`. All operations are synchronous and in-memory. Every `put`, `get`, and `forget` call is recorded in `fake.recorded`. **Signature:** ```dart static FakeCacheManager fake() ``` ### Basic Usage ```dart test('controller caches user list', () async { final fake = Cache.fake(); await Cache.put('users', ['Alice', 'Bob'], ttl: Duration(minutes: 5)); expect(Cache.has('users'), isTrue); expect(Cache.get('users'), equals(['Alice', 'Bob'])); }); ``` ### Assertions | Method | Description | |:---|:---| | `fake.assertHas(String key)` | Assert that the key currently exists in the cache. | | `fake.assertMissing(String key)` | Assert that the key does not exist in the cache. | | `fake.assertPut(String key)` | Assert that the key was stored via `put` at least once. | ```dart test('user list is cached after fetch', () async { final fake = Cache.fake(); await Cache.put('users', ['Alice', 'Bob']); fake.assertHas('users'); fake.assertPut('users'); }); test('cache is cleared after flush', () async { final fake = Cache.fake(); await Cache.put('users', ['Alice']); await Cache.flush(); fake.assertMissing('users'); }); ``` ### Recorded Operations Access `fake.recorded` for a full chronological list of cache operations: ```dart final fake = Cache.fake(); await Cache.put('a', 1); await Cache.get('a'); await Cache.forget('a'); expect(fake.recorded[0].operation, 'put'); expect(fake.recorded[1].operation, 'get'); expect(fake.recorded[2].operation, 'forget'); ``` Each entry is a `FakeCacheCall` record type: `({String operation, String key, dynamic value})`. ### Resetting State Call `fake.reset()` to clear both the in-memory store and the recorded operations list: ```dart fake.reset(); ``` ## Vault.fake() `Vault.fake()` replaces the real `MagicVaultService` (backed by `flutter_secure_storage`) with a `FakeVaultService` that stores data in a plain `Map`. No platform channels are invoked. **Signature:** ```dart static FakeVaultService fake([Map initialValues = const {}]) ``` Pass `initialValues` to pre-seed the vault before the test body runs. ### Basic Usage ```dart test('controller reads token from vault', () async { Vault.fake({'auth_token': 'seed-token'}); final token = await Vault.get('auth_token'); expect(token, 'seed-token'); }); test('controller writes token to vault', () async { final fake = Vault.fake(); await Vault.put('auth_token', 'abc123'); expect(await Vault.get('auth_token'), 'abc123'); }); ``` ### Assertions | Method | Description | |:---|:---| | `fake.assertWritten(String key)` | Assert that `key` was written via `put` at least once. | | `fake.assertDeleted(String key)` | Assert that `key` was deleted via `delete` at least once. | | `fake.assertContains(String key)` | Assert that `key` currently exists in the store. | | `fake.assertMissing(String key)` | Assert that `key` does not currently exist in the store. | ```dart test('logout clears auth token', () async { final fake = Vault.fake({'auth_token': 'abc123'}); await Vault.delete('auth_token'); fake.assertDeleted('auth_token'); fake.assertMissing('auth_token'); }); test('login stores token in vault', () async { final fake = Vault.fake(); await Vault.put('auth_token', 'new-token'); fake.assertWritten('auth_token'); fake.assertContains('auth_token'); }); ``` ### Recorded Operations Access `fake.recorded` for a full list of vault operations: ```dart final fake = Vault.fake(); await Vault.put('token', 'abc'); await Vault.get('token'); await Vault.delete('token'); expect(fake.recorded[0].operation, 'put'); expect(fake.recorded[1].operation, 'get'); expect(fake.recorded[2].operation, 'remove'); ``` Each entry is a `VaultOperation` record type: `({String operation, String key})`. ### Resetting State Call `fake.reset()` to clear the in-memory store and recorded operations: ```dart fake.reset(); ``` ## Log.fake() `Log.fake()` replaces the real `LogManager` with a `FakeLogManager` that captures all log entries in memory instead of writing to the console. **Signature:** ```dart static FakeLogManager fake() ``` ### Basic Usage ```dart test('controller logs error on failure', () async { final fake = Log.fake(); Log.error('Payment failed', {'order': 42}); fake.assertLoggedError('Payment failed'); }); ``` ### Assertions | Method | Description | |:---|:---| | `fake.assertLogged(String level, String message)` | Assert at least one entry matches both level and message. | | `fake.assertLoggedError(String message)` | Shorthand for `assertLogged('error', message)`. | | `fake.assertNothingLogged([String? level])` | Assert no entries recorded. If `level` is given, assert no entries at that level. | | `fake.assertLoggedCount(int expected)` | Assert an exact total number of entries recorded. | ```dart test('no logs emitted during normal operation', () { final fake = Log.fake(); doNormalWork(); fake.assertNothingLogged(); }); test('exactly one error is logged', () async { final fake = Log.fake(); Log.error('Something failed'); fake.assertLoggedCount(1); fake.assertLoggedError('Something failed'); }); test('warning is logged at correct level', () { final fake = Log.fake(); Log.warning('Rate limit approaching'); fake.assertLogged('warning', 'Rate limit approaching'); fake.assertNothingLogged('error'); // No errors logged }); ``` ### Inspecting Entries Access `fake.entries` for the full list of captured log entries: ```dart final fake = Log.fake(); Log.info('User logged in', {'id': 1}); Log.error('Token expired'); expect(fake.entries.length, 2); expect(fake.entries[0].level, 'info'); expect(fake.entries[0].message, 'User logged in'); expect(fake.entries[1].level, 'error'); ``` Each entry is a `FakeLogEntry` record type: `({String level, String message, dynamic context})`. ### Resetting State Call `fake.reset()` to clear all captured entries without restoring the real driver: ```dart fake.reset(); ``` ## Broadcasting Fakes The `Echo` facade and its `FakeBroadcastManager` live in the `magic_socket` plugin, which exports the fake from `package:magic_socket/testing.dart`. Install it with `flutter pub add magic_socket`, then `dart run magic:dowel plugin:install magic_socket`; its assertions are documented on the plugin's [testing page](https://anilcancakir.com/magic/docs/socket/guides/testing). ## Full Example A controller that integrates Auth, Cache, Vault, and Log, with tests covering each fake: ```dart // lib/app/controllers/session_controller.dart class SessionController extends MagicController { Future login(String token, User user) async { await Auth.login({'token': token}, user); await Vault.put('auth_token', token); await Cache.put('current_user', user.toMap()); Log.info('User logged in', {'id': user.id}); } Future logout() async { await Auth.logout(); await Vault.delete('auth_token'); await Cache.forget('current_user'); Log.info('User logged out'); } } // test/app/controllers/session_controller_test.dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic/testing.dart'; import 'package:my_app/app/controllers/session_controller.dart'; import 'package:my_app/app/models/user.dart'; void main() { group('SessionController', () { late SessionController controller; late FakeAuthManager authFake; late FakeCacheManager cacheFake; late FakeVaultService vaultFake; late FakeLogManager logFake; setUp(() { MagicApp.reset(); Magic.flush(); controller = SessionController(); authFake = Auth.fake(); cacheFake = Cache.fake(); vaultFake = Vault.fake(); logFake = Log.fake(); }); tearDown(() { Auth.unfake(); Cache.unfake(); Vault.unfake(); Log.unfake(); }); test('login authenticates user and stores credentials', () async { final user = User()..fill({'id': 1, 'name': 'Alice'}); await controller.login('token-abc', user); authFake.assertLoggedIn(); authFake.assertLoginCount(1); vaultFake.assertWritten('auth_token'); vaultFake.assertContains('auth_token'); cacheFake.assertHas('current_user'); cacheFake.assertPut('current_user'); logFake.assertLogged('info', 'User logged in'); }); test('logout clears all session state', () async { final user = User()..fill({'id': 1, 'name': 'Alice'}); authFake = Auth.fake(user: user); vaultFake = Vault.fake({'auth_token': 'token-abc'}); await controller.logout(); authFake.assertLoggedOut(); vaultFake.assertDeleted('auth_token'); vaultFake.assertMissing('auth_token'); cacheFake.assertMissing('current_user'); logFake.assertLogged('info', 'User logged out'); }); }); } ``` ## Unfaking Call `unfake()` in `tearDown` to remove the fake from the IoC container and restore the real service binding for subsequent tests: ```dart tearDown(() { Auth.unfake(); Cache.unfake(); Vault.unfake(); Log.unfake(); }); ``` After `unfake()`, the next facade call resolves the original singleton binding as if `fake()` was never called. This is the recommended pattern when each test should start from a clean real-service state. If you want to reuse the same fake across multiple tests in a group without restoring the real driver, call `fake.reset()` instead: ```dart final logFake = Log.fake(); // Install once for the group test('first', () { Log.error('a'); logFake.assertLoggedCount(1); logFake.reset(); // Clear for next test; the fake remains installed }); test('second', () { Log.error('b'); logFake.assertLoggedCount(1); }); ``` ## Related Documentation - [Getting Started](https://anilcancakir.com/magic/docs/testing/getting-started.md) - [HTTP Tests](https://anilcancakir.com/magic/docs/testing/http-tests.md): `Http.fake()`, URL pattern stubs, request assertions --- # Magic CLI The Magic CLI is a `dowel` plugin that ships as part of the magic package, providing `magic:install`, the `make:*` generators and the preview and design commands through magic's bundled `dowel` executable (`dart run magic:dowel`). - [Introduction](#introduction) - [Installation](#installation) - [Project Setup](#project-setup) - [install](#install) - [Make Commands](#make-commands) - [make:resource](#makeresource) - [make:model](#makemodel) - [make:controller](#makecontroller) - [make:view](#makeview) - [make:repository](#makerepository) - [make:action](#makeaction) - [make:form](#makeform) - [make:test](#maketest) - [make:policy](#makepolicy) - [make:provider](#makeprovider) - [make:middleware](#makemiddleware) - [make:enum](#makeenum) - [make:event](#makeevent) - [make:listener](#makelistener) - [make:request](#makerequest) - [make:lang](#makelang) - [make:component](#makecomponent) - [previews:refresh](#previewsrefresh) - [design:sync](#designsync) - [design:lint](#designlint) - [DESIGN.md format](#designmd-format) - [Related Documentation](#related-documentation) ## Introduction Magic CLI is the command-line tool for Magic, modelled on Laravel's `php artisan`. It runs on [dowel](https://pub.dev/packages/dowel) and scaffolds controllers, models, views, actions and more with a single command. ## Installation The Magic CLI ships as a `dowel` plugin bundled with the `magic` package, which exposes its own `dowel` executable. There is no separate install step: run commands via that executable. Once `magic:install` has scaffolded the app's `bin/dispatcher.dart`, `dart run magic:dowel` hands each call to it, so the commands of every plugin registered with `plugin:install` resolve through the same executable (and through `dart run dowel ` or the fast wrapper `bin/dowel`). ```bash dart run magic:dowel [arguments] [options] ``` Magic ships the `dowel` executable in its `pubspec.yaml`, so `dart run magic:dowel` works from any project that depends on magic, with no global activation or package-name substitution. ## Project Setup ### install Initialises Magic in an existing Flutter project with the recommended directory structure and configuration. ```bash dart run magic:dowel magic:install ``` This command: 1. Writes the config files: `lib/config/app.dart`, `view.dart` and `routing.dart` always, and `auth.dart`, `network.dart`, `cache.dart` and `logging.dart` unless skipped 2. Creates the starter service providers (`AppServiceProvider`, `RouteServiceProvider`), `lib/app/kernel.dart`, `lib/routes/app.dart` and a welcome view 3. Writes `lib/main.dart` with the Magic bootstrap 4. Creates `.env`, `.env.example` and `assets/lang/en.json` 5. Replaces the default `test/widget_test.dart` with a smoke test 6. Adds the `material_ui` and `wind_kit` dependencies, then has dowel scaffold `bin/dispatcher.dart` and `bin/dowel`, registers magic in `.dowel/plugins.json` and regenerates `lib/app/_plugins.g.dart` #### Existing `lib/main.dart` The default `flutter create` counter app is overwritten silently. Any other existing `lib/main.dart` prompts for overwrite, preserve, diff or cancel; with `--non-interactive` the command stops with an error instead. | Flag | Effect | |:---|:---| | `--force` | Overwrite without asking | | `--preserve` | Inject the Magic bootstrap into the existing `main()`; a synchronous `main()` is rejected with exit 1 | | `--dry-run` | Print the staged operations without writing | `--force` and `--preserve` together exit with code 2. #### Excluding Features You can exclude features you don't need with `--without-*` flags: ```bash dart run magic:dowel magic:install --without-auth --without-cache ``` | Flag | What it skips | |:---|:---| | `--without-auth` | Auth config, `VaultServiceProvider`, `AuthServiceProvider` | | `--without-network` | `config/network.dart`, `NetworkServiceProvider` | | `--without-cache` | `config/cache.dart`, `CacheServiceProvider` | | `--without-localization` | `LocalizationServiceProvider` | | `--without-logging` | `config/logging.dart` | The SQLite layer, encryption, the file picker and real-time broadcasting are separate plugin packages (`magic_local_storage`, `magic_encryption`, `magic_picker`, `magic_socket`), so the installer no longer carries flags for them. Add a plugin with `flutter pub add ` then `dart run magic:dowel plugin:install `. #### The `--with-devtools` flag The optional debug trio (`magic_devtools` + `tapir` + `crumbs`) gives you the LLM-agent E2E driver (Tapir) and the runtime inspector (Crumbs). Pass `--with-devtools` to wire all three in a single command instead of the manual multi-step bootstrap: ```bash dart run magic:dowel magic:install --with-devtools ``` When set, after the core install completes the command: 1. Adds `magic_devtools ^1.0.0`, `tapir ^1.0.0`, and `crumbs ^1.0.0` to `dependencies` (not `dev_dependencies`: `lib/main.dart` imports them, and the `!kReleaseMode` gate tree-shakes the subsystem out of release builds). 2. Wires the runtime setup into `lib/main.dart` under `!kReleaseMode` (debug and profile builds carry the tools, so a perf measurement runs on a profile build; release tree-shakes them): it imports `package:flutter/foundation.dart` (`show kReleaseMode`), `package:crumbs/crumbs.dart`, `package:magic_devtools/crumbs.dart`, `package:magic_devtools/magic_devtools.dart`, `package:magic_devtools/tapir.dart`, and `package:tapir/tapir.dart`; before `Magic.init(` it calls `TapirPlugin.install()`, `CrumbsPlugin.install()`, `CrumbsPlugin.registerWatcher(ExceptionWatcher())`, `CrumbsPlugin.registerWatcher(DumpWatcher())`, and `MagicPerfIntegration.install()`; after `Magic.init(` it calls `MagicCrumbsIntegration.install()` and `MagicTapirIntegration.install()`. The wiring is idempotent and decided by call presence: an app already calling `MagicDevtools.installPre(` is left untouched, and a block whose call (`TapirPlugin.install(`, `CrumbsPlugin.install(`, `MagicPerfIntegration.install(`, `MagicTapirIntegration.install(`, `MagicCrumbsIntegration.install(`) is already in `lib/main.dart` is not written again, so re-running `magic:install --with-devtools` or running it after hand wiring never duplicates a watcher, a block or a dependency entry. It writes `MagicPerfIntegration`, not the `MagicDevtools` umbrella (`MagicDevtools.installPre()` / `installPost()` is the alternative to this wiring: do not mix the two). Then finish the setup by hand: ```bash flutter pub get dart run magic:dowel plugin:install tapir dart run magic:dowel plugin:install crumbs dart run magic:dowel tapir:install dart run magic:dowel crumbs:install dart run magic:dowel mcp:install ``` `mcp:install` surfaces the `tapir_*` and `crumbs_*` MCP tools. `tapir:install` and `crumbs:install` write single-tool blocks only, never `MagicDevtools` or `MagicPerfIntegration`, and each skips a block whose `TapirPlugin.install(` or `CrumbsPlugin.install(` call is already in `lib/main.dart`, so running them after `--with-devtools` writes nothing twice. ## Make Commands All `make:*` commands support the `--force` flag to overwrite existing files. Nested paths are supported via slash syntax (e.g., `Admin/Dashboard`), which creates subdirectories automatically. Commands that auto-append a suffix (Controller, View, Policy, ServiceProvider, Request, Repository, FormObject) handle duplicates gracefully: `make:controller UserController` will not produce `UserControllerController`. `make:controller`, `make:view`, `make:repository`, `make:action`, `make:form` and `make:request` accept `--test`, which chains [make:test](#maketest) so the matching test lands in the same run. `--force` on the command forwards to the chained test, and a refused test write fails the command with exit 1. ### make:resource Scaffolds a full CRUD vertical for one model, magic's analogue of Laravel's `make:model --all`: ```bash dart run magic:dowel make:resource Monitor dart run magic:dowel make:resource Monitor --no-views dart run magic:dowel make:resource Monitor --no-model dart run magic:dowel make:resource Monitor --force ``` For `Monitor` it writes, each through its own generator: - the model, and its factory when `magic_local_storage` is installed (both kept when they already exist) - `MonitorRepository` - the `CreateMonitor`, `UpdateMonitor` and `DeleteMonitor` actions under `lib/app/actions/monitors/` - `StoreMonitorRequest` and `UpdateMonitorRequest` - `MonitorFormObject` (the `--resource` form) - `MonitorController` (`--resource --actions`) - the `MonitorsListView` and `MonitorFormView` views - tests for the actions, the form and the controller The run is all-or-nothing: every target path is checked first, and any clash other than a kept model or factory fails the run with nothing written unless `--force` is passed. The index and create route lines are printed for your `RouteServiceProvider.boot()`; the command never edits it. #### Options | Option | Description | |:---|:---| | `--no-views` | Stop after the data and write layers (no views, no printed routes) | | `--no-model` | Leave the model and factory out | ### make:model Creates an Eloquent-style model with optional related files: ```bash dart run magic:dowel make:model User dart run magic:dowel make:model Post --migration --controller --factory dart run magic:dowel make:model Comment -mcf dart run magic:dowel make:model Product -mcfsp dart run magic:dowel make:model Order --all ``` #### Options | Option | Shortcut | Description | |:---|:---|:---| | `--migration` | `-m` | Create a database migration (needs `magic_local_storage`) | | `--controller` | `-c` | Create a controller | | `--factory` | `-f` | Create a model factory (needs `magic_local_storage`) | | `--seeder` | `-s` | Create a database seeder (needs `magic_local_storage`) | | `--policy` | `-p` | Create an authorisation policy | | `--all` | `-a` | Create migration, seeder, factory, policy, repository, and resource controller | > [!NOTE] > The migration, factory and seeder generators ship with the `magic_local_storage` plugin. Without it, each of those flags prints a hint naming the package and the rest of the run carries on. > > The `-mcfsp` shorthand combines all five flags: migration, controller, factory, seeder, and policy. The `--all` flag also writes the model's repository and makes the controller a `--resource --model=` controller reading through it. For the write layer and views as well, use [make:resource](#makeresource). The model carries a static `fromMap(Map)` that hydrates it from raw API data. The command exits 1 when the model already exists and `--force` was not passed, without generating any companion. **Output:** `lib/app/models/.dart` ### make:controller Creates a `MagicController` with a `Magic.findOrPut` singleton accessor that implements `SessionScoped`: ```bash dart run magic:dowel make:controller User dart run magic:dowel make:controller UserController dart run magic:dowel make:controller Admin/Dashboard dart run magic:dowel make:controller Post --resource dart run magic:dowel make:controller Post --resource --model=Post --actions dart run magic:dowel make:controller Uptime --timers --test ``` A `--resource` controller owns the read state as one `RepositoryQuery` over `Repository`: it exposes `items`, `ensureFresh()` (for a view's `RefetchesOnMount`) and `reload()`, and its `resetForSession()` clears the repository and refetches. Writes belong on a `MagicAction`, not on the controller. #### Options | Option | Shortcut | Description | |:---|:---|:---| | `--resource` | `-r` | Own a `RepositoryQuery` over the model's repository | | `--model` | `-m` | The model a `--resource` controller reads (defaults to the controller's own name) | | `--actions` | | Mix in `RunsActions` | | `--timers` | | Mix in `OwnsTimers` | | `--validates` | | Mix in `ValidatesRequests` and `CollapsesIndexedErrorKeys` | | `--test` | | Also write the matching controller test | The mixins are always written in one fixed order, whatever order the flags were passed in. **Output:** `lib/app/controllers/_controller.dart` ### make:view Creates a view class: a `StatelessWidget` by default, or a `MagicStatefulView` bound to a controller: ```bash dart run magic:dowel make:view Login dart run magic:dowel make:view LoginView dart run magic:dowel make:view Auth/Register dart run magic:dowel make:view Dashboard --stateful dart run magic:dowel make:view Monitor --controller=Monitor dart run magic:dowel make:view Monitors/List --controller=Monitor --list --form=MonitorFormObject ``` #### Options | Option | Description | |:---|:---| | `--stateful` | Bind a `MagicStatefulView` to the controller derived from the view's name (`Dashboard` -> `DashboardController`), which has to exist | | `--controller` | Bind the view to this controller (suffix optional); implies `--stateful` | | `--list` | Add `RefetchesOnMount`; expects a `--resource` controller, which exposes `ensureFresh()` | | `--form` | Add a State-owned form object field (suffix optional), disposed in `onClose`; implies `--stateful` | | `--test` | Also write the matching view test | **Output:** `lib/resources/views/_view.dart` ### make:repository Creates a `Repository` subclass with a static `instance`, the row cache every screen reading the model shares: ```bash dart run magic:dowel make:repository Monitor dart run magic:dowel make:repository MonitorRepository --test ``` **Output:** `lib/app/repositories/_repository.dart` ### make:action Creates a `MagicAction`, the one place a write lives: ```bash dart run magic:dowel make:action PauseMonitor dart run magic:dowel make:action Monitors/PauseMonitor --test dart run magic:dowel make:action Monitors/CreateMonitor --kind=create --model=Monitor dart run magic:dowel make:action Monitors/UpdateMonitor --kind=update --model=Monitor dart run magic:dowel make:action Monitors/DeleteMonitor --kind=delete --model=Monitor ``` Without `--kind` the action is an empty `handle()` skeleton. The write kinds: - `create` fills and saves a new model from a validated field map. - `update` takes `({String id, Map fields})`, saves the edit and writes it into `Repository`, answering null when the id no longer resolves. - `delete` deletes the model and evicts it from `Repository`. A refused save throws `ActionRequestFailed` (a `ValidationException` when the model carries field errors). #### Options | Option | Description | |:---|:---| | `--kind` | `create`, `update` or `delete`; requires `--model` | | `--model` | The model the action writes | | `--test` | Also write the matching action test | **Output:** `lib/app/actions/.dart` ### make:form Creates a `MagicFormObject` (the `FormObject` suffix is appended; form widgets keep the `Form` name): ```bash dart run magic:dowel make:form Monitor dart run magic:dowel make:form Monitor --request=StoreMonitorRequest dart run magic:dowel make:form Monitor --resource=Monitor ``` `--resource=` writes the full create/edit contract: an `editing` field, `initial` seeded from `editing?.toArray()`, a `request` choosing the Store or Update request, and a `persist` running `Create` or `Update`. After a create it reloads the `--resource` `Controller`, so it expects the controller [make:resource](#makeresource) writes beside it. #### Options | Option | Description | |:---|:---| | `--request` | The `FormRequest` class the form validates against | | `--resource` | The model the form creates and edits | | `--test` | Also write the matching form test | **Output:** `lib/app/forms/_form_object.dart` ### make:test Creates a test skeleton mirroring where another generator writes its class, from `lib/` to `test/`: ```bash dart run magic:dowel make:test Monitor --kind=controller dart run magic:dowel make:test Monitors/PauseMonitor --kind=action dart run magic:dowel make:test Monitor --kind=repository --force ``` The test imports the class as `package:/...`, with `` read from the project's `pubspec.yaml`. #### Options | Option | Description | |:---|:---| | `--kind` | `controller`, `action`, `form`, `repository`, `request`, `view` or `unit` | **Output:** for example `test/app/controllers/_controller_test.dart` ### make:policy Creates an authorisation policy: ```bash dart run magic:dowel make:policy Post dart run magic:dowel make:policy PostPolicy dart run magic:dowel make:policy Post --model=Post dart run magic:dowel make:policy Admin/Dashboard ``` #### Options | Option | Shortcut | Description | |:---|:---|:---| | `--model` | `-m` | The model the policy applies to | **Output:** `lib/app/policies/_policy.dart` ### make:provider Creates a service provider class with `register()` and `boot()` stubs: ```bash dart run magic:dowel make:provider Payment dart run magic:dowel make:provider PaymentServiceProvider ``` The `ServiceProvider` suffix is appended automatically when omitted. **Output:** `lib/app/providers/_service_provider.dart` ### make:middleware Creates a middleware class: ```bash dart run magic:dowel make:middleware EnsureAuthenticated dart run magic:dowel make:middleware Admin/RoleCheck ``` **Output:** `lib/app/middleware/.dart` ### make:enum Creates a string-backed enum with a `value`, a `label` and a `fromValue()` factory: ```bash dart run magic:dowel make:enum MonitorType dart run magic:dowel make:enum Status/OrderStatus dart run magic:dowel make:enum IncidentSeverity --wire ``` `--wire` writes an enum mirroring a backend string value instead: an `unknown` fallback case, a `fromWire()` factory that never throws on an unrecognised value, and a `trans()`-backed `label` getter. **Output:** `lib/app/enums/.dart` ### make:event Creates a dispatchable event class that extends `MagicEvent`: ```bash dart run magic:dowel make:event UserLoggedIn dart run magic:dowel make:event Auth/TokenRefreshed ``` **Output:** `lib/app/events/.dart` ### make:listener Creates an event listener class that extends `MagicListener`: ```bash dart run magic:dowel make:listener AuthRestore dart run magic:dowel make:listener AuthRestore --event=UserLoggedInEvent dart run magic:dowel make:listener Auth/RestoreSession ``` #### Options | Option | Shortcut | Description | |:---|:---|:---| | `--event` | `-e` | The event class the listener handles (defaults to `MagicEvent`) | **Output:** `lib/app/listeners/.dart` ### make:request Creates a `FormRequest` subclass with a `const` constructor and a `rules()` override, which `MagicFormObject.request` and `ValidatesRequests.validateRequest` both accept: ```bash dart run magic:dowel make:request StoreMonitor dart run magic:dowel make:request StoreMonitorRequest --test ``` The `Request` suffix is appended automatically when omitted. **Output:** `lib/app/validation/requests/_request.dart` ### make:lang Creates a language JSON file: ```bash dart run magic:dowel make:lang tr dart run magic:dowel make:lang es dart run magic:dowel make:lang de --from=tr ``` `--from=` (default `en`) copies that file's key tree with each value verbatim, a complete catalogue for a translator; when it does not exist the new file is `{}`. **Output:** `assets/lang/.json` ### make:component Scaffolds an atomic component folder under `lib/ui/components//`, plus its widget test: ```bash dart run magic:dowel make:component Avatar dart run magic:dowel make:component Avatar --variants=intent,size dart run magic:dowel make:component Panel --slots dart run magic:dowel make:component Badge --no-preview ``` **Output** (for `Avatar`): - `lib/ui/components/avatar/avatar.dart` (`class Avatar`, unprefixed PascalCase) - `lib/ui/components/avatar/avatar.recipe.dart` (a `WindRecipe`, or a `WindSlotRecipe` under `--slots`, seeded with the requested `--variants` axes) - `lib/ui/components/avatar/index.dart` (re-exports the component + recipe, NOT the preview) - `test/ui/components/avatar/avatar_test.dart` (imports the barrel with a prefix, so a name like `Badge` stays unambiguous) - `lib/ui/components/avatar/avatar.preview.dart` (a single public `AvatarPreview` matrix), only when the project already keeps a preview catalogue The preview file, and the chained `previews:refresh` that lands it in `_previews.g.dart`, are written only when the project already has a `*.preview.dart` file or a `_previews.g.dart` index under `lib/`. `--preview` or `--no-preview` overrides that detection. #### Options - `--variants=a,b`: seed the named variant axes into the recipe (values left empty to fill in). - `--slots`: scaffold a multi-part `WindSlotRecipe` instead of a single-element `WindRecipe`. - `--preview` / `--no-preview`: force the preview file on or off. - `--force`: overwrite an existing component. ### previews:refresh Regenerates the dev-only preview catalog index from `*.preview.dart` files: ```bash dart run magic:dowel previews:refresh dart run magic:dowel previews:refresh --path=lib/ui/components ``` **Output:** `/_previews.g.dart` (default scan dir `lib`). Each `*.preview.dart` file must declare exactly ONE public `*Preview` class. The command validates the class name, fails fast on a slug collision, sorts deterministically, and writes atomically. The generated file returns a `List` from the `previewEntries()` function (never a top-level const list) so the catalog tree-shakes from release builds. Feed it to the catalog via `MagicPreview.register(previewEntries())` (from `magic_devtools`). #### Options - `--path=DIR`: directory to scan for `*.preview.dart` files (default `lib`). ### design:sync Generates the wind theme (semantic aliases + brand seed) from a `DESIGN.md`: ```bash dart run magic:dowel design:sync dart run magic:dowel design:sync --input=DESIGN.md --output=lib/config/wind_theme.g.dart ``` **Output:** a Dart source file exposing a `Map designAliases` and a `Map designColors`. The aliases map carries the property-prefixed semantic keys (`bg-surface`, `text-fg`, `border-color-border`, ...) with arbitrary-hex light + `dark:` pairs (`'bg-surface': 'bg-[#f9f9ff] dark:bg-[#0f1419]'`), drop-in for `WindThemeData(aliases: designAliases)`. The brand `primary` `MaterialColor` carries a generated 50-900 ramp (seeded from the DESIGN.md `primary` light hex) for `WindThemeData.toThemeData()` Material interop. The command is idempotent (byte-identical output on re-run for an unchanged DESIGN.md) and writes atomically via `.tmp` + rename. Do not hand-edit the generated file; re-run `design:sync` instead. #### Options - `--input=PATH`: path to the DESIGN.md source, relative to the project root (default `DESIGN.md`). - `--output=PATH`: path for the generated wind theme file (default `lib/config/wind_theme.g.dart`). ### design:lint Validates a `DESIGN.md` against the design rules: ```bash dart run magic:dowel design:lint dart run magic:dowel design:lint --input=DESIGN.md ``` The command exits nonzero only on an error-severity finding (a broken reference). Warnings and info notes are reported but do not fail the lint. Rules: - **broken-ref** (error): a component references a token (`{colors.x}`, `{rounded.x}`, `{spacing.x}`) that does not resolve. - **missing-primary** (warning): colours are defined but `primary` is absent. - **unknown-key** (warning): a top-level YAML key looks like a typo of a schema key. The single-file `dark:` overlay lives inside `colors`, so it is never flagged. - **section-order** (warning): markdown `##` sections appear out of canonical order. - **missing-sections** (info): the optional `spacing` / `rounded` groups are absent. - **orphaned-tokens** (warning): a custom colour token is defined but never referenced by any component (Material Design 3 baseline families are exempt). - **contrast-ratio** (warning): a component `backgroundColor` / `textColor` pair falls below the WCAG AA minimum of 4.5:1. #### Options - `--input=PATH`: path to the DESIGN.md to validate, relative to the project root (default `DESIGN.md`). ### DESIGN.md format `DESIGN.md` is the single source of truth for the app theme: a YAML front matter (machine-readable design tokens) followed by a markdown body (human-readable rationale; ignored by `design:sync`). ```markdown --- name: Acme colors: surface: light: "#f9f9ff" # single-file dark: overlay per role dark: "#0f1419" fg: light: "#151c27" dark: "#e6e9f0" primary: light: "#7c3aed" # the brand seed for the MaterialColor ramp dark: "#a78bfa" on-primary: "#ffffff" # a bare hex is light-only (dark mirrors light) typography: body-md: fontFamily: Plus Jakarta Sans fontSize: 16px rounded: md: 8px spacing: md: 16px components: button-primary: backgroundColor: "{colors.primary}" # {group.token} reference textColor: "{colors.on-primary}" rounded: "{rounded.md}" padding: "{spacing.md}" --- ## Overview ... ## Colors ... ``` Each colour role is either a bare hex scalar (light only; dark mirrors light) or a `{ light, dark }` overlay map. The semantic roles map onto the wind alias keys as: `surface`/`surface-container`/`surface-container-high` -> `bg-surface*`; `fg` (or `on-surface`)/`fg-muted`/`fg-disabled` -> `text-fg*`; `primary`/`on-primary`/`primary-container` -> `bg-primary` / `text-on-primary` / `bg-primary-container`; `accent` -> `bg-accent`; `border`/`border-subtle` -> `border-color-border*`; `destructive`/`on-destructive`/`destructive-container` -> `bg-destructive` / `text-on-destructive` / `bg-destructive-container`; `success`/`warning` -> `bg-success` / `bg-warning`. This is a wind-flavored superset of the open DESIGN.md format: the single-file `dark:` overlay is the deliberate divergence that lets one document drive both light and dark wind aliases. ## Related Documentation - [Installation](https://anilcancakir.com/magic/docs/getting-started/installation.md) - [Directory Structure](https://anilcancakir.com/magic/docs/getting-started/directory-structure.md) - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md) - [Repositories](https://anilcancakir.com/magic/docs/eloquent/repositories.md) - [Authorization](https://anilcancakir.com/magic/docs/security/authorization.md) --- # Magic DevTools Magic DevTools wires a magic app into the tapir and crumbs dev tools, and is loaded only under `!kReleaseMode` so release builds tree-shake it away entirely. - [Introduction](#introduction) - [What tapir and crumbs give you](#what-tapir-and-crumbs-give-you) - [Tapir: the E2E driver](#tapir-the-e2e-driver) - [Crumbs: the passive runtime inspector](#crumbs-the-passive-runtime-inspector) - [Installation](#installation) - [Why a regular dependency, not a dev dependency](#why-a-regular-dependency-not-a-dev-dependency) - [Wiring the integrations](#wiring-the-integrations) - [The install order](#the-install-order) - [Tapir](#tapir) - [Crumbs](#crumbs) - [Both together](#both-together) - [The kReleaseMode guard](#the-kreleasemode-guard) - [What the Magic watchers capture](#what-the-magic-watchers-capture) - [Magic watchers](#magic-watchers) - [The HTTP adapter](#the-http-adapter) - [Release builds load nothing](#release-builds-load-nothing) - [Related Documentation](#related-documentation) ## Introduction `magic_devtools` is the adapter layer that connects a magic app to two dev tools, the tapir and crumbs packages: `tapir` (an end-to-end driver for LLM agents and CI) and `crumbs` (a passive runtime inspector). It enriches what those tools observe with Magic-aware context: forms, navigation, controllers, gates, auth, broadcasting, and HTTP. Magic core has zero dependency on tapir or crumbs. The adapter lives in the separate `magic_devtools` package precisely so the framework carries no dev-tooling production dependency. You opt in by adding `magic_devtools`, and you wire it under `!kReleaseMode` so it never reaches a release build. This page covers what each tool gives you, how to install `magic_devtools`, the load-bearing install order, the `!kReleaseMode` guard that keeps release builds clean, and what the Magic watchers capture. ## What tapir and crumbs give you ### Tapir: the E2E driver `tapir` drives a running Flutter app for LLM agents and CI. It exposes the app's Semantics tree as addressable elements and supports tapping, typing, scrolling, and asserting against them. `MagicTapirIntegration` registers Magic-aware enrichers into tapir's snapshot pipeline. Each enricher annotates the snapshot with framework context the raw Semantics tree does not carry: the active route, the form field a text input is bound to, server-side validation errors, controller state, the most recent gate result, the active route's middlewares, the authenticated user, and the most recent HTTP, log and exception records (read from crumbs' `CrumbsStore`). An agent reading a snapshot sees your app the way Magic sees it, not just the way the accessibility tree does. ### Crumbs: the passive runtime inspector `crumbs` passively records runtime activity (HTTP requests, logs, exceptions, queries, and framework events) into in-memory ring buffers and serves them to agents. It does not change app behaviour; it observes. `MagicCrumbsIntegration` registers Magic watchers and an HTTP adapter into crumbs. The watchers subscribe to Magic's model, cache, event, gate, and query activity; the adapter feeds Magic's HTTP traffic into crumbs' HTTP buffer. It also assigns tapir's `pendingHttpCountReader`, `recentLogsReader` and `recentExceptionsReader`, so tapir's network-idle wait and its console and exceptions tools read crumbs' buffers. Crumbs is read-only: it captures and surfaces telemetry, it never writes back into your app. ## Installation From a project that already has magic installed, one command adds the three packages and wires `lib/main.dart`: ```bash dart run magic:dowel magic:install --with-devtools ``` See [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md#install) for exactly what it writes. To add the tooling by hand instead, put `magic_devtools` in your `pubspec.yaml`, plus whichever tooling packages you intend to use: ```yaml dependencies: magic_devtools: ^1.0.0 tapir: ^1.0.0 # add if you use tapir crumbs: ^1.0.0 # add if you use crumbs ``` `magic_devtools` depends on `magic`, `tapir`, and `crumbs` directly, so the tooling packages resolve through `magic_devtools` rather than transitively through `magic` itself. ### Why a regular dependency, not a dev dependency `magic_devtools` and the tooling packages go under `dependencies`, not `dev_dependencies`. There are two reasons: 1. You import these packages from `lib/main.dart` (under `!kReleaseMode`). Because `lib/` imports them, a `dev_dependencies` entry would trip the `depend_on_referenced_packages` lint. Anything imported from `lib/` must be a regular dependency. 2. The `!kReleaseMode` guard is what keeps these packages out of release builds, not the dependency category. `kReleaseMode` is a compile-time constant; the compiler tree-shakes every `if (!kReleaseMode) { ... }` branch out of a release build, so the imported code is dropped entirely. There is zero production cost even though the package is a regular dependency. This matches how `tapir` and `crumbs` are installed on their own. ## Wiring the integrations You wire both integrations in `lib/main.dart`, inside `!kReleaseMode` guards. The wiring splits into two phases around `Magic.init()`, and the order between those phases is load-bearing. ### The install order The plugin installs **before** `Magic.init()`, and the Magic integration installs **after** it: 1. **`TapirPlugin.install()` / `CrumbsPlugin.install()` run BEFORE `Magic.init()`.** This brings the snapshot pipeline (tapir) and the watcher store (crumbs) live during Magic boot, so crumbs' exception watcher catches errors thrown while Magic boots. 2. **`MagicTapirIntegration.install()` / `MagicCrumbsIntegration.install()` run AFTER `Magic.init()`.** The Magic integrations resolve Magic primitives through the IoC container: the tapir enrichers read `Magic.controllers`, `MagicRouter.instance`, `Gate.manager`, and `Auth.user()`; the crumbs adapter resolves the network driver from the container. Those bindings only exist once the service providers have booted, so the integration must run after `Magic.init()` completes. Calling either `install()` twice in the same isolate is a no-op after the first call; both are idempotent. ### Tapir ```dart import 'package:flutter/foundation.dart' show kReleaseMode; import 'package:magic/magic.dart'; import 'package:magic_devtools/tapir.dart'; import 'package:tapir/tapir.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); if (!kReleaseMode) { TapirPlugin.install(); } await Magic.init(configFactories: [...]); if (!kReleaseMode) { MagicTapirIntegration.install(); } runApp(MagicApplication()); } ``` ### Crumbs ```dart import 'package:crumbs/crumbs.dart'; import 'package:flutter/foundation.dart' show kReleaseMode; import 'package:magic/magic.dart'; import 'package:magic_devtools/crumbs.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); if (!kReleaseMode) { CrumbsPlugin.install(); CrumbsPlugin.registerWatcher(ExceptionWatcher()); // catches boot errors CrumbsPlugin.registerWatcher(DumpWatcher()); // captures debugPrint } await Magic.init(configFactories: [...]); if (!kReleaseMode) { MagicCrumbsIntegration.install(); } runApp(MagicApplication()); } ``` ### Both together You can wire either integration on its own, or both at once. The rule is the same either way: install each plugin before `Magic.init()`, and each Magic integration after it. ```dart import 'package:crumbs/crumbs.dart'; import 'package:flutter/foundation.dart' show kReleaseMode; import 'package:magic/magic.dart'; import 'package:magic_devtools/crumbs.dart'; import 'package:magic_devtools/magic_devtools.dart'; import 'package:magic_devtools/tapir.dart'; import 'package:tapir/tapir.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); if (!kReleaseMode) { TapirPlugin.install(); } if (!kReleaseMode) { CrumbsPlugin.install(); CrumbsPlugin.registerWatcher(ExceptionWatcher()); CrumbsPlugin.registerWatcher(DumpWatcher()); } if (!kReleaseMode) { MagicPerfIntegration.install(); } await Magic.init(configFactories: [...]); if (!kReleaseMode) { MagicCrumbsIntegration.install(); } if (!kReleaseMode) { MagicTapirIntegration.install(); } runApp(MagicApplication()); } ``` This is the shape `magic:install --with-devtools` writes, including `MagicPerfIntegration.install()` (the performance data path, installed before `Magic.init()`). The installer decides block by block from call presence: an app already calling `MagicDevtools.installPre(` is left untouched, and a block whose call (`TapirPlugin.install(`, `CrumbsPlugin.install(`, `MagicPerfIntegration.install(`, `MagicTapirIntegration.install(`, `MagicCrumbsIntegration.install(`) is already in `lib/main.dart` is not written again, so a re-run or hand wiring never duplicates a watcher. `tapir:install` and `crumbs:install` skip a block whose `TapirPlugin.install(` or `CrumbsPlugin.install(` call exists, so running them afterwards writes nothing twice. `MagicDevtools.installPre()` and `MagicDevtools.installPost()` from `package:magic_devtools/magic_devtools.dart` are the alternative: they collapse the blocks above into one call per phase, and `installPre()` also installs the performance data path. It registers the exception and dump watchers once, so a second call adds none. Do not mix the umbrella with the wiring above. ## The kReleaseMode guard Every devtools call sits inside `if (!kReleaseMode) { ... }`. This is the mechanism that keeps release builds clean while debug and profile builds carry the tools. `kReleaseMode` is a compile-time constant from `package:flutter/foundation.dart`: it is `true` only in release builds. Because the value is known at compile time, the compiler eliminates the entire `if (!kReleaseMode)` branch from a release build. The calls to `TapirPlugin.install()`, `MagicTapirIntegration.install()`, and their crumbs counterparts are dropped, and with them the imported `magic_devtools`, `tapir`, and `crumbs` code is tree-shaken out. Because the guard is `!kReleaseMode`, a profile build keeps the tools, which a performance measurement needs: run the measurement on a profile build. The guard is what makes the regular-dependency choice safe: the package ships in your dependency graph for resolution, but none of its code survives into a release build. ## What the Magic watchers capture `MagicCrumbsIntegration.install()` registers five watchers and one HTTP adapter into crumbs. ### Magic watchers | Watcher | Captures | |:---|:---| | `MagicModelWatcher` | Model lifecycle events: `ModelCreated`, `ModelSaved`, `ModelDeleted`. | | `MagicCacheWatcher` | Cache activity: `CacheHit`, `CacheMiss`, `CachePut`, `CacheForget` and `CacheFlush`. | | `MagicEventWatcher` | The curated set of magic auth, database, and gate-definition events. | | `MagicGateWatcher` | Authorization checks via `GateAccessChecked` (ability, result, user). | | `MagicQueryWatcher` | Database queries run through Magic's ORM (the `QueryExecuted` event). | Each watcher is read-only: it subscribes to Magic activity and records it into crumbs' store. None of them writes back into your app. ### The HTTP adapter `MagicHttpFacadeAdapter` wraps Magic's network driver with an interceptor that feeds every HTTP request and response into crumbs' HTTP buffer. The adapter resolves the network driver from the IoC container, which is why `MagicCrumbsIntegration.install()` must run after `Magic.init()`. ## Release builds load nothing There is no production load path. The integrations are reachable only through `if (!kReleaseMode)` branches in `main()`, and the compiler strips those branches from release builds. A release build loads none of `magic_devtools`, tapir, or crumbs: zero enrichers, zero watchers, zero adapters, zero runtime cost. The package is a regular dependency for resolution and lint correctness only; its code never runs in production. ## Related Documentation - [Magic CLI](https://anilcancakir.com/magic/docs/packages/magic-cli.md): `magic:install --with-devtools` and the `previews:refresh` catalog. - [Perf Hooks](https://anilcancakir.com/magic/docs/digging-deeper/perf-hooks.md): the performance data path `MagicPerfIntegration` feeds. - [Service Providers](https://anilcancakir.com/magic/docs/getting-started/service-providers.md): why the Magic integrations install after `Magic.init()`. - [Testing](https://anilcancakir.com/magic/docs/testing/getting-started.md): the in-process fakes for tests that do not drive a running app. --- # Contribution Guide This guide covers everything you need to contribute to the Magic framework: local setup, coding style, testing requirements, and the pull-request process. - [Introduction](#introduction) - [Development Setup](#development-setup) - [Project Structure](#project-structure) - [Coding Style](#coding-style) - [Testing](#testing) - [Pull Requests](#pull-requests) - [Documentation](#documentation) - [Questions](#questions) - [Related Documentation](#related-documentation) ## Introduction Thank you for considering contributing to Magic. This guide will help you get started with contributing to the framework. ## Development Setup ### Prerequisites - Dart SDK 3.13.0 or higher - Flutter 3.47.0 or higher - Git ### Clone the Repository ```bash git clone https://github.com/anilcancakir/magic.git cd magic ``` ### Install Dependencies ```bash flutter pub get ``` ### Run Tests ```bash flutter test ``` ## Project Structure ``` lib/ ├── src/ │ ├── actions/ # MagicAction and RunsActions │ ├── auth/ # Authentication system │ ├── cache/ # Caching │ ├── cli/ # magic:install and the make:* generators │ ├── concerns/ # ValidatesRequests │ ├── data/ # Repository and RepositoryQuery │ ├── database/ # Base Eloquent model, casts, LocalModelStore │ ├── events/ # Event system │ ├── facades/ # Facade classes │ ├── forms/ # MagicFormObject │ ├── foundation/ # Core Magic class │ ├── helpers/ # Date helpers │ ├── http/ # Controllers, middleware pipeline, Kernel │ ├── launch/ # URL, email, phone and SMS launching │ ├── localization/ # i18n │ ├── logging/ # Log manager and drivers │ ├── network/ # HTTP client │ ├── perf/ # Performance hooks │ ├── policies/ # Policy base class │ ├── providers/ # Service providers │ ├── routing/ # Router │ ├── security/ # Vault │ ├── session/ # Form flash and SessionScope │ ├── storage/ # File storage │ ├── support/ # Helpers & utilities │ ├── testing/ # Test fakes │ ├── ui/ # View system │ └── validation/ # Validation rules ├── cli.dart # Flutter-free dowel provider barrel ├── magic.dart # Barrel export └── testing.dart # Test fakes barrel ``` ### Key Files | File | Purpose | |:---|:---| | `lib/src/foundation/magic.dart` | Core Magic facade | | `lib/src/facades/*.dart` | Public API facades | | `lib/src/providers/*.dart` | Service providers | | `lib/magic.dart` | Public exports | ## Coding Style ### Dart Style Follow the [Dart Style Guide](https://dart.dev/effective-dart/style): ```dart // Use camelCase for variables and functions final userName = 'John'; void loadUsers() {} // Use PascalCase for classes class UserController {} // Use snake_case for file names // user_controller.dart ``` ### Documentation Document all public APIs using DartDoc: ```dart /// Retrieves a configuration value using dot notation. /// /// ```dart /// final name = Config.get('app.name', 'Default'); /// ``` /// /// Returns [defaultValue] if the key doesn't exist. static T? get(String key, [T? defaultValue]) { // ... } ``` ### Laravel-Style APIs When designing APIs, follow Laravel's patterns: ```dart // ✅ Good: Laravel-like fluent API MagicRoute.page('/users', () => UserController.instance.index()) .name('users.index') .middleware(['auth']); // ❌ Bad: Non-fluent API registerRoute('/users', 'users.index', UserController, 'index', ['auth']); ``` ## Testing ### Test Structure The `test/` tree mirrors `lib/src/`: a class in `lib/src/auth/` is tested in `test/auth/`. ### Writing Tests Write the failing test first, then the code that makes it pass. Every `setUp` resets the container and the facade caches, so state never leaks between tests. Mock by implementing the contract (or use the fakes in `package:magic/testing.dart`), not with a mocking package: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; void main() { setUp(() { MagicApp.reset(); Magic.flush(); }); group('ClassName', () { test('method does something', () { // Arrange final instance = ClassName(); // Act final result = instance.method(); // Assert expect(result, equals(expected)); }); }); } ``` ### Test Coverage Aim for high test coverage on new code: ```bash flutter test --coverage ``` ## Pull Requests ### Before Submitting 1. **Fork** the repository 2. **Create a branch** from `master` for your feature or fix (`feat/*`, `fix/*`, `chore/*` or `docs/*`) 3. **Write tests** for new functionality, failing test first 4. **Run all tests** to ensure nothing is broken 5. **Run the analyzer and formatter**: `flutter analyze --no-fatal-infos` and `dart format .` are clean 6. **Update documentation** and `CHANGELOG.md` (an entry under `[Unreleased]`) ### PR Template ```markdown ## What - ## Why - ## Testing - [ ] A failing test came first, and `flutter test` is green - [ ] `flutter analyze --no-fatal-infos` and `dart format .` are clean - [ ] `CHANGELOG.md` has an entry under `[Unreleased]` - [ ] README, `llms.txt`, `doc/`, the agent skill and `CLAUDE.md` match the change - [ ] `python3 tool/check-docs.py` reports 0 issues ``` ### Commit Messages Use clear, descriptive commit messages: ``` feat(auth): add token refresh functionality - Add refreshToken() method to Guard contract - Implement auto-refresh in AuthInterceptor - Update documentation ``` Prefixes: - `feat:` for a new feature - `fix:` for a bug fix - `docs:` for documentation only - `refactor:` for code refactoring - `test:` for adding tests - `chore:` for maintenance ## Documentation ### Updating Docs Documentation lives in the `doc/` directory: ``` doc/ ├── getting-started/ ├── architecture/ ├── basics/ ├── security/ ├── digging-deeper/ ├── eloquent/ ├── testing/ ├── packages/ └── contributing/ ``` ### Documentation Style Follow Laravel's documentation style: 1. Start with a one-sentence description, then a table of contents 2. Use anchor links for sections 3. Include code examples 4. Use GitHub-style alerts for important notes 5. Keep explanations concise The page template and the per-feature checklist are in the [Documentation Authoring Guide](https://anilcancakir.com/magic/docs/contributing/documentation.md). ### Checking Docs Check page shape, links and anchors from the repository root: ```bash python3 tool/check-docs.py ``` ## Questions If you have questions about contributing, open a GitHub issue. ## Related Documentation - [Documentation Authoring Guide](https://anilcancakir.com/magic/docs/contributing/documentation.md): the page template and the doc-sync checklist. - [Testing](https://anilcancakir.com/magic/docs/testing/getting-started.md): the test bootstrap and the facade fakes. - [Facades](https://anilcancakir.com/magic/docs/architecture/facades.md): the public API surface a change usually touches. --- # Documentation Authoring Guide This guide explains how to write, update, and maintain documentation pages in Magic's `doc/` directory so they stay accurate, consistently formatted, and correctly rendered on the docs site. - [Page Template](#page-template) - [Folder Taxonomy](#folder-taxonomy) - [Downstream Rendering](#downstream-rendering) - [API Accuracy Rule](#api-accuracy-rule) - [Per-Feature Doc-Sync Checklist](#per-feature-doc-sync-checklist) - [Code Samples](#code-samples) - [What Not to Add](#what-not-to-add) - [Related Documentation](#related-documentation) ## Page Template Every page (new and rewritten) must conform to this structure exactly: ``` # Page Title One-line description sentence (plain prose, feeds the downstream page description). - [Section One](#section-one) - [Section Two](#section-two) - [Related Documentation](#related-documentation) ## Section One Content. ## Section Two Content. ## Related Documentation - [Sibling Page](./sibling-page.md): what it covers. ``` Rules derived from this template: - **H1 title**: the bare `# Title` line, no YAML frontmatter above or below it. - **Lead sentence**: a single plain-prose sentence immediately after the H1, before the TOC bullets. The docs site extracts this paragraph as the page description; omitting it leaves the page with an empty description. - **Anchor-TOC**: a bulleted `- [Label](#anchor)` list before the first section. Every TOC entry must have a matching `` in the body, and every `` in the body must appear in the TOC. Mismatches break in-page navigation. - **Section anchors**: `` on the line immediately before the `##` or `###` heading. Use the same kebab-case slug in both the TOC link and the anchor. - **Related Documentation**: every page ends with a `## Related Documentation` section of relative `.md` links to sibling pages, each with a short description. - **No YAML frontmatter**: do not add `---` blocks, `title:`, `description:`, or any ordering metadata. The downstream site derives all of this from the page itself (see [Downstream Rendering](#downstream-rendering)). `python3 tool/check-docs.py` checks the page shape, the relative links, the fragments and the TOC-to-anchor match from the repository root, and must print 0 issues. ## Folder Taxonomy Pages live under `doc/` in one of these subdirectories. Do not create new subdirectories; place new pages in the closest existing folder. | Folder | Content | |:---|:---| | `getting-started/` | Installation, configuration, service providers, directory structure | | `architecture/` | Facades, service container, request lifecycle | | `basics/` | Routing, controllers, middleware, views, forms, HTTP client, UI helpers | | `security/` | Authentication, authorisation, vault | | `eloquent/` | Eloquent ORM getting started, mutators, serialisation | | `digging-deeper/` | Cache, events, validation, localisation, logging, file storage, launch, session, carbon, helpers | | `testing/` | Getting started with testing, HTTP tests, facade fakes | | `packages/` | The CLI, devtools (tapir and crumbs), and other first-party integrations | | `contributing/` | Contribution guide (code) and this authoring guide (docs) | ## Downstream Rendering The docs site ingests `doc/` at a release tag. It reads: - **H1** to set the page title. - **Lead paragraph** (the single sentence after H1, before the TOC) to set the page description. - **Directory name** to determine the section label. Navigation order and menu labels are curated on the site. The `doc/` directory needs no nav manifest, no ordering file, and no frontmatter. Adding any of these would have no effect on the rendered site. ## API Accuracy Rule Every documented method, signature, and parameter must exist in the current `lib/src/` code. This is the backbone of the overhaul that produced these docs, and it must stay the backbone going forward. Before writing or updating a code sample: 1. Open the relevant facade in `lib/src/facades/.dart` (or the concrete class under `lib/src/`) and read the actual method signatures. 2. Copy the method name and parameter list verbatim into the example. Do not invent parameters or default values that are not in the source. 3. If a method you expected to find is absent, do not document it. File a separate issue or add it to the framework first. Corollary: when a facade or class is renamed or a method is removed, update the matching doc page in the same change set. The `CLAUDE.md` Post-change sync rule (`doc/` item) mandates this; see the [Per-Feature Doc-Sync Checklist](#per-feature-doc-sync-checklist) below for the detailed steps. ## Per-Feature Doc-Sync Checklist When a facade or framework feature changes, apply this checklist before marking the work done. This checklist is the doc-layer expansion of the Post-change sync rule in `CLAUDE.md` (section "Post-change sync"). **1. Identify the matching doc page.** | Changed surface | Primary doc page | |:---|:---| | `Auth` facade | `doc/security/authentication.md` | | `Cache` facade | `doc/digging-deeper/cache.md` | | `Config` facade | `doc/getting-started/configuration.md` | | `Event` facade | `doc/digging-deeper/events.md` | | `Gate` facade / policies | `doc/security/authorization.md` | | `Http` facade / HTTP client | `doc/basics/http-client.md` | | `Lang` facade | `doc/digging-deeper/localization.md` | | `Launch` facade | `doc/digging-deeper/launch.md` | | `Log` facade | `doc/digging-deeper/logging.md` | | `Route` facade / routing | `doc/basics/routing.md` | | `Session` facade | `doc/digging-deeper/session.md` | | `Storage` facade | `doc/digging-deeper/file-storage.md` | | `Vault` facade | `doc/security/vault.md` | | Eloquent model / ORM | `doc/eloquent/getting-started.md` | | `Crypt`, `Echo`, `DB`, `Schema`, `Pick`, sync | The plugin's own docs (`magic_encryption`, `magic_socket`, `magic_local_storage`, `magic_picker`), never core `doc/` | | Service providers / container | `doc/architecture/service-container.md` and `doc/getting-started/service-providers.md` | | Middleware | `doc/basics/middleware.md` | | UI helpers (`MagicBuilder`, etc.) | `doc/basics/ui-helpers.md` | | Validation rules | `doc/digging-deeper/validation.md` | | Forms / `FormRequest` | `doc/basics/forms.md` | | Testing fakes | `doc/testing/facades.md` | | CLI commands | The CLI page under `doc/packages/` | | Tapir / crumbs integration | `doc/packages/magic-devtools.md` | **2. Update the page.** - Add, rename, or remove the section(s) that cover the changed surface. - For a renamed method: update every occurrence in prose, code samples, and the TOC. - For a removed method: delete its section and its TOC entry. - For a new method: add a section with a dart code example (see [Code Samples](#code-samples)), add a TOC entry, and add the matching `` anchor. **3. Verify the method set.** Open the facade source and compare it line by line against the page's documented methods. Confirm every documented method still exists with the same signature. Remove or correct any that drifted. **4. Check TOC-to-anchor consistency.** Every `- [Label](#anchor)` in the TOC must have a corresponding `` in the body. Every `` in the body must appear in the TOC. Fix any orphaned entry on either side. **5. For a brand-new feature.** Choose the correct folder from the [Folder Taxonomy](#folder-taxonomy), create the file, and write it according to the [Page Template](#page-template). H1, lead sentence, TOC, then sections with anchors. ## Code Samples Use fenced dart blocks for all Dart/Flutter code: ```` ```dart final value = Config.get('app.name', 'Magic'); ``` ```` Import the public barrel at the top of samples that need it: ```dart import 'package:magic/magic.dart'; ``` For test samples, use the testing barrel: ```dart import 'package:magic/testing.dart'; ``` For CLI-related samples, use the CLI barrel: ```dart import 'package:magic/cli.dart'; ``` Keep samples minimal: show the method call and its most common parameters. Omit boilerplate (`main()`, `MaterialApp`, widget scaffolding) unless the scaffolding is the point of the example. ## What Not to Add - **No YAML frontmatter.** The `---` block adds no downstream value and is explicitly excluded. - **No nav manifest or ordering file.** Navigation is curated on the docs site; a repo-side manifest would be ignored. - **No invented version attribution** such as "Added in v1.2". Use `CHANGELOG.md` and release notes for that. - **No `` live-preview tags.** Magic has no preview app configured. - **No documentation of a plugin's API.** `Crypt`, `Echo`, `DB`, `Schema`, `Pick` and sync live in the plugin packages; a core page links to the plugin's docs instead. - **No new subdirectories.** The folder taxonomy is locked; place pages in the closest existing folder. - **No em-dash or en-dash.** Use comma, colon, semicolon, period, or parentheses instead. ## Related Documentation - [Contribution Guide](https://anilcancakir.com/magic/docs/contributing/contribution-guide.md): setup, coding style and the pull-request process. - [Facades](https://anilcancakir.com/magic/docs/architecture/facades.md): the facade list the doc-sync checklist maps to pages.