Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ecbf3d1
chore(ui): add participant tile plan and point core at a local path
renefloor Aug 28, 2026
820dc5c
fix(ui): make the participant and video theme merges carry every value
renefloor Aug 28, 2026
4890de8
feat(ui): add generated component themes for the participant tile
renefloor Aug 28, 2026
b1aa1fc
feat(ui): redesign the participant tile and give it an overflow menu
renefloor Aug 28, 2026
85a9678
test(ui): snapshot the participant tile
renefloor Aug 28, 2026
76161d6
fix(ui): always show the sound indicator, icon only when muted
renefloor Aug 28, 2026
705d924
fix(ui): draw the connection indicator at its designed size
renefloor Aug 28, 2026
f4015c9
feat(ui): migrate the deprecated participant theme onto the new ones
renefloor Aug 28, 2026
5f7ceeb
docs(ui): document the participant tile migration
renefloor Aug 28, 2026
15b205c
feat(ui): make the floating self-view a component
renefloor Aug 28, 2026
475c7e9
feat(ui): build the user avatar on the design system and make it repl…
renefloor Aug 28, 2026
bed0e51
feat(ui): make the participant video replaceable
renefloor Aug 28, 2026
ea63419
update core_flutter dep
renefloor Aug 28, 2026
e5cb87f
fix(ui, samples): stop the lobby camera restarting on every rebuild
renefloor Aug 28, 2026
bc784c9
fix(ui): address review findings on the participant tile
renefloor Aug 28, 2026
4ac6205
feat(samples): offer pin and mute from the participant tile
renefloor Aug 28, 2026
08698ec
fix(ui, samples): leave the lobby tracks to whoever is handed them
renefloor Aug 28, 2026
8bf4092
chore: update goldens
renefloor Aug 28, 2026
951b910
fix(ui): fix participant tile chrome arithmetic and legacy deprecations
renefloor Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# stream-video-flutter

Melos monorepo for the Stream Video Flutter SDK.

| Package | What it is |
|---|---|
| `packages/stream_video` | Pure-Dart low-level client. No Flutter. |
| `packages/stream_video_flutter` | UI toolkit — widgets, themes, call screens. |
| `packages/stream_video_*` | Filters, noise cancellation, push notifications, screen sharing. |
| `dogfooding/` | The internal sample app. The first place a UI change gets tried. |

```bash
melos bootstrap # after any pubspec change
melos run analyze # dart analyze --fatal-infos, every package
melos run format
melos run generate:flutter # build_runner, for *.g.theme.dart and friends
melos run test:all
```

## The design system lives in another repository

`stream_core_flutter` is the design system and is **not** in this repo. It is a
git dependency pinned in the root `pubspec.yaml`, so its source is at:

```text
~/.pub-cache/git/stream-core-flutter-<ref>/packages/stream_core_flutter
```

That checkout also carries `CLAUDE.md`, `STYLE_GUIDE.md` and `TESTING.md` at its
root. **`STYLE_GUIDE.md` is the authority for anything below** — this file only
records what is specific to the video repo, or what is easy to get wrong here.

Design comes from Figma, read through the Figma MCP. When a value looks like it
needs inventing, it usually already exists: pull the component and read its
geometry rather than deriving proportions from a size. Raw token values live in
the `design-system-tokens` repo, whose `build/flutter/` output is a useful
cross-check when a token seems to be missing from core.

## Theming

**Two theme systems coexist.** `StreamVideoTheme` is this repo's older,
hand-written `ThemeExtension`, with non-nullable sub-themes carrying hardcoded
defaults. `StreamTheme` is the design system's, and is what new code uses. New
component themes in this repo follow the core pattern but are still registered
on `StreamVideoTheme`, since video cannot add fields to `StreamTheme`.

A new component theme, as in `src/theme/components/`:

```dart
@themeGen
@immutable
class StreamWidgetThemeData with _$StreamWidgetThemeData {
const StreamWidgetThemeData({this.style});
final StreamWidgetStyle? style; // every field nullable — no defaults here

static StreamWidgetThemeData? lerp(a, b, t) =>
_$StreamWidgetThemeData.lerp(a, b, t);
}

class StreamWidgetTheme extends InheritedTheme {
static StreamWidgetThemeData of(BuildContext context) {
final local = context.dependOnInheritedWidgetOfExactType<StreamWidgetTheme>();
// Merges, so a subtree can override one property and inherit the rest.
return StreamVideoTheme.of(context).widgetTheme.merge(local?.data);
}
// ...wrap(), updateShouldNotify()
}
```

Rules worth stating outright:

