noma_chat 0.34.0 copy "noma_chat: ^0.34.0" to clipboard
noma_chat: ^0.34.0 copied to clipboard

Plug & play Flutter chat: SDK with REST + real-time client, offline Hive cache, UI adapter and ready-to-use UI components for the Nomasystems chat backend.

noma_chat #

pub package ci codecov License: Apache-2.0

Full-featured Flutter chat in one dependency. Drop it in, wire five lines, ship.

Two users chatting in real time with reactions


What you get #

Layer What's included
SDK REST client · WebSocket / SSE / polling with auto-failover · auth · retry · circuit breaker · offline queue
Cache Persistent Hive CE storage — messages, rooms and receipts survive cold restarts
UI components 30+ production-ready widgets: bubbles, voice messages, reactions, mentions, threads, group flows, search

Quick start #

# pubspec.yaml
dependencies:
  noma_chat: ^0.34.0
  # The default persistent cache is Hive-backed; you initialise it (see below).
  hive_ce_flutter: ^2.3.4
import 'package:flutter/widgets.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:noma_chat/noma_chat.dart';

Future<void> main() async {
  // Required before NomaChat.create: the SDK's default Hive cache opens its
  // boxes immediately, so Hive must be initialised first. Skip this only if
  // you disable the cache (`enableCache: false`) or supply your own
  // `localDatasource`.
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();

  final chat = await NomaChat.create(
    baseUrl: 'https://chat.myapp.com/v1',
    realtimeUrl: 'https://chat.myapp.com',
    tokenProvider: () => authService.getToken(),
    currentUser: ChatUser(id: userId, displayName: name),
  );
  await chat.connect();

  runApp(MyApp(chat: chat));
}

Cache disabled? If you pass enableCache: false (or your own localDatasource), you don't need hive_ce_flutter or Hive.initFlutter().

Drop the UI into your widget tree:

// Full chat-room screen — app bar + message list + every room behavior
// (history & pin load, unread divider, group member hydration, blocked /
// room-removed reactions, role-aware context menu, report dialog) auto-wired.
NomaChatView(
  roomId: roomId,
  adapter: chat.adapter,
  onRoomLeft: () => Navigator.of(context).maybePop(),
)

// Room list — the SDK owns the controller; pass currentUserId so the
// own-message ticks and the "You:" group prefix render correctly.
RoomListView(
  controller: chat.roomListController,
  currentUserId: userId,
)

NomaChatView is the recommended way to render a room. For a fully custom screen, compose ChatView (with your own ChatController, app bar and callbacks) by hand instead — see the Developer Guide.


Screenshots #

Room list with unread badges    Chat with reactions and mentions    Voice recorder with lock gesture    Group info and member management


Features at a glance #

