# Notifications

- [Introduction](#introduction)
- [Real-Time Polling](#real-time-polling)
- [Notification List](#notification-list)
- [Notification Preferences](#notification-preferences)
- [Notification Dropdown](#notification-dropdown)
- [Notification Type Icons](#notification-type-icons)
- [Overriding a Screen](#overriding-a-screen)
- [Feature Gate](#feature-gate)

## Introduction

The notification UI moved to the `magic_notifications` package. This package no longer ships the list view, the preferences view, the controller behind them, or the bell dropdown; `magic_notifications` >= 1.0.0 does, under `NotificationsListView`, `NotificationPreferencesView`, `NotificationPreferencesController` and `NotificationDropdown`.

What stays here is what a published notifications package cannot know on its own: `registerMagicStarterNotificationRoutes()` owns the two paths (`/notifications` and `/settings/notifications`), mounts them inside the authenticated `layout.app` shell, and wraps `magic_notifications`'s screens in this package's own page geometry (`MSPageContainer`) so they read like every other page in the app rather than spreading full-bleed. `MagicStarterAppLayout` owns the polling lifecycle and renders the bell.

The entire feature is still opt-in behind `magic_starter.features.notifications`. When enabled, the layout starts polling and renders the bell; the routes resolve through `Notify.view`, not `MagicStarter.view`.

## Real-Time Polling

Notification polling is managed automatically by `MagicStarterAppLayout`. When the authenticated layout mounts and the notifications feature is enabled, it calls `Notify.startPolling()` to begin fetching notifications at a regular interval. Polling stops when the layout is disposed:

```dart
@override
void initState() {
  super.initState();

  if (MagicStarterConfig.hasNotificationFeatures()) {
    Notify.startPolling();
  }
}

@override
void dispose() {
  if (MagicStarterConfig.hasNotificationFeatures()) {
    Notify.stopPolling();
  }
  super.dispose();
}
```

`Notify.startPolling()` is idempotent — calling it multiple times has no effect. It also triggers an immediate fetch so the UI has data without waiting for the first interval tick.

> [!NOTE]
> In test environments where Magic may not be fully initialized, `startPolling()` and `stopPolling()` are wrapped in try-catch blocks inside the layout. Your test tearDown should still call `Notify.stopPolling()` with a silent catch to prevent timer leaks.

## Notification List

`NotificationsListView` (from `magic_notifications`) is a full-page view that displays all notifications with server-side pagination. It supports mark-as-read, mark-all-as-read, delete, and navigation to the notification's action URL.

`registerMagicStarterNotificationRoutes()` mounts it under the key `'notifications.list'` on `Notify.view`, wrapped in this package's page container, and renders it at `MagicStarterConfig.notificationsRoute()`:

```dart
MagicRoute.page(
  MagicStarterConfig.notificationsRoute(),
  () => Notify.view.make('notifications.list'),
).title('magic_starter.titles.notifications');
```

### Deleting a notification

The mount supplies the view's `onDelete`, which is what makes the per-row delete
control render at all: `NotificationsListView` draws it only when that callback
is non-null. It does not hand the callback straight to
`Notify.deleteNotification`. A delete is destructive, irreversible and one tap
away in a scrollable list, so the mount asks first, through this package's own
`MSConfirmDialog`:

- The dialog is shown against `MagicRouter.instance.navigatorKey.currentContext`,
  because neither the view registry nor `onDelete` provides a `BuildContext`.
  A null context refuses rather than deleting: nobody could have been asked.
- `MSConfirmDialog` rather than `Magic.confirm` so the dialog follows
  `MagicStarter.manager.modalTheme` and the host's dark mode. `Magic.confirm`
  styles from `view.confirm.*` with light-mode fallbacks.
- Copy comes from `notifications.delete_confirm_title`,
  `notifications.delete_confirm_message`, `common.delete` and `common.cancel`.
  All four ship in `assets/stubs/install/en.stub`, so an app scaffolded by
  `starter:install` has them; an app with a hand-written catalogue needs them
  added, or the dialog renders the raw keys.

To take the confirmation out, or to change what it asks, register your own
screen for `'notifications.list'` before the routes are mapped and pass whatever
`onDelete` you want. That registration wins, as described above.

Each notification item resolves its icon through the notification type icon slot (see [Notification Type Icons](#notification-type-icons)). When a notification is tapped, it is marked as read and the user is navigated to the notification's `actionUrl`.

## Notification Preferences

`NotificationPreferencesView` (from `magic_notifications`) displays a type-by-channel preference matrix fetched from the backend. Each notification type (e.g., "Monitor Down") shows its available channels (email, in-app, push) as toggle switches.

`registerMagicStarterNotificationRoutes()` mounts it under `'notifications.preferences'`, passing the one thing the notifications package cannot know: where "back" goes in this app.

```dart
Notify.view.register(
  'notifications.preferences',
  () => NotificationPreferencesView(
    backRoute: MagicStarterConfig.settingsHubRoute(),
  ),
);
```

Both mounts also pass `headerBuilder`, which draws each screen's header as an `MSPageHeader` inside the screen's own scroll view, so the two pages follow [host-owned page chrome](https://anilcancakir.com/magic/docs/starter/v1.0/architecture/manager.md#host-page-chrome) like every other starter page: under `MagicStarter.useHostPageChrome` the inbox reports its title and keeps only its mark-all-as-read action, and the preferences report the settings hub as their back fallback. A screen you register yourself draws its own header unless you pass a `headerBuilder` too.

The matrix structure returned by the API:

```json
{
  "monitor_down": {
    "label": "Monitor Down",
    "channels": {
      "mail":     { "enabled": true,  "locked": false },
      "database": { "enabled": true,  "locked": true },
      "push":     { "enabled": false, "locked": false }
    }
  }
}
```

Locked channels display a lock icon and their toggle is disabled — the backend enforces that certain channels cannot be turned off.

> [!NOTE]
> Preference updates use optimistic UI. The toggle flips immediately and a `PUT /notification-preferences` request is sent. On failure, the matrix reverts to its pre-update snapshot. This state lives in `magic_notifications`'s own `NotificationPreferencesController` now; this package no longer carries one.

Since `magic_notifications` 0.3.0 the screen also opens with a bulk card above the
matrix, one switch per channel applying to every type at once, backed by the batch
shape `PUT /notification-preferences` with `{preferences: [{type, channel, is_enabled}, ...]}`.
`magic-starter-laravel` has accepted that shape since 0.0.7; a hand-rolled endpoint
that only takes the single shape answers 422 and the card is dead.

The notification screens read five `notifications.*` keys that no package supplies:
`bulk_title` and `bulk_description` head the bulk card, `delete` names the list
row's delete glyph for a screen reader, `delete_failed` is shown when the server
refuses a delete, and `channel_sms` labels the SMS row. All five ship in
`assets/stubs/install/en.stub`, so an app scaffolded by `starter:install` has them;
an app with a hand-written catalogue adds them, or each renders as its own key.

## Notification Dropdown

`NotificationDropdown` (from `magic_notifications`, no longer `MSNotificationDropdown`) is a standalone widget, not a view, that renders a bell icon with a live unread badge. It uses `StreamBuilder<List<DatabaseNotification>>` to reactively display the current notification count. `MagicStarterAppLayout` mounts it with the same five callbacks it always passed:

```dart
NotificationDropdown(
  notificationStream: Notify.notifications(),
  onMarkAsRead: (id) => Notify.markAsRead(id),
  onMarkAllAsRead: () => Notify.markAllAsRead(),
  onNotificationTap: (notification) =>
      MagicRoute.to(notification.actionUrl ?? '/'),
  onViewAll: () =>
      MagicRoute.to(MagicStarterConfig.notificationsRoute()),
)
```

The dropdown is rendered inside `AppLayout`'s header and sidebar via the centralized `_buildNotificationBell()` helper. It only appears when `MagicStarterConfig.hasNotificationFeatures()` returns `true`.

Key behaviors:

| State | Display |
|-------|---------|
| Loading | Bell icon with no badge, spinner in dropdown body |
| Error | Bell icon with no badge, error icon in dropdown body |
| Empty | Bell icon with no badge, "No notifications" empty state |
| Unread > 0 | Bell icon with red badge (`9+` overflow), notification list |

When a notification is tapped, it is marked as read and the `onNotificationTap` callback fires. The dropdown closes automatically after tap.

> [!TIP]
> The dropdown uses `WPopover` for overlay positioning. It aligns to `PopoverAlignment.bottomRight` by default and constrains its height to 400 logical pixels.

## Notification Type Icons

`MagicStarter.useNotificationTypeMapper(...)` and the `MagicStarterNotificationTypeMapper` typedef are gone. Saying what a notification type looks like is `magic_notifications`'s own slot now, and it answers the same question for the list screen and the bell at once:

```dart
import 'package:magic_notifications/magic_notifications.dart';

Notify.view.slot(
  NotificationViewRegistry.typeIconSlotView,
  'monitor_down',
  (context) => WIcon(Icons.error_outline, className: 'text-lg text-red-500'),
);
```

Register the slot name `'default'` to answer for every type you did not name individually:

```dart
Notify.view.slot(
  NotificationViewRegistry.typeIconSlotView,
  'default',
  (context) => WIcon(Icons.info_outline, className: 'text-lg text-blue-500'),
);
```

Both the list view and the dropdown read `Notify.view.buildTypeIcon(type, context)`, which checks the type's own slot first and falls back to `'default'`, so one registration call answers for both surfaces.

## Overriding a Screen

An app that wants to swap either screen no longer registers on `MagicStarter.view`; it registers on `Notify.view`:

```dart
Notify.view.register(
  'notifications.list',
  () => const MyCustomNotificationsListView(),
);
```

**The order does not matter.** `registerMagicStarterNotificationRoutes()` installs its own wrapped screens only when nobody has chosen one for that key (`Notify.view.hasOverride`), so a host registration wins whether it runs before or after the routes are mapped.

The wording matters here: the key is never *absent*. Reading `Notify.view` is what seeds `magic_notifications`' own two defaults into the registry, so `has(key)` is true before anybody has decided anything, and gating on it would make this package skip every time and never apply the page geometry below. That is worth saying explicitly here, because the two calls land in different files: the installer injects the route mount into `route_service_provider.dart` while the scaffold points `Notify.view` work at `AppServiceProvider`, and which of those boots first is a property of the host's provider list rather than of anything this package can see.

The registry's shape is identical to `MagicStarter.view`'s (`register` / `has` / `make` / `slot` / `buildSlot` / `clear`), so an app that already knew one knows the other.

## Feature Gate

All notification functionality is gated behind `MagicStarterConfig.hasNotificationFeatures()`:

```dart
if (MagicStarterConfig.hasNotificationFeatures()) {
  Notify.startPolling();
}
```

Enable it in your configuration:

```dart
Config.set('magic_starter.features.notifications', true);
```

When disabled:

- Notification routes are not registered (the registration function is a no-op)
- The bell icon dropdown does not render in the app layout
- Polling never starts
- Calling notification route paths throws `StateError`

> [!TIP]
> The install command can enable notifications automatically: `dart run magic_starter install --non-interactive --features notifications`. This also triggers the `magic_notifications` package installer.

---

**Related Links:**

- [Notification Routes](https://magic.fluttersdk.com/packages/starter/routes/notifications)
- [Views and Layouts](https://magic.fluttersdk.com/packages/starter/basics/views-and-layouts)
- [Configuration](https://magic.fluttersdk.com/packages/starter/getting-started/configuration)
- [magic_notifications Package](https://magic.fluttersdk.com/packages/notifications)
