
Stream Flutter
- 208 installs
- 17 repo stars
- Updated August 4, 2026
- getstream/agent-skills
Implement Stream Chat or Feeds in Flutter with correct widgets, state handling, theming, and platform channels for iOS and Android realtime messaging.
About
Stream Flutter from getstream/agent-skills teaches agents to build production-grade GetStream experiences in Flutter, covering SDK initialization, channel lists, message composers, theming, and platform-appropriate realtime updates for cross-platform chat and activity feed apps.
- Flutter Stream Chat and Feeds widgets
- Cross-platform iOS and Android patterns
- Theming, channels, and message list setup
- Realtime listener and state management guidance
- Mobile-specific Stream SDK conventions
Stream Flutter by the numbers
- 208 all-time installs (skills.sh)
- Ranked #437 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/getstream/agent-skills --skill stream-flutterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 208 |
|---|---|
| repo stars | ★ 17 |
| Last updated | August 4, 2026 |
| Repository | getstream/agent-skills ↗ |
What it does
Implement Stream Chat or Feeds in Flutter with correct widgets, state handling, theming, and platform channels for iOS and Android realtime messaging.
Files
Stream Flutter - skill router + execution flow
Rules: Read [`RULES.md`](RULES.md) once per session - every non-negotiable rule is stated there, nowhere else.
This file is the single entrypoint: intent classification, local project detection, and module pointers for Stream work in Flutter apps.
---
Step 0: Intent classifier (mandatory first - never skip)
Before any tool call, decide the track from the user's input alone - no probes first.
Signals -> track
| Signal in user input | Track |
|---|---|
Explicit package/widget token: stream_chat_flutter, StreamChannelListView, StreamMessageListView, StreamChatClient, etc. | C - Reference lookup |
Explicit video token: stream_video_flutter, StreamCallContainer, StreamVideo, StreamVideoRenderer, goLive, stopLive, livestream call type | C - Reference lookup |
Explicit feeds token: stream_feed, stream_feed_flutter_core, StreamFeedClient, FlatFeedCore, FlatFeed, FeedBloc, activity feed, feeds flutter | C - Reference lookup |
| Words "docs" or "documentation" around Stream Flutter work | C - Reference lookup |
| "How do I {X} in Flutter?", "What does {widget/method} do?" | C - Reference lookup |
| "Build me a new Flutter app", "create a Flutter chat app" + Stream | A - New app |
| "Build a Flutter video call app", "create a livestream app in Flutter" | A - New app (load VIDEO-FLUTTER.md + VIDEO-FLUTTER-blueprints.md or LIVESTREAM-FLUTTER.md + LIVESTREAM-FLUTTER-blueprints.md) |
| "Build an audio room / Twitter Spaces clone", "TikTok-style live feed", "call while livestreaming", "chat with video calls", "two calls at once" | A or B (load VIDEO-ADVANCED-FLUTTER.md + VIDEO-ADVANCED-FLUTTER-blueprints.md on top of the Video/Livestream pair) |
| "Add ringing / incoming calls", "video call with push notifications", "CallKit", "VoIP push", "FCM ringing", "missed call notification" | A or B (load RINGING-FLUTTER.md + RINGING-FLUTTER-blueprints.md on top of the Video pair) |
| "Build a Flutter feeds app", "create an activity feed app", "build a social feed in Flutter", "create a Twitter/Instagram clone" | A - New app (load FEEDS-FLUTTER.md + FEEDS-FLUTTER-blueprints.md; use Twitter-style UI unless the user explicitly specifies otherwise) |
| "Add/integrate Stream into this app", "wire Chat into my Flutter project" | B - Existing app |
| "Add video calling to my Flutter app", "integrate Stream Video into my existing app" | B - Existing app (load VIDEO-FLUTTER.md + VIDEO-FLUTTER-blueprints.md) |
| "Add a feed to my Flutter app", "integrate Stream Feeds into my existing app", "add activity feed" | B - Existing app (load FEEDS-FLUTTER.md + FEEDS-FLUTTER-blueprints.md; use Twitter-style UI unless the user explicitly specifies otherwise) |
| "Install Stream packages", "set up Stream in Flutter", "wire auth/token" with no broader feature request | D - Bootstrap / setup |
Bare /stream-flutter with no args | List the tracks briefly and wait |
Disambiguation flow
If the request is ambiguous between build/integrate and reference lookup, ask one short question and wait:
Do you want me to wire this into the project, or just map the Flutter SDK pattern and widgets?
After classification
- Tracks A, B, D -> run Project signals once per session, then continue in `builder.md` and `sdk.md`.
- Track C -> skip the probe if the product + package are explicit. Only run it on demand if the SDK layer is ambiguous.
---
Step 0.5: Credentials, token, and seed data (tracks A, B, D only)
Run this once per session, right after intent classification, before the Project signals probe.
Goal
Collect the Stream API key, a user token, and optionally seed channels or calls - all before touching code - so the app has real data to show from the first run.
Single upfront question (ask exactly once, then act immediately)
Post one message asking all relevant things together. Do not split into multiple rounds.
For Chat projects:
To wire everything up with real data, I need a few quick answers:
>
1. Credentials - Should I fetch your API key from the dashboard and generate a token via the Stream CLI, or will you paste them yourself?
2. Token expiry - If I'm generating the token: should it expire? (e.g.1h,1d,30m) or never expire?
3. Seed channels - Should I pre-create a few channels with random usernames so the app has something to show immediately?
>
If you want to handle everything yourself, just paste your API key and token and tell me whether to seed channels.
For Video projects (calls are ephemeral - no seeding needed):
To wire everything up, I need a couple of quick answers:
>
1. Credentials - Should I fetch your API key from the dashboard and generate a token via the Stream CLI, or will you paste them yourself?
2. Token expiry - If I'm generating the token: should it expire? (e.g.1h,1d,30m) or never expire?
>
If you want to handle everything yourself, just paste your API key and token.
Guest-viewer requirement — surface this BEFORE building any app that signs viewers in as guests. A guest connects with theguestrole, which by default has minimal capabilities. On thelivestreamcall type guests cannot even read or join a call until the integrator grants those capabilities to theguestrole. When a guest-based viewer flow is in scope, tell the integrator it is a prerequisite to grant theguestroleread-callandjoin-call(pluscreate-callif a viewer may open the call before the host) on the relevant call type via Stream Dashboard → Video & Audio → Call Types → <type> → Roles & Permissions (or the API), or to use authenticatedUser.regularviewers instead. Details: `references/VIDEO-FLUTTER.md` → Guest users, and `references/LIVESTREAM-FLUTTER.md` → Roles, permissions, and backstage security.
For Feeds projects (no pre-built UI; feed groups required):
Ask one message with all setup questions together — do not split into rounds:
To wire everything up, I need a few quick answers:
>
1. Credentials - Should I fetch your API key from the dashboard and generate a token via the Stream CLI, or will you paste them yourself?
2. Token expiry - If I'm generating the token: should it expire? (e.g.1h,1d,30m) or never expire?
3. Feed groups - I need to create 3 feed groups in your Stream project (user, timeline, notification). Should I set these up automatically, or have you already created them?
4. Seed posts - Should I add a few sample posts so the feed has content from the first run?
>
If you want to handle credentials yourself, just paste your API key and token.
Once the user replies, execute all steps without pausing. For feed groups, if the user said "set up automatically":
getstream api CreateFeedGroup --request '{"id": "user", "type": "flat"}'
getstream api CreateFeedGroup --request '{"id": "timeline", "type": "flat"}'
getstream api CreateFeedGroup --request '{"id": "notification", "type": "notification"}'If the CLI commands fail (the Feeds API may use different endpoints than Chat), tell the user once:
Please create these in Stream Dashboard → Activity Feeds → Feed Groups:user(Flat),timeline(Flat),notification(Notification).
For Feeds projects, always generate two separate helpers in main after connect():
1. _setupFollows(client) — always called, unconditionally. Makes timeline follow user so the user's own posts appear there. Do not merge this into seed logic — once seed data exists the guard returns early and the follow call never runs. 2. _seedPosts(client) — only if the user said yes to seeding. Adds sample activities and exits early if data already exists.
See `references/FEEDS-FLUTTER.md` for both implementations.
The package is stream_feeds: ^0.5.1 — not the deprecated stream_feed or stream_feed_flutter_core.
After the user replies - act without further prompting
Once the user answers, execute all CLI steps in sequence without pausing for confirmation between them. Narrate each step briefly as you go (one line per action), but do not stop to ask "shall I continue?".
Step A - API key
getstream env --target flutterThis writes the public API key to dart_defines.json; the app reads it via String.fromEnvironment('STREAM_API_KEY') and is run with flutter run --dart-define-from-file=dart_defines.json. If the command returns a 401 error, the CLI session has expired - run getstream login to re-authenticate, then retry.
If `getstream` is not installed (command not found): ask the user to install it from https://getstream.io and wait. Or, if the user prefers, skip the CLI entirely and have them paste the API key + a token per user (Dashboard -> Explorer has a token generator). Decide based on the user's answer to the upfront credentials question; don't stall.
Step B - Token
# Never-expiring
getstream token <user_id>
# Expiring
getstream token <user_id> --ttl <duration>Hold the token in context. Use it (and the API key) in every code snippet - no placeholder strings.
Step C - Seed channels (only if the user said yes)
Create 3-5 channels with random realistic usernames. Use messaging as the default channel type.
Sub-step C1 — upsert all users (seed users + the token user):
getstream api UpdateUsers --request '{
"users": {
"<token_user_id>": {"id": "<token_user_id>", "name": "<Display Name>"},
"alice": {"id": "alice", "name": "Alice"},
"bob": {"id": "bob", "name": "Bob"},
"carol": {"id": "carol", "name": "Carol"},
"dave": {"id": "dave", "name": "Dave"}
}
}'Sub-step C2 — create each channel (no members in the body; members are added in C3):
getstream api GetOrCreateChannel --type messaging --id <channel-id> \
--request '{"data": {"name": "<Channel Name>"}}'Repeat for each channel (e.g. general, random, team-alpha).
Sub-step C3 — add members to each channel using add_members. The token user must be in every channel so the Filter.in_('members', [userId]) query in the app returns results.
getstream api UpdateChannel --type messaging --id <channel-id> \
--request '{
"add_members": [
{"user_id": "<token_user_id>"},
{"user_id": "alice"},
{"user_id": "bob"}
],
"user_id": "<token_user_id>"
}'Generate short memorable channel IDs (e.g. general, random, team-alpha) and use a small set of random usernames (e.g. alice, bob, carol, dave). The token user must be added to every channel — the channel list filter is Filter.in_('members', [tokenUserId]) and will return nothing if the user is absent.
After seeding, print a brief summary:
Created channels:general(token_user, alice, bob),random(token_user, carol, dave),team-alpha(token_user, alice, carol)
Step D - Proceed automatically
After all CLI steps succeed, move straight to Project signals and then into builder.md - no additional prompt needed. If any CLI step fails, explain the error briefly and ask the user to paste the missing value manually before continuing.
What NOT to do
- Never put the API secret in app code - the CLI uses it server-side only.
- Never invent or fabricate credentials.
- Never ask "should I continue?" between Step A, B, C, and D - execute the whole sequence once the user's upfront answers are in.
Permissions awareness (Chat - surface proactively)
Stream Chat checks permissions per role, per scope on every client-side call — but server-side calls (the CLI and your backend, using the API _secret_) bypass all checks. That asymmetry is the #1 source of "it worked when you seeded it, but the app 403s": seeding channels via the CLI succeeds regardless of grants, then the same query/join from the app hits the connected user's role and fails.
When the app you're about to build does anything beyond chatting inside channels the user is already a member of, tell the integrator about the relevant grants _before_ writing the feature — don't wait for a runtime 403. Map the scenario to the grant:
| App behaviour you're building | Grant the connecting role needs on the channel type | Default messaging for user/guest |
|---|---|---|
Discover / browse groups the user didn't create (queryChannels without a members filter) | Read Channel (ReadChannel) | often off |
Join an existing group (channel.addMembers([myId])) | Add Own Channel Membership (AddOwnChannelMembership) | often off |
Leave a group (channel.removeMembers([myId])) | Remove Own Channel Membership (RemoveOwnChannelMembership) | varies |
| Create a group | Create Channel (CreateChannel) | usually on |
guest users (name-only / no-backend sign-in) are stricter than user — if the app uses guest auth, the same grants must be added to the `guest` role too. Point the integrator to Dashboard → Chat → Roles & Permissions (permissions v2) for the role + messaging type, or UpdateChannelType via API/CLI. Full detail and the exact error string: `references/CHAT-FLUTTER.md` → Channel permissions & roles (custom-UI builds: `references/CHAT-CORE.md`).
This is a prompt, not a blocker — build the feature as requested, but call out the prerequisite in the same turn so discover/join/create don't silently fail on first run.
---
Project signals (tracks A/B/D - once per session; Track C on demand only)
Read-only local probe. Use it to detect whether the user is in a Flutter project or an empty directory.
bash -c 'echo "=== FLUTTER ==="; find . -maxdepth 2 -name "pubspec.yaml" -print 2>/dev/null; echo "=== STREAM ==="; grep -rE "stream_chat|stream_video|stream_feed" . --include="pubspec.yaml" -l 2>/dev/null; echo "=== EMPTY ==="; test -z "$(ls -A 2>/dev/null)" && echo "EMPTY_CWD" || echo "NON_EMPTY"'Hold the result in conversation context. Don't re-run it unless the user changes directory or the project shape clearly changed.
Use the result to produce a one-line status, for example:
Flutter app detected - stream_chat_flutter already in pubspec.yamlFlutter app detected - stream_video_flutter already in pubspec.yamlFlutter app detected - stream_feed already in pubspec.yamlFlutter app detected - no Stream dependency yet, ready to installNo Flutter project found - user needs to run flutter create first
Version prerequisite (Chat - existing project)
When a Stream Chat dependency is already present, check the resolved version (pubspec.yaml constraint or the version: in pubspec.lock). These skills target `stream_chat_flutter` / `stream_chat_flutter_core` v10 only. If the project is pinned to 9.x or earlier, stop before editing code and tell the user:
Your project uses stream_chat_flutter v<found>. These instructions cover v10. A lot changed between v9 and v10 (widget names, controllers, theming, reaction/delete APIs). The official migration guides are at https://github.com/GetStream/stream-chat-flutter/tree/master/migrations — refer to the relevant version's guide for step-by-step instructions. Once you're on v10, I can continue with the full feature set.If the user asks for help with the migration itself, fetch the relevant migration doc from the URL above and walk them through it step by step.
Only proceed with Chat work once the project resolves a v10 (^10.0.0) dependency. New installs always use ^10.0.0, so this check applies to existing integrations only.
---
Module map
| Track | Module(s) |
|---|---|
| A - New app | `builder.md` + `sdk.md` + relevant reference files |
| B - Existing app | `builder.md` + `sdk.md` + relevant reference files |
| C - Reference lookup | `sdk.md` + relevant reference files |
| D - Bootstrap / setup | `builder.md` + `sdk.md` |
Feeds note (Track A/B): Use Twitter-style UI by default. Only deviate if the user explicitly requests a different style (e.g., "Instagram grid", "Reddit-style votes").
---
Reference layout
Shared Flutter/Dart patterns live in [`sdk.md`](sdk.md).
Product and package specifics live under `references/` using a flat naming scheme:
- Reference:
references/<PRODUCT>-<PACKAGE>.md - Blueprints:
references/<PRODUCT>-<PACKAGE>-blueprints.md
Current extracted modules:
- Chat + pre-built UI (`stream_chat_flutter`): `references/CHAT-FLUTTER.md` + `references/CHAT-FLUTTER-blueprints.md`
- Chat + custom UI (`stream_chat_flutter_core`): `references/CHAT-CORE.md` + `references/CHAT-CORE-blueprints.md`
- Chat advanced (push, offline, lifecycle — both UI tiers): `references/CHAT-ADVANCED-FLUTTER.md` + `references/CHAT-ADVANCED-FLUTTER-blueprints.md` - push notifications, offline/local persistence, connection lifecycle & backgrounding
- Video (`stream_video_flutter`): `references/VIDEO-FLUTTER.md` + `references/VIDEO-FLUTTER-blueprints.md`
- Livestream (`stream_video_flutter`): `references/LIVESTREAM-FLUTTER.md` + `references/LIVESTREAM-FLUTTER-blueprints.md`
- Video advanced use cases (`stream_video_flutter`): `references/VIDEO-ADVANCED-FLUTTER.md` + `references/VIDEO-ADVANCED-FLUTTER-blueprints.md` - audio rooms, multicall, chat+video, livestream feed, querying/events/preferences/moderation
- Ringing / incoming calls + push (`stream_video_flutter` + `stream_video_push_notification`): `references/RINGING-FLUTTER.md` + `references/RINGING-FLUTTER-blueprints.md` - outgoing ring, foreground/background/terminated incoming, CallKit (iOS) + FCM (Android)
- Feeds (`stream_feed` / `stream_feed_flutter_core`): `references/FEEDS-FLUTTER.md` + `references/FEEDS-FLUTTER-blueprints.md`
Additional Stream product coverage should stay in this naming family instead of creating more top-level skills.
---
Track A - New app
Full detail: `builder.md` - use the new-project path.
| Phase | Name | What you do |
|---|---|---|
| A1 | Detect | Run Project signals. If there is no Flutter app yet, tell the user to run flutter create my_app first. |
| A2 | Choose lane | Confirm package choice: stream_chat_flutter (pre-built UI, fastest), stream_chat_flutter_core (custom UI), stream_video_flutter (video/livestream), or stream_feed / stream_feed_flutter_core (activity feeds, no pre-built UI). For Feeds, default to Twitter-style UI. |
| A3 | Install + wire | Follow `builder.md` + `sdk.md`, then load only the needed reference files. |
| A4 | Verify | Confirm flutter pub get succeeds, client connects, and first screen renders. If the app discovers/joins channels or uses guest auth, re-state the permission prerequisite (Step 0.5 → Permissions awareness) so those flows don't 403 on first run. |
---
Track B - Existing app
Full detail: `builder.md` - use the existing-project path.
| Phase | Name | What you do |
|---|---|---|
| B1 | Detect | Run Project signals and inspect the existing app structure before editing. |
| B2 | Preserve | Keep the current navigation, state management, and widget architecture unless the user asks for a change. |
| B3 | Integrate | Use `sdk.md` for shared wiring, then load only the needed reference files. |
| B4 | Verify | Confirm the requested Stream flow builds and renders inside the existing app. If it discovers/joins channels or uses guest auth, re-state the permission prerequisite (Step 0.5 → Permissions awareness) so those flows don't 403 on first run. |
---
Track C - Reference lookup
Load only the relevant files for the requested package.
- Shared lifecycle / auth / state / client-ownership patterns -> `sdk.md`
- Chat advanced production concerns — push notifications, offline/local persistence, connection lifecycle & backgrounding (both UI tiers) -> `references/CHAT-ADVANCED-FLUTTER.md`
- Chat advanced wiring blueprints (FCM setup + background handler, persistence init, lazy-connect gate) -> `references/CHAT-ADVANCED-FLUTTER-blueprints.md`
- Chat pre-built UI setup, widgets, theming, member/user lists, message search, composer flags (voice/polls/drafts), filter operators, permissions -> `references/CHAT-FLUTTER.md`
- Chat pre-built UI widget blueprints -> `references/CHAT-FLUTTER-blueprints.md`
- Chat custom UI setup and controllers -> `references/CHAT-CORE.md`
- Chat custom UI widget blueprints -> `references/CHAT-CORE-blueprints.md`
- Video setup, call types, controls, state, StreamCallContainer -> `references/VIDEO-FLUTTER.md`
- Video widget blueprints (entry point, join, call container, controls, participant tile) -> `references/VIDEO-FLUTTER-blueprints.md`
- Livestream SDK patterns (call type, backstage, goLive/stopLive, HLS) -> `references/LIVESTREAM-FLUTTER.md`
- Livestream widget blueprints (mode selection, creator, WebRTC viewer, HLS viewer) -> `references/LIVESTREAM-FLUTTER-blueprints.md`
- Video advanced patterns (audio rooms, multicall, chat+video wiring, queryCalls, call events, preferences, moderation, session timers, network handling) -> `references/VIDEO-ADVANCED-FLUTTER.md`
- Video advanced use-case blueprints (audio room screen, TikTok-style livestream feed, floating call panel, chat-with-video) -> `references/VIDEO-ADVANCED-FLUTTER-blueprints.md`
- Ringing SDK patterns (push-enabled init, outgoing ring, incoming foreground/background/terminated, CallKit/FCM setup, accept/reject/end, customization, missed calls) -> `references/RINGING-FLUTTER.md`
- Ringing blueprints (push init, background FCM handler, home-screen observers, outgoing ring, call screen, iOS AppDelegate, Android manifest/Gradle) -> `references/RINGING-FLUTTER-blueprints.md`
- Feeds SDK setup, StreamFeedClient, feed types, activities, reactions, follow/unfollow, realtime -> `references/FEEDS-FLUTTER.md`
- Feeds widget blueprints (Twitter-style by default: home feed, activity card, compose, profile, notifications; also Instagram/Reddit variants) -> `references/FEEDS-FLUTTER-blueprints.md`
---
Track D - Bootstrap / setup
Use when the user wants the install and wiring path more than a feature build:
- detect the project shape
- choose
stream_chat_fluttervsstream_chat_flutter_core - add Stream dependencies to
pubspec.yamland runflutter pub get - wire
StreamChatClientand theStreamChatwidget via `sdk.md` - complete platform setup (Android permissions, iOS Info.plist keys) for the chosen packages
- stop before product-specific UI if the user only asked for setup
Stream Flutter - build and integration flow
Use this module after intent classification and, when needed, the local Project signals probe from `SKILL.md`.
---
1. Detect the workspace
Start by understanding what kind of Flutter project is in front of you:
pubspec.yamlwithflutterdependency -> active Flutter projectpubspec.yamlwithstream_chat_flutteralready present -> Stream already installed, check existing wiringpubspec.yamlwith no Stream dependency -> add dependency, then wire- no
pubspec.yamlandEMPTY_CWD-> see scaffolding rule below
Scaffolding (Track A only): if the user explicitly asked to create a new app, scaffold it yourself with flutter create --org <reverse.domain> --project-name <name> --platforms android,ios <dir> (a pre-named empty dir like ringing/ is where it goes). Otherwise — integration/setup with no project present — do not scaffold; tell the user to run flutter create my_app first. See `RULES.md` > Project ownership.
---
2. Choose the integration lane
Resolve three things before editing:
1. Product: Chat, Video, Livestream, Feeds, or a combination 2. Package / tier:
- Chat pre-built UI:
stream_chat_flutter - Chat custom UI:
stream_chat_flutter_core - Video calling or livestreaming:
stream_video_flutter - Activity Feeds:
stream_feeds(the only correct package;stream_feedandstream_feed_flutter_coreare deprecated)
3. Scope: full app bootstrap, auth, a specific screen, or a targeted feature 4. Feeds only — UI style: Default to Twitter-style. Only use a different style if the user explicitly says so.
If the user has not stated a Chat preference, default to stream_chat_flutter. For Video, stream_video_flutter covers both standard calls and livestreaming. For Feeds, use stream_feeds — it is the only current package. Do not use stream_feed or stream_feed_flutter_core.
If the user only asked for setup, stop after the shared wiring in `sdk.md` (for Chat), after client initialization in `references/VIDEO-FLUTTER.md` (for Video), or after setUser + FeedProvider wiring (for Feeds).
---
3. Install the SDK
Chat
Add the dependency to pubspec.yaml:
dependencies:
stream_chat_flutter: ^10.0.0 # pre-built UI
# OR
stream_chat_flutter_core: ^10.0.0 # custom UI only
# Optional - localized strings for SDK widgets
stream_chat_localizations: ^10.0.0Install only the packages needed for the requested scope. Do not add stream_chat_flutter_core when stream_chat_flutter was chosen - the UI package already re-exports it.
Video
Add the dependency to pubspec.yaml:
dependencies:
stream_video_flutter: ^1.4.0 # pre-built UI + core
# optional - video filters (blur/virtual background)
stream_video_filters: ^1.4.0
# OR for core only (no pre-built call UI)
stream_video: ^1.4.0Do not add stream_video separately when stream_video_flutter is chosen - the UI package re-exports it.
Feeds
Add the dependency to pubspec.yaml:
dependencies:
stream_feeds: ^0.5.1 # check pub.dev for latest; requires Dart >=3.10.0Package name is `stream_feeds` (plural). Do not usestream_feed(deprecated, fails to compile on Dart 3) orstream_feed_flutter_core(old package, incompatible withstream_feeds).stream_feedsis the only package needed.
The Feeds SDK has no pre-built UI widgets; all feed screens are built with standard Flutter widgets.
Then run:
flutter pub get---
4. Platform setup
Complete the required platform setup before wiring the client. Missing setup causes runtime crashes or missing permissions.
Chat platform setup
Android
Add the following permissions to android/app/src/main/AndroidManifest.xml if not already present:
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>photo_manager requires additional setup for Android 10+ (API 29+). Follow pub.dev/packages/photo_manager#android-10-q-29 for the manifest changes needed to access the photo library.
⚠️ AGP 9 breaks `file_picker` (transitive dep of `stream_chat_flutter`). Ifflutter build/runfails with:
>
```
GeneratedPluginRegistrant.java:NN: error: cannot find symbol
...add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
symbol: class FilePickerPlugin
```
>
the project was scaffolded with the bleeding-edge Android toolchain (checkandroid/settings.gradle.kts— AGPcom.android.applicationversion9.x).file_picker(pulled in for attachment picking) skips applying its Kotlin plugin under AGP 9, soFilePickerPlugin.ktnever compiles and the generated registrant can't find the class. This is a toolchain mismatch, not an app-code bug — don't `flutter clean` and retry (it won't help). Pin to a compatible set:
>
| File | Setting | Change to |
| -------------------------------------------------- | -------------------------------------------- | ----------------------- |
|android/settings.gradle.kts|id("com.android.application") version|"8.9.1"|
|android/settings.gradle.kts|id("org.jetbrains.kotlin.android") version|"2.1.0"|
|android/gradle/wrapper/gradle-wrapper.properties|distributionUrl|gradle-8.11.1-all.zip|
>
Then rebuild (no clean needed — Gradle downloads the pinned distribution). AGP 8.9.1 + Gradle 8.11.1 + Kotlin 2.1.0 supportcompileSdk35/36 and thekotlin { compilerOptions { jvmTarget } }DSL the Flutter template uses. Bump these as the Stream/Flutter plugins gain AGP 9 support.
iOS
Add these keys to ios/Runner/Info.plist for file access and media:
<!-- file picker -->
<key>NSDocumentsFolderUsageDescription</key>
<string>This app needs access to your files to share attachments.</string>
<!-- image picker / camera -->
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to capture photos and videos.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access to record audio messages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs photo library access to share images.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app needs permission to save images to your photo library.</string>For localization, add supported languages to ios/Runner/Info.plist:
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
</array>Web
Edit web/index.html and add oncontextmenu="return false;" to the <body> tag to allow the SDK to override right-click behavior:
<body oncontextmenu="return false;"></body>macOS
Add entitlements to macos/Runner/Release.entitlements and macos/Runner/DebugProfile.entitlements:
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>Feeds platform setup
No native dependencies beyond standard network access.
Android — add to android/app/src/main/AndroidManifest.xml if not already present:
<uses-permission android:name="android.permission.INTERNET"/>iOS — no additional Info.plist keys are needed for basic feed functionality. If the user adds image upload, add NSPhotoLibraryUsageDescription and NSCameraUsageDescription following the same pattern as Chat.
Video platform setup
Android
Add permissions to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>Set the minimum SDK to 24.
// android/app/build.gradle.kts
android {
defaultConfig {
minSdk = maxOf(24, flutter.minSdkVersion)
}
}Older projects with the Groovy android/app/build.gradle use minSdkVersion 24 inside defaultConfig instead. Check which file your project actually has before editing.
On Android 6+ (API 23+), also request runtime permissions before joining a call. Add permission_handler to pubspec.yaml and call:
await [Permission.camera, Permission.microphone].request();iOS
Add to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Video calls require camera access.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Video calls require microphone access.</string>Set the minimum deployment target to iOS 14.0+. Flutter 3.32+ enables Swift Package Manager by default (flutter config shows enable-swift-package-manager: true), and a fresh flutter create then produces no `ios/Podfile` - the Stream plugins resolve as Swift packages. Don't create a Podfile just to set the target; set it in the Xcode project instead, which all three build configs read from:
# ios/Runner.xcodeproj/project.pbxproj - set in all 3 configs (Debug/Release/Profile)
IPHONEOS_DEPLOYMENT_TARGET = 14.0;Equivalently in Xcode: Runner target -> General -> Minimum Deployments -> iOS 14.0.
⚠️ Under SPM you must ALSO add `MinimumOSVersion` to `ios/Flutter/AppFrameworkInfo.plist`
(<key>MinimumOSVersion</key><string>15.0</string>). The Xcode target alone doesnot raise the generated plugin SPM package's platform, so the build fails with
_"increase your app's minimum platform version from 13.0 to at least 14.0"_. And add
analyzer: { exclude: [build/**] }toanalysis_options.yamlto silence phantom
errors from the plugin sources SPM copies into build/. Both steps, withverification, are in `references/VIDEO-FLUTTER.md` ->
Platform Setup -> iOS.
Only if your project still uses CocoaPods (an ios/Podfile is present - e.g. a plugin without SPM support pulled it in) also set platform :ios, '14.0' at the top of the Podfile.
---
5. Wire the shared app setup
Before writing any code, confirm that Step 0.5 in `SKILL.md` has completed - API key, token, and optional seed channels should already be in context. If not, run that step now before continuing.
Chat
Follow `sdk.md` for:
- client lifetime - initialize
StreamChatClientbeforerunApp StreamChatwidget placement in the tree- auth and token transport - use the real API key and token from Step 0.5, never placeholder strings
- localization setup if
stream_chat_localizationswas added - disconnect/reconnect rules when changing users
If seed channels were created in Step 0.5, the app should render them on first launch without any extra setup.
Video
Follow `references/VIDEO-FLUTTER.md` for:
StreamVideoinitialization beforerunApp- no wrapper widget needed- user and token wiring - use the real API key and token from Step 0.5, never placeholder strings
call.getOrCreate()+call.join()sequence before showing the call UI- platform runtime permission requests on Android before joining
Keep the existing app shell intact. Add only the minimum composition points needed for Stream.
Feeds
Follow `references/FEEDS-FLUTTER.md` for:
StreamFeedClient('apiKey')initialization beforerunAppclient.setUser(user, token)— alwaysawaitbeforerunApp; use real credentials from Step 0.5FeedProvider(bloc: FeedBloc(client: client), child: MaterialApp(...))wrapping the app- feed group references:
client.flatFeed('user', userId),client.flatFeed('timeline', userId) - activity queries with
getEnrichedActivitiesand reactions viaclient.reactions
UI style: Twitter-style by default. Load FEEDS-FLUTTER-blueprints.md and use those blueprints directly. Only adapt to Instagram/Reddit/custom layout if the user explicitly asks for it.
---
6. Load only the needed reference files
Use the product and package tier to choose the smallest relevant reference set.
Available extracted modules:
- Chat pre-built UI: `references/CHAT-FLUTTER.md`
- Chat pre-built UI widget blueprints: `references/CHAT-FLUTTER-blueprints.md`
- Chat custom UI (core): `references/CHAT-CORE.md`
- Chat custom UI blueprints: `references/CHAT-CORE-blueprints.md`
- Chat advanced — push notifications, offline/local persistence, connection lifecycle & backgrounding (both UI tiers): `references/CHAT-ADVANCED-FLUTTER.md`
- Chat advanced wiring blueprints: `references/CHAT-ADVANCED-FLUTTER-blueprints.md`
- Video setup, call types, controls, state: `references/VIDEO-FLUTTER.md`
- Video widget blueprints: `references/VIDEO-FLUTTER-blueprints.md`
- Livestream SDK patterns: `references/LIVESTREAM-FLUTTER.md`
- Livestream widget blueprints: `references/LIVESTREAM-FLUTTER-blueprints.md`
- Video advanced use cases (audio rooms, multicall, chat+video, livestream feed): `references/VIDEO-ADVANCED-FLUTTER.md`
- Video advanced use-case blueprints: `references/VIDEO-ADVANCED-FLUTTER-blueprints.md`
- Ringing / incoming calls + push (CallKit, FCM): `references/RINGING-FLUTTER.md`
- Ringing blueprints: `references/RINGING-FLUTTER-blueprints.md`
- Feeds SDK setup, activities, reactions, follow/unfollow, realtime: `references/FEEDS-FLUTTER.md`
- Feeds widget blueprints (Twitter-style default; Instagram/Reddit variants): `references/FEEDS-FLUTTER-blueprints.md`
If the exact file is not present yet, say so directly instead of faking a reference.
---
7. Verify before you stop
Check the smallest set of outcomes that proves the integration works:
flutter pub getsucceeds with no version conflicts- the app compiles without errors (
flutter buildor hot reload)
Chat:
StreamChatClientis initialized beforerunAppStreamChatwidget appears in the tree before any Stream Chat widget renders- the requested screen (channel list, channel view, thread) appears where expected
- controllers are disposed properly - no "setState after dispose" warnings
- switching users or logging out does not leave orphaned WebSocket connections
Video:
StreamVideois initialized beforerunAppand accessed viaStreamVideo.instancecall.getOrCreate()is called beforecall.join()- the results of both
call.getOrCreate()andcall.join()are checked withresult.fold(...) call.leave()is called indispose()as a safety net- Android runtime camera and microphone permissions are requested before joining
- the
StreamCallContaineror custom call UI appears after a successful join
Feeds:
StreamFeedClientis initialized beforerunAppandsetUseris awaitedFeedProviderwraps the widget tree before anyFlatFeedCoreorFeedBlocaccessclient.flatFeed('timeline', userId)returns activities from followed usersclient.flatFeed('user', userId)stores activities posted by the user- reactions are added/removed via
client.reactions.add/client.reactions.delete - feed subscriptions created in
initStateare cancelled indispose() - the requested feed screen (home timeline, profile, notifications) renders with real data
Chat Advanced - Wiring Blueprints
Load only the section you are implementing. For the SDK patterns and gotchas behind these blueprints, see CHAT-ADVANCED-FLUTTER.md. For client initialization, connectUser, and the StreamChat widget, see CHAT-FLUTTER.md / CHAT-FLUTTER-blueprints.md (pre-built UI) or CHAT-CORE.md (custom UI).
These blueprints are package-agnostic — client is a StreamChatClient regardless of which UI tier you use.
---
Push Notifications Blueprint
Docs: Adding Push Notifications (V2)
End-to-end FCM wiring: a top-level background handler, device registration after connect, token-refresh re-registration, and deregistration on logout. Assumes FlutterFire is already set up (google-services.json / GoogleService-Info.plist, APNs key) and push (v2) is enabled in the Stream Dashboard. If it's not setup correctly let the integrator know and ask to do it. You can create a template/placehodler files.
// main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
// Top-level — required by FCM. Runs in its own isolate with no app state, so
// connect WITHOUT a WebSocket and fetch the message by id from the payload.
@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {
await Firebase.initializeApp();
final client = StreamChatClient('api_key', logLevel: Level.OFF);
await client.connectUser(
User(id: 'user-id'),
'user-token',
connectWebSocket: false, // no live socket in the background isolate
);
final messageId = message.data['id'];
if (messageId != null) {
final response = await client.getMessage(messageId);
// Render with flutter_local_notifications using response.message.* and
// message.data['type'] / message.data['cid'].
}
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage);
final client = StreamChatClient('api_key', logLevel: Level.OFF);
await client.connectUser(User(id: 'user-id', name: 'User'), 'user-token');
await _registerDevice(client); // see helper below
runApp(MyApp(client: client));
}// push_registration.dart — register/deregister helpers
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
Future<void> _registerDevice(StreamChatClient client) async {
final settings = await FirebaseMessaging.instance.requestPermission();
if (settings.authorizationStatus == AuthorizationStatus.denied) return;
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await client.addDevice(token, PushProvider.firebase);
}
// Re-register whenever FCM rotates the token.
FirebaseMessaging.instance.onTokenRefresh
.listen((t) => client.addDevice(t, PushProvider.firebase));
}
// On logout: deregister BEFORE disconnecting.
Future<void> logout(StreamChatClient client) async {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await client.removeDevice(token);
await client.disconnectUser();
}Wiring:
_onBackgroundMessageis top-level and annotated@pragma('vm:entry-point')— a method or closure won't survive the background isolate.- In that isolate use
connectUser(..., connectWebSocket: false)andgetMessage(id); the live app keeps its normal WebSocket connection. addDeviceruns afterconnectUser;onTokenRefreshkeeps the registration current.removeDeviceruns beforedisconnectUserso the server stops targeting a logged-out device.- Push requires channel membership and fires only for new messages; no push arrives while the app holds an active WebSocket.
---
Offline Persistence Blueprint
Docs: Offline Support / Local Data Persistence
Attach the persistence client before connectUser. Nothing else changes — the channel list, message lists, and search read from the local SQLite cache transparently when offline, and queued writes retry on reconnect.
// main.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_persistence/stream_chat_persistence.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final client = StreamChatClient('api_key', logLevel: Level.OFF)
..chatPersistenceClient = StreamChatPersistenceClient(
logLevel: Level.OFF,
// .background runs the DB on an isolate, keeping heavy reads/writes
// off the UI thread. Use .regular for the simplest setup.
connectionMode: ConnectionMode.background,
);
await client.connectUser(
User(id: 'user-id', name: 'User'),
'user-token',
);
runApp(MyApp(client: client));
}
// On logout, flush this user's local DB:
Future<void> logout(StreamChatClient client) async {
await client.disconnectUser(flushChatPersistence: true);
}Wiring:
- Add
stream_chat_persistence: ^10.0.0topubspec.yaml(not a default dependency). - Assign
chatPersistenceClientwith the cascade (..) beforeconnectUser— attaching it after connect does nothing for the current session. - The DB is keyed by
userId; switching users uses a separate DB.flushChatPersistence: trueclears the cache on logout.
---
Lazy-Connect / Backgrounding Blueprint
Docs: Initialize Stream Chat in Part of the Widget Tree
When chat is one section of a larger app, don't block runApp on connectUser. Kick off the connection once, store the future, and gate the chat UI on it. Force route disposal on logout so the WebSocket actually closes.
// chat_section.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
class ChatSection extends StatefulWidget {
const ChatSection({super.key, required this.client, required this.userId, required this.token});
final StreamChatClient client;
final String userId;
final String token;
@override
State<ChatSection> createState() => _ChatSectionState();
}
class _ChatSectionState extends State<ChatSection> {
// Started once — NOT in build, so it doesn't re-fire on rebuild.
late final Future<OwnUser> _connection =
widget.client.connectUser(User(id: widget.userId), widget.token);
@override
void dispose() {
// Closes the WebSocket when the chat section leaves the tree.
widget.client.disconnectUser();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<OwnUser>(
future: _connection,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('Connection failed: ${snap.error}'));
}
return const ChannelListPage();
},
);
}
}
// On logout, force the whole stack to dispose so disconnectUser runs:
void logout(BuildContext context) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}Wiring:
_connectionis alate finalfield — created once, never insidebuild.- The
FutureBuildershows a spinner while connecting, an error view on failure, and the channel list on success. disconnectUser()indispose()closes the socket when the section unmounts;pushAndRemoveUntil(..., (r) => false)guarantees the section is actually disposed on logout so the connection doesn't linger.- Backgrounding/foregrounding is automatic — the SDK closes and reopens the WebSocket with app lifecycle; no manual handling needed.
Chat Advanced - Flutter Production Concerns & Advanced SDK Patterns
Advanced Stream Chat concerns beyond basic messaging UI: push notifications, offline / local data persistence, and connection lifecycle & app backgrounding. These are package-agnostic — they apply equally to stream_chat_flutter (pre-built UI) and stream_chat_flutter_core (custom UI). For full copy-pasteable wiring, see CHAT-ADVANCED-FLUTTER-blueprints.md.
Prerequisites: package install, client init, connectUser, and the StreamChat widget from `CHAT-FLUTTER.md` (pre-built UI) or `CHAT-CORE.md` (custom UI). Shared client-ownership and auth patterns live in `../sdk.md`.
Rules: ../RULES.md (secrets stay server-side, proper disconnect).
Quick ref
- Push:
firebase_messaging+flutter_local_notifications; register withclient.addDevice(token, PushProvider.firebase)afterconnectUser; deregister withclient.removeDevice(token)beforedisconnectUser - Offline: attach
StreamChatPersistenceClientbeforeconnectUser; packagestream_chat_persistence: ^10.0.0 - Lifecycle: WS opens on
connectUser, closes on background, reopens on foreground; gate lazy-connect UI on theconnectUserfuture - Disconnect:
await client.disconnectUser()before switching users; force route disposal on logout so the WS actually closes
---
Push notifications
Docs: Adding Push Notifications (V2)
Push is wired at the SDK level via device registration; the transport (FCM/APNs) is standard FlutterFire setup. Applies to both stream_chat_flutter and stream_chat_flutter_core.
- Packages:
firebase_messaging(FCM) +flutter_local_notifications(to render the notification on Android). Do the normal FlutterFire setup (google-services.json/GoogleService-Info.plist, APNs key). Stream ships no separate push package. - Register after `connectUser`, and on every token refresh:
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await client.addDevice(token, PushProvider.firebase);
FirebaseMessaging.instance.onTokenRefresh
.listen((t) => client.addDevice(t, PushProvider.firebase));addDevice(String id, PushProvider provider, {String? pushProviderName}). PushProvider values: firebase, apn, huawei, xiaomi. pushProviderName: selects a named provider config when you run more than one.
- Deregister on logout:
await client.removeDevice(token)beforedisconnectUser(). - Server config: upload your Firebase service-account JSON in Stream Dashboard → app → push and enable push (v2); APNs is delivered through Firebase by default.
- Handling (per docs): iOS gets a
notification+datapayload (OS shows it); Android getsdata-only — render it yourself. Use a top-level@pragma('vm:entry-point')handler viaFirebaseMessaging.onBackgroundMessage; in that isolate connect withclient.connectUser(user, token, connectWebSocket: false)and fetch the message withclient.getMessage(messageId)(payload keysdata['type'],data['cid'],data['id']). - Gotchas: push requires channel membership (watching isn't enough) and fires only for new messages. No push is sent while the user holds an active WebSocket; setting
onBackgroundEventReceivedonStreamChatkeeps the WS alive ~backgroundKeepAlive(default 15s) after backgrounding. Max 25 devices/user;skip_push, muted channels, andpush_notificationsdisabled on the channel type all suppress delivery. Test on a physical iOS device (simulator won't receive push).
---
Offline & local data persistence
Docs: Offline Support / Local Data Persistence
Attach the official persistence client before connectUser and the SDK caches channels/messages/users in a local SQLite DB, serves them instantly when offline, and queues writes (send/edit/delete) for automatic retry on reconnect.
- Package (not a default dependency — add it):
stream_chat_persistence: ^10.0.0(built ondrift/SQLite). - Wire it:
final client = StreamChatClient('api_key', logLevel: Level.OFF)
..chatPersistenceClient = StreamChatPersistenceClient(
logLevel: Level.OFF,
connectionMode: ConnectionMode.regular, // or .background (DB on an isolate, frees the UI thread)
);
await client.connectUser(user, token);- The DB is keyed by
userId— a different user uses a different DB. Flush a user's cache on logout withawait client.disconnectUser(flushChatPersistence: true). - Once attached there's nothing else to call: the channel list, message lists, and search read from cache transparently when offline.
---
Connection lifecycle & app backgrounding
Docs: Initialize Stream Chat in Part of the Widget Tree
StreamChatopens a WebSocket only once a user is connected (connectUser); before that it just registers lifecycle listeners. Backgrounding closes the WS; foregrounding reopens it automatically.- When chat is one section of a larger app (not the whole app), connect lazily and gate the UI on the connection future rather than blocking
runApp:
late final Future<OwnUser> _connection =
client.connectUser(User(id: userId), token);
// ...
FutureBuilder<OwnUser>(
future: _connection,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) return ErrorView(error: snap.error!);
return const ChannelListPage();
},
)- Call
client.disconnectUser()when the chat section is disposed.disconnectUseronly runs if the route is actually disposed — on logout force it withNavigator.pushAndRemoveUntil(..., (r) => false), otherwise the connection lingers.
---
Verification checklist (advanced)
- Push:
addDeviceis called afterconnectUserand re-registered ononTokenRefresh;removeDeviceruns beforedisconnectUser; the background handler is a top-level@pragma('vm:entry-point')function; the connecting user is a member of the channel (not just watching). - Offline:
chatPersistenceClientis assigned beforeconnectUser; the channel/message lists render from cache with the network off; logout usesflushChatPersistence: trueto clear the user's DB. - Lifecycle: lazy-connect UI is gated on the
connectUserfuture; logout forces route disposal so the WebSocket actually closes (no orphaned connections).
Chat stream_chat_flutter_core - Widget Blueprints
Load only the section you are implementing. For setup, controllers, and gotchas, see CHAT-CORE.md.
---
App Entry Point Blueprint
// main.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final client = StreamChatClient(
'your_api_key',
logLevel: Level.OFF,
);
await client.connectUser(
User(id: 'alice', name: 'Alice'),
'your_user_token',
);
runApp(MyApp(client: client));
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.client});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, widget) => StreamChatCore(
client: client,
child: widget,
),
home: const ChannelListPage(),
);
}
}Wiring:
stream_chat_flutter_coreexportsStreamChatClientfromstream_chat- import onlystream_chat_flutter_coreStreamChatCore(notStreamChat) is the inherited widget for the core packageawait client.connectUser(...)beforerunApp- same as the UI package
---
Custom Channel List Blueprint
Docs: StreamChannelListController · PagedValueListenableBuilder
// channel_list_page.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
class ChannelListPage extends StatefulWidget {
const ChannelListPage({super.key});
@override
State<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
late final _controller = StreamChannelListController(
client: StreamChatCore.of(context).client,
filter: Filter.and([
Filter.equal('type', 'messaging'),
Filter.in_(
'members',
[StreamChatCore.of(context).currentUser!.id],
),
]),
channelStateSort: const [SortOption.desc('last_message_at')],
limit: 20,
);
@override
void initState() {
super.initState();
_controller.doInitialLoad();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Messages')),
body: PagedValueListenableBuilder<int, Channel>(
valueListenable: _controller,
builder: (context, value, child) {
return value.when(
(channels, nextPageKey, error) {
if (channels.isEmpty) {
return const Center(child: Text('No channels yet.'));
}
return RefreshIndicator(
onRefresh: _controller.refresh,
child: ListView.builder(
itemCount: channels.length + (nextPageKey != null ? 1 : 0),
itemBuilder: (context, index) {
if (index == channels.length) {
_controller.loadMore(nextPageKey!);
return const Center(child: CircularProgressIndicator());
}
final channel = channels[index];
return _ChannelTile(
channel: channel,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StreamChannel(
channel: channel,
child: const ChannelPage(),
),
),
),
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${e.message}'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _controller.retry,
child: const Text('Retry'),
),
],
),
),
);
},
),
);
}
class _ChannelTile extends StatelessWidget {
const _ChannelTile({required this.channel, required this.onTap});
final Channel channel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return StreamBuilder<Message?>(
stream: channel.state?.lastMessageStream,
initialData: channel.state?.lastMessage,
builder: (context, snapshot) {
return ListTile(
title: Text(
channel.name ?? channel.id ?? 'Channel',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
snapshot.data?.text ?? 'No messages yet',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: StreamBuilder<int>(
stream: channel.state?.unreadCountStream,
initialData: channel.state?.unreadCount,
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
if (count == 0) return const SizedBox.shrink();
return Badge(label: Text('$count'));
},
),
onTap: onTap,
);
},
);
}
}Wiring:
_controller.doInitialLoad()ininitStatetriggers the first page fetchPagedValueListenableBuilder.value.when(...)covers loaded / loading / error states- Sentinel item at
index == channels.lengthtriggersloadMorewhen scrolled into view _controller.refresh()onRefreshIndicatorpulls fresh data_controller.retry()on error retries the last failed request
---
Custom Channel Page Blueprint
// channel_page.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
class ChannelPage extends StatefulWidget {
const ChannelPage({super.key});
@override
State<ChannelPage> createState() => _ChannelPageState();
}
class _ChannelPageState extends State<ChannelPage> {
final _textController = TextEditingController();
final _scrollController = ScrollController();
bool _isLoadingMore = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
// Watch the channel to load initial messages and subscribe to events
WidgetsBinding.instance.addPostFrameCallback((_) {
StreamChannel.of(context).channel.watch();
});
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadMore() async {
if (_isLoadingMore) return;
final channel = StreamChannel.of(context).channel;
final messages = channel.state!.messages;
if (messages.isEmpty) return;
setState(() => _isLoadingMore = true);
await channel.query(
messagesPagination: PaginationParams(
lessThan: messages.first.id,
limit: 20,
),
);
if (mounted) setState(() => _isLoadingMore = false);
}
Future<void> _sendMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
_textController.clear();
final channel = StreamChannel.of(context).channel;
await channel.sendMessage(Message(text: text));
}
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final channel = StreamChannel.of(context).channel;
return Scaffold(
appBar: AppBar(
title: Text(channel.name ?? channel.id ?? 'Channel'),
),
body: Column(
children: [
if (_isLoadingMore)
const LinearProgressIndicator(),
Expanded(
child: StreamBuilder<List<Message>>(
stream: channel.state!.messagesStream,
initialData: channel.state!.messages,
builder: (context, snapshot) {
final messages = (snapshot.data ?? []).reversed.toList();
return ListView.builder(
controller: _scrollController,
reverse: true,
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final isOwn = message.user?.id ==
StreamChatCore.of(context).currentUser?.id;
return _MessageBubble(message: message, isOwn: isOwn);
},
);
},
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: const InputDecoration(
hintText: 'Write a message...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 8,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _sendMessage,
),
],
),
),
),
],
),
);
}
}
class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message, required this.isOwn});
final Message message;
final bool isOwn;
@override
Widget build(BuildContext context) {
return Align(
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
decoration: BoxDecoration(
color: isOwn
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOwn)
Text(
message.user?.name ?? message.user?.id ?? '',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
Text(
message.text ?? '',
style: TextStyle(
color: isOwn
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
);
}
}Wiring:
channel.watch()inaddPostFrameCallbackloads initial messages and subscribes to real-time eventsStreamBuilder<List<Message>>re-renders the list on every new messagemessages.reversed.toList()+reverse: trueonListView.buildershows newest messages at the bottom- Scroll listener triggers
loadMorewhen the user reaches the top (index 0 in reversed order) channel.query(messagesPagination: PaginationParams(lessThan: oldestId, limit: 20))fetches older messages
---
Typing Indicator Blueprint
StreamBuilder<Map<User, Event>>(
stream: StreamChannel.of(context).channel.state?.typingEventsStream,
initialData: StreamChannel.of(context).channel.state?.typingEvents,
builder: (context, snapshot) {
final typingUsers = snapshot.data?.keys.toList() ?? [];
if (typingUsers.isEmpty) return const SizedBox.shrink();
final names = typingUsers.map((u) => u.name ?? u.id).join(', ');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Text(
'$names ${typingUsers.length == 1 ? 'is' : 'are'} typing...',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
fontStyle: FontStyle.italic,
),
),
);
},
)Send typing events from the input field:
TextField(
controller: _textController,
onChanged: (text) {
if (text.isNotEmpty) {
StreamChannel.of(context).channel.keyStroke();
} else {
StreamChannel.of(context).channel.stopTyping();
}
},
)---
Unread Count Badge Blueprint
Display unread channel counts using the current user's unread state:
StreamBuilder<OwnUser?>(
stream: StreamChatCore.of(context).client.state.currentUserStream,
initialData: StreamChatCore.of(context).client.state.currentUser,
builder: (context, snapshot) {
final totalUnread = snapshot.data?.totalUnreadCount ?? 0;
if (totalUnread == 0) return const Icon(Icons.chat_bubble_outline);
return Badge(
label: Text('$totalUnread'),
child: const Icon(Icons.chat_bubble),
);
},
)---
Message Search Blueprint
Docs: StreamMessageSearchListController
class _MessageSearchPageState extends State<MessageSearchPage> {
late final _controller = StreamMessageSearchListController(
client: StreamChatCore.of(context).client,
filter: Filter.in_('members', [StreamChatCore.of(context).currentUser!.id]),
searchQuery: '',
sort: const [SortOption.desc('created_at')],
messageFilter: Filter.equal('type', 'regular'),
limit: 20,
);
@override
void initState() {
super.initState();
_controller.doInitialLoad();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _search(String query) {
_controller.searchQuery = query;
_controller.doInitialLoad();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: TextField(
decoration: const InputDecoration(
hintText: 'Search messages...',
border: InputBorder.none,
),
onChanged: _search,
),
),
body: PagedValueListenableBuilder<int, GetMessageResponse>(
valueListenable: _controller,
builder: (context, value, child) {
return value.when(
(results, nextPageKey, error) => ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final result = results[index];
return ListTile(
title: Text(result.message.text ?? ''),
subtitle: Text(result.channel?.name ?? ''),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e) => Center(child: Text('Search error: ${e.message}')),
);
},
),
);
}Wiring:
StreamMessageSearchListControllermirrorsStreamChannelListControllerin structure- Update
searchQueryand calldoInitialLoad()on each new search term - Results are
GetMessageResponseobjects with.messageand.channelfields
Chat - stream_chat_flutter_core Setup & Integration
stream_chat_flutter_core provides business logic and data controllers without any pre-built UI. Use it when you need full control over your widget layer. This file covers setup, controllers, reactive state, and gotchas. For widget blueprints, see CHAT-CORE-blueprints.md.
Rules: ../RULES.md (secrets, no dev tokens in production, proper disconnect).
Quick ref
- Package:
stream_chat_flutter_corevia pub.dev - Version:
^10.0.0 - Dart SDK:
^3.11.0| Flutter:>=3.41.0 - Use when:
stream_chat_flutterpre-built widgets don't fit your design system - First: Install -> client init ->
StreamChatCorewidget ->connectUser-> controllers -> custom widgets - Docs:
https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter_core/
For shared client setup patterns see `../sdk.md`. Push notifications, offline/local persistence, and connection-lifecycle/backgrounding (all package-agnostic, apply equally to core) are in `CHAT-ADVANCED-FLUTTER.md` + `CHAT-ADVANCED-FLUTTER-blueprints.md`. Filter operators and permissions are in `CHAT-FLUTTER.md`.
Prerequisite — the existing project must already be on `stream_chat_flutter_core` v10. This skill targets the v10 API exclusively. If the integrator'spubspec.yaml/pubspec.lockpins a 9.x or earlier version, stop and tell the user they must migrate to v10 first — controllers, the reaction/delete APIs, andClientStateimmutability all changed and this skill does not cover the migration path. Resume only once the project resolvesstream_chat_flutter_core: ^10.0.0.
---
Installation
Docs: Installation · Core Setup
# pubspec.yaml
dependencies:
stream_chat_flutter_core: ^10.0.0flutter pub getNo platform-specific setup is required for stream_chat_flutter_core itself - it has no native dependencies. If you add image_picker, file_picker, or other media plugins for your custom attachment UI, follow their individual platform guides.
---
StreamChatCore Widget
Docs: Chat Client / StreamChatCore
stream_chat_flutter_core has its own inherited widget, StreamChatCore, that is separate from StreamChat. Place it in the widget tree before any core widget or controller accesses it.
MaterialApp(
builder: (context, widget) => StreamChatCore(
client: client,
child: widget,
),
home: const ChannelListPage(),
)Access the client anywhere below StreamChatCore:
final client = StreamChatCore.of(context).client;
final currentUser = StreamChatCore.of(context).currentUser;`StreamChatCore` behaviour to be aware of:
- It sets
client.recoverStateOnReconnect = falseon mount. If you watch channels outside a list controller, subscribe toclient.on(EventType.connectionRecovered)and callchannel.watch()to refresh on reconnect. - Default
backgroundKeepAliveis 15 seconds.
---
StreamChannelListController
Docs: StreamChannelListController · User list · Member list · Message search
The primary controller for a paginated, filtered channel list.
class _ChannelListPageState extends State<ChannelListPage> {
late final _controller = StreamChannelListController(
client: StreamChatCore.of(context).client,
filter: Filter.and([
Filter.equal('type', 'messaging'),
Filter.in_(
'members',
[StreamChatCore.of(context).currentUser!.id],
),
]),
channelStateSort: const [SortOption.desc('last_message_at')],
limit: 20,
);
@override
void initState() {
super.initState();
_controller.doInitialLoad();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}doInitialLoad() triggers the first page fetch. Do not call it in build. Dispose the controller in dispose().
---
PagedValueListenableBuilder
Docs: PagedValueListenableBuilder
StreamChannelListController extends PagedValueNotifier. Build reactive UI with PagedValueListenableBuilder:
PagedValueListenableBuilder<int, Channel>(
valueListenable: _controller,
builder: (context, value, child) {
return value.when(
(channels, nextPageKey, error) {
if (channels.isEmpty) {
return const Center(child: Text('No channels yet.'));
}
return ListView.builder(
itemCount: channels.length + (nextPageKey != null ? 1 : 0),
itemBuilder: (context, index) {
if (index == channels.length) {
// Trigger next page when the sentinel item becomes visible
_controller.loadMore(nextPageKey!);
return const Center(child: CircularProgressIndicator());
}
return ChannelListTile(channel: channels[index]);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${e.message}'),
TextButton(
onPressed: _controller.retry,
child: const Text('Retry'),
),
],
),
),
);
},
)value.when(...) covers three states: loaded data (with optional nextPageKey for pagination), loading, and error.
---
StreamChannel (inherited widget)
Wrap each channel screen with StreamChannel so descendant widgets can call StreamChannel.of(context).channel:
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StreamChannel(
channel: channel,
child: const CustomChannelPage(),
),
),
);Inside any descendant widget:
final channel = StreamChannel.of(context).channel;---
Channel state streams
All channel-level reactive state is available as streams on channel.state:
final channel = StreamChannel.of(context).channel;
// Messages
channel.state!.messagesStream // Stream<List<Message>>
channel.state!.messages // List<Message> (current value)
// Members and typing
channel.state!.membersStream // Stream<List<Member>>
channel.state!.typingEventsStream // Stream<Map<User, TypingStartEvent>>
// Reads and unread
channel.state!.readStream // Stream<List<Read>>
channel.state!.unreadCountStream // Stream<int>
channel.state!.lastMessageStream // Stream<Message?>Use StreamBuilder to subscribe:
StreamBuilder<List<Message>>(
stream: channel.state!.messagesStream,
initialData: channel.state!.messages,
builder: (context, snapshot) {
final messages = snapshot.data ?? [];
return CustomMessageList(messages: messages);
},
)---
Sending messages
// Send a plain text message
await channel.sendMessage(Message(text: 'Hello!'));
// Reply in thread
await channel.sendMessage(Message(
text: 'Got it!',
parentId: parentMessageId,
showInChannel: true,
));
// Send with attachment
await channel.sendMessage(Message(
text: 'Check this out',
attachments: [
Attachment(
type: 'image',
imageUrl: 'https://example.com/photo.jpg',
),
],
));---
Reactions
Docs: Message Reactions
sendReaction and deleteReaction take the Message object and a Reaction:
// Add a reaction
await channel.sendReaction(message, Reaction(type: 'like'));
// Remove a reaction
await channel.deleteReaction(message, Reaction(type: 'like'));Reaction counts and scores are accessed via message.reactionGroups (a Map<String, ReactionGroup>).
---
Pagination (messages)
// Load older messages (call when user scrolls to top)
await channel.query(
messagesPagination: PaginationParams(lessThan: oldestMessageId, limit: 20),
);---
StreamMessageComposerController
StreamMessageComposerController manages compose state independently of any UI. It is available in stream_chat_flutter_core.
final composerController = StreamMessageComposerController();
// Set quoted message for reply
composerController.quotedMessage = message;
// Clear quote
composerController.clearQuotedMessage();
// Enter edit mode
composerController.editMessage(existingMessage);
// Check if in edit mode
final editing = composerController.isEditing;
// Access the message being edited
final original = composerController.messageBeingEdited;
// Cancel edit
composerController.cancelEditMessage();
// Clear a set command
composerController.clearCommand();
// Current text
final text = composerController.text;
// Always dispose
composerController.dispose();Edit-mode semantics:
- To enter edit mode call
editMessage(msg)— do not pass a non-initial message to the constructor (the constructor'smessage:is for pre-filling a draft, e.g. a thread reply withparentId) clear()does not exit edit mode; callcancelEditMessage()to exitcancelEditMessage()is a no-op when no edit is active (safe to call unconditionally)
---
Deleting Messages
// Delete for everyone
await channel.deleteMessage(message);
// Delete only for the current user
await channel.deleteMessageForMe(message);Both take the Message object. deleteMessageForMe hides the message only for the calling user; other participants still see it. Delete-for-me state is reflected on the message's state — message.state.isDeletedForMe, isDeletingForMe, isDeletingForMeFailed.
Channel helpers
// True if distinct channel with exactly 2 members — use for "DM" checks
final isDm = channel.isOneToOne;
// True for non-distinct channels (including two-member non-distinct) or larger groups
final isGroup = channel.isGroup;---
Channel permissions & roles
Permissions are enforced per role, per scope, and apply identically whether you build UI with stream_chat_flutter or stream_chat_flutter_core — the core package just means _you_ trigger the queries (StreamChannelListController.doInitialLoad(), client.queryChannels(...), channel.addMembers(...)), so the same grants gate them.
Key point: client-side calls are permission-checked; server-side calls (CLI / backend using the API _secret_) bypass all checks. So seeding works but the same action from the app can 403. The two grants that most often block an open "discover & join groups" UI on the default messaging type:
- `Read Channel` (`ReadChannel`) — required to
queryChannelsfor / open channels the user is not a member of. Often not granted touser/guestby default. - `Add Own Channel Membership` (`AddOwnChannelMembership`) — required for
channel.addMembers([myId])to self-join. Often not granted by default. (Leaving needsRemoveOwnChannelMembership.)
Fix in Dashboard → Chat → Roles & Permissions (permissions v2) for the user and/or guest role on the messaging type, or via UpdateChannelType / per-channel UpdateChannelPartial config_overrides.grants. Guests are stricter than user — grant the guest role too if you sign people in by name. Full table and symptoms: CHAT-FLUTTER.md → Channel permissions & roles. Reference: <https://getstream.io/chat/docs/flutter-dart/chat-permission-policies.md>
---
Gotchas
- Discover/join features depend on channel-type permissions. Querying non-member channels needs
Read Channeland self-join needsAdd Own Channel Membershipon the channel type; the defaultmessaginggrants foruser/guestoften omit both. Works when seeded server-side, 403s from the app. See Channel permissions & roles. - `doInitialLoad()` must be called manually. Unlike
stream_chat_flutter'sStreamChannelListView, the core controller does not auto-fetch. Call it ininitState. - Dispose all controllers.
StreamChannelListController,StreamMessageComposerController, and any manual stream subscriptions must be disposed inState.dispose(). - `channel.state` can be null before watch. Call
await channel.watch()orawait channel.query(...)before readingchannel.state. - `StreamChatCore` vs `StreamChat`.
stream_chat_flutter_coreusesStreamChatCore;stream_chat_flutterusesStreamChat. Don't mix them in the same tree - pick one. - Message pagination is manual. Call
channel.query(messagesPagination: ...)when the user scrolls to the top -StreamMessageListViewhandles this automatically but your custom list does not. - Never create channels in `build`. Channel objects should be stable references held in state, not recreated on every rebuild.
- `sendReaction`/`deleteReaction` take a `Message` and a `Reaction`. Use
channel.sendReaction(message, Reaction(type: 'like'))— there is no(id, 'like')string shorthand. - Reaction counts live on `message.reactionGroups` (
Map<String, ReactionGroup>). - `StreamChatCore` sets `recoverStateOnReconnect = false`. If your app manually watches channels outside a list controller, subscribe to
client.on(EventType.connectionRecovered)to re-query them on reconnect. - `StreamMessageComposerController` edit mode is explicit. Call
controller.editMessage(msg)to enter edit mode;clear()does not exit it — callcancelEditMessage(). - `ClientState` collections are immutable.
client.state.channels/users/activeLiveLocationsthrow on mutation, andaddChannels()/removeChannel()are@internal. Let the SDK manage state viaqueryChannels()/channel.watch().
Chat stream_chat_flutter - Widget Blueprints
Load only the section you are implementing. For setup, client initialization, and gotchas, see CHAT-FLUTTER.md.
---
App Entry Point Blueprint
// main.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_localizations/stream_chat_localizations.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final client = StreamChatClient(
'your_api_key',
logLevel: Level.OFF,
);
await client.connectUser(
User(id: 'alice', name: 'Alice'),
'your_user_token',
);
runApp(MyApp(client: client));
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.client});
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: GlobalStreamChatLocalizations.delegates,
supportedLocales: const [Locale('en')],
builder: (context, widget) => StreamChat(
client: client,
child: widget,
),
home: const ChannelListPage(),
);
}
}Wiring:
WidgetsFlutterBinding.ensureInitialized()is required before any async work inmainawait client.connectUser(...)must complete beforerunAppso the app starts with an active sessionStreamChatinbuilderwraps every route in the app, not justhomeGlobalStreamChatLocalizations.delegatesenables localized Stream widget strings
---
Channel List Page Blueprint
Docs: StreamChannelListView
// channel_list_page.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
class ChannelListPage extends StatefulWidget {
const ChannelListPage({super.key});
@override
State<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
late final _listController = StreamChannelListController(
client: StreamChat.of(context).client,
filter: Filter.in_(
'members',
[StreamChat.of(context).currentUser!.id],
),
channelStateSort: const [SortOption.desc('last_message_at')],
limit: 20,
);
@override
void dispose() {
_listController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Messages')),
body: StreamChannelListView(
controller: _listController,
onChannelTap: (channel) => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StreamChannel(
channel: channel,
child: const ChannelPage(),
),
),
),
),
);
}Wiring:
StreamChannelListControllermust belate finalonState- never created inbuildFilter.in_('members', [userId])restricts the list to channels the current user belongs toSortOption.desc('last_message_at')sorts by most recent activityStreamChannelwraps each destination soChannelPagewidgets can access channel state- Call
_listController.dispose()inState.dispose()to clean up WebSocket listeners
---
Channel Page Blueprint
Docs: StreamMessageListView · StreamChannelHeader
// channel_page.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
class ChannelPage extends StatefulWidget {
const ChannelPage({super.key});
@override
State<ChannelPage> createState() => _ChannelPageState();
}
class _ChannelPageState extends State<ChannelPage> {
final _composerController = StreamMessageComposerController();
final _focusNode = FocusNode();
@override
void dispose() {
_composerController.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: const StreamChannelHeader(),
body: Column(
children: [
Expanded(
child: StreamMessageListView(
config: const StreamMessageListViewConfiguration(
swipeToReply: true,
),
threadBuilder: (_, parent) => ThreadPage(parent: parent!),
onReplyTap: _reply,
),
),
StreamMessageComposer(
messageComposerController: _composerController,
focusNode: _focusNode,
onQuotedMessageCleared: _composerController.clearQuotedMessage,
),
],
),
);
void _reply(Message message) {
_composerController.quotedMessage = message;
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
}Wiring:
StreamChannelHeaderreads channel name and state from theStreamChannelancestorStreamMessageListViewhandles message loading, pagination, and reactions automaticallythreadBuilder(top-level) receives the parent message and returns the thread screenconfig.swipeToReply: trueenables the swipe-to-quote gestureonReplyTapsetsquotedMessageon the composer controller and focuses the inputStreamMessageComposeris the composer widget; its controller parameter ismessageComposerController:- Dispose both
_composerControllerand_focusNodeindispose()
---
Thread Page Blueprint
// thread_page.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
class ThreadPage extends StatelessWidget {
const ThreadPage({super.key, required this.parent});
final Message parent;
@override
Widget build(BuildContext context) => Scaffold(
appBar: StreamThreadHeader(parent: parent),
body: Column(
children: [
Expanded(
child: StreamMessageListView(parentMessage: parent),
),
StreamMessageComposer(
messageComposerController: StreamMessageComposerController(
message: Message(parentId: parent.id),
),
),
],
),
);
}Wiring:
StreamThreadHeadershows the parent message context in the app barparentMessage: parenttellsStreamMessageListViewto show thread replies onlyStreamMessageComposerController(message: Message(parentId: parent.id))pre-configures the composer to send replies into the thread
---
Custom Theme Blueprint
Docs: StreamChat & Theming
Base colors come from your app's Material 3 ColorScheme. Set that on MaterialApp, then pass StreamChatThemeData to StreamChat (via themeData:) only to override specific components.
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF005FFF)),
useMaterial3: true,
),
builder: (context, widget) => StreamChat(
client: client,
themeData: StreamChatThemeData(
messageListViewTheme: const StreamMessageListViewThemeData(
backgroundColor: Color(0xFFF7F7F8),
),
channelListItemTheme: const StreamChannelListItemThemeData(
titleStyle: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
child: widget,
),
home: const ChannelListPage(),
)Wiring:
- The whole UI's palette derives from the ambient Material
ColorScheme— recolor by customizingThemeDataonMaterialApp StreamChatThemeDatais passed viathemeData:and overrides individual component themes- Component slots:
channelHeaderTheme/channelListHeaderTheme/threadHeaderTheme(StreamAppBarThemeData),messageListViewTheme(StreamMessageListViewThemeData),channelListItemTheme(StreamChannelListItemThemeData),quotedMessageTheme, plus thread/voice/poll themes - Unset properties fall back to SDK defaults
- Read the resolved theme with
StreamChatTheme.of(context)
---
Custom Channel List Item Blueprint
Replace individual tiles using itemBuilder while keeping the default tap handling:
StreamChannelListView(
controller: _listController,
onChannelTap: _onChannelTap,
itemBuilder: (context, channels, index, defaultWidget) {
final channel = channels[index];
final lastMessage = channel.state?.lastMessage;
return ListTile(
leading: StreamChannelAvatar(channel: channel),
title: StreamChannelName(channel: channel),
subtitle: lastMessage != null
? Text(
lastMessage.text ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
)
: null,
trailing: channel.state?.unreadCount != null &&
channel.state!.unreadCount > 0
? Badge(
label: Text('${channel.state!.unreadCount}'),
)
: null,
onTap: () => _onChannelTap(channel),
);
},
)Wiring:
StreamChannelAvatarrenders the channel's avatar (auto-generated from members if no image is set)StreamChannelNamerenders the channel display name, falling back to member names for DMsdefaultWidget.copyWith(selected: true)preserves the default layout with a selection highlight (useful for split-view)onChannelTapat the controller level andonTapinsideitemBuildershould call the same handler
---
Split View Blueprint (tablet / desktop)
class SplitView extends StatefulWidget {
const SplitView({super.key});
@override
State<SplitView> createState() => _SplitViewState();
}
class _SplitViewState extends State<SplitView> {
Channel? _selectedChannel;
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: ChannelListPage(
onChannelTap: (channel) => setState(() => _selectedChannel = channel),
selectedChannel: _selectedChannel,
),
),
Expanded(
flex: 2,
child: _selectedChannel != null
? StreamChannel(
key: ValueKey(_selectedChannel!.cid),
channel: _selectedChannel!,
child: const ChannelPage(),
)
: const Center(child: Text('Select a channel')),
),
],
);
}Wiring:
ValueKey(_selectedChannel!.cid)forcesStreamChannelto rebuild when the selected channel changes, preventing stale message listsChannelListPagereceivesselectedChannelto highlight the active item (passed todefaultWidget.copyWith(selected: ...)initemBuilder)- Use
Expanded(flex: 2, ...)to give the message list 2/3 of the available width
---
Login / Connect User Blueprint
Show a login screen before connecting. Call connectUser once per session - not on every screen appear.
class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.onLogin});
final VoidCallback onLogin;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _userIdController = TextEditingController();
bool _isLoading = false;
String? _error;
@override
void dispose() {
_userIdController.dispose();
super.dispose();
}
Future<void> _connect() async {
if (_userIdController.text.trim().isEmpty) return;
setState(() { _isLoading = true; _error = null; });
try {
final client = StreamChat.of(context).client;
final userId = _userIdController.text.trim();
// Fetch token from your backend
final response = await http.get(
Uri.parse('https://your-backend.com/stream-token?user_id=$userId'),
);
final token = response.body.trim();
await client.connectUser(User(id: userId), token);
widget.onLogin();
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _userIdController,
decoration: const InputDecoration(labelText: 'User ID'),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _connect(),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(color: Colors.red)),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _connect,
child: _isLoading
? const CircularProgressIndicator()
: const Text('Connect'),
),
],
),
),
);
}Wiring:
StreamChat.of(context).clientaccesses the already-created client from the widget treeconnectUseris async - alwaysawaitit- Check
mountedbefore callingsetStateafter an async gap to avoid "setState after dispose"
---
State Layer - StreamBuilder Blueprint
Use StreamBuilder for reactive channel state without the full message list widget:
StreamBuilder<List<Member>>(
stream: StreamChannel.of(context).channel.state!.membersStream,
initialData: StreamChannel.of(context).channel.state!.members,
builder: (context, snapshot) {
final members = snapshot.data ?? [];
return ListView.builder(
itemCount: members.length,
itemBuilder: (context, index) => ListTile(
title: Text(members[index].user?.name ?? members[index].userId),
),
);
},
)Common streams on channel.state:
| Stream | Data type |
|---|---|
messagesStream | List<Message> |
membersStream | List<Member> |
lastMessageStream | Message? |
typingEventsStream | Map<User, TypingStartEvent> |
unreadCountStream | int |
readStream | List<Read> |
Feeds Flutter - Widget Blueprints
Load only the section you are implementing. For client initialization, feed types, and gotchas, see FEEDS-FLUTTER.md.
Default style: Twitter-style — build it directly without asking. Only switch to Instagram, Reddit, or a custom layout if the user explicitly requests it; the SDK calls are identical regardless of style.
Package: stream_feeds: ^0.5.1 (not the deprecated stream_feed). See FEEDS-FLUTTER.md for v0.5.x breaking changes.
---
App Entry Point Blueprint
Initializes StreamFeedsClient, connects the user, establishes the self-follow, and optionally seeds sample data.
// main.dart
import 'package:flutter/material.dart';
import 'package:stream_feeds/stream_feeds.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final client = StreamFeedsClient(
apiKey: 'your_api_key',
user: User(id: 'your-user-id', name: 'Your Name'),
tokenProvider: TokenProvider.static(UserToken('your_user_token')),
);
await client.connect();
// Required: timeline must follow the user feed or posts never appear there.
// This is unconditional — it must run on every start, not just during seeding.
await _setupFollows(client);
// Optional dev seed — remove for production.
await _seedPosts(client);
runApp(MyApp(client: client));
}
/// Establishes the self-follow so the user's own posts appear in their timeline.
///
/// Without this, posting to the `user` feed has no effect on the `timeline`
/// feed — the two are independent until a follow relationship is created.
/// Loads the feed first so we can skip if the follow already exists (avoids
/// duplicate follow/notification activities).
Future<void> _setupFollows(StreamFeedsClient client) async {
final userId = client.user.id;
final timelineFeed = client.feedFromId(FeedId.timeline(userId));
await timelineFeed.getOrCreate();
final alreadyFollowing = timelineFeed.state.following
.any((f) => f.targetFeed.fid == FeedId.user(userId));
if (!alreadyFollowing) {
await timelineFeed.follow(
targetFid: FeedId.user(userId),
createNotificationActivity: false,
);
}
}
/// Seeds sample posts for development. Skips if posts already exist.
/// Remove or gate behind a debug flag in production.
Future<void> _seedPosts(StreamFeedsClient client) async {
final userId = client.user.id;
final userFeed = client.feedFromId(FeedId.user(userId));
await userFeed.getOrCreate();
if (userFeed.state.activities.isNotEmpty) return;
for (final text in [
'Just shipped a new feature! 🚀',
'Loving the Flutter ecosystem lately.',
'Stream Feeds makes social apps surprisingly simple.',
]) {
await userFeed.addActivity(
request: FeedAddActivityRequest(
type: 'post',
feeds: [userFeed.fid.rawValue],
text: text,
),
);
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.client});
final StreamFeedsClient client;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Stream Feeds',
home: HomePage(client: client),
);
}
}Wiring:
- Import only
stream_feeds— do not import the deprecatedstream_feed connect()is async — alwaysawaitbeforerunApp- Pass
clientdown through the widget tree or inject via a DI system
---
Home Feed Page Blueprint (Twitter-style timeline)
Shows the current user's timeline with a compose FAB.
// home_page.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stream_feeds/stream_feeds.dart';
import 'activity_card.dart';
import 'compose_page.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key, required this.client});
final StreamFeedsClient client;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
late final Feed _timelineFeed = widget.client.feedFromId(
FeedId.timeline(widget.client.user.id),
);
List<ActivityData> _activities = [];
bool _loading = true;
StreamSubscription? _sub;
@override
void initState() {
super.initState();
_sub = _timelineFeed.stream.listen((state) {
if (mounted) setState(() => _activities = state.activities);
});
_timelineFeed.getOrCreate().then((_) {
if (mounted) {
setState(() { _activities = _timelineFeed.state.activities; _loading = false; });
}
});
}
@override
void dispose() {
_sub?.cancel();
_timelineFeed.dispose();
super.dispose();
}
Future<void> _refresh() async {
await _timelineFeed.getOrCreate();
setState(() => _activities = _timelineFeed.state.activities);
}
Future<void> _loadMore() async {
if (_timelineFeed.state.canLoadMoreActivities) {
await _timelineFeed.queryMoreActivities(limit: 20);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home'), centerTitle: true),
body: _loading
? const Center(child: CircularProgressIndicator())
: _activities.isEmpty
? const Center(child: Text('Follow people to see their posts here.'))
: RefreshIndicator(
onRefresh: _refresh,
child: ListView.separated(
itemCount: _activities.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
if (i == _activities.length - 1) _loadMore();
return ActivityCard(
activity: _activities[i],
feed: _timelineFeed,
currentUserId: widget.client.user.id,
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ComposePage(client: widget.client),
),
);
_refresh();
},
child: const Icon(Icons.edit_outlined),
),
);
}
}---
Activity Card Blueprint (Tweet card)
Renders a single activity in Twitter style: avatar, name, time, text, and action buttons.
// activity_card.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stream_feeds/stream_feeds.dart';
class ActivityCard extends StatefulWidget {
const ActivityCard({
super.key,
required this.activity,
required this.feed,
required this.currentUserId,
});
final ActivityData activity;
final Feed feed;
final String currentUserId;
@override
State<ActivityCard> createState() => _ActivityCardState();
}
class _ActivityCardState extends State<ActivityCard> {
late bool _liked;
late int _likeCount;
@override
void initState() {
super.initState();
_liked = widget.activity.ownReactions.any((r) => r.type == 'like');
_likeCount = widget.activity.reactionGroups['like']?.count ?? 0;
}
Future<void> _toggleLike() async {
if (_liked) {
await widget.feed.deleteActivityReaction(
activityId: widget.activity.id,
type: 'like',
);
setState(() { _liked = false; _likeCount--; });
} else {
await widget.feed.addActivityReaction(
activityId: widget.activity.id,
request: const AddReactionRequest(type: 'like', enforceUnique: true),
);
setState(() { _liked = true; _likeCount++; });
}
}
@override
Widget build(BuildContext context) {
final activity = widget.activity;
final name = activity.user.name ?? activity.user.id;
final handle = '@${activity.user.id}';
final text = activity.text ?? '';
final time = activity.createdAt;
final commentCount = activity.commentCount;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 20,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Text(name[0].toUpperCase()),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 4),
Text(handle, style: TextStyle(color: Colors.grey[600], fontSize: 13)),
const SizedBox(width: 4),
Text('·', style: TextStyle(color: Colors.grey[600])),
const SizedBox(width: 4),
Text(_formatTime(time), style: TextStyle(color: Colors.grey[600], fontSize: 13)),
],
),
const SizedBox(height: 4),
Text(text),
const SizedBox(height: 8),
Row(
children: [
_ActionButton(
icon: Icons.chat_bubble_outline,
count: commentCount,
onTap: () {},
),
const SizedBox(width: 32),
_ActionButton(
icon: _liked ? Icons.favorite : Icons.favorite_border,
count: _likeCount,
color: _liked ? Colors.red : null,
onTap: _toggleLike,
),
],
),
],
),
),
],
),
);
}
String _formatTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays}d';
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.count,
required this.onTap,
this.color,
});
final IconData icon;
final int count;
final VoidCallback onTap;
final Color? color;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Row(
children: [
Icon(icon, size: 18, color: color ?? Colors.grey[600]),
const SizedBox(width: 4),
if (count > 0)
Text('$count', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
],
),
);
}
}---
Compose Page Blueprint
Lets the user write and post a tweet.
// compose_page.dart
import 'package:flutter/material.dart';
import 'package:stream_feeds/stream_feeds.dart';
class ComposePage extends StatefulWidget {
const ComposePage({super.key, required this.client});
final StreamFeedsClient client;
@override
State<ComposePage> createState() => _ComposePageState();
}
class _ComposePageState extends State<ComposePage> {
final _controller = TextEditingController();
bool _posting = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _post() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
setState(() => _posting = true);
try {
final userFeed = widget.client.feedFromId(
FeedId.user(widget.client.user.id),
);
await userFeed.addActivity(
request: FeedAddActivityRequest(
type: 'post',
feeds: [userFeed.fid.rawValue],
text: text,
),
);
if (mounted) Navigator.pop(context);
} finally {
if (mounted) setState(() => _posting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
actions: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: FilledButton(
onPressed: _posting ? null : _post,
child: _posting
? const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Post'),
),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _controller,
maxLines: null,
maxLength: 280,
autofocus: true,
decoration: const InputDecoration(
hintText: "What's happening?",
border: InputBorder.none,
),
style: const TextStyle(fontSize: 18),
),
),
);
}
}---
User Profile Page Blueprint
Shows a user's posts with a follow/unfollow button.
// profile_page.dart
import 'package:flutter/material.dart';
import 'package:stream_feeds/stream_feeds.dart';
import 'activity_card.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key, required this.client, required this.userId});
final StreamFeedsClient client;
final String userId;
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
late final Feed _userFeed = widget.client.feedFromId(
FeedId.user(widget.userId),
);
late final Feed _timelineFeed = widget.client.feedFromId(
FeedId.timeline(widget.client.user.id),
);
bool? _isFollowing;
bool _loadingFollow = false;
List<ActivityData> _activities = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_userFeed.dispose();
_timelineFeed.dispose();
super.dispose();
}
Future<void> _load() async {
// Load the target user's posts and the current user's timeline (for follow state).
await Future.wait([_userFeed.getOrCreate(), _timelineFeed.getOrCreate()]);
setState(() {
_activities = _userFeed.state.activities;
_isFollowing = _timelineFeed.state.following
.any((f) => f.targetFeed.fid == FeedId.user(widget.userId));
_loading = false;
});
}
Future<void> _toggleFollow() async {
setState(() => _loadingFollow = true);
if (_isFollowing!) {
await _timelineFeed.unfollow(targetFid: FeedId.user(widget.userId));
} else {
await _timelineFeed.follow(targetFid: FeedId.user(widget.userId));
}
setState(() { _isFollowing = !_isFollowing!; _loadingFollow = false; });
}
@override
Widget build(BuildContext context) {
final isOwnProfile = widget.userId == widget.client.user.id;
return Scaffold(
appBar: AppBar(title: Text('@${widget.userId}')),
body: _loading
? const Center(child: CircularProgressIndicator())
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 32,
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Text(
widget.userId[0].toUpperCase(),
style: const TextStyle(fontSize: 24),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.userId,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18)),
Text('@${widget.userId}',
style: TextStyle(color: Colors.grey[600])),
],
),
),
if (!isOwnProfile && _isFollowing != null)
FilledButton(
onPressed: _loadingFollow ? null : _toggleFollow,
style: _isFollowing!
? FilledButton.styleFrom(
backgroundColor: Colors.grey[200],
foregroundColor: Colors.black87)
: null,
child: Text(_isFollowing! ? 'Following' : 'Follow'),
),
],
),
),
),
const SliverToBoxAdapter(child: Divider(height: 1)),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) => Column(
children: [
ActivityCard(
activity: _activities[i],
feed: _userFeed,
currentUserId: widget.client.user.id,
),
const Divider(height: 1),
],
),
childCount: _activities.length,
),
),
],
),
);
}
}---
UI Style Variants
If the user explicitly asks for a style other than Twitter, adapt the following:
| Style | Change from Twitter default |
|---|---|
| Replace ListView with a staggered grid; make images primary; full-width cards with bottom action bar | |
| Add upvote/downvote reactions; show score prominently; group by feed-group-style sections | |
| Custom | Ask the user what the feed card and action buttons should look like before writing widget code |
The SDK calls (activities, reactions, follow/unfollow) are identical across all styles — only the widget layer changes.