SDK

  • Real-time: WebSocket → SSE → polling, automatic failover between transports
  • Circuit breaker + exponential backoff + offline message queue — the queue drains on every connection, including the first one after a cold start; inspect it with pendingOperationCount and force a drain with flushPendingOperations()
  • The same resilience primitives are exported for your own calls to the backend (computeBackoffMs, CircuitBreaker, CircuitBreakerRegistry from package:noma_chat/noma_chat_advanced.dart)
  • Duplicate-submission guard on rooms.create / rooms.updateConfig / members.invite / members.remove — a double-tap fires one request, not two (client-side only; the backend does not honour Idempotency-Key yet)
  • Dead-peer detection — a pong watchdog forces a reconnect on a "zombie" WS a NAT timeout or network handoff left half-open
  • SDK-owned app lifecycle — reconnects and resyncs on resume by default (manageAppLifecycle); opt out to drive it yourself
  • Cache-first room list — never flashes empty across a background/reconnect cycle; self-heals in the background instead
  • Token rotation without reconnecting
  • 8 sub-APIs: auth, users, rooms, members, messages, contacts, presence, attachments
  • Global or per-room message search — messages.search(query) spans every room the user belongs to; messages.search(query, roomId:) stays scoped
  • Bidirectional opaque-cursor pagination — page back through history and catch up on newer messages
  • Stable, localizable error tokens — branch and translate on ChatFailure.errorToken (a snake_case code such as room_not_found or rate_limited), never on an English string
  • GDPR self-service deletion — users.deleteCurrentUser() erases the authenticated account, token-scoped so it can't target the wrong user
  • Host user directory (ChatUiAdapter(userDirectoryResolver:)) — resolve a display name/avatar for an id from your own users table instead of chat's profile, with a durable Hive-backed cache (userDirectoryTtl, 12h by default); see Developer Guide — userDirectoryResolver
  • No id is ever painted as a name — a display name nobody can resolve is a blank the host fills with its own placeholder, never the raw UUID
  • Outgoing images are shrunk to fit before upload by default (AttachmentPolicy.shrinkSteps / shrinkEnabled, attachmentShrinker: to swap the engine or NoAttachmentShrinker to opt out) — see Developer Guide — attachmentShrinker
  • The first send after a lazily-created DM room retries once it exists — ChatUiAdapter(sendRetryPolicy:), default SendRetryPolicy.firstSendOnly()
  • Optional user bootstrap on connect (ChatUiAdapter(bootstrapCurrentUser:)) — creates the signed-in user's chat profile the first time it is missing
  • Read-only rooms driven by the backend's room config (RoomConfig.writePolicy) close the composer for everyone but the owner, independent of room type — see Developer Guide — ReadOnlyNoticeBuilder
  • Room list search also matches a room by the people in it (RoomListController(participantNameResolver:) / ChatUiAdapter.recordRoomRoster), and shows which participant matched