- **Never hand-roll `copyWith` / `merge` / `lerp` / `==` / `hashCode`** on a
theme. `theme_extensions_builder` generates them into `*.g.theme.dart`.
- **Every theme field is nullable.** Null means "no override".
- **Defaults live in the widget**, as a `_StreamXStyleDefaults extends
StreamXStyle` overriding getters non-nullably off `context.streamColorScheme`,
`streamTextTheme`, `streamSpacing`, `streamRadius`, `streamElevation`.
Type the local as the defaults class, not the style — declaring it as the base
type throws away the non-null overrides.
- **Never let a defaults instance into a theme.** Generated `==` compares
`runtimeType` and every getter is non-null, so merging one forces every field.
- **Generated `merge` recurses into nested styles**, so handing over an all-null
sub-style leaves whatever the ambient theme set alone. What does overwrite a
field is a style that has a value for it — which is why a defaults instance,
every getter non-null, must never reach a theme. A widget that resolves a
sub-style itself rather than passing the nested one straight down still has to
merge it explicitly.
- Naming: `<Component>ThemeData` for the top-level theme, `<Component>Style` for
the visual style it carries.

## Components

Every replaceable component is a triple:

```dart
class StreamX extends StatelessWidget {
StreamX({...}) : props = .new(...);
final StreamXProps props;

@override
Widget build(BuildContext context) {
final builder = context.videoComponentBuilder<StreamXProps>();
return builder?.call(context, props) ?? DefaultStreamX(props: props);
}
}
```

Register replacements through `streamVideoComponentBuilders`. Video is external
to core, so its builders go through the type-keyed `extensions` map rather than
named fields on `StreamComponentBuilders`.

Props carry data and decisions; appearance goes in the style object. A `show*`
toggle that an app sets globally *and* a call site overrides lives in both, with
the prop winning.

## Tokens

Read them from the context extensions — `context.streamColorScheme`,
`streamTextTheme`, `streamSpacing`, `streamRadius`, `streamElevation`,
`streamIcons` — not from `StreamVideoTheme.colorTheme` / `.textTheme`, which
belong to the older system.

One trap: the barrel re-exports core as
`export 'package:stream_core_flutter/core.dart' hide StreamIcons, StreamTextTheme;`.
A bare `StreamIcons` is therefore this repo's own three-icon class, while
`context.streamIcons` returns core's full set. Naming `StreamTextTheme` in a file
that also imports core directly is ambiguous — let the accessors infer instead.

Shadows come from `StreamElevation` through a `Material`, not a hand-painted
`BoxShadow`. Material clips with `PhysicalShape`, so draw borders in a
`DecoratedBox` outside it.

`src/widgets/design_system_candidates/` holds components that implement a design
the core package does not ship yet, staged to graduate to core later.

## Testing

Golden tests use `alchemist`, through `streamGoldenTest` and `TestWrapper` in
`test/test_utils/` — never a bare `MaterialApp`, or the snapshot picks up
Flutter's defaults instead of the pinned theme, platform and locale.

- Only `goldens/ci/*.png` is committed. `goldens/<platform>/` is local and
gitignored, and is generated on first run — a fresh checkout fails until
`flutter test --tags golden --update-goldens` has run once.
- Regenerate the committed ones by dispatching the `update_goldens` workflow
from the branch, not locally: it runs on the same Linux host CI compares
against.
- The CI capture path drops anything painted into an `Overlay`, so a menu,
tooltip or dialog snapshots blank. Assert those in a widget test.
- `BackdropFilter` is a no-op under `flutter test`; a blurred surface snapshots
as a flat fill.
- `pumpBeforeTest` defaults to `onlyPumpAndSettle`, which never returns against a
repeating animation. Pump an explicit duration instead.

## Conventions

- Changelog entries go under `## Upcoming (next major)` in the affected
package's `CHANGELOG.md`, using the `### ✅ Added` / `### ⚠️ Deprecated` /
`### ⚠️ Breaking` / `### 🔄 Changed` headings already there.
- Deprecations pair `@Deprecated('Use X instead.')` with a transform in
`lib/fix_data.yaml`, so `dart fix --apply` migrates call sites. A bulk `rename`
is only safe when the old and new APIs accept the same parameters.
- Conventional Commit titles: `feat(ui):`, `fix(llc):`, `chore(repo):`.
37 changes: 10 additions & 27 deletions dogfooding/lib/app/app_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import '../di/injector.dart';
import '../router/router.dart';
import '../router/routes.dart';
import '../utils/consts.dart';
import '../widgets/dogfooding_participant_tile.dart';
import 'custom_video_localizations.dart';
import 'firebase_messaging_handler.dart';
import 'user_auth_controller.dart';
Expand Down Expand Up @@ -43,9 +44,8 @@ class _StreamDogFoodingAppContentState
late final _componentBuilders = StreamComponentBuilders(
extensions: [
...streamVideoComponentBuilders(
// No-op change for [StreamParticipantTile] as demo example.
participantTile: (context, props) =>
DefaultStreamParticipantTile(props: props),
DogfoodingParticipantTile(props: props),
),
// You can combine both chat and video component builders.
...streamChatComponentBuilders(),
Expand Down Expand Up @@ -380,31 +380,14 @@ class _StreamDogFoodingAppContentState
initialsBackground: colorScheme.brand.shade100,
),
),
callParticipantTheme: StreamCallParticipantThemeData(
borderRadius: const BorderRadius.all(Radius.circular(16)),
speakerBorderColor: colorScheme.accentPrimary,
backgroundColor: colorScheme.backgroundSurface,
userAvatarTheme: StreamUserAvatarThemeData(
constraints: const BoxConstraints.tightFor(
height: 100,
width: 100,
),
borderRadius: const BorderRadius.all(Radius.circular(50)),
initialsTextStyle: textTheme.title1.copyWith(
color: colorScheme.brand,
),
initialsBackground: colorScheme.brand.shade100,
selectionColor: colorScheme.accentPrimary,
),
audioLevelIndicatorColor: colorScheme.accentPrimary,
participantLabelTextStyle: textTheme.footnote.copyWith(
color: colorScheme.textOnAccent,
),
disabledMicrophoneColor: colorScheme.textOnAccent,
connectionLevelActiveColor: const Color(0xFF00FF00),
participantsGridPadding: const EdgeInsets.all(4),
participantsGridMainAxisSpacing: 4,
participantsGridCrossAxisSpacing: 4,
// The participant tile is styled by the design system now. Nothing
// is overridden here, which is what an app wanting the stock look
// should do: setting `callParticipantTheme` opts back into the
// deprecated shape.
callParticipantsGridTheme: const StreamCallParticipantsGridThemeData(
padding: EdgeInsets.all(4),
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
),
],
Expand Down
22 changes: 15 additions & 7 deletions dogfooding/lib/screens/lobby_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,16 @@ class _LobbyScreenState extends State<LobbyScreen> {
}

