This style guide is adapted from the Flutter repository's style guide. Where our conventions differ, the divergence is called out inline so readers understand the choice was deliberate.
The primary audience is contributors (human and AI) working inside this monorepo. If you are integrating the design system into your own app, follow whatever style your app uses — this guide is not meant to constrain consumers.
Optimize for readability. Split the public API cleanly between core.dart and
chat.dart. Use relative imports inside lib/src/. Every component ships with a
theme, a golden test, and a Widgetbook use-case. Update the affected package's
CHANGELOG.md in the same PR.
This document describes high-level philosophy, policy decisions, and specific style
rules for the code in this monorepo. It applies to the two SDK packages (stream_core,
stream_core_flutter) and the Widgetbook gallery under apps/design_system_gallery/.
The linter here opts into all of Dart's lints via all_lint_rules.yaml, then
selectively disables those that don't fit; the analyzer is the source of truth for
what compiles cleanly. This guide covers the human conventions above and around the
linter.
Sections below cover:
- Quick rules — the short checklist to skim before every change
- Philosophy — the "why" behind the rules
- Repository structure — monorepo layout, Melos, barrel contract
- Documentation — dartdoc conventions
- Coding patterns — asserts, dispose, equality, streams, etc.
- Testing — unit, widget, and golden tests
- Naming — identifiers, callbacks, booleans
- Comments — when to write them, when not to
- Formatting — line length, class ordering
- Widgets, themes, and design system — components, theme hierarchy, Widgetbook, icons
- Commits, PRs, and changelogs
The hard rules to check before opening a PR. Every rule below is expanded later in the document; the section link is provided.
Public API and package boundaries
- Every new public file under
lib/src/must be exported from exactly one ofcore.dartorchat.dart.melos run check:barrelsenforces this and fails PR builds. → Public barrel contract - No file under
lib/src/may import a public barrel (core.dart,chat.dart,stream_core_flutter.dart). Use a relative import to the source file. → Public barrel contract stream_core(the pure-Dart LLC) never depends on Flutter. Anything visual or widget-related belongs instream_core_flutter.
Code
- Line width: 120 characters (configured in
analysis_options.yaml). Comments and docs follow the same limit. - Single quotes, relative imports inside
lib/src/(always_use_package_importsis disabled here — a deliberate divergence from Flutter's style, favouring refactor-friendly relative paths inside the package). - Trailing commas preserved,
constwherever possible,finalfor locals that aren't reassigned. - Prefer named parameters for booleans (
avoid_positional_boolean_parameters). - File names are
snake_case.dart(file_names). Imports follow the standard order:dart:→package:→ relative — one blank line between groups (directives_ordering).
Design system
- Every new component ships with:
- A widget file under
lib/src/components/<category>/. - A
<Widget>Theme+<Widget>ThemeDatainlib/src/theme/components/, annotated with@themeGen. → Theme system - A golden test in
test/components/<name>/. - A Widgetbook use-case in
apps/design_system_gallery/.
- A widget file under
- Defaults live in the widget implementation (nullable theme fields, null-coalescing
chain in
build) — mirrors Flutter's ownAppBar/TabBarpattern. - Never hand-roll
copyWith,merge,lerp,==, orhashCodeon theme classes — the generator produces them. → Theme system - Drop shadows use Material
elevationin dp, not hand-paintedBoxShadowlists.StreamBoxShadowis reserved for the places Material cannot reach. → Elevation and shadows - Icons are generated from SVGs. Do not hand-edit the icon font or the generated
StreamIconsclass. → Icons
Docs and comments
- Public dartdoc describes the observable contract, not the implementation. Skip
mentions of
BehaviorSubject, "unmodifiable", "Stream emits X", or which internal type is used. → Public docs describe the contract, not implementation _-prefixed members receive//block comments, not///dartdoc.- Default to zero inline
//comments in implementation. Prefer well-named locals and early returns over comments explaining what code does. // ignore: ...directives do not require an explanatory comment. This is the repo style, not the Flutter convention.
Process
- Update the affected package's
CHANGELOG.mdunder theUpcomingheading with labels like### ✨ Features,### 🐛 Bug Fixes,### 🛑 Breaking / Removals. → Changelog policy - PR titles follow Conventional Commits:
fix(scope): …,feat(scope): …,refactor(scope)!: …for breaking changes.
An SDK API is for years, not just for the one PR you are working on. A design system is even worse — every widget lands in downstream apps, and each field of each theme becomes a compat constraint. A signature committed today is one we have to keep supporting until we ship a major version bump.
Write what you need and no more, but when you write it, do it right.
Avoid implementing features you don't need. You can't design a feature without knowing what the constraints are. Implementing features "for completeness" results in unused code that is expensive to maintain, learn about, document, test, etc.
Avoid workarounds. Workarounds merely kick the problem further down the road, but at a higher cost. Take the time to fix a problem properly rather than being the one who fixes everything quickly but leaves cleanup for later.
When you fix a bug, first write a test that fails, then fix the bug and verify the test passes.
When you implement a new component or feature, write tests for it (widget tests and golden tests for visible components). If something isn't tested, it is very likely to regress or get "optimized away" during a refactor.
Don't submit code with the promise to "write tests later".
There should be no objects that represent live state that reflect some state from another source, since they are expensive to maintain. Keep only one source of truth, and don't replicate live state.
Concretely for this repo: theme values flow one direction (theme data → widget
build); widgets don't cache computed theme values. ChangeNotifier-driven state
(controllers) is the source of truth; snapshots in local State are stale by design.
Getters should be O(1) or return a cached value — callers may hit them repeatedly
during build, layout, or event handling. If an operation is slow or side-effectful,
use a method. A getter that returns a Future or Stream returns the existing one;
it doesn't kick off new work.
There should be no public APIs that require synchronously completing an expensive
operation (e.g. blocking on a network call). Expensive work should be asynchronous
and the type signature (Future, Stream) should show it.
The SDK is two-package layered:
stream_core # Pure Dart transport layer
└── stream_core_flutter # Flutter UI primitives + design system
Convenience APIs belong at the layer above the one they are simplifying. Do not push a "convenience" API down a layer just to have it available to lower layers — that pulls higher-level concepts into places they don't belong.
stream_core_flutter further splits its public API through two barrels: core.dart
(cross-product primitives) and chat.dart (chat-domain widgets). See
Public barrel contract.
Themes, @freezed models, and other value classes are immutable — callers get
a new instance via copyWith, not mutation. Widgets that take a child are
agnostic about its runtime type; don't is-check the child.
A function should operate only on its arguments and, if it is an instance method, data stored on its object. Global state makes code hard to test, hard to reason about, and hard to reuse.
Theme values are threaded through StreamTheme.of(context) — an
ThemeExtension lookup that resolves to a concrete StreamTheme. We do
not have singletons for theme, colors, or design tokens.
Having dedicated APIs for performance reasons is fine. If one specific operation is expensive using the general API but could be implemented more efficiently using a dedicated API, that is where a dedicated API belongs.
Don't provide APIs that walk entire trees, or that encourage O(N²) algorithms, or
that encourage sequential long-lived operations where the operations could be run
concurrently. Similarly, if an operation is expensive, that expense should be
represented in the API (e.g. by returning a Future or a Stream).
Predictable APIs that give the developer control are generally preferred over APIs that mostly do the right thing but don't give the developer any way to adjust the results. Predictability is reassuring.
Before adding a public API, be able to point to a specific integration that needs
it — a real customer request, a downstream consumer in stream-chat-flutter or
stream-video-flutter, a documented gallery use-case. APIs designed against
hypothetical needs tend to have the wrong shape for every real caller; APIs
designed against one real caller are easier to generalize later, once a second
caller shows up.
When we create a new feature that requires a change across the stack, it's tempting to design the lowest-level API first, since that's the closest to the "interesting" code. Design the top-level API first — the widget or theme field a caller will touch — then work down to the primitives.
If logs contain messages callers can safely ignore, they will do so, and eventually the logs are so chatty the critical messages get lost. Only log actual errors and actionable warnings.
Use the SDK's Logger utility, gated at an appropriate level. In Flutter code,
prefer debugPrint over raw print.
Every time you find the need to report an error, consider how you can make this the most useful and helpful error message. Put yourself in the shoes of whoever sees it. Every error message is an opportunity to make someone love our product.
Temporary workarounds (// ignore hacks, monkey-patches of upstream APIs) should be
documented with a link to the tracking issue and a plan for removing them. Long-term
workarounds should be turned into proper fixes.
Code that is no longer maintained should be deleted, not commented out. Commented-out code bitrots quickly and will confuse people maintaining the code.
If a component is being deprecated, follow the deprecation policy: annotate with
@Deprecated('Use X instead.'), add a ### 🛑 Breaking / Removals CHANGELOG entry,
and keep the deprecated API for at least one minor release before removal.
Deprecating an API that a product SDK (stream-chat-flutter, stream-video-flutter)
re-exports is different — those product SDKs have their own deprecation cycles with
their own users. Removing a re-exported API in the next minor of core would break
the product SDK's public surface without going through its deprecation cycle. Do
not remove such an API until every product SDK that re-exports it has completed a
deprecation cycle for it (typically the next major of the product SDK). When in
doubt, check who re-exports the symbol before removing it.
Third-party code must live in a third_party/ subdirectory of the package with a
LICENSE file that describes the license and a README describing its
provenance. Avoid third-party code unless strictly necessary.
stream-core-flutter/
├── melos.yaml # Workspace + centralized dependencies
├── analysis_options.yaml # Delegates to all_lint_rules.yaml + selective disables
├── all_lint_rules.yaml # Opt-in-all Dart lint set
├── STYLE_GUIDE.md # This file
├── CLAUDE.md # AI-agent pointer to this guide + repo overview
├── packages/
│ ├── stream_core/ # LLC — pure Dart transport layer
│ └── stream_core_flutter/ # Flutter UI + design system
├── apps/
│ └── design_system_gallery/ # Widgetbook-based interactive showcase
└── scripts/ # Repo-level helpers
stream_core_flutter exposes multiple narrow public barrels so each Stream product
SDK (chat today; video, feeds, … in the future) can pull in just the primitives it
needs without paying for other products' code:
package:stream_core_flutter/core.dart— cross-product primitives, theme tokens, the component factory. Safe for any Stream SDK.package:stream_core_flutter/chat.dart— chat-specific widgets (message bubble, composer attachments, reactions, …). Chat SDKs import this alongsidecore.dart.package:stream_core_flutter/stream_core_flutter.dart— deprecated convenience barrel that re-exports the others. Will be removed at 1.0.0.
Additional product barrels (video.dart, feeds.dart, …) can be added when a new
Stream product needs domain-specific widgets that don't belong in core.dart. Each
new barrel gets its own entry in check_barrels.yaml under barrels:, and the
same rules apply.
Rules enforced by melos run check:barrels (config at
packages/stream_core_flutter/check_barrels.yaml, wired into CI):
- Every public file under
lib/src/must appear in exactly one listed barrel. No duplicates, no orphans, no dangling exports. - No file under
lib/src/may import a public barrel (core.dart,chat.dart,stream_core_flutter.dart, and any future product barrel). Use a relative import to the source file — barrels are for consumers, not internal code. - Anything under a directory listed in
check_barrels.yaml'sinternal_dirsis excluded from coverage. Use this for figma-generated tokens and other implementation-only artefacts (currently:lib/src/theme/primitives/internal).
When adding a new public widget or theme: create the file under lib/src/…, then
add an export 'src/…/my_file.dart'; line to the appropriate barrel — core.dart
if the widget is product-agnostic, chat.dart if it's chat-specific, or a new
product barrel if you're introducing one. The check fails on PR if you forget.
Dependencies for all packages are centrally managed in melos.yaml under
command.bootstrap.dependencies. Do not edit version constraints directly in an
individual package's pubspec.yaml — update melos.yaml and run melos bootstrap.
When you add a new dependency:
- Add it to
melos.yamlundercommand.bootstrap.dependencies(ordev_dependencies). - Add the bare package name to the affected
pubspec.yamlfiles. - Run
melos bootstrap.
Generated files (*.g.dart, *.freezed.dart, *.g.theme.dart) are excluded from
analysis (packages/*/lib/**/*.*.dart in analysis_options.yaml). Do not edit them
by hand. If a generated file is stale, run:
melos run generate:allSub-tasks:
melos run generate:icons— regenerates the icon font from SVGs inassets_source/icons/. → Iconsmelos run gen-l10n— regenerates localization ARB output.
Public dartdocs are encouraged but currently not lint-enforced
(public_member_api_docs is disabled in analysis_options.yaml; this is temporary
while the repo catches up). New public code should still ship with dartdocs.
In general, follow the Effective Dart documentation guide except where this page contradicts it.
When working on the SDK, if you find yourself asking a question about our systems, place the answer into the documentation where you first looked. That way, the docs consist of answers to real questions, in the places where people would look to find them.
If someone could have written the same documentation without knowing anything about the class other than its name, then it's useless.
// BAD:
/// The size.
final StreamAvatarSize size;
// GOOD:
/// The diameter of the avatar in logical pixels.
///
/// Defaults to [StreamAvatarSize.md] (32px). Use a preset like
/// [StreamAvatarSize.sm] rather than a raw pixel value to stay aligned with the
/// design system.
final StreamAvatarSize size;Dartdoc describes the observable behavior of an API, not how it happens to be implemented today. Skip mentions of:
- Specific implementation types the caller doesn't see (e.g.
BehaviorSubject,UnmodifiableListView) - Internal caching strategies unless the caller's code needs to know
- "Stream emits X" style — describe what values are produced and when, not the stream mechanics
Exception: public base classes and mixins in the type signature (e.g.
extends ValueNotifier<T>, with ChangeNotifier) are part of the contract, not a
leak. Mentioning them helps callers reach for ValueListenableBuilder.
Do not justify code by cross-referencing Flutter framework internals ("matching
Flutter's AppBar", "same behavior as MaterialButton"). Describe what the code
does directly. This applies even here, where we deliberately follow Flutter's
AppBar/TabBar pattern for defaults — say "defaults live in the widget's build,
not the theme data" without name-dropping.
If you're stuck coming up with useful documentation, some prompts:
- If someone is looking at this documentation, they have a question they couldn't answer by guessing or reading the code. What could that question be?
- What might a caller want to know that isn't obvious from the type?
- Are there edge cases outside the normal range (negative numbers, empty lists,
null,disabled,loading)? - Does this member interact with any others?
- Are there lifecycle considerations? Who owns the object? Who calls
dispose?
// BAD:
/// Note: It is important to be aware of the fact that in the absence of an
/// explicit value, this property defaults to 2.
// GOOD:
/// Defaults to 2.Do not start sentences with "Note:" or "Note that". It adds nothing.
If a class is typically obtained via some mechanism other than its constructor, mention that in the class documentation.
Use See also: to link to related APIs:
/// See also:
///
/// * [StreamAvatar], which uses these size variants.
/// * [StreamAvatarThemeData.size], for setting a global default size.Each See also: line ends with a period. Prefer "which…" over parenthetical
descriptions.
If writing the documentation proves difficult because the API is convoluted, rewrite the API rather than trying to document it.
Avoid starting a sentence with a lowercase letter. End all sentences with a period.
// BAD:
/// [foo] must not be null.
// GOOD:
/// The [foo] argument must not be null.Avoid "you" and "we". Rather than telling someone to do something, use "Consider", as in "To obtain the foo, consider using [bar]."
Never use "simply", or say the reader need "just" do something.
_-prefixed members receive // block comments, not /// dartdoc. Dartdoc
machinery (cross-references, IDE hover from outside the library) buys nothing for
library-private surfaces, and using /// on private members makes the tooling
suggest they should be public.
// GOOD (private member):
// Cached because computing the mask involves iterating every pixel.
Path? _cachedClipMask;
// GOOD (public member):
/// The diameter of the avatar in logical pixels.
final double size;Include a short dart code block in the dartdoc for widgets and complex APIs.
Longer, runnable examples belong in apps/design_system_gallery/ (Widgetbook).
Do not use {@tool dartpad} — we don't have infrastructure to render it.
Use @Deprecated('Use X instead.'). Add a CHANGELOG.md entry under
### 🛑 Breaking / Removals describing the deprecation and pointing at the
replacement.
The first paragraph of any dartdoc section must be a short, self-contained sentence explaining the purpose of the item. Subsequent paragraphs elaborate. Avoid multi- sentence first paragraphs — the first paragraph gets extracted for tables of contents.
When referencing a parameter, use backticks. When referencing a parameter that also corresponds to a property, use square brackets instead.
Avoid using "above" or "below" to reference other dartdoc sections. Dartdoc pages are often viewed in isolation.
The linter enforces most of the rules in this section — see
analysis_options.yaml (which delegates to
all_lint_rules.yaml) for the authoritative list. Rules
highlighted below either extend a lint (adding rationale or a repo-specific pattern)
or capture conventions the linter can't check.
assert() lets us verify invariants without paying a cost in release mode, because
Dart only evaluates asserts in debug mode.
Use asserts for conditions that should be impossible unless there is a bug. Do not use asserts to validate user input or network data (those must throw at runtime).
Assert messages are not required in this repo (prefer_asserts_with_message is
disabled). Add a message when the invariant isn't self-evident from the expression;
otherwise a bare assert(condition) is fine.
// Fine — the expression is self-explanatory.
assert(size > 0);
// Better with a message — the invariant needs context.
assert(!_disposed, 'StreamAvatarController used after dispose()');Use the most relevant constructor when there are multiple options.
// BAD:
const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 8.0);
// GOOD:
const EdgeInsets.symmetric(vertical: 8.0);Prefer a local const or a static const in a relevant class over a global
constant. Global constants that do need to exist should be prefixed with k.
Use switch (statement or expression) with exhaustive cases when examining an enum;
the analyzer will warn if you miss a value. Avoid default: unless the switched
value isn't statically known — a default clause silences the exhaustiveness check.
// GOOD:
final radius = switch (size) {
StreamAvatarSize.xs => 10.0,
StreamAvatarSize.sm => 12.0,
StreamAvatarSize.md => 16.0,
StreamAvatarSize.lg => 20.0,
StreamAvatarSize.xl => 24.0,
StreamAvatarSize.xxl => 40.0,
};The analyzer runs with strict-inference: true and strict-raw-types: true.
Combined with the linter, this means:
- All public API members should have explicit type annotations — parameters, fields,
and return types (
always_declare_return_types,type_annotate_public_apis). - Raw types (
List,Map,Futurewithout a type argument) are flagged; declare the element type. - Avoid
dynamic. If the type is unknown, preferObject?and casting;dynamicdisables all static checking.
For local variables, follow the omit_local_variable_types lint — omit the
annotation when the type is obvious from the initializer, but keep it when it isn't.
Inside packages/<pkg>/lib/src/, use relative imports
(always_use_package_imports is disabled here — a deliberate divergence from
Flutter's style guide, matching Dart's Effective Dart recommendation for
package-internal code).
// GOOD — relative for in-package files.
import '../../theme/components/stream_avatar_theme.dart';
import '../common/stream_network_image.dart';
// GOOD — package: for external and cross-package imports.
import 'package:flutter/material.dart';
import 'package:stream_core/stream_core.dart';Do not use package:stream_core_flutter/... inside lib/src/ — that path is
reserved for consumers, and using it internally would round-trip through the public
barrel (which the check:barrels rule also forbids).
Directive order: dart: → package: → relative, with one blank line between
groups (directives_ordering).
Extension methods let you add additional functionality to an existing type. When choosing between declaring a regular instance method and an extension method, consider the trade-offs. Extension methods are resolved statically and cannot be overridden. Furthermore, misusing extension methods can pollute IDE suggestions and cause naming collisions.
Don't declare an extension method when declaring a regular method will do.
Don't use extension methods if the end developer might want to override the extension method's implementation. Extension methods cannot be overridden.
Don't create extension methods with the same name on the same type in separate libraries. This causes collisions if both libraries are imported.
FutureOr is a Dart-internal type used to explain aspects of the Future API.
avoid_futureor_void is enabled here. In public APIs, avoid the temptation to
create APIs that are both synchronous and asynchronous — it results in APIs that
are less type-safe and harder to reason about.
You may use FutureOr for callback parameters where the caller's callback may or
may not be async.
The @visibleForTesting annotation marks a public API such that callers get a
warning outside test/ directories. The API is still public.
Rather than rely on it, design APIs so they are testable through the public API
without exposing sensitive internals. If a member is only used for testing,
prefix its name with debug or move it into the test file.
If you look for an available port, then try to open it, several times a week some other code will open that port between your check and your open. Similarly, timeouts based on how long something "usually takes" will trigger spuriously.
Race conditions are the primary cause of flaky tests. Avoid timeouts entirely. Wait for a triggering event.
Numbers should be understandable. If the derivation isn't obvious, either restructure the expression to be self-describing or add a comment.
// BAD:
final radius = 4.24264068712;
// GOOD:
final radius = 3.0 * math.sqrt(2);When defining mutable properties that require notifying listeners on change:
StreamAvatarSize get size => _size;
StreamAvatarSize _size;
set size(StreamAvatarSize value) {
if (_size == value) {
return;
}
_size = value;
notifyListeners();
}Do not perform side effects in setters other than marking the object dirty and updating internal state.
For value classes without generated equality, use:
@override
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
return other is Foo
&& other.bar == bar
&& other.baz == baz;
}
@override
int get hashCode => Object.hash(bar, baz);Themes get their ==/hashCode from theme_extensions_builder; models often use
equatable. Do not hand-roll equality when a generator or an Equatable base can
do it.
For classes that appear in error messages or logs, override toString. Avoid bare
$runtimeType — use objectRuntimeType(this, 'ClassName'), which strips runtime
type at release-mode.
If a class holds a StreamSubscription, a Listenable listener, a
ChangeNotifier, or a persistent connection, provide a dispose() method and
document who is responsible for calling it.
The close_sinks, cancel_subscriptions, and
use_late_for_private_fields_and_variables lints catch some cases; the rest is a
review responsibility.
InheritedWidget.of(context) and .maybeOf(context) use
context.dependOnInheritedWidgetOfExactType, which is forbidden inside initState.
Move the lookup to didChangeDependencies (for lifecycle-scoped lookups) or
didUpdateWidget (for reactions to prop changes). Reading the inherited widget in
initState throws in debug mode and returns the wrong value in release mode.
// BAD:
@override
void initState() {
super.initState();
final theme = StreamTheme.of(context); // Throws in debug mode.
}
// GOOD:
@override
void didChangeDependencies() {
super.didChangeDependencies();
final theme = StreamTheme.of(context);
}Return inside each branch of a conditional rather than reassigning a shared variable that gets post-processed after the branches.
// BAD:
Widget build(BuildContext context) {
Widget child;
if (isLoading) {
child = const CircularProgressIndicator();
} else if (hasError) {
child = const StreamErrorView();
} else {
child = _content();
}
return Padding(padding: const EdgeInsets.all(8), child: child);
}
// GOOD:
Widget build(BuildContext context) {
const padding = EdgeInsets.all(8);
if (isLoading) return const Padding(padding: padding, child: CircularProgressIndicator());
if (hasError) return const Padding(padding: padding, child: StreamErrorView());
return Padding(padding: padding, child: _content());
}In general we avoid direct use of Stream classes in this repo. Streams in
general are fine — we encourage people to use them — but they have some
disadvantages that make them awkward as a public reactive primitive, and we
prefer to wrap them in our own emitter types for this reason. For example:
-
Streams have a heavy API. For example, they can be synchronous or asynchronous, broadcast or single-client, and they can be paused and resumed. It is non-trivial to determine the right semantics for a particular stream when it will be used in all the ways SDK code could be used, and it is non-trivial to fully implement the semantics correctly.
-
The APIs for manipulating streams are non-trivial (e.g. transformers).
We generally prefer SharedEmitter for broadcast events and StateEmitter for
state with a definite "current value". Both implement Stream<T>, so consumers
can still use StreamBuilder.
At the Flutter widget layer, we prefer Listenable subclasses (e.g.
ValueNotifier or ChangeNotifier) for widget-owned state.
This section covers repo-level testing conventions. For guidance on how to write
good tests — naming, factoring, one behavior per test — see
TESTING.md.
Embrace code duplication in tests. It makes it easier to create new tests by copying and tweaking existing ones.
Avoid test-global variables or state shared between tests — they make maintenance,
debugging, and refactoring significantly harder. Instead of setUp, use local
helper functions called inside each test block. For cleanup, prefer addTearDown
over the global tearDown callback.
Organize tests into smaller files grouped by feature, widget, or behavior. Split
one big stream_avatar_test.dart into stream_avatar_layout_test.dart,
stream_avatar_theme_test.dart, etc., as the test surface grows.
group(...) is fine — and used widely across the repo — for a small cluster of
tests that share a precondition, e.g. "when the widget is in dark mode", "when
textDirection is RTL". Keep the group's description short and describe the
precondition, not the widget under test. Prefer splitting the file over piling
up nested groups; if a group is doing the job a separate file should be doing,
split the file instead.
Prefer mocking at the boundary between your code and the outside world (HTTP client, WebSocket, image loading). Do not mock every collaborator.
- Use
mocktail(no code generation required). ExtendMockand stub the methods you exercise. - For simple stubs, prefer explicit fake classes over
Mock— they read better and survive interface changes without a regeneration step.
Widget tests that verify pixel-level rendering use the alchemist package. Golden
tests live in _golden_test.dart files next to the unit tests, and the generated
images go under test/components/<category>/goldens/. The alchemist config in
test/flutter_test_config.dart runs:
ciGoldensConfig— enabled only whenGITHUB_ACTIONSis set. Producesgoldens/ci/*.png. These are the goldens that get committed.platformGoldensConfig— enabled only locally. Producesgoldens/<platform>/(e.g.goldens/macos/). These are auto-generated during local runs and gitignored (.gitignoreallowlists onlygoldens/ci/).
Golden tests are tagged with golden in dart_test.yaml. Every visible component
must ship with a golden test that exercises the primary variants (sizes, states,
theme brightness).
Regenerate committed goldens via the update_goldens GitHub Action, not
locally. The action runs on Linux — the same host CI uses — so the committed
goldens/ci/*.png match what CI compares against. Locally-generated goldens
carry host-specific font hinting and antialiasing that will fail CI on other
machines.
For local iteration only, you can run:
melos run update:goldens…but do not commit those files. Once you're confident the visual change is what
you want, dispatch the update_goldens workflow from the PR branch — it runs
melos run update:goldens on Linux and auto-commits the updated PNGs back to
the branch as a chore: Update Goldens commit. Pull the branch after the
workflow finishes.
const double kDefaultBorderRadius = 8;
const String kDefaultLocale = 'en';Prefer avoiding global constants — StreamAvatar.defaultSize reads better than
kDefaultAvatarSize. Reach for a class-scoped constant first.
Unless the abbreviation is more recognizable than the expansion (e.g. XML, HTTP,
JSON, URL, SDK), expand it. Avoid one-character names unless idiomatic
(i for a loop counter is fine; x and y for coordinates are fine).
Every public widget, theme, and enum in stream_core_flutter is prefixed with
Stream (e.g. StreamAvatar, StreamAvatarThemeData, StreamAvatarSize). This
avoids collisions with consumer app code and Material/Cupertino types.
Private implementation classes do not need the prefix.
For callbacks, use FooCallback for the typedef, onFoo for the property, and
handleFoo for the method that is called.
If Foo is a verb, prefer present tense over past tense (onTap, not onTapped).
Never call a method onFoo. If a property is called onFoo it must be a function
type. Prefer typedefs for callbacks — they can be documented and make it easier to
grep for common signatures.
Prefer US English spellings. color, not colour. canceled, not cancelled.
If a word is written as a single compound word (e.g. toolbar, scrollbar), keep
it compound: no inner capitalization. If it's two words (e.g. app bar), use
camelCase: appBar, tabBar.
Avoid class names containing iOS. Prefer Cupertino or UIKit. If you must use
iOS in an identifier, capitalize it as IOS.
Name boolean variables positively, even if the default is true.
Unless it causes problems, use value for the setter's argument.
Prefix debug-only helpers with debug (or _debug for private).
The definition of "new" changes as code grows. Name things after the idea, not the version.
Find the answers to the questions, or describe the confusion, including references where you found answers.
If commenting on a workaround for a bug, describe the constraint and (when one exists) link the tracking issue:
// TODO(localize): move "remove" hint to localizations.
// TODO: When the minimum Flutter SDK is >= 3.40, replace this with X.TODOs are either bare // TODO: or use a category tag // TODO(<tag>): where the
tag names a workstream (e.g. localize, perf-migration). This diverges from
Flutter's guide, which requires TODO(github-handle):. Include an issue link when
the deferred work is tracked; if the constraint is self-explanatory, a link isn't
required.
// ignore: rule_name directives do not require an explanatory comment
(document_ignores is disabled). This intentionally diverges from Flutter's guide.
// GOOD (matches repo style):
foo(); // ignore: unnecessary_null_comparisonIf an // ignore covers something genuinely subtle, a comment is welcome. Do not
add "explanatory" comments to every ignore just to match Flutter's convention.
Default to zero // comments in implementation. Prefer named locals over rationale
comments; prefer early returns over "// handle the loading case" markers.
// BAD:
Widget build(BuildContext context) {
// If the user is offline, show the offline banner.
if (!isOnline) return const OfflineBanner();
// Otherwise, show the content.
return const _Content();
}
// GOOD:
Widget build(BuildContext context) {
if (!isOnline) return const OfflineBanner();
return const _Content();
}Comments earn their place when they explain why — a hidden constraint, a subtle invariant, a workaround for a specific bug. Pre-existing comments should not be removed as part of unrelated changes.
Every skipped test must carry a reason as its skip argument. Bare skip: true is
a red flag — the next person to look will not know whether the skip is temporary,
permanent, or forgotten.
// GOOD:
skip: 'Golden diverges on M1 hardware — investigating.'
skip: 'Blocked on Flutter #12345 — remove once that ships.'
// BAD:
skip: trueInclude an issue link when the skip is tied to a tracked bug; otherwise a plain reason is fine. File an issue if the skip becomes long-lived.
Usually the closure passed to setState includes all the state changes. Sometimes
the state changed elsewhere and setState is called in response — in those cases
include a comment describing what changed:
setState(() {
// The stream subscription fired; the state is already up to date.
});Run the formatter via Melos, not directly:
melos run format # dart format . across every package
melos run format:verify # check-only; used in CI
melos run lint:all # analyze + format checkmelos run format wraps dart format so every package is checked with the same
settings. Do not invoke dart format on a single file with ad-hoc flags — the
workspace-level config (line length, trailing commas) applies uniformly.
Line length is 120 characters for both code and comments, configured in
analysis_options.yaml. Trailing commas are preserved rather than automatically
added, so include a trailing comma anywhere you want the formatter to break the
argument list onto multiple lines.
The default constructor comes first, followed by named constructors, followed by
everything else. Enforced by sort_constructors_first and
sort_unnamed_constructors_first.
If there's a clear lifecycle, order members chronologically (e.g. initState before
build before dispose).
If no order is obvious, use:
- Constructors, default first.
- Constants of the same type as the class.
- Static methods that return the same type as the class.
- Final fields set from the constructor.
- Other static methods.
- Static properties and constants.
- Mutable-property members (getter, private field, setter — no blank lines separating the three).
- Read-only properties (other than
hashCode). - Operators (other than
==). - Methods (other than
toStringandbuild). - The
buildmethod (together with its_build*helpers — see below). operator ==,hashCode,toString, and diagnostics methods.
The list above is a fallback ordering. Within slots 10–11 (Methods and
build), group by concept, not by public/private:
- A private helper called by one public method should live directly under that method, not in a separate "private helpers" block at the bottom of the class.
- Related methods (e.g. all the drag handlers for a sheet, or all the setup
helpers used by
initState) sit as a run. _build*helpers used bybuildcluster around it.buildheads the block, its helpers follow. Don't separate them with unrelated methods.
This matches the existing repo (e.g. StreamSheet, StreamSnackbarMessenger)
and keeps a private helper visually close to the code that uses it.
Use a block (with braces) when a body would wrap onto more than one line.
+= reads as an assignment. ++ hides mutation.
Include a decimal point in double literals, even for whole numbers:
Padding(padding: EdgeInsets.all(8.0)); // good
Padding(padding: EdgeInsets.all(8)); // avoid — reads as intComponents live under lib/src/components/<category>/, one directory per
category. Add a new category directory when a component doesn't fit an existing
one.
Each new component ships with:
-
Widget file — under
lib/src/components/<category>/<name>.dart. -
Theme file — under
lib/src/theme/components/<name>_theme.dart. See Theme system.Naming convention: top-level component themes use
<Component>ThemeData; sub-configurations nested inside a top-level theme use<Component>Style. For example,StreamButtonThemeData(top-level) containsStreamButtonTypeStyle+StreamButtonThemeStyle(per-variant sub-configurations). New code follows this split. A handful of existing top-level themes are grandfathered into theStylesuffix (e.g.StreamMessageBubbleStyle,StreamMessageMetadataStyle) — don't add more. -
Widget or unit tests — under
test/components/<category>/<name>_test.dart. Golden variants use the_golden_test.dartsuffix (e.g.stream_button_golden_test.dart,stream_button_test.dart). Every visible component needs a golden test covering the default state and the primary theme variants. See Golden tests. -
Widgetbook use-case — under
apps/design_system_gallery/lib/components/<category>/, exercising every meaningful prop combination. -
Barrel export — an
export '…';line added tocore.dartorchat.dart.
The widget itself should:
- Accept
Key? keyin its constructor viasuper.key(use_key_in_widget_constructors,use_super_parameters). - Support accessibility on both Android (TalkBack) and iOS (VoiceOver). A
Tooltipis usually sufficient as the accessible label. - Support both LTR and RTL layouts.
- Support text scaling.
- Have documentation for every public member.
Some earlier components predate parts of this checklist. When you touch such a component for a substantive change, try to close the gap in the same PR — but don't block landing a fix on backfilling years of missing coverage.
Themes are generated via theme_extensions_builder. Never hand-roll copyWith,
merge, lerp, ==, or hashCode. Annotate with @themeGen (or
@ThemeExtensions for the root) and let the generator produce them.
The hierarchy is layered: primitives (theme/primitives/, raw tokens) →
semantics (theme/semantics/, semantic mappings) → component themes
(theme/components/, per-widget classes, 50+) → tokens (figma-generated,
internal).
Adding a new component theme:
// lib/src/theme/components/stream_widget_theme.dart
@immutable
@themeGen
class StreamWidgetThemeData with _$StreamWidgetThemeData {
const StreamWidgetThemeData({this.backgroundColor, this.borderRadius});
// All fields are nullable — defaults do not live here.
final Color? backgroundColor;
final double? borderRadius;
}
class StreamWidgetTheme extends InheritedTheme {
const StreamWidgetTheme({super.key, required this.data, required super.child});
final StreamWidgetThemeData data;
static StreamWidgetThemeData of(BuildContext context) {
final local = context.dependOnInheritedWidgetOfExactType<StreamWidgetTheme>();
return StreamTheme.of(context).widgetTheme.merge(local?.data);
}
// ... wrap + updateShouldNotify.
}Then add widgetTheme as a field on StreamTheme, run melos run generate:flutter,
and consume it in the widget:
Widget build(BuildContext context) {
final theme = StreamWidgetTheme.of(context);
final backgroundColor = widget.backgroundColor
?? theme.backgroundColor
?? Theme.of(context).colorScheme.surface;
// ...
}Place defaults in the widget's build, not in the theme data class. This
mirrors Flutter's AppBar/TabBar pattern: theme data holds overrides with
nullable fields; the widget resolves the effective value via null-coalescing.
Note: the root StreamTheme is an exception — it extends
ThemeExtension<StreamTheme> and uses
@ThemeExtensions(constructor: 'raw', buildContextExtension: false) so it plugs
into Material's ThemeData.extensions. New component themes follow the
@themeGen pattern above, not the root pattern.
Prefer Material elevation over a hand-painted BoxShadow. A component that
needs a drop shadow exposes elevation (a double, in dp) on its theme data and
renders through Material — not boxShadow on a BoxDecoration.
The design system specifies each elevation token as both a shadow and a Material
level, so the dp value is the authoritative representation for Flutter. Take it
from StreamElevation (context.streamElevation.level3, or StreamTheme.elevation)
rather than writing a number — that class carries the token-to-dp table and is the
single place it lives. StreamElevation.none is a fixed 0 for the unelevated
case; the four levels are themeable.
Two shadow systems side by side do not match. Canvas.drawShadow (what Material
renders) computes an ambient and a spot shadow from a single colour, which no
multi-layer BoxShadow list reproduces — so a component painting its own shadow
reads visibly different from the elevated component next to it.
StreamBoxShadow stays for the cases Material genuinely cannot reach:
- text shadows (
TextStyle.shadows), as inStreamBadgeCount; - custom painting, where there is no
Materialto elevate; - a surface that must stay translucent —
Materialtreats a transparent colour as a transparent occluder and the shadow shows through.
Reaching for a BoxShadow outside those cases needs a comment explaining why
Material did not work.
Two things to expect when elevating a component:
- Material clips its children with
PhysicalShape, so a border withstrokeAlignOutsideon the Material's ownshapegets clipped. Draw the border in aDecoratedBoxoutside theMaterialinstead.StreamAvatarshows the shape. - The shadow colour resolves to
ThemeData.shadowColorfrom the host app, since this package contributes aThemeExtensionrather than building its ownThemeData. Pass an explicitshadowColorwhen a component must not drift with the embedding app's theme.
The design system exposes StreamComponentFactory so consumers can substitute
individual components without forking. When adding a component with a default
implementation, register a factory hook: add a nullable builder field on the
factory class, wire it through copyWith and the default fallback, and consume
it in the component's build with the standard null-coalescing chain.
Look at any existing component that goes through the factory for the reference shape.
Source SVGs live in packages/stream_core_flutter/assets_source/icons/. They come
from the design-system-tokens
repository.
When adding or updating icons:
- Pull the latest SVGs from
design-system-tokens/assets/icons/intoassets_source/icons/. - If the icon should mirror in RTL layouts, add its base name to the
_rtlIconslist inscripts/generate_icons.dartso the generator emitsmatchTextDirection: truefor it. This covers obvious directional glyphs (arrows, chevrons,reply,send,sidebar) but also icons with directional metaphors that read wrong when unmirrored (audio,megaphone,search,video). Skip icons that are symmetric or shouldn't mirror (a bell, a heart, brand logos). If in doubt, look at what comparable icons already do in_rtlIcons. - Run
melos run generate:iconsto regenerate the icon font and theStreamIconsclass. - Commit both the SVG sources and the regenerated font + Dart output together — they must stay in sync.
Do not edit the generated StreamIcons.dart or the icon font by hand.
PR titles follow Conventional Commits:
fix(scope): description— bug fixfeat(scope): description— new featurerefactor(scope)!: description— breaking change (note the!)chore(scope): description,docs:,test:,ci:
scope is usually the affected package (llc for stream_core, ui for
stream_core_flutter, or repo for monorepo-wide changes).
Every PR that changes package behavior updates the affected package's
CHANGELOG.md under the Upcoming heading. Entries live under one of these
sub-headings:
## Upcoming
### ✨ Features
- Added `StreamJumpToUnreadButton` component and `StreamJumpToUnreadButtonTheme`.
### 🐛 Bug Fixes
- Fixed a crash when opening the media viewer with an empty attachments list.
### 🛑 Breaking / Removals
- Removed `StreamCoreMessageComposer`. Use `StreamMessageComposer` from
`stream_chat_flutter` instead.Prefer one short bullet per entry, describing the functional change. Longer entries are acceptable for user-visible multi-facet features where the extra context matters to someone deciding whether to upgrade — but avoid sub-bullets, per-method enumeration, and internal implementation notes.
Older entries in the changelog use ### 🐞 Fixed and ### 💥 Breaking Changes /
### 💥 BREAKING CHANGES — those forms are grandfathered but new entries should
use the labels above.
If a PR touches both stream_core and stream_core_flutter, update each package's
CHANGELOG.md separately. Cross-linking between packages ("bumps stream_core to
X.Y.Z") is handled by the release tooling — do not write these entries by hand.
Publishing to pub.dev is automated. Packages are versioned
independently, each on its own tag <package>-v<version> (e.g.
stream_core-v0.4.0) — but a single release PR may bump any number of
packages at once. Each bumped package still gets its own tag and its own
publish run, so releasing all three together and releasing one on its own follow
the exact same steps.
Cut every release from a release/... branch (e.g. release/2026-07-30). This
is required, not a convention: the changelog-placement check in
pr_title.yml only allows a ## Upcoming
heading to become ## X.Y.Z on a release/ branch. On that branch, for each
package you are releasing:
- bump its
versioninpubspec.yaml - promote its CHANGELOG
## Upcomingheading to## X.Y.Z
Title the PR chore(repo): release packages for a multi-package release
(generic, so it stays short), or chore(<scope>): release <package> vX.Y.Z
(scope llc / ui / thumb) for a single package. The tooling keys only on the chore(...): release prefix — tags
are derived from package state, not the title — so a title mentioning one
version while the PR bumps several still tags and publishes every bumped package.
Squash-merge the release PR. release_tag.yml's gate reads the tip
commit's message (github.event.head_commit.message), so a squash lands the
chore(...): release title as that commit. A merge commit would make the tip
Merge pull request #… — the gate wouldn't fire and nothing would tag/publish,
silently. (This is why the tag job also has a workflow_dispatch escape hatch.)
When the PR merges to main:
release_tag.ymltags every package whose current version is not yet on pub.dev —<package>-vX.Y.Z— and pushes the tags one at a time.release_publish.ymlfires once per pushed tag and publishes only that package (OIDC — no stored credentials), then creates a GitHub Release whose body is the package's## X.Y.ZCHANGELOG section.
Dependent order is handled automatically. stream_core_flutter depends on
stream_core, and each package publishes in its own run, so releasing both
together could otherwise let the dependent reach pub.dev before its dependency
is indexed (which the server rejects with Dependency … does not exist). Before
publishing, release_publish.yml's ⏳ Wait for in-workspace dependencies step
polls pub.dev's per-version endpoint until every in-workspace dependency of the
tagged package is live, so publish never races ahead of a dependency. The
dependency's own run lands moments earlier (tags push in dependency order), so
the wait usually resolves within a poll or two — an already-live dependency
passes on the first check; a just-published one needs a retry or so while
pub.dev indexes it. If a dependency's publish genuinely fails,
the dependent's wait times out and reports it — re-run the failed dependency
(workflow_dispatch on its tag), then the dependent. Re-runs are safe: the
publish step skips if the version is already on pub.dev (checked against the
live per-version endpoint, not melos --no-published), so re-running a tag
publishes it only if it isn't already there.
Tagging is state-derived — mind two consequences. release_tag.yml tags
every package whose current pubspec.yaml version isn't on pub.dev yet, not
only the ones this PR bumped. So:
- Keep version bumps to release PRs. If a
version:bump merges in an ordinary feature PR, the next release will tag and publish it as a side effect. Bump versions only on arelease/branch. - Publish a brand-new package before releasing anything that depends on it.
A new package's first publish needs pub.dev automated-publishing configured for
it; until then its automated publish fails. If that new package is also an
in-workspace dependency of an existing one (as
stream_coreis forstream_core_flutter), releasing the dependent alongside it makes the dependent's wait step poll for a version that never appears and time out after 15 minutes. Land the new package on its own first (or set up its publishing and let its run finish), then release the dependents.
- Repo-wide overview:
CLAUDE.md— architecture, commands, package layout. Points here for style rules. - Testing guide:
TESTING.md— how to write effective tests. - Melos commands:
melos.yaml— every task the repo runs. - Design source: the Chat SDK Design System Figma project — accessed via the Figma MCP when implementing UI.
- Design tokens: the design-system-tokens sibling repo (mirrored internally in the theme primitives).
When something isn't covered here and isn't obvious from surrounding code, prefer to ask in the PR rather than guessing. If a convention isn't documented, propose adding it to this guide as part of the PR.