Security & observability

  • Standard TLS transport — the SDK relies on the operating system's CA trust store to validate server certificates; it does not pin certificates
  • Optional at-rest cache encryption — hand NomaChat.create a Hive AES cipher and the offline message / room store is encrypted on device
  • Structured logging pipeline (ChatLogTag/ChatLogLevel, pluggable ChatLogSinks, one-tap file export via ChatLogExporter) alongside the classic logger callback + metricCallback hook — every metric name and when it fires is documented in TELEMETRY.md, and nothing leaves the device unless you wire a sink yourself
  • Product-analytics channel (ChatConfig.analyticsSink / ChatUiAdapter's constructor) — a separate, opt-in stream of ChatAnalyticsEvents (room opened, message received, voice played, send outcome) that, unlike metricCallback, is allowed to carry room/message identifiers; see ANALYTICS.md

UI components — messages

  • Text, image, audio, video, file and link-preview bubbles — media bubbles re-mint an expired signed download URL automatically and retry once
  • Cancellable photo/video/file uploads — a determinate progress ring with an X that aborts the transfer mid-flight and removes the provisional bubble, wired by default; the retry arrow shows only after a genuine failure
  • Retriable failed uploads — the bytes of an upload that failed are held in memory (ChatUiAdapter.failedUploads, 8 files of up to 12 MB by default), so "Retry" re-uploads the same file instead of asking for it again, and messages.discardFailed removes a send the user gave up on. Both empty the offline queue of that row, so a discarded send never delivers late and a retried one delivers once. A media send that failed also stops being advertised as sent in the chat list
  • Confirmed deletion — "Delete" (for everyone, irreversible) asks first; "Delete for me" and "Discard" do not. Turn the dialog off with ChatViewBehaviors(confirmDeleteForEveryone: false)
  • Blocked senders are pruned inside groups, not just anonymized — ChatViewBehaviors.blockedContentPolicy (placeholder by default, or hide / show). Bubble, quoted reply, reactions and the room-list preview all go, and the room says it is pruning; a 1:1 chat is left alone with its existing blocked banner and its whole history
  • Screen readers hear what a non-text bubble is ("You: Photo, Sent", "You: Location, Sent") and hear a failed send announced as failed
  • Built-in camera screen wired by default — tap the shutter for a photo, hold it to record a clip, pinch to zoom, flip the lens; every capture then lands on a WhatsApp-style review step (send / retake / discard) and only a confirmed one is sent, EXIF stripped, without leaving the chat. Override the whole flow with ChatViewCallbacks.onPickCamera, or push CameraCapturePage.show() yourself and keep the result
  • Attachment picker rejections (too large, wrong type, unreadable) surface via onRejected instead of a silent drop
  • Voice recording with lock-to-record gesture
  • Emoji reactions + reaction picker
  • @mentions with autocomplete overlay
  • Threaded replies
  • WhatsApp-style delivery ticks (sending → sent → delivered → read → failed), cursor-based and confirmed automatically
  • Per-user read receipts (DM any-read → blue; group all-read → blue). In a group the grey ✓✓ no longer waits on members who have never acknowledged anything in the room — a roster entry that never showed up cannot be told apart from an invitee who never will, and holding the whole group's delivery state on them told the sender nothing. Blue stays strict: it still means every member read the message. Revert with ChatController(groupReceiptPolicy: GroupReceiptPolicy.allMembers)
  • "N new messages" divider anchored on the reader's own read cursor (GET /receipts for the room), not on a count taken from the end of the list — own messages never count as unread and never anchor the line, and the line is not drawn until the first page of history has settled. With no cursor available it degrades to counting back over incoming messages only. The decision itself is exported as the pure function resolveUnreadBoundary, for a host that wants to reason about or test it directly
  • A sender's name and avatar come back on the first bubble after anything that interrupts a run — a system notice, a date separator or the unread line
  • A room with no messages is a starting card, not a dead end: the SDK draws one on its own (and, in a 1:1 that can be written to, offers the first message with one tap), and the host fills it with its own content through ChatViewBuilders.emptyRoomBuilder — see The empty room
  • The row whose context menu is open is tinted for as long as the menu stays up (MessageList.activeRowColor / activeRowDecorationBuilder / highlightRowWhileContextMenuOpen, or drive it yourself with activeRowMessageId)
  • In-room search opens focused, explains what it searches before anything is typed, says how many characters it still needs while the query is shorter than minQueryLength, labels the user's own hits "You" (MessageSearchView.currentUserId) and heads the list with a count plus previous/next arrows (emptyPromptText, tooShortPromptText, resultCountLabelBuilder, showResultNavigation, autofocus). Every string is a bundle key — searchPromptEmpty, searchPromptTooShortTemplate (which carries the minimum as {count}, never a hard-coded 2) and the searchResultCount*Template pair — so every locale gets its own copy and a host can restyle the wording with ChatUiLocalizations.copyWith. GroupSetupPage.minSearchQueryLength gates its member picker the same way, with the same copy
  • The three "Shared in this chat" tabs say what will fill them, not just that they are empty: noMediaSubtitle, galleryNoDocsSubtitle and galleryNoLinksSubtitle render as a second line under the title, styled with ChatTheme.emptyStateSubtitleStyle
  • "Message info" dates a member's row only when it can prove the hour: the server keeps read / delivered cursors, so the time is that member's time for this message only when their cursor points at it. Everywhere else the row says "No exact time" instead of borrowing the cursor's clock — turn MessageInfoSheet.showApproximateReceiptTimes on to state the bound ("By 10:05 at the latest"), or take the line over with receiptSubtitleBuilder / receiptTimeFormatter
  • "What the checks mean" ships as a surface, DeliveryStatusLegendSheet: every delivery state with the glyph the bubbles paint, its name and an explanation, plus a group footnote. The room menu belongs to the host, so wire one entry to DeliveryStatusLegendSheet.show(context, theme: …, isGroup: …) and title it with l10n.deliveryStatusLegendTitle
  • Typing indicators
  • Forward to multiple rooms
  • Pinned messages banner
  • Message search — and the highlight it paints its results with is public, chatHighlightSpans(text, query, baseStyle:, matchStyle:), so a row of your own (a chat list saying WHY a room matched, for one) marks the query the same way the in-room search does instead of carrying a second copy of it. Case-insensitive and literal, so a query with . or ( in it highlights those characters rather than throwing

UI components — rooms & people

  • Room list with unread badges, mute, pin and hide
  • Deep-link-safe room open (adapter.rooms.open(roomId)) — fetches an unsynced room (push notification, shared link) with typed failures instead of a blind "this chat doesn't exist"
  • WhatsApp-style DM flow (lazy room creation before first message)
  • Block / unblock (blocker syncs list; blocked user is never notified)
  • Group creation, name + avatar edit, member add / remove / promote
  • Profile and avatar management with built-in crop flow
  • Media gallery page
  • Quick replies bar
  • Invitation accept / reject callbacks

Theme & l10n

  • ChatTheme with 155+ fields
  • ChatTheme.branded(accent:) — derives ~12 accent slots from one colour
  • Light / dark presets, high-contrast WCAG-AAA mode
  • Localized out of the box: en, es, fr, de, it, pt, ca, sv, no, da, pl, cs

Automation

  • Stable names on the chat room and its eleven internal surfaces — see Test identifiers below

Theming #

One line to match your brand:

theme: ChatTheme.branded(
  accent: Colors.indigo,
  contrastingOnAccent: Colors.white,
)

Or full control:

theme: ChatTheme(
  bubble: ChatBubbleTheme(outgoingColor: Color(0xFF4F46E5)),
  input: ChatInputTheme(backgroundColor: Colors.white),
  roomList: ChatRoomListTheme(unreadBadgeColor: Color(0xFF4F46E5)),
)

Even the delivery ticks are replaceable — per state, with SDK fallback:

bubble: ChatBubbleTheme(
  statusIconBuilder: (context, data) =>
      data.state == MessageDeliveryState.read
      ? Icon(Icons.done_all, size: data.size, color: Colors.teal)
      : null, // SDK default for the other states
)

See Developer Guide — Theming for all 155+ fields.

The short notices the SDK shows on its own (an unblock that failed, a permission denied, a role change the server refused) are snackbars out of the box — nothing to mount. Wrap the app to present them your way:

ChatNoticeScope(
  presenter: (context, message) {
    myBanners.show(message);
    return true; // false leaves this one to the SDK
  },
  child: MaterialApp(/* … */),
)

The empty room #

A room nobody has written in yet shows a card instead of a blank page. The SDK owns the slot — where it sits, when it appears, what it falls back to — and the host owns the content:

NomaChatView(
  roomId: roomId,
  adapter: adapter,
  builders: ChatViewBuilders(
    emptyRoomBuilder: (context, room) {
      final plan = myPlans.forRoom(room.roomId);
      if (plan == null) return null; // SDK card for rooms you know nothing about
      return EmptyRoomState(
        title: plan.name,
        subtitle: '${plan.weekday} · ${plan.time}',
        header: OrganiserCard(plan.organiser),
        actions: [
          FilledButton(onPressed: () => share(plan), child: Text('Share plan')),
          TextButton(onPressed: () => open(plan), child: Text('View plan')),
        ],
      );
    },
  ),
)

EmptyRoomInfo carries what the SDK knows about the room — roomId (null while a DM is still a local draft), isGroup, currentUser, otherUsers / otherUser, and onSendFirstMessage, which sends text as if it had been typed in the composer. onSendFirstMessage is null in a room that cannot be written to (read-only, blocked, or no send callback wired), so hide any "send this" affordance when it is.

Wire nothing and the room still says something: DefaultEmptyRoomState draws the SDK explanation, plus a one-tap 👋 in a 1:1 that can be written to. It is an emoji rather than a phrase because the SDK cannot translate a greeting into a locale it does not ship. Replace the labels without replacing the card through ChatViewBehaviors.emptyTitle / emptySubtitle / emptyIcon.

Divergence from WhatsApp, which leaves an empty room bare except for its encryption notice.


Platform support #

Platform Status Notes
Android Production Primary target. Chat, attachments, voice, presence, offline cache exercised end-to-end.
iOS Production Primary target. Same as Android.
macOS / Linux / Windows Best effort SDK and UI components work; voice uses platform audio backends. Not exercised in production.
Web Limited SDK, cache (IndexedDB) and audio playback work. Voice recording is disabled (filesystem staging).
Feature Status Notes
In-app camera (CameraCapturePage) Production on Android / iOS · Limited elsewhere Tap the shutter for a photo, hold it to record a clip, then confirm on the review step (full-screen still or playable clip, send / retake / discard); pinch to zoom, flip lens, permission recovery via Settings. On desktop and web the composer's Camera row falls back to image_picker's system camera (stills only) — PlatformSupport.supportsInAppCameraCapture.
Video poster frames (VideoThumbnailer) Production on Android / iOS · Limited elsewhere Sending a video generates a preview frame and uploads it as a second small blob, so the bubble shows a real still instead of a grey placeholder. The backend never transcodes, so the sender is the only place this can happen. Off on desktop (no plugin implementation) and on web (the extractor needs a file path, which web has no equivalent of, and every attachment URL needs a Bearer token) — PlatformSupport.supportsVideoThumbnails. Where it is off, and for videos sent before it existed, the bubble keeps the placeholder + play button.

⚠️ Android: adopting noma_chat makes your app camera-required on Google Play #

camera is a direct dependency of this package, and its Android implementation (camera_android_camerax) ships these lines in its own manifest, which the merger folds into your app:

<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

android:required defaults to true, and the two permissions add three more requirements you never wrote: Google Play implies a <uses-feature> from a permission that needs one, so CAMERA implies android.hardware.camera and android.hardware.camera.autofocus, and RECORD_AUDIO implies android.hardware.microphone — all four required. So the moment you add noma_chat, Google Play stops offering your app to every device without a camera, without autofocus or without a microphone — most Android TV boxes, many Chromebooks, some tablets, kiosk and emulator device profiles. Nothing warns you: the build succeeds, and the drop only shows up as a smaller supported-device count in the Play Console.

Chat does not need a camera to work (CameraCapturePage is one row of the attachment sheet; without a camera that row simply fails to open a viewfinder). Unless you want the filter, put all four of these in your app's android/app/src/main/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-feature
        android:name="android.hardware.camera.any"
        android:required="false"
        tools:replace="android:required" />

    <!-- Implied by the CAMERA / RECORD_AUDIO permissions the plugin merges
         in. Nothing declares these, so they need no tools:replace — but
         without them the filter stays on. -->
    <uses-feature
        android:name="android.hardware.camera"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.camera.autofocus"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.microphone"
        android:required="false" />

    <!-- … the rest of your manifest … -->
</manifest>

The first one needs tools:replace, the other three must not have it. android:required on <uses-feature> is OR-merged, so a plain android:required="false" for camera.any loses to the library's explicit declaration silently — no error, no warning; tools:replace is what makes yours win. The other three are never declared by anyone, so an explicit false is all it takes to override the implied requirement, and a tools:replace on them fails the build. Both the xmlns:tools declaration and the placement above are load-bearing. Confirm the outcome in app/build/outputs/logs/manifest-merger-*-report.txt.


Backend #

noma_chat is built to talk to a Nomasystems chat backend. That backend exposes a REST + WebSocket/SSE API described by a public OpenAPI 3.0 contractbrowse the rendered API reference or read the source spec. The SDK speaks exactly that contract, so it runs against any backend that implements the spec — not only ours.

The Nomasystems chat backend is planned to be open-sourced, but is not public yet. To use it as part of a commercial product, get in touch: info@nomasystems.com.


Documentation #

Document Contents
Developer Guide Architecture · all APIs · configuration · theming · customization · events · testing
ARCHITECTURE.md Internal layers and data-flow diagrams
INTEGRATION.md Backend contract (endpoints, auth, WS frames, S2S)
Backend API reference Rendered OpenAPI 3.0.1 (Redoc) · source spec
SECURITY.md Threat model · what the SDK does and does not guarantee · consumer hardening checklist
TELEMETRY.md SDK observability metrics (metricCallback) — every metric name, fields, and when it fires
ANALYTICS.md Product-analytics events (analyticsSink) — separate from telemetry, carries room/message identifiers
MIGRATING.md Step-by-step upgrade guide for every breaking release
ACCESSIBILITY.md Screen-reader semantic labels for message bubbles, by message state
CHANGELOG.md Version history

When NOT to use #

  • Custom backend with incompatible wire protocol — the SDK speaks the Nomasystems chat API contract (REST + WS/SSE, JWT, specific error codes). Any backend that implements that OpenAPI spec works out of the box; for anything else you can plug a custom ChatClient via NomaChat.fromClient(), but adapting the full contract is non-trivial. Consider whether it fits before adopting.
  • End-to-end encryption — TLS in transit, with optional at-rest encryption of the on-device cache, but messages are not end-to-end encrypted. If E2EE is a hard requirement, use a different SDK.
  • Hard latency SLO under ~100 ms — the SDK is push-based but does not advertise a real-time SLO. For voice / video signalling, use a dedicated SDK.

Troubleshooting #

Common issues and fixes are documented in the Developer Guide — Troubleshooting section.

Test identifiers #

Every actionable control, observable state and collection row the SDK paints carries a stable name, published twice with the same literal: as the widget's ValueKey — what find.byKey and an integration_test see from inside the app — and as Semantics(identifier:), which surfaces outside it as resource-id on Android and accessibilityIdentifier on iOS. The same string drives a widget test, a UiAutomator dump and an XCUITest run.

Names read <area>_<element>_<kind> in lower snake case under a chat_ prefix (chat_message_input, chat_send_button, chat_gallery_media_tab, chat_camera_review_send); collection rows carry their own id (chat_message_<messageId>_outgoing, chat_starred_item_<messageId>). For the templated ones, ask the SDK instead of re-deriving the format — messageBubbleSemanticsId, messageStatusSemanticsId, attachmentSemanticsId, mediaCellSemanticsId, docRowSemanticsId, linkRowSemanticsId, searchResultSemanticsId, starredRowSemanticsId, starredUnstarSemanticsId, quickReplySemanticsId, reactionRemoveSemanticsId, audioPlaySemanticsId, audioSpeedSemanticsId, attachmentUploadCancelSemanticsId, attachmentRetrySemanticsId, contactSuggestionSemanticsId, videoBubbleSemanticsId, fileBubbleSemanticsId, locationBubbleSemanticsId, linkPreviewBubbleSemanticsId and roomTileSemanticsId are exported.

The composer names all four of its controls, not just the two it used to: chat_attach_button, chat_message_input, chat_camera_button and chat_voice_button — the last two swap for chat_send_button as soon as there is text. The rest of the room chrome is named the same way: chat_reply_close_button and chat_edit_cancel_button on the two composer banners, chat_link_preview_close_button on the fetched link preview, chat_pinned_close_button, chat_blocked_banner_button, chat_scroll_to_bottom_button, chat_avatar_picker_button, and chat_voice_overlay_{delete,pause,resume,prelisten,send}_button plus chat_voice_overlay_prelisten_play_button on the voice recorder's own panels — only one panel is settled at a time, so the locked and the pre-listen one share the delete and send names (see Names during a transition below for the 200 ms in which they do not). A quick-reply chip is named by its position in QuickRepliesBar.replies (chat_quick_reply_<position>), stable only while the list keeps its order, because its labels are host-supplied free text that can repeat.

The screens the room sits between are named too: chat_room_back_button and chat_room_title on the room header — the title row opens whatever "room info" screen the host wires to ChatRoomAppBar.onTap —, chat_thread_close_button on the thread panel, and chat_room_list_new_chat_button on the room list header. A row of the room list answers to chat_room_tile_<roomId>, the room id rather than the position, because that list reorders on every incoming message.

Not every tap target is a button. A suggestion chip in ContactSuggestionsBar answers to chat_contact_suggestion_<contactId> — the contact id, not the position or the display name, because that list is host-supplied and two people can share a name. A row of the reaction detail sheet that takes your own reaction back answers to chat_reaction_remove_<emoji>. And the bubbles whose whole surface is the tap target name it: chat_message_<messageId>_video, chat_message_<messageId>_file, chat_message_<messageId>_location and chat_message_<messageId>_link_preview.

A message row states who wrote it in its own name: the bubble answers to chat_message_<messageId>_outgoing when the current user sent it and to chat_message_<messageId>_incoming otherwise, so a driver reads authorship off the tree instead of inferring it from the bubble colour or from which side of the room it sits on. Its delivery tick is named separately, chat_message_<messageId>_status, and carries the sent / delivered / read rendering of that one message.

AttachmentSheetOption.identifier names a row of the attachment sheet, so a driver points at an option regardless of the locale its label renders in. A row in extraOptions that passes nothing falls back to chat_attachment_option_extra_<position>, stable only while the list keeps its order.

Eight names live inside a bubble and therefore carry the delivery-tick caveat below in full, not just on iOS: chat_message_<messageId>_audio_play and chat_message_<messageId>_audio_speed on a voice bubble, chat_message_<messageId>_upload_cancel / chat_message_<messageId>_upload_retry on an attachment row, and chat_message_<messageId>_video / _file / _location / _link_preview on the tappable surface of the bubble itself. A bubble excludes its own subtree from the semantics tree, so for these eight the ValueKey half is the reachable one — widget tests, integration_test, the VM Service — while the Semantics half reaches a native dump only when the bubble (AudioBubble, AttachmentUploadRing, AttachmentRetryIcon, VideoBubble, FileBubble, LocationBubble, LinkPreviewBubble) is rendered standalone. All eight are named after the message they belong to, so pass messageId when you build one of those bubbles yourself; without it they publish no name rather than one two rows could answer to. MessageBubble hands the id down to every one of them. The composer's own link preview passes none on purpose: it decorates a message that does not exist yet.

Names during a transition #

A name is unique in a settled frame. Two of them are not while an animation runs, because the widget that is leaving stays mounted next to the widget that is arriving:

  • chat_voice_overlay_delete_button and chat_voice_overlay_send_button exist twice for the 200 ms the recorder cross-fades between its locked and pre-listen panels — both panels carry both controls, and the cross-fade holds both subtrees.
  • chat_reaction_remove_<emoji> exists twice while the reaction detail sheet slides between its "all" tab and an emoji tab: your own row is listed on both pages, and the tab view keeps them both alive for the slide.

Settle the frame before addressing either by id — pumpAndSettle() in a widget test, a short wait in an external driver. A findsOneWidget taken mid-slide is reporting the animation, not a duplicated name.

Three caveats. Turning the semantics tree on is your call (WidgetsBinding.instance.ensureSemantics() under a test flavour, or the platform's own accessibility service) — without it the Semantics half is invisible to a native driver, while the ValueKey half works regardless. Surfaces you own are yours to name: the AppBar around MessageSearchView and StarredMessagesView, and any attachment sheet injected in place of the SDK's. And chat_message_<messageId>_status does not reach an iOS dump from inside a bubble. A bubble consolidates the announcements of everything it contains into a single screen-reader label and excludes its own subtree, so there the ValueKey stays on the tick while the identifier rides a bare sibling node — name only, no label, value, hint or action. On iOS that node is not published: SemanticsObject.isAccessibilityElement defers to isFocusable, which asks for a label, a value, a hint or a non-scrolling action and never looks at the identifier, so XCUITest and idb do not list it. Inside a bubble the tick's name is therefore reachable by ValueKey (widget tests, integration_test, the VM Service) and as resource-id on Android, and not from an iOS dump — where the delivery state is instead readable from the bubble's own label, which ends in it, localised. Rendered standalone the tick keeps both halves on itself and is published normally: its own label makes it focusable.

The full convention lives in CONVENTIONS.md §10.11.

Development #

  • Tests: flutter test -x golden. Golden (snapshot) tests run on CI/Linux as the source of truth — see test/golden/README.md.
  • Regenerate golden baselines after a UI change, without a Linux machine: tool/regen_goldens.sh (drives the regen-goldens workflow and pulls the PNGs).

3
likes
120
points
1.37k
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Plug & play Flutter chat: SDK with REST + real-time client, offline Hive cache, UI adapter and ready-to-use UI components for the Nomasystems chat backend.

Repository (GitHub)
View/report issues
Contributing

Topics

#chat #messaging #realtime #websocket #ui-kit

License

Apache-2.0 (license)

Dependencies

audioplayers, cached_network_image, camera, characters, dio, emoji_picker_flutter, file_picker, flutter, freezed_annotation, get_thumbnail_video, hive_ce, html, image, image_cropper, image_picker, intl, meta, open_filex, path_provider, permission_handler, record, shared_preferences, url_launcher, uuid, video_player, web_socket_channel

More

Packages that depend on noma_chat