conduit/lib/features/notifications/models
cogwheel f448f43f82
feat(notifications): in-app notification parity layer from socket events (#526)
* feat(notifications): in-app notification parity layer from socket events

Derive notifications client-side from the existing Socket.IO streams,
mirroring Open WebUI semantics (strict parity: chat:completion done-frame
and new channel messages only). Surfaces as in-app banner (foreground) or
OS notification (background), with a haptic cue, channel unread badges,
and tap-to-deeplink.

- Pure NotificationEventClassifier owns envelope semantics (16 tests)
- NotificationRouter: gating predicate + LRU dedup, UI-free (14 tests)
- Global socket listener reuses the ActiveChatsSync._bindSocket pattern
- ActiveViewTracker reads existing active chat/channel providers
- Settings page + 7 prefs (3 mirrored to server for cross-device parity)
- Master toggle defaults off; permission requested on opt-in

Master toggle off by default; voice-call service refactor deferred.

* test(notifications): listener test + reset-on-visit unread clear

- Add notification_socket_listener_test: wildcard handler registration
  (requireFocus:false), classify->route happy paths, non-notifiable
  filtering, reconnect->channels refresh, and re-bind on socket change.
- ChannelsList.markRead clears the local unread badge on channel open
  (reset-on-visit), pairing with the existing server emitLastReadAt.
  Wired into ChannelPage._loadChannel; covered by new tests.

* fix(notifications): address code review findings

- Classifier suppresses channel messages with missing/empty author
  (malformed / system messages) instead of notifying with no title.
- Mirror server notification prefs into local settings only once per
  server (first load / switch), so a fresh local toggle isn't clobbered
  by a settings reload that raced the write-through.
- Wrap fire-and-forget notification ops (deep-link tap, cancelAll on
  sign-out) so failures log instead of becoming uncaught async errors.

Note: CodeRabbit's 'missing break' critical is a false positive — this
is a Dart 3 switch statement (no implicit fall-through); analyzer clean.

* fix(notifications): address PR bot review (Macroscope/CodeRabbit)

- Serialize LocalNotificationService.initialize() via a shared in-flight
  future so concurrent show()s don't run _plugin.initialize() in parallel.
- Guard _onResponse against add-after-close: the plugin singleton keeps the
  tap callback registered after dispose(), which could throw on a late tap.
- Drop unused areEnabled() (reported as returning a misleading iOS status;
  it had no callers).
- Set the once-per-server notification-prefs flag only after the mirror
  succeeds, so a failed apply retries on a later reload.

* fix(notifications): honor sound pref + harden OS notification posting

Address second PR review cycle (Greptile/CodeRabbit):
- Thread notificationSound into LocalNotificationService.show() so the OS
  notification respects the user's Sound toggle (iOS presentSound /
  Android playSound; Android 8+ channel-level sound noted as best-effort).
- Use a monotonic notification id instead of dedupKey.hashCode to avoid
  31-bit collisions silently replacing a notification in the drawer.
- Contain plugin init failures inside _doInitialize (log + keep
  _initialized false for retry) so they can't bubble into routing.
- Test: OS notification sound follows the notificationSound pref.

* fix(notifications): await init before reading cold-launch tap

Greptile P1: handleLaunchTap() called launchTap() ->
getNotificationAppLaunchDetails() which returns null on Android until the
plugin finishes native init, racing build()'s unawaited initialize() and
silently dropping the deep link. Await initialize() (idempotent, shares the
in-flight future) before querying the launch intent.

* feat(notifications): surface settings in the iOS native profile sheet

The iOS profile menu is rendered natively (data-driven via the native-sheet
bridge), separate from the Flutter profile page. Add a Notifications row +
toggle detail there so the feature isn't iOS-invisible:
- NativeSheetRoutes.notificationSettings + menu row + loading detail
- _hydrateNativeNotificationsDetail renders the 7 prefs as native toggles
- main.dart handles each toggle's NativeSheetControlChanged, requests OS
  permission on master opt-in, and mirrors the 3 OWUI-aligned prefs to server

* fix(notifications): skip channel notifications until current user resolves

Macroscope: _currentUserId is '' while currentUserProvider is still loading,
so the classifier's self-author filter (authorId == currentUserId) never
matches and the user could be notified for their own channel messages. Skip
channel classification when the id is unavailable. Adds a guard test.

* fix(notifications): error-handling in native notifications hydration

CodeRabbit: match the other hydration methods — wrap
_hydrateNativeNotificationsDetail in try/catch and call
_patchNativeDetailError so a failed _applyNativeDetail doesn't leave the
native sheet stuck on the loading placeholder.

* fix(notifications): render iOS native toggles as flat items

The native notifications detail used sections:, but applyDetailPatch (the
dynamic hydration path) only forwards items: — sections are dropped, so the
sheet rendered empty. Flatten the 7 toggles into a single ordered items list
like every other hydrated detail (about/account/voice).

* fix(notifications): notify for the active chat when backgrounded

Active-view suppression ran unconditionally, so a chat:completion for the
chat the user just backgrounded (still the 'active' chat) was dropped and no
OS notification fired — the reported bug. Gate active-view suppression on
foreground only, mirroring Open WebUI's '(notViewingChat) || isInBackground'.
Adds a test: backgrounded + active chat -> system notification.
2026-06-22 02:50:25 +05:30
..
app_notification.dart feat(notifications): in-app notification parity layer from socket events (#526) 2026-06-22 02:50:25 +05:30