diff --git a/skills/magic-framework/SKILL.md b/skills/magic-framework/SKILL.md index da6eaf7..40048d2 100644 --- a/skills/magic-framework/SKILL.md +++ b/skills/magic-framework/SKILL.md @@ -2,10 +2,10 @@ name: magic-framework description: "Write correct, idiomatic code in a Flutter app that depends on the `magic` framework (Laravel-inspired: IoC container, 18 facades, Eloquent-style ORM, service providers, reactive controllers, GoRouter routing, validation, auth, broadcasting). Use whenever code imports `package:magic/magic.dart` or `package:magic/testing.dart`, or the work touches Magic.init, MagicApp, a facade (Auth/Http/Cache/DB/Echo/Event/Gate/Config/Lang/Launch/Log/Pick/MagicRoute/Schema/Session/Storage/Vault/Crypt), a Model, MagicController, a MagicView, MagicFormData, FormRequest, a ServiceProvider, a migration, or the artisan make:* CLI. UI styling is Wind (separate wind-ui skill). Do NOT use for plain Flutter or Wind-only work with no magic import." when_to_use: "Use proactively when editing or scaffolding a magic app: Magic.init / a facade / a Model / a MagicController or MagicView / a form (MagicFormData, FormRequest, Validator) / a ServiceProvider / a route or MagicMiddleware / a migration / MagicStateMixin + RxStatus + fetchList / Session flash + old() + trans() / testing with MagicTest + Http.fake/Auth.fake / the artisan make:* CLI / the magic_deeplink, magic_notifications, magic_social_auth, magic_starter, magic_payments, or magic_devtools plugins. Trigger even when the user does not say the word 'magic'. Do NOT trigger for plain Flutter or Wind-only UI with no package:magic import." -version: 0.1.13 +version: 0.1.14 --- - + # Magic Framework @@ -364,6 +364,24 @@ Official plugins, each its own package + service provider + config. When a user | Subscriptions + billing (Stripe on web, store IAP on mobile) | `magic_payments` | `Payments` facade | `references/plugin-payments.md` | | E2E (dusk) + runtime inspection (telescope) + component previews | `magic_devtools` | `MagicDevtools`, `MagicPreview` | `references/plugin-devtools.md` | +### Installing a magic plugin into an existing app + +Same five steps for every plugin, in this order. Run them from the app root; `dart run magic:artisan ` delegates to the app's own `bin/dispatcher.dart` when there is one, which is what makes a plugin's commands reachable. + +1. `flutter pub add `. +2. `dart run magic:artisan plugin:install `. This registers the plugin's `ArtisanServiceProvider` in `.artisan/plugins.json` and regenerates `lib/app/_plugins.g.dart`, which is what makes the plugin's own commands dispatchable. Skip it and step 3 reports an unknown command. +3. `dart run magic:artisan :install` (`deeplink:install`, `notifications:install`, `starter:install`, `social:install`, ...). The manifest install: publishes the config file, injects the service provider into `lib/config/app.dart`, and adds the config factory to `lib/main.dart`. A plugin whose `install.yaml` declares a `bootstrap_command` (magic_starter does) has this chained for you by step 2; run it by hand when that subprocess reports a failure. +4. `dart run magic:artisan :doctor` where the plugin ships one: `notifications:doctor`, `starter:doctor`, and `deeplink:doctor` (magic_deeplink 0.1.0). It is the only step that tells you the install actually took; `dart run magic:artisan list` showing the plugin's commands is the fallback check. +5. Whatever the manifest cannot do, which the plugin's own installation guide names. This is where the real failures live: magic_deeplink needs the iOS associated-domains entitlement plus an Android `autoVerify` intent filter and a `flutter_deeplinking_enabled` `false` meta-data inside ``, and a plugin installed without them compiles and never fires. + +Provider ORDER in `lib/config/app.dart` is free for BINDINGS and load-bearing for everything else. Every `register()` runs before any `boot()` (`lib/src/foundation/application.dart:353`), so a plugin that resolves another plugin's binding in `boot()` finds it whichever order they sit in. But `boot()` itself is a sequential await over the list (`application.dart:378-381`), so anything a provider DOES in `boot()` is invisible to a provider that booted before it, and the failure is silent both ways: + +- A same-key overwrite, where the later-booting provider's `Gate.define()` or config value wins. +- A publish nobody is subscribed to yet. `magic_notifications` publishes a cold-start push tap from its `boot()`, and `magic_deeplink` subscribes in its own; with notifications first the tap went into a broadcast stream with no listener and was dropped, so the app opened on its initial route instead of the link's screen. Neither order errors, and artisan's installer appends each provider to the END of the list, so which one an app gets is decided by install order. +- `AppServiceProvider` before `AuthServiceProvider`, so `setUserFactory` lands before auth restore runs (see the top of this file). + +The plugins named above now buffer that tap, so that specific case is closed from `magic_notifications 0.3.0` and `magic_deeplink 0.1.0`. The shape is not: when a provider's `boot()` has to observe what another provider's `boot()` did, order it, do not assume it. + `magic_devtools` is a REGULAR dependency loaded under `kDebugMode` so it tree-shakes out of release builds. Two calls straddle the bootstrap: `MagicDevtools.installPre()` before `Magic.init()` (boots the dusk + telescope plugins and telescope's `ExceptionWatcher` + `DumpWatcher`), `MagicDevtools.installPost()` after it (wires `MagicTelescopeIntegration` + `MagicDuskIntegration`, which resolve through the container). Keep `kDebugMode` at the call site, never inside the methods, or the release tree-shake breaks. `dart run magic:artisan magic:install --with-devtools` wires all of it in one step. Use it to drive and inspect a running app when verifying your work. ## 12. Community: star and issue (optional, consent-first) diff --git a/skills/magic-framework/references/plugin-deeplink.md b/skills/magic-framework/references/plugin-deeplink.md index 81ecbbe..8c6d089 100644 --- a/skills/magic-framework/references/plugin-deeplink.md +++ b/skills/magic-framework/references/plugin-deeplink.md @@ -1,12 +1,13 @@ - + # magic_deeplink Plugin -Deep link handling plugin for Magic Framework: wraps `app_links` with a handler chain, IoC binding, and CLI tooling for generating server-side verification files. +Deep link handling plugin for Magic Framework: wraps `app_links` with a handler chain, IoC binding, and CLI tooling for generating the platform association files. Universal Links on iOS and macOS, App Links on Android. The web DRIVER is a deliberate no-op, which is not the same as no deep links on web: tapped push notifications still route, and address-bar links are GoRouter's half. See [AppLinksDriver](#applinksdriver). ## Contents - [Installation](#installation) +- [Platform setup](#platform-setup) - [DeeplinkManager API](#deeplinkmanager-api) - [Contracts](#contracts) - [Built-in Implementations](#built-in-implementations) @@ -20,6 +21,8 @@ Deep link handling plugin for Magic Framework: wraps `app_links` with a handler ## Installation ```bash +flutter pub add magic_deeplink + # Register the plugin's artisan provider with the app dispatcher (once) dart run magic:artisan plugin:install magic_deeplink @@ -27,13 +30,71 @@ dart run magic:artisan plugin:install magic_deeplink dart run magic:artisan deeplink:install ``` -The order matters: `deeplink:install` and `deeplink:generate` are contributed by `DeeplinkArtisanProvider`, and the dispatcher only knows about that provider after `plugin:install` has written it into `.artisan/plugins.json` and regenerated `lib/app/_plugins.g.dart`. Run the second command first and the dispatcher reports an unknown command. +The order matters: every `deeplink:*` command is contributed by `MagicDeeplinkArtisanProvider`, and the dispatcher only knows about that provider after `plugin:install` has written it into `.artisan/plugins.json` and regenerated `lib/app/_plugins.g.dart`. Run the second command first and the dispatcher reports an unknown command. + +**Know it worked**: `dart run magic:artisan list` lists the `deeplink:*` commands, and `dart run magic:artisan deeplink:doctor` reports on the config, the Dart wiring and both platforms' setup. Run it before reaching for a device; every way of getting this install wrong is silent, and the doctor is the only thing that separates "installed" from "installed and inert". + +`deeplink:install` scaffolds `lib/config/deeplink.dart`, injects `DeeplinkServiceProvider` into `lib/config/app.dart`, injects `deeplinkConfig` into `lib/main.dart`'s `configFactories`, and sets `FlutterDeepLinkingEnabled` to `false` in `ios/Runner/Info.plist`. Everything under [Platform setup](#platform-setup) that is not that plist key is manual. + +## Platform setup + +An operating system will not hand the app a link until the app proves it owns the domain, and Flutter's own deep link handler (on by default since Flutter 3.27) races the `app_links` driver this package wires in unless it is switched off. A plugin installed without these steps compiles and never fires. + +### iOS + +1. Add the Associated Domains capability in Xcode (Runner target, Signing & Capabilities), with an entry `applinks:`. It writes the entitlement: + + ```xml + com.apple.developer.associated-domains + + applinks:example.com + + ``` + +2. `ios/Runner/Info.plist` carries `FlutterDeepLinkingEnabled` `false`. `deeplink:install` applies this key; re-check it on a project that predates the manifest installer. + + ```xml + FlutterDeepLinkingEnabled + + ``` -`deeplink:install` scaffolds `lib/config/deeplink.dart`, injects `DeeplinkServiceProvider` into `lib/config/app.dart`, and injects `deeplinkConfig` into `lib/main.dart`. +3. Upload `apple-app-site-association` (from `deeplink:generate`) to `https:///.well-known/apple-app-site-association`, over HTTPS with no redirect. + +### Android + +1. Add an `autoVerify` intent filter inside the `.MainActivity` `` in `android/app/src/main/AndroidManifest.xml`, with a `` element for **both** `http` and `https` (Android requires both, even for an HTTPS-only site): + + ```xml + + + + + + + + + + + ``` + +2. Add the Flutter switch **inside that same ``**, not inside ``: + + ```xml + + ``` + + `deeplink:install` does NOT apply this one. Artisan's `XmlEditor` inserts `` into ``, and Flutter reads this key from ``, so automating it would write an entry Flutter never looks at. + +3. Upload `assetlinks.json` to `https:///.well-known/assetlinks.json`. Android verifies it at install time, not at click time. + +**Know it worked**: `xcrun simctl openurl booted "https://example.com/products/42"` on a booted simulator, `adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/products/42" ` on a device, and the registered handler runs. `adb shell pm get-app-links ` reports the domain verification state on Android. ## DeeplinkManager API -No facade — accessed via singleton `DeeplinkManager()` or IoC `Magic.make('deeplinks')`. +No facade. Reach it as the singleton `DeeplinkManager()` or through IoC as `Magic.make('deeplinks')`. | Method / Property | Signature | Description | |:------------------|:----------|:------------| @@ -42,29 +103,36 @@ No facade — accessed via singleton `DeeplinkManager()` or IoC `Magic.make` | Emit `uri` on `onLink` and delegate to first matching handler. Returns `true` if handled. | -| `getInitialLink()` | `Future` | Get the URI that cold-launched the app (cached after first call). | +| `handleUri(uri, {source, payload})` | `Future` | Emit `uri` on `onLink`, then delegate to the first matching handler. `source` is required. Returns `true` if a handler handled it. **Does not wait for a frame**: see below. | +| `getInitialLink()` | `Future` | The URI that cold-launched the app, cached after the first call. The provider does NOT call this; see [ServiceProvider](#serviceprovider). | | `onLink` | `Stream` | Broadcast stream of all incoming links (fired before handler dispatch). | -| `driver` | `DeeplinkDriver` | Getter — throws `DeeplinkException(code: 'NO_DRIVER')` if unset. | +| `driver` | `DeeplinkDriver` | Getter. Throws `DeeplinkException(code: 'NO_DRIVER')` if unset. | +| `reset()` | `void` | `@visibleForTesting`. Forgets handlers and driver, drops the cached initial link, and replaces the `onLink` controller. | + +Both in-package callers of `handleUri` wait for the first frame before routing, and it is the CALLER that waits, not the sink. Call `handleUri` or `getInitialLink` yourself during boot and you inherit none of that: magic's router cannot accept a navigation before anything is drawn, so the link goes nowhere and the app finishes booting onto its initial route, with no exception and no log. Measured on a device, that is exactly how the push path failed before it took the same wait. If you drive the chain by hand from boot: + +```dart +await WidgetsFlutterBinding.ensureInitialized().endOfFrame; +await manager.handleUri(uri, source: DeeplinkSource.manual); +``` + +`endOfFrame` rather than a post-frame callback, because it SCHEDULES a frame when the scheduler is idle; a post-frame callback on an application nobody is drawing waits for a frame that never comes. Capture it once if you route more than one link, or each await queues behind a different frame and the links can arrive out of order. ```dart import 'package:magic_deeplink/magic_deeplink.dart'; final manager = DeeplinkManager(); -// Register a custom handler manager.registerHandler(MyCustomHandler()); -// Listen to all links (raw stream, before handlers) -manager.onLink.listen((uri) { - print('Incoming link: $uri'); -}); +// Raw stream, before handlers. +manager.onLink.listen((uri) => Log.info('Incoming link', {'uri': '$uri'})); -// Get initial link (cold launch) -final initial = await manager.getInitialLink(); -if (initial != null) { - await manager.handleUri(initial); -} +// Drive the chain by hand (a debug button, a test). +await manager.handleUri( + Uri.parse('https://example.com/products/42'), + source: DeeplinkSource.manual, +); ``` ## Contracts @@ -76,31 +144,58 @@ Abstract contract for platform link providers. | Member | Type | Description | |:-------|:-----|:------------| | `name` | `String` | Driver identifier. | -| `isSupported` | `bool` | Whether this driver works on the current platform. | -| `initialize(config)` | `Future` | Boot the driver with config map. | -| `getInitialLink()` | `Future` | Return the cold-launch URI, if any. | -| `onLink` | `Stream` | Stream of subsequent incoming links. | +| `isSupported` | `bool` | Whether this driver works on the current platform. Read by the provider before anything is wired. | +| `initialize(config)` | `Future` | Boot the driver with the `deeplink` config map. | +| `getInitialLink()` | `Future` | The cold-launch URI, if any. Return `null` rather than throwing. | +| `onLink` | `Stream` | Stream of incoming links. | | `dispose()` | `void` | Release resources. | ### DeeplinkHandler -Abstract contract for URI handlers. Handlers are tested in registration order — first match wins. +Abstract contract for URI handlers. Handlers are tested in registration order, first match wins, and `handle` never throws. -| Member | Type | Description | -|:-------|:-----|:------------| -| `canHandle(uri)` | `bool` | Return `true` if this handler claims the URI. | -| `handle(uri)` | `Future` | Process the URI. Return `true` on success. | +```dart +abstract class DeeplinkHandler { + bool canHandle(Uri uri); + Future handle( + Uri uri, { + required DeeplinkSource source, + Map? payload, + }); +} +``` + +### DeeplinkSource ```dart -// Custom handler example -class PaymentHandler extends DeeplinkHandler { +enum DeeplinkSource { osLink, push, manual } +``` + +| Value | Meaning | `payload` | +|:------|:--------|:----------| +| `osLink` | The OS opened the app on a Universal Link or App Link. Attacker-craftable: anyone who can get the device to open a URI produces one. | Always `null`. | +| `push` | The user tapped a push notification, and the payload is the server's own. | The full push payload. | +| `manual` | The app asked for the link itself, in code or in a test. | Whatever the caller passed, or `null`. | + +`source` is required rather than defaulted on purpose. A handler that acts on more than the path (switching the active team off a `team_id` key, say) may only do that when `source == DeeplinkSource.push`, because that is the one case where the payload was authored by the server. A handler that forgot to ask would treat a crafted OS link exactly like a trusted push. + +```dart +class TeamInviteHandler extends DeeplinkHandler { @override - bool canHandle(Uri uri) => uri.path.startsWith('/payment'); + bool canHandle(Uri uri) => uri.path == '/invite'; @override - Future handle(Uri uri) async { - final orderId = uri.queryParameters['order_id']; - Route.to('/payment/confirm', extra: {'orderId': orderId}); + Future handle( + Uri uri, { + required DeeplinkSource source, + Map? payload, + }) async { + // Only a server-authored payload may switch the active team. + if (source == DeeplinkSource.push && payload?['team_id'] != null) { + await TeamController.instance.switchTeam(payload!['team_id']); + } + + MagicRoute.to('/invite', query: uri.queryParameters); return true; } } @@ -110,50 +205,68 @@ class PaymentHandler extends DeeplinkHandler { ### AppLinksDriver -Wraps the `app_links` package. Supports Android, iOS, and macOS. Not supported on web. +A conditional-export barrel over three arms, selected at compile time: -- **Driver name**: `'app_links'` -- **`isSupported`**: `true` on Android, iOS, macOS; `false` on web and other platforms. -- Registered automatically by `DeeplinkServiceProvider` when `deeplink.driver` is `'app_links'`. +```dart +export 'app_links_driver_stub.dart' + if (dart.library.js_interop) 'app_links_driver_web.dart' + if (dart.library.io) 'app_links_driver_io.dart'; +``` + +- **Driver name**: `'app_links'` on every arm. +- **io arm**: wraps the `app_links` package; `isSupported` is `Platform.isAndroid || Platform.isIOS || Platform.isMacOS`. It is the only arm that touches a platform channel. +- **web arm**: inert, not partial. `isSupported` is `false`, `getInitialLink()` is `null`, `onLink` is `const Stream.empty()`, `initialize`/`dispose` do nothing. It is NOT wired to `app_links_web`, which reads `location.href` once at boot and never reacts to later navigation, while GoRouter already owns the address bar. + + **An inert driver is not an inert feature, and reading only this line has sent people away from something that works.** The push bridge is wired OUTSIDE the `isSupported` gate, so on web a tapped OneSignal push with the tab open reaches the same handler chain as on mobile, through `OneSignalWebDriver`. Put the link in the notification's `additionalData` under `url`, `deep_link`, `link` or `uri`; a launch URL set on the OneSignal side alone is not read by the bridge. The address-bar half is GoRouter's and needs two things this package does not own and cannot check: `routing.url_strategy: 'path'`, and a host rewrite of unknown paths to `index.html`. Without either, the URL is a 404 or a hash route and the app never sees it. A push clicked with NO tab open runs no Dart at all, so it arrives as an ordinary page load and needs both. +- **stub arm**: the default when neither guard matches; same inert answers. + +The provider registers it automatically when `deeplink.driver` is `'app_links'` and `isSupported` is true. ### RouteDeeplinkHandler -Maps URI path patterns to `MagicRoute.to()` navigation. Patterns support wildcards (`*`) and named segments (`:param`). +Maps URI path patterns to navigation. Constructor: `RouteDeeplinkHandler({required List paths})`. ```dart -import 'package:magic_deeplink/magic_deeplink.dart'; - -// Match specific paths -final handler = RouteDeeplinkHandler( - paths: ['/products/:id', '/orders/*', '/promo/:code'], +DeeplinkManager().registerHandler( + RouteDeeplinkHandler(paths: ['/products/:id', '/orders/*', '/promo/:code']), ); -DeeplinkManager().registerHandler(handler); ``` -- Pattern `/products/:id` matches `/products/42` (`:param` matches one path segment). -- Pattern `/orders/*` matches `/orders/any/nested/path` (`*` matches everything). -- Matching is case-insensitive. -- On match, navigates to `uri.path` and passes `uri.queryParameters`. +- `:param` matches one path segment; `*` matches anything (it compiles to `.*`). +- Matching is case-insensitive, and a trailing slash is stripped before comparison. +- On match it calls `MagicRoute.to(uri.path, query: uri.queryParameters)` and returns `true`. +- It ignores `source` and `payload` deliberately: navigating to a path the consumer listed is safe whoever asked for it. ### OneSignalDeeplinkHandler -Auto-registered by `DeeplinkServiceProvider` when `magic_notifications` is bound in the IoC container. Extracts URIs from notification click payloads and feeds them into `DeeplinkManager.handleUri()`. +Not a `DeeplinkHandler`. It is a listener adapter that turns a tapped push into a `handleUri` call, wired automatically by `DeeplinkServiceProvider` when `'notifications'` is bound in the container. -Checks for URI under these payload keys in order: `'url'`, `'deep_link'`, `'link'`, `'uri'`. +```dart +void setup(DeeplinkManager manager, dynamic notifications) +void dispose() +Uri? extractUri(Map? data) +Map? extractData(dynamic event) +``` -No manual registration needed when using `magic_notifications`. +- It subscribes to the notification MANAGER's `onPushClicked` stream (owned from construction), never a push driver's, which is what makes it independent of provider order. +- `notifications` is read structurally as `dynamic`, so this package declares no dependency on `magic_notifications`. +- URI keys checked in order: `url`, `deep_link`, `link`, `uri`. First non-empty string that parses wins. +- The whole payload travels with the URI as `source: DeeplinkSource.push`. +- Failures (a manager with no `onPushClicked`, an event with no readable `data`, a throwing handler) are reported at error level through `Log`, guarded by `Magic.bound('log')`. + +`NotificationManager.onPushClicked` arrives in `magic_notifications` 0.1.0. This package declares no dependency on it, so nothing enforces that floor: pair it with an older release and you get the error-level report instead of a routed link. ## Configuration -Scaffolded to `lib/config/deeplink.dart` by `dart run magic:artisan deeplink:install`. The `ios` and `android` sub-keys are only read by `dart run magic:artisan deeplink:generate` — they are not used at runtime. +Scaffolded to `lib/config/deeplink.dart` by `deeplink:install`. The `ios` and `android` sub-keys are read by `deeplink:generate` only; they are not used at runtime. ```dart Map get deeplinkConfig => { 'deeplink': { 'enabled': true, - 'driver': 'app_links', // Only built-in driver - 'domain': 'example.com', // Your web domain for universal/app links - 'scheme': 'https', // 'https' or custom scheme + 'driver': 'app_links', // only built-in driver + 'domain': 'example.com', // your Universal Link / App Link domain + 'scheme': 'https', 'ios': { 'team_id': 'YOUR_TEAM_ID', // Apple Developer Team ID @@ -163,37 +276,43 @@ Map get deeplinkConfig => { 'android': { 'package_name': 'com.example.app', 'sha256_fingerprints': [ - 'YOUR_SHA256_FINGERPRINT', // Colon-separated hex string + 'YOUR_SHA256_FINGERPRINT', // keystore SHA-256, colon-separated ], }, 'paths': [ - '/*', // Patterns passed to generate command + '/*', // patterns passed to the generate command ], }, }; ``` +`deeplink.enabled` is honoured by the provider: an ABSENT key means enabled, and only an explicit `false` wires nothing. + ## ServiceProvider -`DeeplinkServiceProvider` is **NOT auto-registered** — add it explicitly or use `dart run magic:artisan deeplink:install` which does this automatically. +`DeeplinkServiceProvider` is **NOT auto-registered**; `deeplink:install` injects it into `lib/config/app.dart`. -**register()**: Binds `DeeplinkManager()` as a singleton under key `'deeplinks'`. +```dart +DeeplinkServiceProvider(super.app, {DeeplinkDriver Function()? driverFactory}) +``` -**boot()**: Resolves driver from `deeplink.driver` config → initializes it → subscribes driver's `onLink` stream to `manager.handleUri()` → schedules `getInitialLink()` via `Future.delayed(Duration.zero)` (defers until after first frame so router is ready). Also auto-registers `OneSignalDeeplinkHandler` if `'notifications'` is bound. +**register()**: binds `DeeplinkManager()` as a singleton under the key `'deeplinks'`. -```dart -// lib/config/app.dart -import 'package:magic_deeplink/magic_deeplink.dart'; +**boot()**, in order: -final appConfig = { - 'app': { - 'providers': [ - (app) => DeeplinkServiceProvider(app), - ], - }, -}; -``` +1. Returns immediately when `deeplink.enabled` is explicitly `false`. +2. Builds the driver through `driverFactory` (default `AppLinksDriver.new`) when `deeplink.driver` is `'app_links'`. +3. Wires it only when `driver.isSupported`: sets it on the manager and awaits `driver.initialize(config)`. An unsupported platform leaves `manager.driver` unset. +4. Subscribes to `driver.onLink` as the ONE delivery path. `manager.getInitialLink()` is not called: `app_links` serves the cold-start link on the stream too, and reading both ran the whole handler chain twice per tap. +5. Defers each delivery until `WidgetsFlutterBinding.ensureInitialized().endOfFrame`, captured once at boot, because `MagicRoute.to` throws until `MagicApp` has built the router. `endOfFrame` schedules a frame when the scheduler is idle, so a link handed to an app nobody is drawing still lands. A routing failure is reported through `Log.error`, not swallowed and not left to escape as an unhandled async error. +6. Wires `OneSignalDeeplinkHandler` when `app.bound('notifications')`, guarded: a throw here would abort app boot and every provider after it, over an optional plugin. + +**dispose()**: idempotent provider-level teardown. Disposes the push-click handler, cancels the link subscription, disposes the driver, and calls `manager.forgetDriver()`. A teardown landing inside `boot`'s `await` is covered by an internal flag. + +`driverFactory` exists for tests: the real driver answers `isSupported` from the host platform and takes its stream from `app_links`, so neither the gate nor the delivery path can be exercised through it. + +`boot()` does NOT register a `RouteDeeplinkHandler`; it has no way to know which paths the app claims. Register one yourself (see below). ## CLI Commands @@ -201,11 +320,10 @@ final appConfig = { ```bash dart run magic:artisan deeplink:install -dart run magic:artisan deeplink:install --force # Overwrite existing config +dart run magic:artisan deeplink:install --force # overwrite lib/config/deeplink.dart +dart run magic:artisan deeplink:install --dry-run # preview, write nothing ``` -Writes `lib/config/deeplink.dart`, injects `DeeplinkServiceProvider` into `lib/config/app.dart`, and injects `deeplinkConfig` factory into `lib/main.dart`. - ### generate ```bash @@ -218,17 +336,27 @@ dart run magic:artisan deeplink:generate \ --output public ``` -Reads values from `lib/config/deeplink.dart` and merges with any CLI flags (flags take priority). Outputs: -- `apple-app-site-association` — iOS Universal Links verification file. -- `assetlinks.json` — Android App Links verification file. +Reads `lib/config/deeplink.dart` and merges CLI flags over it (flags win). Options: `--output` (default `public`), `--root` (default `.`), `--team-id`, `--bundle-id`, `--package-name`, plus the multi-value `--sha256-fingerprints` and `--paths` (default `['/*']`). Outputs: + +- `apple-app-site-association`, in Apple's modern `appIDs` + `components` shape (TN3155), no `apps` key. Written only when both `--team-id` and `--bundle-id` resolve; otherwise the command WARNS and continues. +- `assetlinks.json`. Written only when both `--package-name` and `--sha256-fingerprints` resolve, same warning otherwise. + +### doctor -Upload both files to your web server's `/.well-known/` directory (or domain root for AASA). +```bash +dart run magic:artisan deeplink:doctor +dart run magic:artisan deeplink:doctor --verbose +dart run magic:artisan deeplink:doctor --remote # also fetch both files from the live domain +``` + +Ships in 0.1.0, the release this file is stamped for. It reads `lib/config/deeplink.dart` (rejecting the scaffold placeholders `example.com`, `YOUR_TEAM_ID`, `com.example.app`, `YOUR_SHA256_FINGERPRINT`), then checks iOS (`applinks:` entitlement host, `FlutterDeepLinkingEnabled`), Android (the manifest's element TREE, so a `flutter_deeplinking_enabled` meta-data sitting on `` instead of `` is caught where a grep cannot see it, plus the `autoVerify` filter's `http`/`https` schemes and host) and the two generated association files against the config. Everything is local and read-only without `--remote`. The one thing it cannot prove is that a real device matches an incoming link to this app, and the report says so. ## Usage Patterns -### Basic setup with route handler +### Registering the route handler ```dart +import 'package:magic/magic.dart'; import 'package:magic_deeplink/magic_deeplink.dart'; class AppServiceProvider extends ServiceProvider { @@ -237,41 +365,28 @@ class AppServiceProvider extends ServiceProvider { @override Future boot() async { - final manager = DeeplinkManager(); - - // Handle all paths defined in config - manager.registerHandler( - RouteDeeplinkHandler(paths: ['/products/:id', '/orders/:id', '/*']), + DeeplinkManager().registerHandler( + RouteDeeplinkHandler( + paths: app.make('config').get('deeplink.paths'), + ), ); } } ``` -### Custom handler with specific logic +### Specific handler before the catch-all ```dart -class InviteHandler extends DeeplinkHandler { - @override - bool canHandle(Uri uri) => uri.path == '/invite' && uri.queryParameters.containsKey('code'); - - @override - Future handle(Uri uri) async { - final code = uri.queryParameters['code']!; - await Magic.make('invites').redeem(code); - Route.to('/welcome'); - return true; - } -} +final manager = DeeplinkManager(); -// Register specific handlers before catch-all -manager.registerHandler(InviteHandler()); -manager.registerHandler(RouteDeeplinkHandler(paths: ['/*'])); // Catch-all last +manager.registerHandler(TeamInviteHandler()); // specific first +manager.registerHandler(RouteDeeplinkHandler(paths: ['/*'])); // catch-all last ``` -### Listening to all links without intercepting +### Listening without intercepting ```dart -// Subscribe to raw link stream — does not affect handler chain +// Raw stream: does not affect the handler chain. DeeplinkManager().onLink.listen((uri) { Log.info('Deep link received', {'uri': uri.toString()}); }); @@ -282,30 +397,38 @@ DeeplinkManager().onLink.listen((uri) { ```dart setUp(() { MagicApp.reset(); - Magic.flush(); - DeeplinkManager().forgetHandlers(); - DeeplinkManager().forgetDriver(); + DeeplinkManager().reset(); // handlers, driver, cached initial link, onLink controller }); +tearDown(() => DeeplinkManager().reset()); + test('handles product deep link', () async { - final handler = RouteDeeplinkHandler(paths: ['/products/:id']); - DeeplinkManager().registerHandler(handler); + DeeplinkManager().registerHandler(RouteDeeplinkHandler(paths: ['/products/:id'])); final handled = await DeeplinkManager().handleUri( Uri.parse('https://example.com/products/42'), + source: DeeplinkSource.osLink, ); + expect(handled, isTrue); }); ``` +Inject a fake driver to exercise the provider: `DeeplinkServiceProvider(app, driverFactory: () => FakeDeeplinkDriver())`. + ## Gotchas | Mistake | Fix | |:--------|:----| -| Accessing `DeeplinkManager().driver` before provider boots | `DeeplinkServiceProvider` sets the driver in `boot()` — accessing `driver` before that throws `DeeplinkException(code: 'NO_DRIVER')` | -| Initial link never handled | `getInitialLink()` is deferred via `Future.delayed(Duration.zero)` — router must be initialized before it fires | -| Catch-all handler registered first | Handler chain is first-match-wins — register specific handlers before `RouteDeeplinkHandler(paths: ['/*'])` | -| `OneSignalDeeplinkHandler` not activating | Requires `'notifications'` to be bound in IoC before `DeeplinkServiceProvider.boot()` runs — ensure provider order in `app.dart` | -| `forgetHandlers()` skipped in tests | Always call `DeeplinkManager().forgetHandlers()` + `forgetDriver()` in `setUp()` — manager is a singleton | -| `generate` command missing iOS output | Requires both `--team-id` and `--bundle-id` — skips AASA silently if either is missing | -| `RouteDeeplinkHandler` `:param` not matching | `:param` matches a single path segment only — use `*` for multi-segment patterns | +| Plugin installed, nothing ever fires | The [platform setup](#platform-setup) is the usual cause: no associated-domains entitlement, no `autoVerify` intent filter, or Flutter's own deep linking still on. Every one of those fails silently (the link just opens the browser), so run `deeplink:doctor` rather than reading the manifest by eye. | +| `handle(Uri uri)` with no `source` | The contract is `handle(uri, {required DeeplinkSource source, Map? payload})`. A pre-0.1.0 handler does not compile. | +| Navigating with the bare `Route` facade | The facade is `MagicRoute`; unqualified `Route` resolves to Flutter's own `Route`. The signature is `MagicRoute.to(String path, {Map? query})`; there is no `extra` parameter. | +| Acting on `payload` regardless of `source` | Only `DeeplinkSource.push` carries a server-authored payload. An `osLink` URI is attacker-craftable and carries none. | +| Accessing `DeeplinkManager().driver` before boot, or on web | The provider sets the driver in `boot()`, and only when `isSupported`. Otherwise the getter throws `DeeplinkException(code: 'NO_DRIVER')`. | +| Expecting `getInitialLink()` to be called for you | The provider delivers the cold-start link off `driver.onLink` and never calls it. Call it yourself only if you want to ask directly. | +| Deep links dead after setting `deeplink.enabled: false` | An explicit `false` wires nothing at all: no driver, no subscription, no push bridge. An absent key means enabled. | +| Catch-all handler registered first | First match wins. Register specific handlers before `RouteDeeplinkHandler(paths: ['/*'])`. | +| Worrying about provider order for the push bridge | It does not matter, PROVIDED `magic_notifications` buffers cold-start clicks (its reference says whether yours does). Boot runs after every provider has registered (`magic/lib/src/foundation/application.dart:353`), and the bridge subscribes to the notification manager's own click stream. That stream is a broadcast one, and a broadcast stream drops what it publishes to nobody: the tap that COLD-STARTS the app is drained during `driver.initialize()`, so a build where notifications boots first, and whose manager does not buffer, loses it with no exception and no log. On such a build, list `DeeplinkServiceProvider` before `NotificationServiceProvider`. Note the installer appends each provider to the END of the list, so the order you get is the order you installed in. | +| `reset()` skipped in tests | `DeeplinkManager` is a singleton that outlives the container; a stale cached initial link or handler leaks into the next test. | +| `generate` produced only one file | It warns and continues: AASA needs `--team-id` plus `--bundle-id`, `assetlinks.json` needs `--package-name` plus `--sha256-fingerprints`. | +| `:param` not matching a nested path | `:param` matches a single segment only. Use `*` for multi-segment patterns. | diff --git a/skills/magic-framework/references/plugin-notifications.md b/skills/magic-framework/references/plugin-notifications.md index 0c6fa6f..30d945d 100644 --- a/skills/magic-framework/references/plugin-notifications.md +++ b/skills/magic-framework/references/plugin-notifications.md @@ -1,8 +1,8 @@ - + # magic_notifications Plugin -Push and in-app notification system for Magic Framework: the `Notify` facade, database (in-app) notifications with real-time streaming, OneSignal push integration, and two ways to learn about a new row: a broadcast socket (preferred, 0.0.3+) or background polling (the fallback). +Push and in-app notification system for Magic Framework: the `Notify` facade, database (in-app) notifications with real-time streaming, OneSignal push integration, the notification UI (bell, list, preference matrix), and two ways to learn about a new row: a broadcast socket (preferred, 0.0.3+) or background polling (the fallback). ## Contents @@ -13,6 +13,7 @@ Push and in-app notification system for Magic Framework: the `Notify` facade, da - [Channels](#channels) - [PushDriver](#pushdriver) - [Models](#models) +- [UI: views, controllers, registry](#ui-views-controllers-registry) - [Configuration](#configuration) - [Service Provider Setup](#service-provider-setup) - [Usage Patterns](#usage-patterns) @@ -21,13 +22,31 @@ Push and in-app notification system for Magic Framework: the `Notify` facade, da ## Installation ```bash +flutter pub add magic_notifications + # Register the plugin's artisan provider with the app dispatcher (once) dart run magic:artisan plugin:install magic_notifications # Scaffold lib/config/notifications.dart, inject the provider, wire the config factory dart run magic:artisan notifications:install + +# Confirm the install +dart run magic:artisan notifications:doctor ``` +Requires `magic ^0.0.6` (for `Echo.connection`, the accessor the realtime path needs to tell an open connection from a closed one). + +### Two iOS pieces no command can install + +Push works without both of these, which is why they are easy to miss and why `notifications:doctor` warns about them: a build with no Notification Service Extension delivers notifications normally and quietly reports no confirmed deliveries, no rich media and no badge counts. The absence looks like the product working. + +1. An **App Group** on the Runner target, named `group..onesignal`. +2. A **Notification Service Extension** target carrying that same App Group. + +Both add or change an Xcode target, which a pub package cannot do. The steps are in the package's `doc/getting-started/installation.md`. They fail independently: an extension with no shared group gives rich media and still no confirmed delivery, because the container is how the extension hands what it saw back to the app. + +**Testing the cold-start path needs a profile or release build.** iOS refuses to launch a debug Flutter build from a link or the home screen, and OneSignal documents that on iOS in Debug a force-closed app opened from a notification never registers the click listener. A cold tap that appears to do nothing in Debug is usually this rather than the wiring. + ## CLI commands and MCP tools Seven commands, all through the app's artisan dispatcher: @@ -58,32 +77,35 @@ All methods are accessed via the static `Notify` facade after importing `package | Method | Parameters | Return Type | Description | |:-------|:-----------|:------------|:------------| -| `Notify.notifications()` | — | `Stream>` | Broadcast stream — emits current cache immediately, then re-emits on every fetch/read/delete. | -| `Notify.fetchNotifications()` | — | `Future` | Fetch from `GET /notifications` and push updated list to stream. | -| `Notify.refreshNotifications()` | — | `Future` | Alias for `fetchNotifications()`. | -| `Notify.fetchPaginatedNotifications({page, perPage})` | `int page = 1`, `int perPage = 15` | `Future` | Returns paginated response with meta (current_page, last_page, total). | -| `Notify.unreadCount()` | — | `Future` | Fetch unread count from `GET /notifications/unread-count`. | +| `Notify.notifications()` | none | `Stream>` | Broadcast stream: emits current cache immediately, then re-emits on every fetch/read/delete. | +| `Notify.fetchNotifications()` | none | `Future` | Fetch from `GET /notifications` and push updated list to stream. | +| `Notify.refreshNotifications()` | none | `Future` | Alias for `fetchNotifications()`. | +| `Notify.fetchPaginatedNotifications({page, perPage})` | `int page = 1`, `int perPage = 15` | `Future` | Paginated response with meta (current_page, last_page, total). **Throws `NotificationException` on a failed read** (0.1.0+); it does not answer an empty page, which a caller cannot tell from an empty inbox. | +| `Notify.unreadCount()` | none | `Future` | Fetch unread count from `GET /notifications/unread-count`. | | `Notify.markAsRead(id)` | `String id` | `Future` | Optimistically mark read locally, then `POST /notifications/{id}/read`. Reverts on failure. | -| `Notify.markAllAsRead()` | — | `Future` | Optimistically mark all read locally, then `POST /notifications/read-all`. Reverts on failure. | -| `Notify.deleteNotification(id)` | `String id` | `Future` | Optimistically remove locally, then `DELETE /notifications/{id}`. Reverts on failure. | +| `Notify.markAllAsRead()` | none | `Future` | Optimistically mark all read locally, then `POST /notifications/read-all`. Reverts on failure. | +| `Notify.deleteNotification(id)` | `String id` | `Future` | Optimistically remove locally, then `DELETE /notifications/{id}`. **Rolls the row back and rethrows on failure** (0.1.0+), so a caller can tell a delete that worked from one that did not. | ### Push Notifications | Method | Parameters | Return Type | Description | |:-------|:-----------|:------------|:------------| -| `Notify.initializePush(userId)` | `String userId` | `Future` | Associate logged-in user with push device. Call after `Auth.login()`. | -| `Notify.requestPushPermission()` | — | `Future` | Show system permission dialog. Returns `true` if granted. | -| `Notify.logoutPush()` | — | `Future` | Unlink device from user account. Call before `Auth.logout()`. | +| `Notify.initializePush(userId)` | `String userId` | `Future` | Record the intent to be subscribed as `userId`, then reconcile it against the driver. Call after `Auth.login()`. A build with no push driver is a supported state: it no longer throws (0.1.0+). | +| `Notify.requestPushPermission()` | none | `Future` | Show system permission dialog. Returns `true` if granted. | +| `Notify.logoutPush()` | none | `Future` | Drop the cached rows, clear the intent, unlink the device. Call before `Auth.logout()`. | +| `Notify.describePushUserUsing(resolver)` | `PushUserAttributesResolver?` | `void` | Register once how the app describes whoever signs in (email + tags). Nothing is sent until `notifications.push.share_user_attributes` is on, and it ships OFF. | +| `Notify.extend(name, factory)` | `String`, `PushDriver Function()` | `void` | Register a push driver under a name; the config's `push.driver` picks one. | +| `Notify.forgetDrivers()` | none | `void` | Drop every channel, registered driver and resolved instance. The test-isolation seam. | ### Polling | Method | Parameters | Return Type | Description | |:-------|:-----------|:------------|:------------| -| `Notify.startPolling()` | — | `void` | Start 30-second polling. Fetches immediately on start. Idempotent. **No-op while realtime is live**, so it is safe to wire next to `startRealtime()` as the fallback. | -| `Notify.stopPolling()` | — | `void` | Stop polling and destroy timer. Call on logout. | -| `Notify.pausePolling()` | — | `void` | Pause (timer keeps running, fetches are skipped). Use on app background. | -| `Notify.resumePolling()` | — | `void` | Resume paused polling. Fetches immediately on resume. | -| `Notify.isPolling` | — | `bool` | Whether the periodic timer is currently armed. | +| `Notify.startPolling()` | none | `void` | Start 30-second polling. Fetches immediately on start. Idempotent. **No-op while realtime is live**, so it is safe to wire next to `startRealtime()` as the fallback. | +| `Notify.stopPolling()` | none | `void` | Stop polling and destroy timer. Call on logout. | +| `Notify.pausePolling()` | none | `void` | Pause (timer keeps running, fetches are skipped). Use on app background. | +| `Notify.resumePolling()` | none | `void` | Resume paused polling. Fetches immediately on resume. | +| `Notify.isPolling` | none | `bool` | Whether the periodic timer is currently armed. | ### Realtime (0.0.3+) @@ -92,8 +114,8 @@ Notification state can arrive over the app's broadcast socket instead of being p | Method | Parameters | Return Type | Description | |:-------|:-----------|:------------|:------------| | `Notify.startRealtime()` | `{String? channel, String event = 'notification.created'}` | `Future` | Subscribe to the notifiable's private channel and apply each frame to the cache. Returns `false` (changing nothing) when the app has no broadcast driver, so the caller keeps polling. | -| `Notify.stopRealtime()` | — | `void` | Leave the channel and drop the connection watcher. Does NOT close the connection (it is shared) and does NOT restart polling. | -| `Notify.isRealtime` | — | `bool` | Whether state is currently arriving over a socket. | +| `Notify.stopRealtime()` | none | `void` | Leave the channel and drop the connection watcher. Does NOT close the connection (it is shared) and does NOT restart polling. | +| `Notify.isRealtime` | none | `bool` | Whether state is currently arriving over a socket. | `channel` has to come from the caller: this package has no user model and cannot know whose notifications these are. Laravel's default for a `Notifiable` that has not overridden `receivesBroadcastNotificationsOn()` is `App.Models.User.{id}`. @@ -166,7 +188,7 @@ class User extends Model with Notifiable { | `notifiableEmail` | `String?` | Optional. Used by mail channel. Defaults to `null`. | | `pushExternalId` | `String` | Push targeting ID. Defaults to `notifiableId`. | | `notificationPreference` | `dynamic` | Optional `NotificationPreference` instance. Defaults to `null`. | -| `notify(notification)` | `Future` | Convenience method — calls `NotificationManager().send(this, notification)`. | +| `notify(notification)` | `Future` | Convenience method that calls `NotificationManager().send(this, notification)`. | ### NotificationChannel (abstract) @@ -182,11 +204,11 @@ Implement to create a custom channel. ### DatabaseChannel (`'database'`) -Stores notifications via `POST /notifications`. Reads `toDatabase()` from the notification. Returns early if `toDatabase()` returns `null`. +`isAvailable` is always `true`, and `send()` is a **no-op**: it reads `toDatabase()`, returns early on `null`, and writes nothing. Database rows are created SERVER-side; the channel exists for API parity with Laravel and the client learns about a row by socket or poll. To create one from the client, `Http.post('/notifications', data: notification.toDatabase(user))` yourself. ### PushChannel (`'push'`) -Sends push via the configured `PushDriver`. Uses `toPush()` from the notification. Skipped if `isAvailable` is `false` (no driver configured or not opted in). +Posts `toPush()` to a self-addressed endpoint that makes the platform emit a real push to the caller's own device. `isAvailable` is `_driver.isSupported && notifications.push.self_test_enabled`, and that key ships OFF (an absent or non-boolean value reads as off), so the channel sends nothing until a deployment switches both halves on (the backend carries the same switch and answers 501 while it is off). It refuses a `Notifiable` that is not the authenticated user: the endpoint derives the recipient from the session. ## PushDriver @@ -196,21 +218,29 @@ Sends push via the configured `PushDriver`. Uses `toPush()` from the notificatio |:-------|:-----|:------------| | `name` | `String` | Driver identifier (e.g., `'onesignal'`). | | `isSupported` | `bool` | Whether push is supported on this platform. | -| `permissionState` | `PushPermissionState` | Current permission state. | +| `permissionState()` | `Future` | Current permission state. **Async since 0.1.0**: both platforms answer asynchronously. | | `isOptedIn` | `bool` | Whether user is opted in. | +| `subjectGuard` / `mayDisplay(data)` | `bool Function(Map)?` / `bool` | The guard that keeps a push addressed to the previous account off this device. | | `initialize(config)` | `Future` | Initialize driver with config map. | -| `login(externalId)` | `Future` | Associate push subscription with user ID. | -| `logout()` | `Future` | Remove user association from push subscription. | +| `login(externalId)` / `logout()` | `Future` | Attach / detach the external id on the subscription. | +| `currentExternalId()` / `currentSubscriptionId()` | `Future` | ABSTRACT since 0.1.0; the reconciler reads what the device is actually subscribed as. | | `requestPermission()` | `Future` | Show permission dialog. Returns grant result. | -| `optIn()` | `Future` | Opt user in to push. | -| `optOut()` | `Future` | Opt user out of push. | -| `setTags(tags)` | `Future` | Set targeting tags for segmentation. | -| `removeTag(key)` | `Future` | Remove a specific targeting tag. | -| `onNotificationReceived` | `Stream` | Fires when notification arrives in foreground. | -| `onNotificationClicked` | `Stream` | Fires when user taps notification. | -| `onPermissionChanged` | `Stream` | Fires when permission state changes. | +| `canRaisePermissionRequest()` | `Future` | Whether a request would actually show something. Defaulted. | +| `canOpenPlatformSettings` | `bool` | Defaults to `false`; mobile overrides it. | +| `optIn()` / `optOut()` | `Future` | Opt the user in or out. | +| `setTags(tags)` / `removeTag(key)` / `removeTags(keys)` | `Future` | Targeting tags. `removeTags` is defaulted (a loop over `removeTag`). | +| `addEmail(email)` / `removeEmail(email)` | `Future` | Email subscription, both defaulted. | +| `reachability()` | `Future` | `unavailable` / `blocked` / `off` / `on`, without triggering the OS dialog. Defaulted. | +| `onNotificationReceived` | `Stream` | Fires when a notification arrives in the foreground. | +| `onNotificationClicked` | `Stream` | Fires when the user taps a notification. | +| `onPermissionChanged` | `Stream` | Fires when the permission state changes. | +| `onIdentityChanged` | `Stream` | ABSTRACT since 0.1.0; the SDK's own view of external id, subscription id and opt-in. | + +`PushPermissionState` enum values: `notDetermined`, `denied`, `authorized`, `provisional`. A custom driver written against 0.0.3 does not compile on 0.1.0+ until it implements the three members marked ABSTRACT. -`PushPermissionState` enum values: `notDetermined`, `denied`, `authorized`, `provisional`. +On the manager rather than the driver: `Notify.manager.onPushClicked` and `onPushReceived` republish every driver's events on streams the manager owns from construction, so a listener attached before any driver exists still receives later events. That is the stream `magic_deeplink` bridges. + +Both are BROADCAST streams, which drop what they publish to nobody, so "the stream exists from construction" never meant "the stream remembers". `onPushClicked` alone is now an exception: it holds clicks until the first listener and replays them once, because the tap that COLD-STARTS the app is drained inside `driver.initialize()`, which this package's own provider awaits in `boot()`, and a consumer that lists notifications before `magic_deeplink` subscribes only afterwards. Without the buffer that tap opened the app on its initial route with no exception and no log. The replay is one-shot: a second listener arriving later is not handed the same tap again. `onPushReceived` has no buffer and needs none, since nothing navigates off it. ### OneSignalDriver @@ -234,7 +264,7 @@ Represents an in-app notification from the backend. | `readAt` | `DateTime?` | When notification was read (`null` if unread). | | `isRead` | `bool` (getter) | `true` if `readAt != null`. | -Factory: `DatabaseNotification.fromMap(map)` — parses Laravel notification response shape. +Factory: `DatabaseNotification.fromMap(map)`, which parses Laravel notification response shape. ### PaginatedNotifications @@ -269,7 +299,7 @@ PushMessage() | `data(value)` | `Map` | Set full data payload. Returns `this`. | | `addData(key, value)` | `String key`, `dynamic value` | Add single key to data payload. Returns `this`. | | `url(value)` | `String` | Set deep link URL. Returns `this`. | -| `toMap()` | — | Convert to `Map` (excludes null fields). | +| `toMap()` | none | Convert to `Map` (excludes null fields). | ### NotificationPreference @@ -284,31 +314,69 @@ User-level channel preferences. Use `isEnabled(type, channel)` to gate channel d `isEnabled(notificationType, channel)` returns `false` if either the global toggle or the type-specific toggle is disabled. Returns `true` by default if no type-specific preference exists. +## UI: views, controllers, registry + +The package owns the notification UI since 0.1.0. `magic_starter` used to ship its own copies and no longer exports any of them. + +| Symbol | Shape | +|:-------|:------| +| `NotificationDropdown` | The bell. `{required notificationStream, onMarkAsRead, onMarkAllAsRead, onNotificationTap, onViewAll}` plus five className overrides (`panelClassName`, `triggerClassName`, `triggerIconClassName`, `badgeClassName`, `badgeTextClassName`). | +| `NotificationsListView` | `{onMarkAsRead, onMarkAllAsRead, onDelete, onNavigate, perPage = 15}`. `onDelete` is `Future Function(String id)?` (0.2.0): `true` means the row is gone and the page reloads, `false` means the host declined and nothing is re-read. The per-row delete control renders only when it is non-null. | +| `NotificationPreferencesView` | `{pushProvisioned, backRoute}`. The per-type channel matrix plus a bulk row per channel. | +| `NotificationsListController` | `.instance`; owns the page and its rows. `loadPage(int page)`, `refresh()`, `currentPage`. | +| `NotificationPreferencesController` | `.instance`; `fetchPreferences()`, `updateTypePreference(String type, String channel, bool isEnabled)`, `updateChannelAcrossTypes(String channel, bool isEnabled)`, plus `matrixNotifier`, `pushProvisionedNotifier`, `bulkSavingNotifier`. | + +`Notify.view` is a `NotificationViewRegistry` seeding `notifications.list` and `notifications.preferences` on first read. API: `register`, `registerDefault`, `has`, `hasOverride`, `make`, `registerLayout`, `registerModal`, `slot`, `buildSlot`, `clear`; `Notify.forgetView()` drops the registry itself. + +```dart +// Swap a screen. +Notify.view.register('notifications.preferences', + () => const NotificationPreferencesView(backRoute: '/settings')); + +// Say what one of the app's own notification types looks like. +Notify.view.slot(NotificationViewRegistry.typeIconSlotView, 'monitor_down', + (context) => WIcon(Icons.error_outline, className: 'text-lg text-red-500')); +``` + +Ask `hasOverride(key)`, not `has(key)`, before installing your own default: reading `Notify.view` is what seeds the package's screens, so `has` is true from the first read. Register `'default'` (`NotificationViewRegistry.typeIconFallbackSlot`) as the slot name to answer for every remaining type. + +The package ships no translation catalogue: the host supplies every `notifications.*` key, and `Translator.get` renders a missing key as the key itself. + ## Configuration -Add to `lib/config/notifications.dart` and register via `configFactories`: +Scaffolded to `lib/config/notifications.dart` by `notifications:install` and registered via `configFactories`. Every switch below ships OFF, and an absent key reads as off. ```dart 'notifications': { 'push': { - 'driver': env('PUSH_DRIVER', 'onesignal'), // 'onesignal' is the only built-in driver - 'app_id': env('ONESIGNAL_APP_ID', ''), // OneSignal app ID - 'safari_web_id': env('ONESIGNAL_SAFARI_ID', ''), // Safari web push ID (web only) - 'notify_button_enabled': false, // Show OneSignal bell widget (web) + 'driver': 'onesignal', // the only built-in driver + 'app_id': '', + 'service_worker_path': '...', // web + 'service_worker_scope': '...', // web + 'notify_button_enabled': false, // OneSignal bell widget (web) + 'self_test_enabled': false, // gates PushChannel.send(); backend carries the same switch + 'auto_request_on_login': false, // raise the OS prompt once after sign-in (think twice on web) + 'reprompt_after_hours': 0, // the app's OWN reminder cadence; 0 means never + 'fallback_to_settings': true, // mobile: a request on a denied device opens app settings + 'share_user_attributes': false, // gates email + tags reaching OneSignal }, 'database': { 'enabled': true, - 'polling_interval': 30, // Seconds between background fetches + 'polling_interval': 30, // seconds; read at runtime since 0.2.0 }, 'mail': { - 'enabled': false, // Mail channel requires backend handler + 'enabled': false, // mail channel requires a backend handler }, 'soft_prompt': { - // Soft prompt dialog configuration (see PushPromptDialog) + 'enabled': true, // read by pushPromptAdvice(); the dialog widget itself was removed in 0.1.0 + 'title': 'Enable Notifications', + 'message': 'Stay updated with important alerts and updates', }, }, ``` +`Notify.manager.pushPromptAdvice({declinedAt})` answers whether the app's own reminder may be shown right now and what its button can accomplish; the package never stores the decline timestamp itself. + ## Service Provider Setup Register `NotificationServiceProvider` in `config/app.dart`. It is NOT auto-registered. @@ -321,7 +389,7 @@ Register `NotificationServiceProvider` in `config/app.dart`. It is NOT auto-regi ], ``` -`NotificationServiceProvider.register()` binds `NotificationManager` singleton. `boot()` reads config, creates the `OneSignalDriver`, and initializes it. +`register()` binds the `NotificationManager` singleton under `'notifications'`. `boot()` resolves it back THROUGH the container (so a missing binding surfaces as magic's own diagnostic), registers `DatabaseChannel`, reads the persisted push intent BEFORE resolving a driver (resolving one attaches the receive listeners, and the SDK replays a cold-start tap while `initialize` runs), then resolves the driver through the manager's name-keyed registry: an explicitly set or `Notify.extend`-registered driver outranks the config, an absent `push.driver` is a quiet `null`, and a configured name nothing can serve is logged at error level and degrades rather than failing boot. With a driver it registers `PushChannel` and initializes it, and it always ends on one unconditional `reconcilePushIdentity()`, because a signed-out cold boot fires no auth event at all. ## Usage Patterns @@ -387,15 +455,19 @@ if (result.hasMorePages) { ### Listening to Push Events ```dart -// In a controller or service provider boot() -Notify.manager.pushDriver.onNotificationClicked.listen((event) { +// In a controller or service provider boot(). Listen on the MANAGER, not the +// driver: the manager owns these streams from construction, so this works +// before any driver has been resolved and survives one being swapped. +Notify.manager.onPushClicked.listen((event) { final url = event.data['url'] as String?; if (url != null) { - Route.to(url); + MagicRoute.to(url); } }); ``` +An app that also installs `magic_deeplink` gets this wiring for free: its provider bridges `onPushClicked` into the deep link handler chain. + ### Custom Channel Registration ```dart @@ -408,14 +480,16 @@ Notify.manager.registerChannel(MyCustomChannel()); | Mistake | Fix | |:--------|:----| | `NotificationServiceProvider` not registered | It is NOT auto-registered. Add `(app) => NotificationServiceProvider(app)` to `config/app.dart`. | -| `Notify.initializePush()` throws `PUSH_DRIVER_NOT_CONFIGURED` | `NotificationServiceProvider` must be registered and `notifications.push.app_id` must be non-empty in config. | -| `Notify.manager.pushDriver` accessed before push init | Throws `NotificationException`. Guard with `try/catch` or ensure provider is registered. | -| Push login called before permission granted | `initializePush()` silently defers the external ID association. It will not throw, but the device won't be linked until a subscription is active. | -| Polling not stopped on logout | Always call `Notify.stopPolling()` on logout — the timer holds a reference to `NotificationManager` and will keep fetching. Pair it with `Notify.stopRealtime()`, which the manager does not do for you (only the caller knows the user is gone). | +| Expecting `Notify.initializePush()` to throw without a driver | It does not (0.1.0+). A build with no push driver is a supported state: the intent is recorded and reconciled against nothing. `Notify.manager.pushDriver` is the call that throws `NotificationException(code: 'PUSH_DRIVER_NOT_CONFIGURED')`; `pushDriverOrNull` is the quiet read. | +| Push login called before permission granted | `initializePush()` records the intent and reconciles it. It will not throw, but the device is not linked until a subscription is active. | +| Polling not stopped on logout | Always call `Notify.stopPolling()` on logout: the timer holds a reference to `NotificationManager` and keeps fetching. Pair it with `Notify.stopRealtime()`, which the manager does not do for you (only the caller knows the user is gone). | | `startRealtime()` returned `false` and the bell stays empty | It returns `false` without changing anything when the app has no broadcast driver (a null `BROADCAST_CONNECTION`). That is why `startPolling()` is armed next to it: reporting success there would stop the poller and leave the bell permanently empty. | | `startRealtime()` called without a channel | `channel` is required in practice: `null` or empty returns `false` immediately. The package has no user model and cannot derive the name. | | `notifications()` stream never emits | The stream emits current cache immediately to each new listener. If the cache is empty, subscribe then call `fetchNotifications()` to trigger the first emission. | -| `markAsRead()` / `deleteNotification()` reverts | These are optimistic — if the backend call fails, local state is reverted. UI will flash back to previous state. | -| `via()` returns unknown channel name | `NotificationManager.send()` logs a warning but does not throw. The notification is silently skipped for that channel. | -| `toDatabase()` returns `null` for `'database'` channel | `DatabaseChannel` skips delivery without error. Ensure `toDatabase()` returns a map with `title` and `body` keys. | -| `PushNotSupportedException` on unsupported platform | Check `Notify.manager.pushDriver.isSupported` before calling push methods. | +| `markAsRead()` reverts | It is optimistic: a failed backend call reverts local state, so the UI flashes back. `deleteNotification()` reverts AND rethrows (0.1.0+), so a caller has to handle the throw. | +| `via()` returns an unknown channel name | `NotificationManager.send()` logs a warning and skips that channel. A channel that THROWS no longer stops the others: the first error is rethrown after every channel has had its turn. | +| `toDatabase()` returns `null` for the `'database'` channel | `DatabaseChannel` skips without error. It writes nothing either way: the row is created server-side. | +| Waiting for `PushNotSupportedException` | Removed in 0.1.0. The platform factory throws `UnsupportedPlatformException` (a `NotificationException`) instead of silently handing back the wrong driver. | +| Reaching for `PushPromptDialog` | Removed in 0.1.0; the package ships no prompt widget. Build your own and ask `Notify.manager.pushPromptAdvice(declinedAt: ...)` whether to show it. | +| `permissionState` read as a getter | It is `Future permissionState()` since 0.1.0. A custom driver also has to implement `currentExternalId()`, `currentSubscriptionId()` and `onIdentityChanged`. | +| A raw `notifications.*` key rendering on screen | The package ships no catalogue; the host supplies every key. 0.1.0+ added `notifications.delete_failed`, and `magic_starter` adds three delete-confirmation keys. | diff --git a/skills/magic-framework/references/plugin-starter.md b/skills/magic-framework/references/plugin-starter.md index e4e94b4..300275e 100644 --- a/skills/magic-framework/references/plugin-starter.md +++ b/skills/magic-framework/references/plugin-starter.md @@ -1,8 +1,8 @@ - + # magic_starter Plugin -Full-stack Flutter starter kit for Magic Framework: pre-built auth flows, team management, profile settings, notification UI, and responsive app/guest layouts with an opt-in feature flag system. +Full-stack Flutter starter kit for Magic Framework: pre-built auth flows, team management, profile settings, billing, and responsive app/guest layouts with an opt-in feature flag system. The notification UI moved to `magic_notifications` in alpha.25; this package mounts it and requires `magic_notifications ^0.2.0`. ## Contents @@ -15,7 +15,7 @@ Full-stack Flutter starter kit for Magic Framework: pre-built auth flows, team m - [Session scope (cross-tenant leak guard)](#session-scope-cross-tenant-leak-guard) - [Route middleware](#route-middleware) - [Plan upgrade wall](#plan-upgrade-wall) -- [Settings page width](#settings-page-width) +- [Page geometry](#page-geometry) - [Controllers](#controllers) - [Layouts & Notification Integration](#layouts--notification-integration) - [Gate Abilities](#gate-abilities) @@ -24,18 +24,24 @@ Full-stack Flutter starter kit for Magic Framework: pre-built auth flows, team m ## Installation & Setup ```bash -# Register the plugin's artisan provider with the app dispatcher (once) +flutter pub add magic_starter + +# Register the plugin's artisan provider with the app dispatcher (once). +# The manifest declares `bootstrap_command: starter:install`, so this chains +# the install below by itself; run it again by hand if that subprocess failed. dart run magic:artisan plugin:install magic_starter -# Scaffold config, register provider, inject config into main.dart +# Scaffold config, register provider, inject config into main.dart. +# --features implies non-interactive AND turns every key it does not list OFF. dart run magic:artisan starter:install +dart run magic:artisan starter:install --features=teams,two_factor + +# Confirm the install (published-but-unregistered views, missing billing origin, ...) +dart run magic:artisan starter:doctor # Reconfigure features interactively dart run magic:artisan starter:configure -# Diagnose configuration issues -dart run magic:artisan starter:doctor - # Publish views/layouts for customization (Jetstream-style) dart run magic:artisan starter:publish @@ -47,12 +53,14 @@ Register the service provider in `lib/config/app.dart`: ```dart 'providers': [ - AppServiceProvider, // Must boot before MagicStarterServiceProvider + AppServiceProvider, // MagicStarter.bootstrap() lives here AuthServiceProvider, (app) => MagicStarterServiceProvider(app), ], ``` +View defaults are register-if-absent (the manager constructor calls `registerDefaultViews()`), so a host registration made in any provider wins. Two things do care about order: the teams warning `MagicStarterServiceProvider.boot()` logs when no team resolver is configured yet, and a `Gate.define()` on one of the nine starter abilities, which is silently replaced when the starter boots after you. Override an ability AFTER this provider. + ## MagicStarter Facade API Accessed via `package:magic_starter/magic_starter.dart`. All configuration calls should be made in a `ServiceProvider.boot()` method. @@ -149,6 +157,7 @@ The manager holds 7 sub-theme objects. Set all at once via `useTheme()` or indiv | `useCardTheme(theme)` | `void` | Override `MSCard` variant backgrounds, border radius, padding. | | `usePageHeaderTheme(theme)` | `void` | Override page header container, title, subtitle tokens. | | `useLayoutTheme(theme)` | `void` | Override sidebar, header, content/drawer background, brand bar tokens. | +| `useWindTheme(theme)` | `void` | Derive all 7 sub-themes from a `WindThemeData`'s semantic aliases (`MagicStarterTheme.fromWind`) and delegate to `useTheme()`. One call instead of 7 structs; individual setters still override afterwards. | ```dart // Set everything at once @@ -205,20 +214,14 @@ All sub-theme classes live in `lib/src/configuration/magic_starter_theme.dart`. ### Notifications -| Method / Property | Signature | Description | -|:------------------|:----------|:------------| -| `useNotificationTypeMapper(mapper)` | `void` | Register a mapper to resolve notification types to icons and color classes. | -| `notificationTypeMapper` | `MagicStarterNotificationTypeMapper?` | Get registered mapper, or `null` (views use built-in defaults). | +The notification UI belongs to `magic_notifications` (alpha.25 removed this package's copies with no shim): `MagicStarterNotificationController`, `MagicStarterNotificationsListView`, `MagicStarterNotificationPreferencesView`, `MSNotificationDropdown`, `MagicStarter.useNotificationTypeMapper` and the `MagicStarterNotificationTypeMapper` typedef are all gone. Use `NotificationPreferencesController`, `NotificationsListView`, `NotificationPreferencesView` and `NotificationDropdown` from `package:magic_notifications/magic_notifications.dart`, and say what a type looks like through the notification package's own slot: ```dart -MagicStarter.useNotificationTypeMapper((type) => switch (type) { - 'monitor_down' => (icon: Icons.error_outline, colorClass: 'text-red-500'), - 'monitor_up' => (icon: Icons.check_circle_outline, colorClass: 'text-green-500'), - _ => (icon: Icons.info_outline, colorClass: 'text-blue-500'), -}); +Notify.view.slot(NotificationViewRegistry.typeIconSlotView, 'monitor_down', + (context) => WIcon(Icons.error_outline, className: 'text-lg text-red-500')); ``` -Notification polling is handled automatically by the app layout. See `plugin-notifications.md` for the `Notify` facade API. +What stays here: `registerMagicStarterNotificationRoutes()` mounts `/notifications` and `/settings/notifications` in the `layout.app` shell and re-registers both screens wrapped in `MSPageContainer`, so they inherit the host's page geometry. The delete row asks first, through this package's `MSConfirmDialog`. See `plugin-notifications.md` for the `Notify` facade API. ### Access @@ -323,10 +326,10 @@ MagicStarter.view.registerModal('modal.confirm', () => CustomConfirmDialog()); | `teams.settings` | `features.teams` | `MagicStarterTeamSettingsView` | | `teams.invitation_accept` | `features.teams` | `MagicStarterTeamInvitationAcceptView` | | `teams.billing` | `features.billing` | `MagicStarterBillingView` | -| `notifications.list` | `features.notifications` | `MagicStarterNotificationsListView` | -| `notifications.preferences` | `features.notifications` | `MagicStarterNotificationPreferencesView` | -The settings surface is an iOS-style hub plus drill-down sub-pages, which is why the keys read the way they do. `settings.hub` is the index; the profile page is `profile.profile` (NOT `profile.settings`, which registers nothing); security pages nest under `settings.security.*`. +`notifications.list` and `notifications.preferences` are NOT on this registry (alpha.25). They live on `Notify.view`, whose API is the same; move the override there. + +The settings surface is an iOS-style hub plus drill-down sub-pages, which is why the keys read the way they do. `settings.hub` is the index; the profile page is `profile.profile` (NOT `profile.settings`, which registers nothing; `MagicStarterProfileSettingsView` is exported and publishable, but nothing mounts it for you); security pages nest under `settings.security.*`. `teams.billing` is gated on its OWN `features.billing` toggle, not on `features.teams`. The key sits in the `teams.` area because that is where the route lives (`MagicStarterConfig.billingRoute()`, default `/teams/billing`), but a subscription is bought by whoever holds the account, so an app with no team features can still sell one. @@ -357,13 +360,15 @@ MagicStarter.view.slot('auth.login', 'header', (context) { return WText('Welcome back!', className: 'text-2xl font-bold text-center'); }); -MagicStarter.view.slot('profile.settings', 'afterSection:info', (context) { +MagicStarter.view.slot('teams.settings', 'afterSection:members', (context) { return MyCustomBillingSection(); }); ``` Slot API: `slot(viewKey, slotName, builder)`, `hasSlot(viewKey, slotName)`, `buildSlot(viewKey, slotName, context)`. `buildSlot()` returns `null` when no slot is registered. Slots are cleared by `registry.clear()`. +Slots that a shipped view actually reads: `header` and `footer` on every auth view, `settings.hub`, `profile.profile` and the three `teams.*` views; `formFooter` on `auth.login` and `auth.register`; `afterSection:members` on `teams.settings`. A slot name a view does not read is silently inert. + **Timing rule**: Slot registration must happen before the view is built (ideally in `AppServiceProvider.boot()`). ### Publish Command (Jetstream-style) @@ -384,14 +389,16 @@ dart run magic:artisan starter:publish --tag=views:auth dart run magic:artisan starter:publish --tag=layouts ``` +`--tag` takes `config`, `views`, `layouts`, `middleware`, `lang` or `all` (the default), each with an optional scope (`views:auth`, `views:auth.login`, `layouts:app`). There is no `views:notifications` any more: this package no longer ships those two screens, so customise them through `Notify.view`. + Published files go to `lib/resources/views/starter/` (views) or `lib/resources/layouts/starter/` (layouts). Auto-wire adds `MagicStarter.view.register()` calls to `AppServiceProvider`. ## Design-system components -39 atomic components, all `MS`-prefixed, exported from `package:magic_starter/magic_starter.dart`. Each lives in a 4-file folder under `lib/src/ui/components/` (`.dart`, `.recipe.dart`, `.preview.dart`, `index.dart`) and styles through a `WindRecipe` that reads `MagicStarterTokens.defaultAliases`, so a consumer's theme drives them. +38 atomic components, all `MS`-prefixed, exported from `package:magic_starter/magic_starter.dart`. Each lives in a 4-file folder under `lib/src/ui/components/` (`.dart`, `.recipe.dart`, `.preview.dart`, `index.dart`) and styles through a `WindRecipe` that reads `MagicStarterTokens.defaultAliases`, so a consumer's theme drives them. > [!IMPORTANT] -> The `MS` prefix is not optional and there is no compat shim. The pre-`MS` component names (`Button`, `Dialog`, `Switch`, ...) were removed in alpha.19, and so were the six `MagicStarter*` alias widgets (`MagicStarterCard`, `MagicStarterPageHeader`, `MagicStarterSocialDivider`, `MagicStarterNotificationDropdown`, `MagicStarterTeamSelector`, `MagicStarterUserProfileDropdown`). Write `MSCard`, `MSPageHeader`, `MSSocialDivider`, `MSNotificationDropdown`, `MSTeamSelector`, `MSUserProfileDropdown`. The prefix is what ends the `package:flutter/material.dart` collision, so no `hide` clause is needed either way. +> The `MS` prefix is not optional and there is no compat shim. The pre-`MS` component names (`Button`, `Dialog`, `Switch`, ...) were removed in alpha.19, and so were the six `MagicStarter*` alias widgets (`MagicStarterCard`, `MagicStarterPageHeader`, `MagicStarterSocialDivider`, `MagicStarterNotificationDropdown`, `MagicStarterTeamSelector`, `MagicStarterUserProfileDropdown`). Write `MSCard`, `MSPageHeader`, `MSSocialDivider`, `MSTeamSelector`, `MSUserProfileDropdown`. The bell is no longer here at all: it is `NotificationDropdown` from `magic_notifications`. The prefix is what ends the `package:flutter/material.dart` collision, so no `hide` clause is needed either way. | Family | Components | |:-------|:-----------| @@ -403,7 +410,7 @@ Published files go to `lib/resources/views/starter/` (views) or `lib/resources/l | Page geometry | `MSPageContainer`, `MSPageScaffold` | | Settings surface | `MSSettingsSection`, `MSSettingsRow`, `MSSettingsNavRow` | | Billing surface | `MSUsageMeter`, `MSUpgradeDialog`, `MSUpgradeNudge` | -| App chrome | `MSNotificationDropdown`, `MSUserProfileDropdown`, `MSTeamSelector` | +| App chrome | `MSUserProfileDropdown`, `MSTeamSelector` | `MSButton`, `MSInput` and `MSTextarea` take `bool fullWidth = false`, which wraps the rendered widget in a `SizedBox(width: double.infinity)` rather than adding a className token (Material widgets ignore cross-axis stretch). @@ -507,6 +514,8 @@ Two guards ship ready to register as the `auth` and `guest` aliases in the app's Both override `redirectTarget` (a pre-build synchronous redirect) rather than `handle` (a post-build remount), so a guarded page never mounts for someone who is about to be sent away. Each one guards its own destination so the redirect cannot loop, which matters because go_router raises after more than five successive redirects. +`EnsureAuthenticated` also records the requested location with `MagicRouter.setIntendedUrl` before bouncing (alpha.27), and the `NavigatesRoutes.navigateHome()` every post-auth path calls reads it back with `pullIntendedUrl`, falling back to `MagicStarterConfig.homeRoute()`. So a deep link that lands on a signed-out device survives the login bounce. Nothing is recorded for the guest-only auth routes themselves, and `redirectTarget` only sees `state.matchedLocation`, so a recorded intent loses the original query string. + ## Plan upgrade wall A plan-gated refusal arrives as a `403` carrying an `upgrade.required_plan` marker. `PlanUpgradeRequirement.fromResponse` reads it and returns `null` for anything else, so a caller branches on "upgrade wall or real failure" without matching English prose. @@ -535,16 +544,15 @@ The marker is REQUIRED on purpose: a `403` without it is an authorization denial Copy comes from the `common.upgrade`, `common.upgrade_available_on`, and `common.upgrade_dialog_not_now` lang keys, added to the published `en` stub. An app that installed an earlier stub adds those three keys itself. -## Settings page width +## Page geometry -`MagicStarter.manager.settingsMaxWidthClassName` (default `MagicStarterManager.defaultSettingsMaxWidth`, `max-w-7xl`) is the width cap the settings scaffold centres its content column at. Set it from the same constant the host's own page container uses, or the two columns centre inside the same content region at different widths: +`MagicStarter.manager.pageContainerClassName` carries the WHOLE geometry `MSPageContainer` applies: width cap, horizontal edge margins, vertical rhythm. It defaults to `MagicStarterManager.defaultPageContainerClassName` (`'max-w-7xl px-4 lg:px-8 pt-6 sm:pt-8 pb-16'`). Set it once, from the same string the host's own pages use, or starter pages and host pages centre at different widths inside the same shell: ```dart -MagicStarter.manager.settingsMaxWidthClassName = PageContainer.maxWidthClassName; +MagicStarter.manager.pageContainerClassName = PageContainer.className; ``` -> [!NOTE] -> The next release renames this to `pageContainerClassName` and widens it to carry the whole geometry (cap plus edge margins plus vertical rhythm). Passing a bare cap stays valid, so the one-value call above survives the rename. +It carries all of it in one string on purpose: a cap that agrees while the padding does not still reads as two different pages. The pre-alpha.25 name `settingsMaxWidthClassName` is gone, with no alias. ## Controllers @@ -557,8 +565,8 @@ All controllers use the `Magic.findOrPut(ControllerClass.new)` singleton pattern | `MagicStarterOtpController` | `.instance` | Phone OTP verification | | `MagicStarterProfileController` | `.instance` | Profile info, password change, sessions, account deletion | | `MagicStarterTeamController` | `.instance` | Team create, settings, member management, team switching | -| `MagicStarterNotificationController` | `.instance` | Notification preferences matrix, per-channel toggles | | `MagicStarterNewsletterController` | `.instance` | Newsletter subscription management | +| `MagicStarterBillingController` | constructed, not `.instance` | Plans, usage meters, the web and store rails. It takes `usageCopy` and `formatNumber` as required arguments (and optional `storeFundedTeamReader` / `isOwnerReader`), so the host registers its own instance with `Magic.put`. | ### Auth Controller Key Methods @@ -589,27 +597,7 @@ await MagicStarterAuthController.instance.doTwoFactorChallenge( await MagicStarterAuthController.instance.logout(); ``` -### Notification Controller Key Methods - -```dart -// Fetch preference matrix from GET /notification-preferences -await MagicStarterNotificationController.instance.fetchPreferences(); - -// Toggle a channel preference (optimistic update, rolls back on failure) -await MagicStarterNotificationController.instance.updateTypePreference( - 'monitor_down', // notification type key - 'email', // channel name - true, // enabled -); - -// Reactive matrix access -ValueListenableBuilder( - valueListenable: MagicStarterNotificationController.instance.matrixNotifier, - builder: (context, matrix, _) { /* ... */ }, -); -``` - -Matrix structure from backend: `{ "type_key": { "label": "...", "channels": { "channel": { "enabled": bool, "locked": bool } } } }` +The preference matrix is `NotificationPreferencesController` in `magic_notifications` now; see `plugin-notifications.md`. ## Layouts & Notification Integration @@ -617,9 +605,10 @@ The app layout (`layout.app`) auto-manages notification polling: - `initState` calls `Notify.startPolling()` when `features.notifications` is enabled - `dispose` calls `Notify.stopPolling()` as a safety net -- `AuthRestored` event triggers `Magic.reload()` to refresh team-scoped data +- The header bell is `NotificationDropdown` from `magic_notifications`, wired to `Notify.notifications()`, `markAsRead`, `markAllAsRead`, the row's `actionUrl`, and the notifications route +- `MagicStarterServiceProvider` registers an `AuthRestored` listener that calls `Magic.reload()` to refresh team-scoped data -For the `Notify` facade API (polling interval, badge counts, push token registration, `logoutPush`), see `plugin-notifications.md`. +Realtime is NOT wired here: the layout arms the poller only. Call `Notify.startRealtime(channel: ...)` from your own auth wiring if the backend broadcasts; `startPolling()` is a no-op while it is live. See `plugin-notifications.md`. ## Gate Abilities @@ -643,10 +632,11 @@ For the `Notify` facade API (polling interval, badge counts, push token registra |:--------|:----| | `features.teams` enabled but no `useTeamResolver()` call | `MagicStarter.isReady` returns `false`; a warning is logged at boot. Call `useTeamResolver()` in `AppServiceProvider.boot()`. | | `useUserModel()` not called | Starter falls back to `MagicStarterAuthUser`. Always register before `MagicStarterServiceProvider` boots. | -| View key not registered | `MagicStarter.view.make(key)` throws `StateError`. Conditional views (`two_factor`, `phone_otp`, notifications) are only registered when their feature flag is `true`. | +| View key not registered | `MagicStarter.view.make(key)` throws `StateError`. Conditional views (`two_factor`, `phone_otp`, `billing`, teams) are only registered when their feature flag is `true`. | +| Overriding a notification screen on the wrong registry | `notifications.list` and `notifications.preferences` live on `Notify.view`, not `MagicStarter.view`. Registering on the starter's registry mounts nothing. | | `features.social_login` enabled but no `useSocialLogin()` builder | The feature flag gates the UI section; without a builder, the social login area renders nothing. | | Custom logout without stopping Notify polling | If you override `useLogout()`, call `Notify.logoutPush()` and `Notify.stopPolling()` manually. See `plugin-notifications.md`. | -| `MagicStarterServiceProvider` registered before `AppServiceProvider` | Order: `AppServiceProvider` first, then `MagicStarterServiceProvider`. | +| A `Gate.define()` override silently lost | The starter defines its nine abilities in `boot()`, and a same-key define is replaced by whichever provider boots last. Override AFTER `MagicStarterServiceProvider`. View defaults are register-if-absent, so they are not order-sensitive. | | `two_factor` view key missing at runtime | The view is only registered when `MagicStarterConfig.hasTwoFactorFeatures()` is `true` at boot time. Feature flags must be set before `Magic.init()`. | | Theme sub-theme ordering | `useTheme()` sets all 7 sub-themes at once; individual `useFormTheme()` etc. can override after. Call unified first if using both. | | Slot not rendering | `MagicStarter.view.slot(viewKey, slotName, builder)` must be called before the view is built. Views call `buildSlot()` at build time. | @@ -657,3 +647,4 @@ For the `Notify` facade API (polling interval, badge counts, push token registra | Navigation theme not affecting UI | `MagicStarter.useNavigationTheme()` must be called before the app layout is first painted. | | Bottom nav visible on fullscreen routes | Wrap route widget with `MagicStarterHideBottomNav(child: widget)` to hide mobile bottom nav. | | Published view not auto-wired | `dart run magic:artisan starter:doctor` detects published but unregistered views. Re-run publish or manually add `MagicStarter.view.register()`. | +| A raw key rendering in a tab title or a dialog | The catalogue is the CONSUMER's; `trans()` answers a missing key with the key. An app upgrading past alpha.24 merges the 20 `magic_starter.titles.*` keys from `assets/stubs/install/en.stub`, and past alpha.26 adds `common.delete`, `notifications.delete_confirm_title` and `notifications.delete_confirm_message`. A fresh `starter:install` already ships them. |