Future<void> _selectVideoInput(RtcMediaDevice? device) async {
_selectedVideoInputDevice = device;

_cameraTrack = device != null
? await _cameraTrack?.selectVideoInput(device, [])
: await _cameraTrack?.recreate([]);
// Recording the choice is enough to get a new track: the key below changes
// with it, so the preview is rebuilt and opens the newly chosen camera.
//
// The track it handed over earlier is ours to release, though, and the
// preview will not do it for us — nothing else holds a reference once it
// reports the replacement.
await _cameraTrack?.stop();
_cameraTrack = null;

if (mounted) setState(() {});
if (mounted) setState(() => _selectedVideoInputDevice = device);
}

@override
Expand Down Expand Up @@ -139,7 +142,12 @@ class _LobbyScreenState extends State<LobbyScreen> {
),
const SizedBox(height: 16),
StreamLobbyVideo(
key: ValueKey(_cameraTrack),
// Keyed on the chosen device, not on the track it produces.
// Keying on the track makes the preview's identity depend on
// its own output, so any later rebuild — switching between
// light and dark, say — tears it down and starts the camera
// over, re-enabling it if it had been turned off.
key: ValueKey(_selectedVideoInputDevice?.id),
call: widget.call,
initialCameraDevice: _selectedVideoInputDevice,
onMicrophoneTrackSet: (track) {
Expand Down
67 changes: 67 additions & 0 deletions dogfooding/lib/widgets/dogfooding_participant_tile.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:stream_video_flutter/stream_video_flutter.dart';

/// The participant tile this app registers on the component factory.
///
/// Adds an overflow menu to every tile in the app at once — the grid, the
/// spotlight strip and the screen-share filmstrip — which is what registering a
/// component rather than passing actions at a call site buys.
class DogfoodingParticipantTile extends StatelessWidget {
/// Creates the app's participant tile.
const DogfoodingParticipantTile({super.key, required this.props});

/// The properties the SDK built this tile with.
final StreamParticipantTileProps props;

@override
Widget build(BuildContext context) {
// Decorate rather than clobber: a call site that supplied its own actions
// asked for something more specific than an app-wide default.
if (props.actions != null || props.actionsBuilder != null) {
return DefaultStreamParticipantTile(props: props);
}

return DefaultStreamParticipantTile(
props: props.copyWith(actionsBuilder: _actionsFor),
);
}

/// The overflow menu offered on a participant's tile.
///
/// Rebuilt for each participant on every build, so it reflects whether they
/// are pinned or muted right now.
List<StreamParticipantTileAction> _actionsFor(
BuildContext context,
CallParticipantState participant,
) {
final call = props.call;
final icons = context.streamIcons;

return [
StreamParticipantTileAction(
icon: participant.isPinned ? icons.unpin : icons.pin,
label: participant.isPinned ? 'Unpin' : 'Pin',
onPressed: () => call.setParticipantPinnedLocally(
sessionId: participant.sessionId,
userId: participant.userId,
pinned: !participant.isPinned,
),
),
// Muting someone else is a moderation action, so it is only offered to a
// user the call has granted it to. Muting yourself is what the call
// controls are for.
if (!participant.isLocal && call.hasPermission(CallPermission.muteUsers))
StreamParticipantTileAction(
icon: icons.voiceOffFill,
label: 'Mute',
// Listed but unselectable once they are muted, so the menu keeps its
// shape as people talk.
enabled: participant.isAudioEnabled,
onPressed: () =>
unawaited(call.muteUsers(userIds: [participant.userId])),
),
];
}
}
Loading
Loading