diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..0d37a8d77 --- /dev/null +++ b/CLAUDE.md @@ -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-/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(); + // 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: `ThemeData` for the top-level theme, `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(); + 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//` 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):`. diff --git a/dogfooding/lib/app/app_content.dart b/dogfooding/lib/app/app_content.dart index 23bd1388f..8c7ded9b9 100644 --- a/dogfooding/lib/app/app_content.dart +++ b/dogfooding/lib/app/app_content.dart @@ -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'; @@ -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(), @@ -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, ), ), ], diff --git a/dogfooding/lib/screens/lobby_screen.dart b/dogfooding/lib/screens/lobby_screen.dart index 1a9ef66b7..b08360dae 100644 --- a/dogfooding/lib/screens/lobby_screen.dart +++ b/dogfooding/lib/screens/lobby_screen.dart @@ -83,13 +83,16 @@ class _LobbyScreenState extends State { } Future _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 @@ -139,7 +142,12 @@ class _LobbyScreenState extends State { ), 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) { diff --git a/dogfooding/lib/widgets/dogfooding_participant_tile.dart b/dogfooding/lib/widgets/dogfooding_participant_tile.dart new file mode 100644 index 000000000..c53f59da3 --- /dev/null +++ b/dogfooding/lib/widgets/dogfooding_participant_tile.dart @@ -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 _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])), + ), + ]; + } +} diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 5775d4d6f..2ff11f4f2 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,6 +2,30 @@ ### ✅ Added +- Redesigned the participant tile onto the design system, and split its theme into one per component. The tile is now a 20px-radius surface with two toolbars: the overflow button and any live reaction at the top, the participant's name and connection quality at the bottom. `StreamParticipantTileTheme`, `StreamParticipantLabelTheme`, `StreamConnectionQualityIndicatorTheme` and `StreamCallParticipantsGridTheme` replace `StreamCallParticipantThemeData`; each has all-nullable properties, merges with the ambient theme instead of replacing it, and leaves defaults to the widget, which derives them from `StreamTheme`. +- Added an overflow menu to the participant tile. Supply `actions`, or `actionsBuilder` for a menu that depends on the participant, and the SDK renders them; supply nothing and the button is not drawn at all. `StreamParticipantTileAction` is an icon, a label and a callback, so what an action does — pinning, muting, blocking, removing — stays with the integrator: + + ```dart + StreamParticipantTile( + call: call, + participant: participant, + actionsBuilder: (context, participant) => [ + StreamParticipantTileAction( + icon: context.streamIcons.pin, + label: participant.isPinned ? 'Unpin' : 'Pin', + onPressed: () => call.setParticipantPinnedLocally( + sessionId: participant.sessionId, + pinned: !participant.isPinned, + ), + ), + ], + ) + ``` +- Added `StreamFloatingParticipantTile`, the draggable self-view as a component of its own, with `StreamFloatingParticipantTileTheme` for its size, corner radius, hairline and elevation. `StreamLocalVideo` now only positions it. Its shadow comes from `StreamElevation` through a `Material` rather than a hand-painted `BoxShadow`, and it is registered as `floatingParticipantTile` on `streamVideoComponentBuilders`. +- `StreamUserAvatar` is now built on the design system's `StreamAvatar`, and is replaceable through a `userAvatar` builder on `streamVideoComponentBuilders`. Its props carry the whole `UserInfo`, so a replacement can draw from fields the SDK never reads — a team badge, a role ring, an identicon from `extraData` — and because every avatar in the SDK routes through it, one registration changes the participant tile, the lobby, the participants list and the incoming and outgoing call screens together. Size, colors and border come from `StreamAvatarTheme`. +- Added `StreamParticipantVideo`, the video area of a tile, with a `participantVideo` builder for replacing it. A `videoRendererBuilder` passed to a single tile still wins over an app-wide one. A replacement is responsible for forwarding `onSizeChanged`, which is what drives dynascale — drop it and the call negotiates quality against stale dimensions. +- Added `StreamParticipantPlaceholder`, what a tile shows in place of video, with a `participantPlaceholder` builder for replacing it. It stands in for the participant rather than the video, so it is drawn whenever there is no picture — camera off, track not yet arrived, or paused. +- Added `participantLabel` and `connectionQualityIndicator` to `streamVideoComponentBuilders`, so both can be replaced app-wide. `StreamParticipantLabel` and `StreamConnectionQualityIndicator` are exported for the first time, along with `StreamAudioIndicator`. - Added component factory support, so the Stream Video UI components can be replaced app-wide instead of threading widget builders through the widget tree. Register the components you want to replace with `streamVideoComponentBuilders` and wrap your app in a `StreamComponentFactory`: ```dart @@ -35,12 +59,30 @@ Every component follows the same shape: `StreamX` resolves the registered builder and falls back to `DefaultX`, which holds the default implementation. The parameters of `StreamX` are carried in a `StreamXProps`, exposed as `StreamX.props`, so a custom builder can read them and `copyWith` them to decorate the default rather than reimplement it. - Added `StreamParticipantTile`, the participant tile as a replaceable component: register a `participantTile` builder to replace it, or use `DefaultStreamParticipantTile` for the default implementation. +### 🐞 Fixed + +- Documented on `StreamLobbyVideo.onCameraTrackSet` and `onMicrophoneTrackSet` that the track becomes the caller's to stop. The widget deliberately does not stop tracks it has handed over: they may be passed to the call as a `TrackOption.provided`, and would otherwise be killed as the lobby is left. + ### ⚠️ Deprecated -- `StreamCallParticipant` is deprecated in favour of `StreamParticipantTile`, matching the component name in the design system. It takes the same parameters and now only wraps `DefaultStreamParticipantTile`. Run `dart fix --apply` to migrate your call sites. +- `StreamCallParticipantThemeData` and `StreamCallParticipantTheme` are deprecated. Their properties now live in `StreamParticipantTileThemeData`, `StreamParticipantLabelThemeData`, `StreamConnectionQualityIndicatorThemeData` and `StreamCallParticipantsGridThemeData`. A theme passed to `StreamVideoTheme(callParticipantTheme: ...)` is still applied — in full, so a tile styled the old way keeps looking the way it did. Stop passing it to pick up the redesign, and pass a theme in the new shape to replace it outright. The translation runs in that factory only: setting `callParticipantTheme` through `copyWith`, or wrapping a subtree in the `StreamCallParticipantTheme` widget, changes the field without restyling anything. +- `StreamCallParticipant` is deprecated in favour of `StreamParticipantTile`, matching the component name in the design system. It keeps its own full parameter list and now only wraps `DefaultStreamParticipantTile`. Swapping the name is a manual migration rather than a `dart fix`: `StreamParticipantTile` replaces the visual parameters with a single `style:` (see the Breaking entry below), so a rename would drop whatever a call site passed. `dart fix --apply` does still strip the parameters that no longer have any effect. ### ⚠️ Breaking +- The participant tile follows the redesigned design system. Its corner radius is 20 (was 0 on mobile and 12 on desktop), the speaking outline is 2px (was 4px), a tile showing no video draws a hairline over a subtle surface instead of a solid grey fill, the placeholder avatar is 80px with a white ring, and the name and connection quality indicator share one 56px toolbar along the bottom. +- The participant name can no longer overlap the connection quality indicator. The two were independent `Stack` children aligned to opposite corners; they are now laid out in a single row, so a long name ellipsizes rather than running underneath. As a consequence `StreamCallParticipantThemeData.participantLabelAlignment` and `connectionLevelAlignment` no longer have any effect, and the same parameters on `StreamCallParticipant` are accepted and ignored. Run `dart fix --apply` to drop them. +- The tile now sheds chrome on tiles too small to carry it, rather than overflowing: the name goes first, then the name pill, then the connection quality indicator. The pill is measured against what this participant makes it draw, so a muted camera-off participant loses it earlier than a plain one. The overflow button and the reaction are dropped on a tile too small to carry them beside each other, or too short for the top toolbar to clear the bottom one. A spotlight thumbnail or a floating self-view will show less than a full-size tile does. +- The sound indicator is always visible and animates only while the participant is speaking. A microphone icon is drawn only when they are muted — there is no unmuted icon, and `enabledMicrophoneColor` on `StreamCallParticipant` therefore has no effect. Run `dart fix --apply` to drop it. +- The connection quality indicator colors each level apart — `accentError`, `accentWarning`, `accentSuccess` — where it previously painted every lit bar one color. `connectionLevelActiveColor` maps onto all three, so an app that set it still gets one flat color. +- `StreamParticipantTile`'s visual parameters (`backgroundColor`, `borderRadius`, `userAvatarTheme`, `speakerBorder*`, `participantLabel*`, `audioLevelIndicatorColor`, `*MicrophoneColor`, `pausedVideoIndicatorColor`, `connectionLevel*`) are replaced by a single `style:` taking a `StreamParticipantTileStyle`. `StreamParticipantTile` was introduced in this same unreleased version, so there is no deprecation period; `StreamCallParticipant` keeps its full parameter list. +- The floating self-view is 140x228 with a 12px radius (was 125x150 with a 16px radius), a noticeably taller aspect. It shows only the connection quality indicator: the name pill, speaking outline and overflow button are suppressed at that size. It is configured through `StreamFloatingParticipantTileThemeData`. `StreamLocalVideo`'s own deprecated parameters still win where they are given, but `StreamLocalVideoThemeData` is no longer read at all — an app that sized the self-view through that theme has to move to `StreamFloatingParticipantTileThemeData` or to the parameters. `StreamLocalVideo.userAvatarTheme` is likewise accepted and ignored; run `dart fix --apply` to drop it. +- `StreamUserAvatar` is always circular and sized from `StreamAvatarSize`. Its `constraints`, `borderRadius`, `initialsTextStyle`, `initialsBackground`, `selected`, `selectionColor`, `selectionThickness` and the `imageBuilder` / `placeholderBuilder` / `errorBuilder` / `fallbackBuilder` parameters are gone, along with the typedefs for those builders. A `StreamUserAvatarTheme` still sizes and colors the avatars beneath it — its constraints round up to the nearest `StreamAvatarSize`, and anything above the largest lands on `xxl` at 80px — but a `StreamAvatarTheme` takes precedence, and is what new code should use. +- `StreamVideoTheme.callParticipantTheme` is nullable and no longer populated by `fromColorAndTextTheme`. Reading it returns `null` unless an app set one. +- The participants grid reads its `padding`, `mainAxisSpacing` and `crossAxisSpacing` from `StreamCallParticipantsGridThemeData` rather than taking them from the participant theme through `StreamCallParticipants`. The gap between tiles defaults to 8 where it was 16: the grid widgets defaulted to 16 and nothing passed the participant theme's spacing down, so only the outer padding was themeable. Set `mainAxisSpacing` and `crossAxisSpacing` on the new theme to keep the wider gap. +- `StreamCallParticipantThemeData.copyWith` accepted `showDominantSpeakerBorder`, `dominantSpeakerBorderThickness` and `dominantSpeakerBorderColor` for fields named `showSpeakerBorder`, `speakerBorderThickness` and `speakerBorderColor`, so the field names — the only names discoverable from the class — were a compile error. The parameters now match the fields. Run `dart fix --apply`. +- `StreamCallParticipantThemeData.merge` dropped `pausedVideoIndicatorColor`, and `StreamVideoTheme.merge` dropped `callControlsTheme` and `localVideoTheme`. All three are fixed, so themes that set those values now take effect where they previously did not. +- Requires the `StreamColorScheme.backgroundOverlayDarkStrong` color added in `stream_core_flutter`. - Call control buttons (`CallControlOption` and the widgets built on it) are now rendered with the shared `StreamButton` from `stream_core_flutter` instead of a raw Material `ElevatedButton`, and are styled by state rather than by colour. `CallControlOption` now takes a `state` — `CallControlState.on` (the default), `off`, `positive`, `negative` or `disabled` — next to `icon` and `onPressed`, and its per-button styling parameters (`iconColor`, `disabledIconColor`, `backgroundColor`, `disabledBackgroundColor`, `elevation`, `shape`, `padding`) are removed. Appearance now comes from the button styling in `StreamTheme` rather than from `StreamCallControlsThemeData`'s `optionElevation`/`optionShape`/`optionPadding` and `optionOff*` colours, so controls that relied on those look different: every control is now the same size — the accept/decline buttons of the incoming and outgoing call controls are no longer enlarged — an `off` control uses the destructive style instead of a custom colour, and a `disabled` control additionally shows an error badge. - The colour parameters on the built-in toggle options — `enabled*IconColor`, `disabled*IconColor`, `enabled*BackgroundColor` and `disabled*BackgroundColor` on `ToggleCameraOption`, `ToggleMicrophoneOption`, `ToggleRecordingOption`, `ToggleClosedCaptionsOption` and `ToggleScreenShareOption` — are still accepted but no longer have any effect. Each option now passes a `CallControlState` down instead, so its colours come from the theme's button styling. - `StreamCallContentThemeData.callContentBackgroundColor` is now nullable and defaults to `null`, which resolves to the design system's `backgroundApp` colour instead of the hard-coded `0xFF272A30`. Set it explicitly to keep a fixed background. diff --git a/packages/stream_video_flutter/build.yaml b/packages/stream_video_flutter/build.yaml new file mode 100644 index 000000000..031e00fa9 --- /dev/null +++ b/packages/stream_video_flutter/build.yaml @@ -0,0 +1,11 @@ +# Without an explicit source list, build_runner also walks +# `example/*/flutter/ephemeral/.plugin_symlinks`, where the symlinked plugin +# sources carry annotations this package has no dependency on. Those fail to +# resolve and take the whole build down with them, even though nothing there is +# ours to generate. +targets: + $default: + sources: + - lib/** + - test/** + - $package$ diff --git a/packages/stream_video_flutter/lib/fix_data.yaml b/packages/stream_video_flutter/lib/fix_data.yaml index c03e50481..fdeb04f19 100644 --- a/packages/stream_video_flutter/lib/fix_data.yaml +++ b/packages/stream_video_flutter/lib/fix_data.yaml @@ -3,15 +3,96 @@ version: 1 transforms: #region Participant tile migration - - title: "replace StreamCallParticipant with StreamParticipantTile" - date: "2026-08-20" + # StreamCallParticipant is NOT renamed in bulk. It still accepts the visual + # parameters StreamParticipantTile dropped in favour of `style:`, so renaming + # a call site that passes any of them turns working code into a compile + # error. Those call sites move by hand; the CHANGELOG carries the mapping. + # + # participantLabelAlignment, connectionLevelAlignment and + # enabledMicrophoneColor are still accepted by the deprecated widget but are + # not forwarded anywhere: the label and the indicator are laid out by the + # toolbar, and an unmuted microphone draws no icon to colour. Removing them + # changes nothing at runtime, which is why it is safe to do in bulk. + + - title: "remove participantLabelAlignment from StreamCallParticipant" + date: "2026-08-28" bulkApply: true element: uris: ["package:stream_video_flutter/stream_video_flutter.dart"] - class: "StreamCallParticipant" + constructor: "" + inClass: "StreamCallParticipant" changes: - - kind: "rename" - newName: "StreamParticipantTile" + - kind: "removeParameter" + name: "participantLabelAlignment" + + - title: "remove connectionLevelAlignment from StreamCallParticipant" + date: "2026-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallParticipant" + changes: + - kind: "removeParameter" + name: "connectionLevelAlignment" + + - title: "remove enabledMicrophoneColor from StreamCallParticipant" + date: "2026-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamCallParticipant" + changes: + - kind: "removeParameter" + name: "enabledMicrophoneColor" + + - title: "remove userAvatarTheme from StreamLocalVideo" + date: "2026-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + constructor: "" + inClass: "StreamLocalVideo" + changes: + - kind: "removeParameter" + name: "userAvatarTheme" + + - title: "rename showDominantSpeakerBorder to showSpeakerBorder" + date: "2026-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + method: "copyWith" + inClass: "StreamCallParticipantThemeData" + changes: + - kind: "renameParameter" + oldName: "showDominantSpeakerBorder" + newName: "showSpeakerBorder" + + - title: "rename dominantSpeakerBorderThickness to speakerBorderThickness" + date: "2026-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + method: "copyWith" + inClass: "StreamCallParticipantThemeData" + changes: + - kind: "renameParameter" + oldName: "dominantSpeakerBorderThickness" + newName: "speakerBorderThickness" + + - title: "rename dominantSpeakerBorderColor to speakerBorderColor" + date: "2026-08-28" + bulkApply: true + element: + uris: ["package:stream_video_flutter/stream_video_flutter.dart"] + method: "copyWith" + inClass: "StreamCallParticipantThemeData" + changes: + - kind: "renameParameter" + oldName: "dominantSpeakerBorderColor" + newName: "speakerBorderColor" #endregion Participant tile migration #region Partial state updates diff --git a/packages/stream_video_flutter/lib/src/call_participants/floating_participant_tile.dart b/packages/stream_video_flutter/lib/src/call_participants/floating_participant_tile.dart new file mode 100644 index 000000000..69eb01dde --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/floating_participant_tile.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +import '../../stream_video_flutter.dart'; +import 'floating_participant_tile_defaults.dart'; + +/// The draggable self-view that floats over a call. +/// +/// A participant tile sized and styled for the corner of the screen: small +/// enough that the name pill and the speaking outline would only crowd it, so +/// by default it carries the connection quality indicator alone. +/// +/// The rendering can be replaced app-wide by registering a +/// `floatingParticipantTile` builder with [streamVideoComponentBuilders] on a +/// [StreamComponentFactory]. When no builder is registered, +/// [DefaultStreamFloatingParticipantTile] is used. +/// +/// See also: +/// +/// * [StreamFloatingParticipantTileTheme], for customizing its appearance. +/// * [StreamParticipantTile], the tile it wraps. +class StreamFloatingParticipantTile extends StatelessWidget { + /// Creates a floating participant tile. + StreamFloatingParticipantTile({ + super.key, + required Call call, + required CallParticipantState participant, + StreamFloatingParticipantTileStyle? style, + CallParticipantBuilder? participantBuilder, + }) : props = .new( + call: call, + participant: participant, + style: style, + participantBuilder: participantBuilder, + ); + + /// The properties that configure this floating tile. + final StreamFloatingParticipantTileProps props; + + @override + Widget build(BuildContext context) { + final builder = context + .videoComponentBuilder(); + return builder?.call(context, props) ?? + DefaultStreamFloatingParticipantTile(props: props); + } +} + +/// Properties for configuring a [StreamFloatingParticipantTile]. +/// +/// See also: +/// +/// * [StreamFloatingParticipantTile], which uses these properties. +/// * [DefaultStreamFloatingParticipantTile], the default implementation. +@immutable +class StreamFloatingParticipantTileProps { + /// Creates properties for a floating participant tile. + const StreamFloatingParticipantTileProps({ + required this.call, + required this.participant, + this.style, + this.participantBuilder, + }); + + /// Represents a call. + final Call call; + + /// The participant to display — normally the local one. + final CallParticipantState participant; + + /// Overrides for this floating tile's appearance. + /// + /// Merged over the ambient [StreamFloatingParticipantTileTheme]. + final StreamFloatingParticipantTileStyle? style; + + /// Builds the tile shown inside the floating surface. + /// + /// Replaces the participant tile entirely; the surface around it — its size, + /// elevation and drag behaviour — is unaffected. + final CallParticipantBuilder? participantBuilder; + + /// Creates a copy of these properties with the given fields replaced. + StreamFloatingParticipantTileProps copyWith({ + Call? call, + CallParticipantState? participant, + StreamFloatingParticipantTileStyle? style, + CallParticipantBuilder? participantBuilder, + }) { + return StreamFloatingParticipantTileProps( + call: call ?? this.call, + participant: participant ?? this.participant, + style: style ?? this.style, + participantBuilder: participantBuilder ?? this.participantBuilder, + ); + } +} + +/// The default implementation of [StreamFloatingParticipantTile]. +class DefaultStreamFloatingParticipantTile extends StatelessWidget { + /// Creates the default floating participant tile. + const DefaultStreamFloatingParticipantTile({ + super.key, + required this.props, + }); + + /// The properties that configure this floating tile. + final StreamFloatingParticipantTileProps props; + + @override + Widget build(BuildContext context) { + final themeStyle = StreamFloatingParticipantTileTheme.of(context).style; + final style = themeStyle?.merge(props.style) ?? props.style; + final defaults = StreamFloatingParticipantTileStyleDefaults(context); + + final size = style?.size ?? defaults.size; + final borderRadius = style?.borderRadius ?? defaults.borderRadius; + + // The surface rounds the outside and the tile rounds the video inside it, + // so the two clips have to agree. Injected rather than left to the tile's + // own default: overriding only the surface radius would leave the tighter + // clip stopping short of the corners, which reads as four transparent + // notches. An explicit tileStyle radius still wins — that is a caller + // asking for the two to differ. + final tileStyle = defaults.tileStyle + .merge(StreamParticipantTileStyle(borderRadius: borderRadius)) + .merge(style?.tileStyle); + + return SizedBox.fromSize( + size: size, + child: Material( + // Elevation rather than a painted shadow, so the self-view lifts off + // the call the same way every other raised Stream surface does. + elevation: style?.elevation ?? defaults.elevation, + shadowColor: style?.shadowColor, + color: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: borderRadius), + // Clips whatever is inside to the same corners, so a replaced tile + // does not have to round itself to sit in the surface. + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + // Outside the Material, which clips its children to its own shape and + // would eat an outward-aligned border. + position: DecorationPosition.foreground, + decoration: BoxDecoration( + borderRadius: borderRadius, + border: style?.border ?? defaults.border, + ), + child: + props.participantBuilder?.call( + context, + props.call, + props.participant, + ) ?? + StreamParticipantTile( + call: props.call, + participant: props.participant, + style: tileStyle, + ), + ), + ), + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/call_participants/floating_participant_tile_defaults.dart b/packages/stream_video_flutter/lib/src/call_participants/floating_participant_tile_defaults.dart new file mode 100644 index 000000000..1c63e568c --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/floating_participant_tile_defaults.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../stream_video_flutter.dart'; + +/// Default style values for [StreamFloatingParticipantTile]. +/// +/// Shared with [StreamLocalVideo], which needs the self-view's dimensions +/// before the floating tile is built. Deliberately not exported. +@internal +class StreamFloatingParticipantTileStyleDefaults + extends StreamFloatingParticipantTileStyle { + /// Resolves the floating tile's defaults from the theme on the given + /// context. + StreamFloatingParticipantTileStyleDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + late final _elevation = _context.streamElevation; + + @override + Size get size => const Size(140, 228); + + @override + double get padding => _spacing.md; + + @override + BorderRadius get borderRadius => BorderRadius.all(_radius.lg); + + @override + BoxBorder get border => Border.all(color: _colorScheme.borderOpacitySubtle); + + @override + double get elevation => _elevation.level2; + + @override + FloatingViewAlignment get initialAlignment => FloatingViewAlignment.topRight; + + @override + bool get enableSnapping => true; + + // The radius is left out on purpose: the tile takes it from the surface it + // sits in, which the caller may have overridden. + @override + StreamParticipantTileStyle get tileStyle => const StreamParticipantTileStyle( + // At this size a name and an outline crowd the video out; the connection + // quality indicator is the one thing still worth the room. + showParticipantLabel: false, + showSpeakerBorder: false, + showMoreButton: false, + // The tile draws no border of its own — the floating surface owns it, and + // two hairlines on the same corner read as one thick one. + border: Border(), + ); +} diff --git a/packages/stream_video_flutter/lib/src/call_participants/indicators/audio_indicator.dart b/packages/stream_video_flutter/lib/src/call_participants/indicators/audio_indicator.dart index 3166f6309..945792753 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/indicators/audio_indicator.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/indicators/audio_indicator.dart @@ -1,62 +1,58 @@ import 'package:flutter/material.dart'; import '../../../stream_video_flutter.dart'; +import '../participant_label_defaults.dart'; import 'audio_level_indicator.dart'; -/// Widget used to indicate the audio state of a given participant. -/// Either shows a mute icon or audio levels. +/// The sound indicator shown at the end of a participant's name pill. +/// +/// Always present, so the pill keeps its shape as someone starts and stops +/// talking: the bars animate while [isSpeaking] and rest as three dots +/// otherwise. Whether the participant is muted is reported separately, by the +/// microphone icon the pill draws next to this. class StreamAudioIndicator extends StatelessWidget { - /// Creates a new instance of [StreamAudioIndicator]. + /// Creates a sound indicator. const StreamAudioIndicator({ super.key, - required this.isAudioEnabled, required this.isSpeaking, - this.audioLevelIndicatorColor, - this.enabledMicrophoneColor, - this.disabledMicrophoneColor, + this.style, }); - /// If the participant has microphone enabled. - final bool isAudioEnabled; - - /// If the participant is speaking. + /// Whether the participant is currently speaking. final bool isSpeaking; - /// The color of an audio level indicator. - final Color? audioLevelIndicatorColor; - - /// The color of an enabled microphone icon. - final Color? enabledMicrophoneColor; - - /// The color of a disabled microphone icon. - final Color? disabledMicrophoneColor; + /// Overrides for this indicator's appearance. + final StreamParticipantLabelStyle? style; @override Widget build(BuildContext context) { - final theme = StreamVideoTheme.of(context).callParticipantTheme; - - if (isAudioEnabled && isSpeaking) { - return StreamAudioLevelIndicator( - color: audioLevelIndicatorColor, - ); - } else if (isAudioEnabled && !isSpeaking) { - return Padding( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.mic, - size: 16, - color: enabledMicrophoneColor ?? theme.enabledMicrophoneColor, + // The pill's own defaults, rather than a second copy of them: the indicator + // is one of the parts the pill is made of, and the two have to agree on how + // big it is for the tile's own arithmetic to hold. + final defaults = StreamParticipantLabelStyleDefaults(context); + + return SizedBox.square( + dimension: style?.audioIndicatorSize ?? defaults.audioIndicatorSize, + child: DecoratedBox( + decoration: BoxDecoration( + color: + style?.audioIndicatorBackgroundColor ?? + style?.backgroundColor ?? + defaults.audioIndicatorBackgroundColor, + borderRadius: + style?.audioIndicatorBorderRadius ?? + defaults.audioIndicatorBorderRadius, ), - ); - } else { - return Padding( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.mic_off, - size: 16, - color: disabledMicrophoneColor ?? theme.disabledMicrophoneColor, + child: Center( + child: StreamAudioLevelIndicator( + isSpeaking: isSpeaking, + size: + style?.audioIndicatorIconSize ?? + defaults.audioIndicatorIconSize, + color: style?.speakingColor ?? defaults.speakingColor, + ), ), - ); - } + ), + ); } } diff --git a/packages/stream_video_flutter/lib/src/call_participants/indicators/audio_level_indicator.dart b/packages/stream_video_flutter/lib/src/call_participants/indicators/audio_level_indicator.dart index ee1f1623b..6e6101391 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/indicators/audio_level_indicator.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/indicators/audio_level_indicator.dart @@ -1,18 +1,31 @@ -import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import '../../theme/stream_video_theme.dart'; - -/// Widget used to indicate the audio levels of a given participant. +/// Three bars reporting whether a participant is speaking. +/// +/// While [isSpeaking] the bars rise and fall around their shared centre line; +/// otherwise they rest at their minimum, reading as three dots. The animation +/// is a free-running loop rather than a reading of the participant's audio +/// level: it says "this person is talking", not how loudly. class StreamAudioLevelIndicator extends StatefulWidget { - /// Creates a new instance of [StreamAudioLevelIndicator]. + /// Creates an audio level indicator. const StreamAudioLevelIndicator({ super.key, - this.color, + required this.color, + required this.isSpeaking, + this.size = 10, }); - /// The color of an audio level. - final Color? color; + /// The color of the bars. + final Color color; + + /// Whether the bars animate. + final bool isSpeaking; + + /// The side length of the square the bars are painted in. + /// + /// The bars and the gaps between them are each a fifth of it, so three bars + /// and two gaps fill the width exactly. + final double size; @override State createState() => @@ -21,7 +34,7 @@ class StreamAudioLevelIndicator extends StatefulWidget { class _StreamAudioLevelIndicatorState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; + late final AnimationController _controller; @override void initState() { @@ -29,7 +42,21 @@ class _StreamAudioLevelIndicatorState extends State _controller = AnimationController( duration: const Duration(milliseconds: 400), vsync: this, - )..repeat(reverse: true); + ); + if (widget.isSpeaking) _controller.repeat(reverse: true); + } + + @override + void didUpdateWidget(StreamAudioLevelIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isSpeaking == oldWidget.isSpeaking) return; + if (widget.isSpeaking) { + _controller.repeat(reverse: true); + } else { + // Back to rest rather than stopping wherever the loop happened to be. + _controller.stop(); + _controller.value = 0; + } } @override @@ -40,67 +67,77 @@ class _StreamAudioLevelIndicatorState extends State @override Widget build(BuildContext context) { - final theme = StreamVideoTheme.of(context).callParticipantTheme; - - return SizedBox( - width: 24, - height: 24, - child: AnimatedBuilder( - animation: _controller, - builder: (_, child) { - return CustomPaint( - size: const Size.square(24), + // The only thing on a tile repainting every frame. It sits above the label + // pill's backdrop filter, so a boundary here does not rob that filter of + // its backdrop. + return RepaintBoundary( + child: SizedBox.square( + dimension: widget.size, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) => CustomPaint( + size: Size.square(widget.size), painter: _AudioLevelIndicatorPainter( animationValue: _controller.value, - color: widget.color ?? theme.audioLevelIndicatorColor, + isSpeaking: widget.isSpeaking, + color: widget.color, ), - ); - }, + ), + ), ), ); } } -/// Painter widget for an the audio level indicator widget. class _AudioLevelIndicatorPainter extends CustomPainter { - /// Constructor for creating a [_AudioLevelIndicatorPainter]. const _AudioLevelIndicatorPainter({ required this.animationValue, + required this.isSpeaking, required this.color, }); - /// The current value of the animation. final double animationValue; - - /// The color of an audio level. + final bool isSpeaking; final Color color; @override void paint(Canvas canvas, Size size) { + const barCount = 3; + // Three bars and two gaps, each a fifth of the width. + final unit = size.width / 5; + final centerY = size.height / 2; + final paint = Paint() ..color = color - ..strokeWidth = 3 + ..strokeWidth = unit ..strokeCap = StrokeCap.round; - final offset = 4 * animationValue; - - canvas.drawLine( - Offset(7, 10 - offset), - const Offset(7, 16), - paint, - ); - canvas.drawLine( - Offset(12, 6 + offset), - const Offset(12, 16), - paint, - ); - canvas.drawLine( - Offset(17, 10 - offset), - const Offset(17, 16), - paint, - ); + for (var i = 0; i < barCount; i++) { + // The middle bar runs against the outer two, so the group reads as + // movement rather than as one bar pulsing three times — but only while + // there is movement to read. At rest every bar collapses, or the + // inverted one would sit at full height with the others already down. + final phase = switch ((isSpeaking, i)) { + (false, _) => 0.0, + (true, 1) => 1 - animationValue, + (true, _) => animationValue, + }; + // A collapsed line has no length at all: the round cap alone draws a dot + // one stroke across, which is the design's resting state. + final length = (size.height - unit) * phase; + + final x = unit / 2 + i * 2 * unit; + canvas.drawLine( + Offset(x, centerY - length / 2), + Offset(x, centerY + length / 2), + paint, + ); + } } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; + bool shouldRepaint(_AudioLevelIndicatorPainter oldDelegate) => + oldDelegate.animationValue != animationValue || + oldDelegate.isSpeaking != isSpeaking || + oldDelegate.color != color; } diff --git a/packages/stream_video_flutter/lib/src/call_participants/indicators/connection_quality_indicator.dart b/packages/stream_video_flutter/lib/src/call_participants/indicators/connection_quality_indicator.dart index 8339ad15a..300836e81 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/indicators/connection_quality_indicator.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/indicators/connection_quality_indicator.dart @@ -1,114 +1,181 @@ import 'package:flutter/material.dart'; import '../../../stream_video_flutter.dart'; - -/// Widget used to indicate the connection quality of a given participant. +import 'connection_quality_indicator_defaults.dart'; + +/// A round chip reporting how good a participant's connection is. +/// +/// Three bars, of which as many are lit as the reported quality warrants. The +/// lit bars are also colored by level, so the state reads at a glance without +/// counting bars. +/// +/// The rendering can be replaced app-wide by registering a +/// `connectionQualityIndicator` builder with [streamVideoComponentBuilders] on +/// a [StreamComponentFactory]. When no builder is registered, +/// [DefaultStreamConnectionQualityIndicator] is used. +/// +/// See also: +/// +/// * [StreamConnectionQualityIndicatorTheme], for customizing its appearance. class StreamConnectionQualityIndicator extends StatelessWidget { - /// Creates a new instance of [StreamConnectionQualityIndicator]. - const StreamConnectionQualityIndicator({ + /// Creates a connection quality indicator. + StreamConnectionQualityIndicator({ super.key, + required SfuConnectionQuality connectionQuality, + StreamConnectionQualityIndicatorStyle? style, + }) : props = .new(connectionQuality: connectionQuality, style: style); + + /// The properties that configure this indicator. + final StreamConnectionQualityIndicatorProps props; + + @override + Widget build(BuildContext context) { + final builder = context + .videoComponentBuilder(); + return builder?.call(context, props) ?? + DefaultStreamConnectionQualityIndicator(props: props); + } +} + +/// Properties for configuring a [StreamConnectionQualityIndicator]. +/// +/// See also: +/// +/// * [StreamConnectionQualityIndicator], which uses these properties. +/// * [DefaultStreamConnectionQualityIndicator], the default implementation. +@immutable +class StreamConnectionQualityIndicatorProps { + /// Creates properties for a connection quality indicator. + const StreamConnectionQualityIndicatorProps({ required this.connectionQuality, - this.activeColor, - this.inactiveColor, + this.style, }); - /// The connection quality of the participant. + /// The connection quality being reported. final SfuConnectionQuality connectionQuality; - /// The color of an active connection quality level. - final Color? activeColor; + /// Overrides for this indicator's appearance. + /// + /// Merged over the ambient [StreamConnectionQualityIndicatorTheme]. + final StreamConnectionQualityIndicatorStyle? style; + + /// Creates a copy of these properties with the given fields replaced. + StreamConnectionQualityIndicatorProps copyWith({ + SfuConnectionQuality? connectionQuality, + StreamConnectionQualityIndicatorStyle? style, + }) { + return StreamConnectionQualityIndicatorProps( + connectionQuality: connectionQuality ?? this.connectionQuality, + style: style ?? this.style, + ); + } +} + +/// The default implementation of [StreamConnectionQualityIndicator]. +class DefaultStreamConnectionQualityIndicator extends StatelessWidget { + /// Creates the default connection quality indicator. + const DefaultStreamConnectionQualityIndicator({ + super.key, + required this.props, + }); - /// The color of an inactive connection quality level. - final Color? inactiveColor; + /// The properties that configure this indicator. + final StreamConnectionQualityIndicatorProps props; @override Widget build(BuildContext context) { - final theme = StreamVideoTheme.of(context).callParticipantTheme; - - return DecoratedBox( - decoration: BoxDecoration( - // ignore: deprecated_member_use - color: Colors.black.withOpacity(0.85), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(10), + final themeStyle = StreamConnectionQualityIndicatorTheme.of(context).style; + final style = themeStyle?.merge(props.style) ?? props.style; + final defaults = StreamConnectionQualityIndicatorStyleDefaults(context); + + final size = style?.size ?? defaults.size; + final iconSize = style?.iconSize ?? defaults.iconSize; + + final activeColor = switch (props.connectionQuality) { + SfuConnectionQuality.poor => style?.poorColor ?? defaults.poorColor, + SfuConnectionQuality.good => style?.fairColor ?? defaults.fairColor, + SfuConnectionQuality.excellent => + style?.greatColor ?? defaults.greatColor, + // Nothing is lit, so the active color never gets painted. + SfuConnectionQuality.unspecified => + style?.inactiveColor ?? defaults.inactiveColor, + }; + + return SizedBox.square( + dimension: size, + child: DecoratedBox( + decoration: BoxDecoration( + color: style?.backgroundColor ?? defaults.backgroundColor, + shape: BoxShape.circle, ), - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: SizedBox( - width: 24, - height: 24, + child: Center( child: CustomPaint( - size: const Size.square(24), + size: Size.square(iconSize), painter: _ConnectionQualityIndicatorPainter( - connectionQuality: connectionQuality, - activeColor: activeColor ?? theme.connectionLevelActiveColor, - inactiveColor: - inactiveColor ?? theme.connectionLevelInactiveColor, + level: _levelOf(props.connectionQuality), + activeColor: activeColor, + inactiveColor: style?.inactiveColor ?? defaults.inactiveColor, ), ), ), ), ); } + + static int _levelOf(SfuConnectionQuality quality) => switch (quality) { + SfuConnectionQuality.poor => 1, + SfuConnectionQuality.good => 2, + SfuConnectionQuality.excellent => 3, + SfuConnectionQuality.unspecified => 0, + }; } -/// Painter widget for the connection quality indicator widget. +// Paints three bars of increasing height, `level` of them in [activeColor]. +// +// The geometry is the design system's `Connection Indicator` icon, expressed in +// its own 24-unit space and scaled to whatever size it is drawn at: three +// 2-thick strokes at x 7, 12 and 17, rising from a shared baseline at y 16 to +// 14, 11 and 8. Unlike the sound indicator's bars these grow from the bottom — +// they report a level, not activity. class _ConnectionQualityIndicatorPainter extends CustomPainter { - /// Constructor for creating a [_ConnectionQualityIndicatorPainter]. const _ConnectionQualityIndicatorPainter({ - required this.connectionQuality, + required this.level, required this.activeColor, required this.inactiveColor, }); - /// The connection quality of the participant. - final SfuConnectionQuality connectionQuality; + static const _viewBox = 24.0; + static const _strokeWidth = 2.0; + static const _baseline = 16.0; + static const _bars = [ + (x: 7.0, top: 14.0), + (x: 12.0, top: 11.0), + (x: 17.0, top: 8.0), + ]; - /// The color of an active connection quality level. + final int level; final Color activeColor; - - /// The color of an inactive connection quality level. final Color inactiveColor; @override void paint(Canvas canvas, Size size) { - final inactivePaint = Paint() - ..color = inactiveColor - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round; - - final activePaint = Paint() - ..color = activeColor - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round; - - for (var i = 0; i < 3; i++) { - final offsetLeft = 7 + i * 5.0; - final offsetTop = 14 - i * 3.0; - final connectionLevel = _getConnectionLevel(); + final scale = size.width / _viewBox; + for (final (index, bar) in _bars.indexed) { canvas.drawLine( - Offset(offsetLeft, offsetTop), - Offset(offsetLeft, 16), - connectionLevel > i ? activePaint : inactivePaint, + Offset(bar.x * scale, bar.top * scale), + Offset(bar.x * scale, _baseline * scale), + Paint() + ..color = level > index ? activeColor : inactiveColor + ..strokeWidth = _strokeWidth * scale + ..strokeCap = StrokeCap.round, ); } } - int _getConnectionLevel() { - switch (connectionQuality) { - case SfuConnectionQuality.poor: - return 1; - case SfuConnectionQuality.good: - return 2; - case SfuConnectionQuality.excellent: - return 3; - case SfuConnectionQuality.unspecified: - return 0; - } - } - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; + bool shouldRepaint(_ConnectionQualityIndicatorPainter oldDelegate) => + oldDelegate.level != level || + oldDelegate.activeColor != activeColor || + oldDelegate.inactiveColor != inactiveColor; } diff --git a/packages/stream_video_flutter/lib/src/call_participants/indicators/connection_quality_indicator_defaults.dart b/packages/stream_video_flutter/lib/src/call_participants/indicators/connection_quality_indicator_defaults.dart new file mode 100644 index 000000000..9f2424fa4 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/indicators/connection_quality_indicator_defaults.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../stream_video_flutter.dart'; + +/// Default style values for [StreamConnectionQualityIndicator]. +/// +/// The chip sits on top of video, so its fill is an overlay and its bars are +/// colored for legibility against that overlay rather than against a surface. +/// +/// Shared with the tile, which has to know how much room the chip takes before +/// it decides what else fits beside it. Deliberately not exported. +@internal +class StreamConnectionQualityIndicatorStyleDefaults + extends StreamConnectionQualityIndicatorStyle { + /// Resolves the indicator's defaults from the theme on the given context. + StreamConnectionQualityIndicatorStyleDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + + @override + double get size => 32; + + @override + double get iconSize => 24; + + @override + Color get backgroundColor => _colorScheme.backgroundOverlayDarkStrong; + + @override + Color get poorColor => _colorScheme.accentError; + + @override + Color get fairColor => _colorScheme.accentWarning; + + @override + Color get greatColor => _colorScheme.accentSuccess; + + @override + Color get inactiveColor => _colorScheme.textOnAccent.withValues(alpha: 0.4); +} + +/// How much room a [StreamConnectionQualityIndicator] takes, in both axes. +/// +/// Resolved the same way the indicator resolves it, so the tile lays out +/// against the chip that is actually going to be drawn rather than the default +/// one. +@internal +double connectionQualityIndicatorSize( + BuildContext context, { + StreamConnectionQualityIndicatorStyle? style, +}) { + final themeStyle = StreamConnectionQualityIndicatorTheme.of(context).style; + final resolved = themeStyle?.merge(style) ?? style; + return resolved?.size ?? + StreamConnectionQualityIndicatorStyleDefaults(context).size; +} diff --git a/packages/stream_video_flutter/lib/src/call_participants/layout/call_participants_grid_view.dart b/packages/stream_video_flutter/lib/src/call_participants/layout/call_participants_grid_view.dart index 6675ad3fe..dc0164d59 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/layout/call_participants_grid_view.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/layout/call_participants_grid_view.dart @@ -2,13 +2,9 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:stream_video/stream_video.dart'; -import '../../theme/stream_video_theme.dart'; +import '../../../stream_video_flutter.dart'; import '../../widgets/tile_view.dart'; -import '../call_participants.dart'; - -const _kDefaultSpacing = 16.0; class CallParticipantsGridView extends StatelessWidget { const CallParticipantsGridView({ @@ -16,9 +12,9 @@ class CallParticipantsGridView extends StatelessWidget { required this.call, required this.participants, required this.itemBuilder, - this.padding = const EdgeInsets.all(_kDefaultSpacing), - this.mainAxisSpacing = _kDefaultSpacing, - this.crossAxisSpacing = _kDefaultSpacing, + this.padding, + this.mainAxisSpacing, + this.crossAxisSpacing, }); /// Represents a call. @@ -31,16 +27,34 @@ class CallParticipantsGridView extends StatelessWidget { final CallParticipantBuilder itemBuilder; /// Space between the items in the main axis. - final double mainAxisSpacing; + /// + /// Overrides [StreamCallParticipantsGridThemeData.mainAxisSpacing]. + final double? mainAxisSpacing; /// Space between the items in the cross axis. - final double crossAxisSpacing; + /// + /// Overrides [StreamCallParticipantsGridThemeData.crossAxisSpacing]. + final double? crossAxisSpacing; /// Padding around the grid. - final EdgeInsets padding; + /// + /// Overrides [StreamCallParticipantsGridThemeData.padding]. + final EdgeInsets? padding; @override Widget build(BuildContext context) { + final theme = StreamCallParticipantsGridTheme.of(context); + final spacing = context.streamSpacing; + + final padding = + this.padding ?? + theme.padding?.resolve(Directionality.maybeOf(context)) ?? + EdgeInsets.all(spacing.xs); + final mainAxisSpacing = + this.mainAxisSpacing ?? theme.mainAxisSpacing ?? spacing.xs; + final crossAxisSpacing = + this.crossAxisSpacing ?? theme.crossAxisSpacing ?? spacing.xs; + if (CurrentPlatform.isIos || CurrentPlatform.isAndroid) { return MobileCallParticipantsGrid( call: call, @@ -69,9 +83,9 @@ class MobileCallParticipantsGrid extends StatelessWidget { required this.call, required this.participants, required this.itemBuilder, - this.padding = const EdgeInsets.all(_kDefaultSpacing), - this.mainAxisSpacing = _kDefaultSpacing, - this.crossAxisSpacing = _kDefaultSpacing, + required this.padding, + required this.mainAxisSpacing, + required this.crossAxisSpacing, }); /// Represents a call. @@ -120,6 +134,9 @@ class MobileCallParticipantsGrid extends StatelessWidget { final pageParticipants = pages.elementAt(index); final pageParticipantsCount = pageParticipants.length; + Widget page(Widget child) => + Padding(padding: padding, child: child); + Widget getParticipantTile(int index) { if (index < pageParticipantsCount) { return Expanded( @@ -132,60 +149,61 @@ class MobileCallParticipantsGrid extends StatelessWidget { } if (index == 0) { - return Column( - children: [ - if (pageParticipantsCount == 1) ...[ - Expanded( - child: Padding( - padding: padding, + return page( + Column( + children: [ + if (pageParticipantsCount == 1) ...[ + Expanded( child: itemBuilder(context, call, pageParticipants[0]), ), - ), - ], - if (pageParticipantsCount == 2) ...[ - getParticipantTile(0), - SizedBox(height: mainAxisSpacing), - getParticipantTile(1), - ], - if (pageParticipantsCount >= 3) ...[ - ...pageParticipants.mapIndexed((index, element) { - if (index.isEven) { - return Expanded( - child: Row( - children: [ - getParticipantTile(index), - SizedBox(width: crossAxisSpacing), - getParticipantTile(index + 1), - ], - ), - ); - } else { - return SizedBox(height: mainAxisSpacing); - } - }), + ], + if (pageParticipantsCount == 2) ...[ + getParticipantTile(0), + SizedBox(height: mainAxisSpacing), + getParticipantTile(1), + ], + if (pageParticipantsCount >= 3) ...[ + ...pageParticipants.mapIndexed((index, element) { + if (index.isEven) { + return Expanded( + child: Row( + children: [ + getParticipantTile(index), + SizedBox(width: crossAxisSpacing), + getParticipantTile(index + 1), + ], + ), + ); + } else { + return SizedBox(height: mainAxisSpacing); + } + }), + ], ], - ], + ), ); } - return Column( - children: [ - ...List.generate(pageSize, (index) => index).map((index) { - if (index.isEven) { - return Expanded( - child: Row( - children: [ - getParticipantTile(index), - SizedBox(width: crossAxisSpacing), - getParticipantTile(index + 1), - ], - ), - ); - } else { - return SizedBox(height: mainAxisSpacing); - } - }), - ], + return page( + Column( + children: [ + ...List.generate(pageSize, (index) => index).map((index) { + if (index.isEven) { + return Expanded( + child: Row( + children: [ + getParticipantTile(index), + SizedBox(width: crossAxisSpacing), + getParticipantTile(index + 1), + ], + ), + ); + } else { + return SizedBox(height: mainAxisSpacing); + } + }), + ], + ), ); }, ); @@ -200,10 +218,10 @@ class DesktopCallParticipantsGrid extends StatefulWidget { required this.call, required this.participants, required this.itemBuilder, + required this.padding, + required this.mainAxisSpacing, + required this.crossAxisSpacing, this.pageSize = 16, - this.padding = const EdgeInsets.all(_kDefaultSpacing), - this.mainAxisSpacing = _kDefaultSpacing, - this.crossAxisSpacing = _kDefaultSpacing, }) : assert(pageSize <= 49, 'We currently support a maximum of 49 items'); /// Represents a call. diff --git a/packages/stream_video_flutter/lib/src/call_participants/local_video.dart b/packages/stream_video_flutter/lib/src/call_participants/local_video.dart index 54cf7ac3b..65a4d473e 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/local_video.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/local_video.dart @@ -1,6 +1,9 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'package:flutter/material.dart'; import '../../stream_video_flutter.dart'; +import 'floating_participant_tile_defaults.dart'; /// Represents a floating item used to feature a participant video. class StreamLocalVideo extends StatelessWidget { @@ -15,6 +18,11 @@ class StreamLocalVideo extends StatelessWidget { this.localVideoPadding, this.initialAlignment, this.enableSnappingBehavior, + @Deprecated( + 'The self-view sizes its avatar from StreamAvatarTheme, and the ' + 'placeholder from StreamParticipantTileStyle.placeholderStyle. This ' + 'parameter has no effect. Will be removed in the next major version.', + ) this.userAvatarTheme, this.borderRadius, this.shadowColor, @@ -47,6 +55,11 @@ class StreamLocalVideo extends StatelessWidget { final bool? enableSnappingBehavior; /// The theme for the avatar. + @Deprecated( + 'The self-view sizes its avatar from StreamAvatarTheme, and the placeholder ' + 'from StreamParticipantTileStyle.placeholderStyle. This parameter has no ' + 'effect. Will be removed in the next major version.', + ) final StreamUserAvatarThemeData? userAvatarTheme; /// The border radius of the local video. @@ -60,53 +73,41 @@ class StreamLocalVideo extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamLocalVideoTheme.of(context); - final localVideoWidth = this.localVideoWidth ?? theme.localVideoWidth; - final localVideoHeight = this.localVideoHeight ?? theme.localVideoHeight; - final localVideoPadding = this.localVideoPadding ?? theme.localVideoPadding; - final initialAlignment = this.initialAlignment ?? theme.initialAlignment; - final enableSnappingBehavior = - this.enableSnappingBehavior ?? theme.enableSnappingBehavior; - final userAvatarTheme = this.userAvatarTheme ?? theme.userAvatarTheme; - final borderRadius = this.borderRadius ?? theme.borderRadius; - final shadowColor = this.shadowColor ?? theme.shadowColor; - - var callParticipantBuilder = participantBuilder; - callParticipantBuilder ??= (context, call, participant) { - return StreamParticipantTile( - call: call, - participant: participant, - borderRadius: borderRadius, - userAvatarTheme: userAvatarTheme, - showParticipantLabel: false, - showSpeakerBorder: false, - ); - }; + final floatingStyle = StreamFloatingParticipantTileTheme.of(context).style; + final defaults = StreamFloatingParticipantTileStyleDefaults(context); + + // The deprecated parameters win where they are given, so existing call + // sites keep positioning the self-view the way they always did. + final style = StreamFloatingParticipantTileStyle( + size: (localVideoWidth != null || localVideoHeight != null) + ? Size( + localVideoWidth ?? defaults.size.width, + localVideoHeight ?? defaults.size.height, + ) + : null, + padding: localVideoPadding, + borderRadius: borderRadius, + initialAlignment: initialAlignment, + enableSnapping: enableSnappingBehavior, + shadowColor: shadowColor, + ); + + final resolved = floatingStyle?.merge(style) ?? style; + final size = resolved.size ?? defaults.size; return FloatingViewContainer( - floatingViewWidth: localVideoWidth, - floatingViewHeight: localVideoHeight, - floatingViewPadding: localVideoPadding, - enableSnappingBehavior: enableSnappingBehavior, - floatingViewAlignment: initialAlignment, - floatingView: Container( - width: localVideoWidth, - height: localVideoHeight, - decoration: BoxDecoration( - borderRadius: borderRadius, - boxShadow: [ - BoxShadow( - color: shadowColor, - blurRadius: 4, - spreadRadius: 2, - ), - ], - ), - child: callParticipantBuilder( - context, - call, - participant, - ), + floatingViewWidth: size.width, + floatingViewHeight: size.height, + floatingViewPadding: resolved.padding ?? defaults.padding, + enableSnappingBehavior: + resolved.enableSnapping ?? defaults.enableSnapping, + floatingViewAlignment: + resolved.initialAlignment ?? defaults.initialAlignment, + floatingView: StreamFloatingParticipantTile( + call: call, + participant: participant, + style: style, + participantBuilder: participantBuilder, ), child: child, ); diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_label.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_label.dart index 3c6c86244..f219e4fee 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/participant_label.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_label.dart @@ -1,114 +1,238 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; -import 'package:stream_video/stream_video.dart'; -import '../theme/stream_video_theme.dart'; -import 'indicators/audio_indicator.dart'; +import '../../stream_video_flutter.dart'; +import 'participant_label_defaults.dart'; -/// Widget used to display participant name and mute status on a call. +/// The pill on a participant tile carrying their name and audio state. +/// +/// Holds the participant's name, a camera-off icon while their video is off, +/// and a [StreamAudioIndicator]. It is meant to be laid out inside a bounded +/// parent: the name shrinks and ellipsizes rather than pushing the pill wider +/// than the space it was given. +/// +/// The rendering can be replaced app-wide by registering a `participantLabel` +/// builder with [streamVideoComponentBuilders] on a [StreamComponentFactory]. +/// When no builder is registered, [DefaultStreamParticipantLabel] is used. +/// +/// See also: +/// +/// * [StreamParticipantLabelTheme], for customizing its appearance. class StreamParticipantLabel extends StatelessWidget { - /// Creates a new instance of [StreamParticipantLabel]. - const StreamParticipantLabel({ - required this.participantName, - required this.isAudioEnabled, - required this.isSpeaking, - required this.isTrackPaused, - this.audioLevelIndicatorColor, - this.enabledMicrophoneColor, - this.disabledMicrophoneColor, - this.pausedVideoIndicatorColor, - this.participantLabelTextStyle, + /// Creates a participant label. + StreamParticipantLabel({ super.key, - }); + required String name, + required bool isAudioEnabled, + required bool isSpeaking, + required bool isVideoEnabled, + bool showName = true, + StreamParticipantLabelStyle? style, + }) : props = .new( + name: name, + isAudioEnabled: isAudioEnabled, + isSpeaking: isSpeaking, + isVideoEnabled: isVideoEnabled, + showName: showName, + style: style, + ); + /// Creates a participant label describing [participant]. StreamParticipantLabel.fromParticipant({ super.key, required CallParticipantState participant, - this.audioLevelIndicatorColor, - this.enabledMicrophoneColor, - this.disabledMicrophoneColor, - this.pausedVideoIndicatorColor, - this.participantLabelTextStyle, - }) : participantName = participant.name, - isAudioEnabled = participant.isAudioEnabled, - isSpeaking = participant.isSpeaking, - isTrackPaused = participant.isTrackPaused(SfuTrackType.video); - - /// The name of the participant. - final String participantName; - - /// If the participant has microphone enabled. + bool showName = true, + StreamParticipantLabelStyle? style, + }) : props = .new( + name: participant.name, + isAudioEnabled: participant.isAudioEnabled, + isSpeaking: participant.isSpeaking, + isVideoEnabled: participant.isVideoEnabled, + showName: showName, + style: style, + ); + + /// The properties that configure this label. + final StreamParticipantLabelProps props; + + @override + Widget build(BuildContext context) { + final builder = context + .videoComponentBuilder(); + return builder?.call(context, props) ?? + DefaultStreamParticipantLabel(props: props); + } +} + +/// Properties for configuring a [StreamParticipantLabel]. +/// +/// See also: +/// +/// * [StreamParticipantLabel], which uses these properties. +/// * [DefaultStreamParticipantLabel], the default implementation. +@immutable +class StreamParticipantLabelProps { + /// Creates properties for a participant label. + const StreamParticipantLabelProps({ + required this.name, + required this.isAudioEnabled, + required this.isSpeaking, + required this.isVideoEnabled, + this.showName = true, + this.style, + }); + + /// The participant's display name. + final String name; + + /// Whether the participant's microphone is on. final bool isAudioEnabled; - /// If the participant is speaking. + /// Whether the participant is currently speaking. final bool isSpeaking; - /// If the track is paused. - final bool isTrackPaused; + /// Whether the participant's camera is on. + final bool isVideoEnabled; - /// The color of an audio level indicator. - final Color? audioLevelIndicatorColor; + /// Whether [name] is shown. + /// + /// A tile too narrow to fit a readable name drops it and keeps the icons, + /// which stay meaningful at any size. + final bool showName; - /// The color of an enabled microphone icon. - final Color? enabledMicrophoneColor; + /// Overrides for this label's appearance. + /// + /// Merged over the ambient [StreamParticipantLabelTheme]. + final StreamParticipantLabelStyle? style; - /// The color of a disabled microphone icon. - final Color? disabledMicrophoneColor; + /// Creates a copy of these properties with the given fields replaced. + StreamParticipantLabelProps copyWith({ + String? name, + bool? isAudioEnabled, + bool? isSpeaking, + bool? isVideoEnabled, + bool? showName, + StreamParticipantLabelStyle? style, + }) { + return StreamParticipantLabelProps( + name: name ?? this.name, + isAudioEnabled: isAudioEnabled ?? this.isAudioEnabled, + isSpeaking: isSpeaking ?? this.isSpeaking, + isVideoEnabled: isVideoEnabled ?? this.isVideoEnabled, + showName: showName ?? this.showName, + style: style ?? this.style, + ); + } +} - /// The color of a paused video track icon. - final Color? pausedVideoIndicatorColor; +/// The default implementation of [StreamParticipantLabel]. +class DefaultStreamParticipantLabel extends StatelessWidget { + /// Creates the default participant label. + const DefaultStreamParticipantLabel({super.key, required this.props}); - /// Text style for the participant label. - final TextStyle? participantLabelTextStyle; + /// The properties that configure this label. + final StreamParticipantLabelProps props; @override Widget build(BuildContext context) { - final theme = StreamVideoTheme.of(context).callParticipantTheme; - - return DecoratedBox( - decoration: BoxDecoration( - // ignore: deprecated_member_use - color: Colors.black.withOpacity(0.85), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(10), - ), - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), + final themeStyle = StreamParticipantLabelTheme.of(context).style; + final style = themeStyle?.merge(props.style) ?? props.style; + final defaults = StreamParticipantLabelStyleDefaults(context); + + final borderRadius = style?.borderRadius ?? defaults.borderRadius; + final nameTextStyle = style?.nameTextStyle ?? defaults.nameTextStyle; + final blurSigma = style?.blurSigma ?? defaults.blurSigma; + + Widget content = Padding( + padding: style?.padding ?? defaults.padding, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: style?.spacing ?? defaults.spacing, + children: [ + if (props.showName) + // Flexible, not Expanded: the pill is only as wide as it needs to + // be, up to whatever its parent allows. Combined with the parent's + // bound this is what makes a long name ellipsize instead of + // sliding under whatever sits next to the pill. Flexible( child: Text( - participantName, - style: - participantLabelTextStyle ?? - theme.participantLabelTextStyle, + props.name, + style: nameTextStyle, + maxLines: 1, + softWrap: false, overflow: TextOverflow.ellipsis, ), ), - const SizedBox(width: 4), - StreamAudioIndicator( - isAudioEnabled: isAudioEnabled, - isSpeaking: isSpeaking, - audioLevelIndicatorColor: audioLevelIndicatorColor, - enabledMicrophoneColor: enabledMicrophoneColor, - disabledMicrophoneColor: disabledMicrophoneColor, + // Only the muted state gets an icon: an unmuted microphone is the + // norm, and the sound indicator already reports whether anything is + // coming through it. + if (!props.isAudioEnabled) + Icon( + context.streamIcons.voiceOffFill, + size: style?.microphoneIconSize ?? defaults.microphoneIconSize, + color: + style?.microphoneOffColor ?? + nameTextStyle.color ?? + defaults.microphoneOffColor, ), - if (isTrackPaused) ...[ - const SizedBox(width: 4), - Icon( - Icons.network_check, - size: 16, - color: - pausedVideoIndicatorColor ?? - theme.pausedVideoIndicatorColor, - ), - ], - const SizedBox(width: 2), - ], + if (!props.isVideoEnabled) + Icon( + context.streamIcons.videoOffFill, + size: style?.videoOffIconSize ?? defaults.videoOffIconSize, + color: + style?.videoOffIconColor ?? + nameTextStyle.color ?? + defaults.videoOffIconColor, + ), + StreamAudioIndicator(isSpeaking: props.isSpeaking, style: style), + ], + ), + ); + + // Text scaling grows the pill, which on a small tile can swallow the video. + // Clamping keeps it legible without letting it take the whole tile. + content = MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.3, + child: content, + ); + + return ClipRRect( + // The pill needs its own clip: without one the backdrop filter blurs + // everything up to the tile's clip rather than just what is behind it. + borderRadius: borderRadius, + child: _MaybeBlur( + sigma: blurSigma, + child: DecoratedBox( + decoration: BoxDecoration( + color: style?.backgroundColor ?? defaults.backgroundColor, + borderRadius: borderRadius, + ), + child: content, ), ), ); } } + +// Applies a backdrop blur, or nothing at all when [sigma] is null. +// +// A BackdropFilter costs a render layer even at sigma zero, and a full grid +// carries one pill per tile, so "no blur" has to mean "no filter". +class _MaybeBlur extends StatelessWidget { + const _MaybeBlur({required this.sigma, required this.child}); + + final double? sigma; + final Widget child; + + @override + Widget build(BuildContext context) { + final sigma = this.sigma; + if (sigma == null || sigma <= 0) return child; + + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: child, + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart new file mode 100644 index 000000000..cd9076ea1 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_label_defaults.dart @@ -0,0 +1,147 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../stream_video_flutter.dart'; + +/// Default style values for [StreamParticipantLabel]. +/// +/// Shared with the parts the pill is built from, so a default lives in one +/// place rather than once per widget that draws it. Deliberately not exported. +@internal +class StreamParticipantLabelStyleDefaults extends StreamParticipantLabelStyle { + /// Resolves the label's defaults from the theme on the given context. + StreamParticipantLabelStyleDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + + @override + Color get backgroundColor => _colorScheme.backgroundOverlayDarkStrong; + + @override + BorderRadius get borderRadius => BorderRadius.all(_radius.lg); + + @override + EdgeInsetsGeometry get padding => EdgeInsetsDirectional.fromSTEB( + _spacing.sm, + _spacing.xxs, + _spacing.xxs, + _spacing.xxs, + ); + + @override + double get spacing => _spacing.xs; + + @override + double get blurSigma => 12.5; + + @override + TextStyle get nameTextStyle => + _textTheme.metadataDefault.copyWith(color: _colorScheme.textOnAccent); + + @override + Color get videoOffIconColor => _colorScheme.textOnAccent; + + @override + double get videoOffIconSize => _spacing.lg; + + @override + double get microphoneIconSize => _spacing.lg; + + @override + Color get microphoneOffColor => _colorScheme.textOnAccent; + + @override + double get audioIndicatorSize => 24; + + @override + double get audioIndicatorIconSize => 10; + + @override + Color get audioIndicatorBackgroundColor => + _colorScheme.backgroundOverlayDarkStrong; + + @override + BorderRadius get audioIndicatorBorderRadius => BorderRadius.all(_radius.md); + + @override + Color get speakingColor => _colorScheme.brand.shade300; +} + +/// The narrowest a [StreamParticipantLabel] can be laid out at, given what this +/// participant makes it draw. +/// +/// The tile drops the pill rather than let it overflow, and to decide that it +/// needs the same number the pill lays itself out to: the sound indicator it +/// always draws, whichever state icons the participant contributes, the padding +/// around them and the gaps between them. A name contributes no width of its +/// own — it ellipsizes away to nothing — but it still claims one of the gaps. +@internal +double participantLabelMinWidth( + BuildContext context, { + required bool showName, + required bool showMicrophoneOff, + required bool showVideoOff, + StreamParticipantLabelStyle? style, +}) { + // The same resolution order the label itself uses, so the two agree on what + // the pill is going to be. + final themeStyle = StreamParticipantLabelTheme.of(context).style; + final resolved = themeStyle?.merge(style) ?? style; + final defaults = StreamParticipantLabelStyleDefaults(context); + + final padding = (resolved?.padding ?? defaults.padding).resolve( + Directionality.maybeOf(context), + ); + + var width = + padding.horizontal + + (resolved?.audioIndicatorSize ?? defaults.audioIndicatorSize); + var children = 1; + + if (showName) children++; + if (showMicrophoneOff) { + width += resolved?.microphoneIconSize ?? defaults.microphoneIconSize; + children++; + } + if (showVideoOff) { + width += resolved?.videoOffIconSize ?? defaults.videoOffIconSize; + children++; + } + + return width + (children - 1) * (resolved?.spacing ?? defaults.spacing); +} + +/// How tall a [StreamParticipantLabel] comes out. +/// +/// The pill wraps its tallest part in its own padding. The name is measured as +/// its icons rather than with a [TextPainter]: at every text scale the pill +/// clamps to, the sound indicator is the tallest thing in the row, and the tile +/// only needs this to keep its two toolbars off each other. +@internal +double participantLabelHeight( + BuildContext context, { + StreamParticipantLabelStyle? style, +}) { + final themeStyle = StreamParticipantLabelTheme.of(context).style; + final resolved = themeStyle?.merge(style) ?? style; + final defaults = StreamParticipantLabelStyleDefaults(context); + + final padding = (resolved?.padding ?? defaults.padding).resolve( + Directionality.maybeOf(context), + ); + + final content = [ + resolved?.audioIndicatorSize ?? defaults.audioIndicatorSize, + resolved?.microphoneIconSize ?? defaults.microphoneIconSize, + resolved?.videoOffIconSize ?? defaults.videoOffIconSize, + ].reduce(math.max); + + return padding.vertical + content; +} diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_placeholder.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_placeholder.dart new file mode 100644 index 000000000..12368bc56 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_placeholder.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '../../stream_video_flutter.dart'; + +/// What a participant tile shows in place of video. +/// +/// Rendered whenever there is no picture to show — the camera is off, the track +/// has not arrived yet, or it is paused — so it stands in for the participant +/// rather than for the video: by default, their avatar centred on the tile. +/// +/// The rendering can be replaced app-wide by registering a +/// `participantPlaceholder` builder with [streamVideoComponentBuilders] on a +/// [StreamComponentFactory]. When no builder is registered, +/// [DefaultStreamParticipantPlaceholder] is used. +/// +/// See also: +/// +/// * [StreamParticipantPlaceholderStyle], for customizing its appearance. +/// * [StreamUserAvatar], which it draws — replace that instead to change every +/// avatar in the SDK at once. +class StreamParticipantPlaceholder extends StatelessWidget { + /// Creates a participant placeholder. + StreamParticipantPlaceholder({ + super.key, + required Call call, + required CallParticipantState participant, + StreamParticipantPlaceholderStyle? style, + }) : props = .new(call: call, participant: participant, style: style); + + /// The properties that configure this placeholder. + final StreamParticipantPlaceholderProps props; + + @override + Widget build(BuildContext context) { + final builder = context + .videoComponentBuilder(); + return builder?.call(context, props) ?? + DefaultStreamParticipantPlaceholder(props: props); + } +} + +/// Properties for configuring a [StreamParticipantPlaceholder]. +/// +/// See also: +/// +/// * [StreamParticipantPlaceholder], which uses these properties. +/// * [DefaultStreamParticipantPlaceholder], the default implementation. +@immutable +class StreamParticipantPlaceholderProps { + /// Creates properties for a participant placeholder. + const StreamParticipantPlaceholderProps({ + required this.call, + required this.participant, + this.style, + }); + + /// Represents a call. + final Call call; + + /// The participant standing in for their missing video. + final CallParticipantState participant; + + /// Overrides for this placeholder's appearance. + final StreamParticipantPlaceholderStyle? style; + + /// Creates a copy of these properties with the given fields replaced. + StreamParticipantPlaceholderProps copyWith({ + Call? call, + CallParticipantState? participant, + StreamParticipantPlaceholderStyle? style, + }) { + return StreamParticipantPlaceholderProps( + call: call ?? this.call, + participant: participant ?? this.participant, + style: style ?? this.style, + ); + } +} + +/// The default implementation of [StreamParticipantPlaceholder]. +class DefaultStreamParticipantPlaceholder extends StatelessWidget { + /// Creates the default participant placeholder. + const DefaultStreamParticipantPlaceholder({super.key, required this.props}); + + /// The properties that configure this placeholder. + final StreamParticipantPlaceholderProps props; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + // Merged rather than replaced: a style naming only a colour should not cost + // the placeholder its size and ring. + final avatarTheme = StreamAvatarThemeData( + size: StreamAvatarSize.xxl, + border: Border.all(color: colorScheme.borderOnInverse, width: 2), + ).merge(props.style?.avatarTheme); + + return Center( + child: StreamAvatarTheme( + data: avatarTheme, + // Through StreamUserAvatar rather than StreamAvatar directly, so an app + // that registers a `userAvatar` builder sees it here too. + child: StreamUserAvatar(user: props.participant.toUserInfo()), + ), + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_tile.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_tile.dart index b4b01559c..4bf15966f 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/participant_tile.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_tile.dart @@ -1,10 +1,13 @@ +import 'dart:math' as math; + import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; import '../../stream_video_flutter.dart'; -import 'indicators/connection_quality_indicator.dart'; -import 'participant_label.dart'; +import '../widgets/avatar_size_from_constraints.dart'; +import 'indicators/connection_quality_indicator_defaults.dart'; +import 'participant_label_defaults.dart'; /// Builder function used to build a video placeholder. typedef VideoPlaceholderBuilder = @@ -24,9 +27,18 @@ typedef VideoRendererBuilder = /// A widget that represents a single participant in a call. /// +/// Shows the participant's video, falling back to their avatar while the camera +/// is off, over two toolbars: an overflow button and any live reaction at the +/// top, the name pill and connection quality indicator at the bottom. +/// /// The rendering can be replaced app-wide by registering a `participantTile` /// builder with [streamVideoComponentBuilders] on a [StreamComponentFactory]. /// When no builder is registered, [DefaultStreamParticipantTile] is used. +/// +/// See also: +/// +/// * [StreamParticipantTileTheme], for customizing its appearance. +/// * [StreamParticipantTileAction], for the overflow menu. class StreamParticipantTile extends StatelessWidget { /// Creates a new instance of [StreamParticipantTile]. StreamParticipantTile({ @@ -35,23 +47,13 @@ class StreamParticipantTile extends StatelessWidget { required CallParticipantState participant, String? rendererScopePrefix, VideoFit? videoFit, - Color? backgroundColor, - BorderRadius? borderRadius, - StreamUserAvatarThemeData? userAvatarTheme, bool? showSpeakerBorder, - double? speakerBorderThickness, - Color? speakerBorderColor, bool? showParticipantLabel, - TextStyle? participantLabelTextStyle, - AlignmentGeometry? participantLabelAlignment, - Color? audioLevelIndicatorColor, - Color? enabledMicrophoneColor, - Color? disabledMicrophoneColor, - Color? pausedVideoIndicatorColor, bool? showConnectionQualityIndicator, - Color? connectionLevelActiveColor, - Color? connectionLevelInactiveColor, - AlignmentGeometry? connectionLevelAlignment, + bool? showReaction, + List? actions, + StreamParticipantTileActionsBuilder? actionsBuilder, + StreamParticipantTileStyle? style, VideoPlaceholderBuilder? videoPlaceholderBuilder, VideoRendererBuilder? videoRendererBuilder, ValueSetter? onSizeChanged, @@ -60,23 +62,13 @@ class StreamParticipantTile extends StatelessWidget { participant: participant, rendererScopePrefix: rendererScopePrefix, videoFit: videoFit, - backgroundColor: backgroundColor, - borderRadius: borderRadius, - userAvatarTheme: userAvatarTheme, showSpeakerBorder: showSpeakerBorder, - speakerBorderThickness: speakerBorderThickness, - speakerBorderColor: speakerBorderColor, showParticipantLabel: showParticipantLabel, - participantLabelTextStyle: participantLabelTextStyle, - participantLabelAlignment: participantLabelAlignment, - audioLevelIndicatorColor: audioLevelIndicatorColor, - enabledMicrophoneColor: enabledMicrophoneColor, - disabledMicrophoneColor: disabledMicrophoneColor, - pausedVideoIndicatorColor: pausedVideoIndicatorColor, showConnectionQualityIndicator: showConnectionQualityIndicator, - connectionLevelActiveColor: connectionLevelActiveColor, - connectionLevelInactiveColor: connectionLevelInactiveColor, - connectionLevelAlignment: connectionLevelAlignment, + showReaction: showReaction, + actions: actions, + actionsBuilder: actionsBuilder, + style: style, videoPlaceholderBuilder: videoPlaceholderBuilder, videoRendererBuilder: videoRendererBuilder, onSizeChanged: onSizeChanged, @@ -95,13 +87,14 @@ class StreamParticipantTile extends StatelessWidget { /// Properties for configuring a [StreamParticipantTile]. /// -/// This class holds all the configuration options for a participant tile, -/// allowing them to be passed through the [StreamComponentFactory]. +/// Appearance lives in [style]; everything here is either the data the tile +/// renders or a decision about what it renders. /// /// See also: /// /// * [StreamParticipantTile], which uses these properties. /// * [DefaultStreamParticipantTile], the default implementation. +@immutable class StreamParticipantTileProps { /// Creates properties for a participant tile. const StreamParticipantTileProps({ @@ -109,23 +102,13 @@ class StreamParticipantTileProps { required this.participant, this.rendererScopePrefix, this.videoFit, - this.backgroundColor, - this.borderRadius, - this.userAvatarTheme, this.showSpeakerBorder, - this.speakerBorderThickness, - this.speakerBorderColor, this.showParticipantLabel, - this.participantLabelTextStyle, - this.participantLabelAlignment, - this.audioLevelIndicatorColor, - this.enabledMicrophoneColor, - this.disabledMicrophoneColor, - this.pausedVideoIndicatorColor, this.showConnectionQualityIndicator, - this.connectionLevelActiveColor, - this.connectionLevelInactiveColor, - this.connectionLevelAlignment, + this.showReaction, + this.actions, + this.actionsBuilder, + this.style, this.videoPlaceholderBuilder, this.videoRendererBuilder, this.onSizeChanged, @@ -140,64 +123,61 @@ class StreamParticipantTileProps { /// Optional prefix to scope renderer keys (e.g. PiP vs main view). final String? rendererScopePrefix; - /// The fit of the [VideoRenderer] widget + /// The fit of the video within the tile. + /// + /// Overrides [StreamParticipantTileStyle.videoFit] when set. final VideoFit? videoFit; - /// The background color of the call participant. - final Color? backgroundColor; - - /// The border radius of the call participant. - final BorderRadius? borderRadius; - - /// The theme for the avatar. - final StreamUserAvatarThemeData? userAvatarTheme; - - /// Whether to highlight the participant when he/she is speaking. + /// Whether to outline the tile while the participant is speaking. + /// + /// Overrides [StreamParticipantTileStyle.showSpeakerBorder] when set. final bool? showSpeakerBorder; - /// The thickness of the speaker border. - final double? speakerBorderThickness; - - /// The color of the speaker border. - final Color? speakerBorderColor; - - /// Whether to show the label with participant name and mute status. + /// Whether to show the name pill. + /// + /// Overrides [StreamParticipantTileStyle.showParticipantLabel] when set. final bool? showParticipantLabel; - /// Text style for the participant label. - final TextStyle? participantLabelTextStyle; - - /// Alignment for the participant label. - final AlignmentGeometry? participantLabelAlignment; - - /// The color of an audio level indicator. - final Color? audioLevelIndicatorColor; - - /// The color of an enabled microphone icon. - final Color? enabledMicrophoneColor; - - /// The color of a disabled microphone icon. - final Color? disabledMicrophoneColor; - - /// The color of a paused video indicator. - final Color? pausedVideoIndicatorColor; - /// Whether to show the connection quality indicator. + /// + /// Overrides [StreamParticipantTileStyle.showConnectionQualityIndicator] + /// when set. final bool? showConnectionQualityIndicator; - /// The color of an active connection quality level. - final Color? connectionLevelActiveColor; - - /// The color of an inactive connection quality level. - final Color? connectionLevelInactiveColor; - - /// Alignment for the connection level. - final AlignmentGeometry? connectionLevelAlignment; + /// Whether to show the participant's live reaction. + /// + /// Overrides [StreamParticipantTileStyle.showReaction] when set. + final bool? showReaction; + + /// The actions offered in the tile's overflow menu. + /// + /// The overflow button is hidden entirely while this resolves to an empty + /// list, which it does by default: the SDK ships no actions of its own. + /// Ignored when [actionsBuilder] is set. + final List? actions; + + /// Builds the actions offered in the tile's overflow menu. + /// + /// Takes precedence over [actions], and is called during build, so the menu + /// can reflect the participant's current state. + final StreamParticipantTileActionsBuilder? actionsBuilder; + + /// Overrides for this tile's appearance. + /// + /// Merged over the ambient [StreamParticipantTileTheme]. + final StreamParticipantTileStyle? style; /// Builder function used to build a video placeholder. + /// + /// Takes precedence over a `participantPlaceholder` builder registered on the + /// [StreamComponentFactory]: a call site that asked for something specific + /// outranks an app-wide default. final VideoPlaceholderBuilder? videoPlaceholderBuilder; /// Builder function used to build a video renderer. + /// + /// Takes precedence over a `participantVideo` builder registered on the + /// [StreamComponentFactory]. final VideoRendererBuilder? videoRendererBuilder; /// Callback that is called when the size of the participant widget changes. @@ -210,23 +190,13 @@ class StreamParticipantTileProps { CallParticipantState? participant, String? rendererScopePrefix, VideoFit? videoFit, - Color? backgroundColor, - BorderRadius? borderRadius, - StreamUserAvatarThemeData? userAvatarTheme, bool? showSpeakerBorder, - double? speakerBorderThickness, - Color? speakerBorderColor, bool? showParticipantLabel, - TextStyle? participantLabelTextStyle, - AlignmentGeometry? participantLabelAlignment, - Color? audioLevelIndicatorColor, - Color? enabledMicrophoneColor, - Color? disabledMicrophoneColor, - Color? pausedVideoIndicatorColor, bool? showConnectionQualityIndicator, - Color? connectionLevelActiveColor, - Color? connectionLevelInactiveColor, - AlignmentGeometry? connectionLevelAlignment, + bool? showReaction, + List? actions, + StreamParticipantTileActionsBuilder? actionsBuilder, + StreamParticipantTileStyle? style, VideoPlaceholderBuilder? videoPlaceholderBuilder, VideoRendererBuilder? videoRendererBuilder, ValueSetter? onSizeChanged, @@ -236,34 +206,14 @@ class StreamParticipantTileProps { participant: participant ?? this.participant, rendererScopePrefix: rendererScopePrefix ?? this.rendererScopePrefix, videoFit: videoFit ?? this.videoFit, - backgroundColor: backgroundColor ?? this.backgroundColor, - borderRadius: borderRadius ?? this.borderRadius, - userAvatarTheme: userAvatarTheme ?? this.userAvatarTheme, showSpeakerBorder: showSpeakerBorder ?? this.showSpeakerBorder, - speakerBorderThickness: - speakerBorderThickness ?? this.speakerBorderThickness, - speakerBorderColor: speakerBorderColor ?? this.speakerBorderColor, showParticipantLabel: showParticipantLabel ?? this.showParticipantLabel, - participantLabelTextStyle: - participantLabelTextStyle ?? this.participantLabelTextStyle, - participantLabelAlignment: - participantLabelAlignment ?? this.participantLabelAlignment, - audioLevelIndicatorColor: - audioLevelIndicatorColor ?? this.audioLevelIndicatorColor, - enabledMicrophoneColor: - enabledMicrophoneColor ?? this.enabledMicrophoneColor, - disabledMicrophoneColor: - disabledMicrophoneColor ?? this.disabledMicrophoneColor, - pausedVideoIndicatorColor: - pausedVideoIndicatorColor ?? this.pausedVideoIndicatorColor, showConnectionQualityIndicator: showConnectionQualityIndicator ?? this.showConnectionQualityIndicator, - connectionLevelActiveColor: - connectionLevelActiveColor ?? this.connectionLevelActiveColor, - connectionLevelInactiveColor: - connectionLevelInactiveColor ?? this.connectionLevelInactiveColor, - connectionLevelAlignment: - connectionLevelAlignment ?? this.connectionLevelAlignment, + showReaction: showReaction ?? this.showReaction, + actions: actions ?? this.actions, + actionsBuilder: actionsBuilder ?? this.actionsBuilder, + style: style ?? this.style, videoPlaceholderBuilder: videoPlaceholderBuilder ?? this.videoPlaceholderBuilder, videoRendererBuilder: videoRendererBuilder ?? this.videoRendererBuilder, @@ -275,174 +225,635 @@ class StreamParticipantTileProps { /// The default implementation of [StreamParticipantTile]. class DefaultStreamParticipantTile extends StatelessWidget { /// Creates a new instance of [DefaultStreamParticipantTile]. - const DefaultStreamParticipantTile({ - super.key, - required this.props, - }); + const DefaultStreamParticipantTile({super.key, required this.props}); /// The properties that configure this participant tile. final StreamParticipantTileProps props; @override Widget build(BuildContext context) { - final theme = StreamCallParticipantTheme.of(context); + final themeStyle = StreamParticipantTileTheme.of(context).style; + final style = themeStyle?.merge(props.style) ?? props.style; + final defaults = _StreamParticipantTileStyleDefaults(context); - final call = props.call; final participant = props.participant; - final rendererScopePrefix = props.rendererScopePrefix; - final onSizeChanged = props.onSizeChanged; - - final videoFit = props.videoFit ?? theme.videoFit; - final backgroundColor = props.backgroundColor ?? theme.backgroundColor; - final borderRadius = props.borderRadius ?? theme.borderRadius; - final userAvatarTheme = props.userAvatarTheme ?? theme.userAvatarTheme; + final borderRadius = style?.borderRadius ?? defaults.borderRadius; + final hasVideo = participant.isVideoEnabled; + final isSpeaking = participant.isSpeaking; final showSpeakerBorder = - props.showSpeakerBorder ?? theme.showSpeakerBorder; - final speakerBorderThickness = - props.speakerBorderThickness ?? theme.speakerBorderThickness; - final speakerBorderColor = - props.speakerBorderColor ?? theme.speakerBorderColor; - final showParticipantLabel = - props.showParticipantLabel ?? theme.showParticipantLabel; - final participantLabelTextStyle = - props.participantLabelTextStyle ?? theme.participantLabelTextStyle; - final participantLabelAlignment = - props.participantLabelAlignment ?? theme.participantLabelAlignment; - final audioLevelIndicatorColor = - props.audioLevelIndicatorColor ?? theme.audioLevelIndicatorColor; - final enabledMicrophoneColor = - props.enabledMicrophoneColor ?? theme.enabledMicrophoneColor; - final disabledMicrophoneColor = - props.disabledMicrophoneColor ?? theme.disabledMicrophoneColor; - final pausedVideoIndicatorColor = - props.pausedVideoIndicatorColor ?? theme.pausedVideoIndicatorColor; - final showConnectionQualityIndicator = - props.showConnectionQualityIndicator ?? - theme.showConnectionQualityIndicator; - final connectionLevelActiveColor = - props.connectionLevelActiveColor ?? theme.connectionLevelActiveColor; - final connectionLevelInactiveColor = - props.connectionLevelInactiveColor ?? - theme.connectionLevelInactiveColor; - final connectionLevelAlignment = - props.connectionLevelAlignment ?? theme.connectionLevelAlignment; + props.showSpeakerBorder ?? + style?.showSpeakerBorder ?? + defaults.showSpeakerBorder; + + // A tile showing video needs no outline — the video defines its own edge. + final border = switch ((isSpeaking && showSpeakerBorder, hasVideo)) { + (true, _) => style?.speakingBorder ?? defaults.speakingBorder, + (false, false) => style?.border ?? defaults.border, + (false, true) => null, + }; return ClipRRect( + // A rounded decoration alone cannot clip the video: on Android the + // renderer can be a platform view, which only a real clip contains. borderRadius: borderRadius, child: Container( decoration: BoxDecoration( - color: backgroundColor, + color: style?.backgroundColor ?? defaults.backgroundColor, borderRadius: borderRadius, ), + // In the foreground so the outline paints over the video rather than + // insetting it, and so toggling it repaints without a relayout. foregroundDecoration: BoxDecoration( borderRadius: borderRadius, - border: participant.isSpeaking && showSpeakerBorder - ? Border.all( - color: speakerBorderColor, - width: speakerBorderThickness, - ) - : null, + border: border, + ), + child: LayoutBuilder( + builder: (context, constraints) => _TileContent( + props: props, + style: style, + defaults: defaults, + constraints: constraints, + ), ), - child: Builder( - builder: (context) { - final theme = StreamVideoTheme.of(context); - var videoPlaceholderBuilder = props.videoPlaceholderBuilder; - videoPlaceholderBuilder ??= (context, call, participant) { - return Center( - child: StreamUserAvatarTheme( - data: userAvatarTheme, - child: StreamUserAvatar( - user: participant.toUserInfo(), - ), + ), + ); + } +} + +// How much of the tile's chrome fits at its current size. +// +// The same tile is a full-width desktop cell, a thumbnail in a spotlight strip +// and a 140px floating self-view, so what it can show is a function of the +// space it was given rather than of the platform. +// +// This ladder covers the bottom toolbar, whose widths come from the chrome's +// own arithmetic — the toolbar's 12px inset on both sides, a 12px gap before +// the indicator, and the narrowest the pill can be drawn at: +// +// indicator only 12 + 32 + 12 = 56 +// pill (icons only) 12 + (12 + 24 + 4) + 12 + 32 + 12 = 108 +// pill with a short name 108 + a readable 44px of text = 152 +// +// It is a floor rather than the whole story: a muted participant's pill carries +// icons the widths above do not account for, and the top toolbar is anchored to +// the opposite edge, so both are measured against what they actually draw. See +// [_TileContent.build] and [_BottomToolbar.build]. +enum _TileDensity { + /// Everything. + full, + + /// No name — the icons still read at this size, a truncated name does not. + compact, + + /// The connection quality indicator alone. + minimal, + + /// No chrome at all. + bare; + + static const _fullWidth = 152.0; + static const _compactWidth = 108.0; + static const _minimalWidth = 56.0; + static const _fullHeight = 128.0; + static const _compactHeight = 72.0; + static const _minimalHeight = 56.0; + + static _TileDensity resolve(BoxConstraints constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + + if (width >= _fullWidth && height >= _fullHeight) return full; + if (width >= _compactWidth && height >= _compactHeight) return compact; + if (width >= _minimalWidth && height >= _minimalHeight) return minimal; + return bare; + } + + bool get showsName => this == full; + + bool get showsLabel => this == full || this == compact; + + bool get showsConnectionQuality => this != bare; + + // Both live in the top toolbar. The ladder decides whether a tile is big + // enough to carry any of it; whether it actually fits is measured. + bool get carriesTopToolbar => this == full || this == compact; +} + +class _TileContent extends StatelessWidget { + const _TileContent({ + required this.props, + required this.style, + required this.defaults, + required this.constraints, + }); + + final StreamParticipantTileProps props; + final StreamParticipantTileStyle? style; + final _StreamParticipantTileStyleDefaults defaults; + final BoxConstraints constraints; + + @override + Widget build(BuildContext context) { + final participant = props.participant; + final density = _TileDensity.resolve(constraints); + + final actions = + props.actionsBuilder?.call(context, participant) ?? + props.actions ?? + const []; + + final showLabel = + (props.showParticipantLabel ?? + style?.showParticipantLabel ?? + defaults.showParticipantLabel) && + density.showsLabel; + final showIndicator = + (props.showConnectionQualityIndicator ?? + style?.showConnectionQualityIndicator ?? + defaults.showConnectionQualityIndicator) && + density.showsConnectionQuality; + final reaction = participant.reaction; + + // The top toolbar hangs off the opposite edge from the bottom one, so the + // ladder's widths say nothing about whether it fits. Measure it: the button + // reserves a tap target, the reaction is drawn at its own size inset from + // the tile edge, and both have to clear whatever the bottom toolbar takes + // rather than land on top of it. + final topPadding = (style?.topToolbarPadding ?? defaults.topToolbarPadding) + .resolve(Directionality.maybeOf(context)); + final reactionSpan = + (style?.reactionSize ?? defaults.reactionSize) + + 2 * _reactionPadding(context, style: style, defaults: defaults); + final clearance = + topPadding.vertical + + _bottomChromeHeight( + context, + style: style, + defaults: defaults, + showLabel: showLabel, + showIndicator: showIndicator, + ); + + final showMore = + actions.isNotEmpty && + (style?.showMoreButton ?? defaults.showMoreButton) && + density.carriesTopToolbar && + constraints.maxWidth >= topPadding.horizontal + _kTapTarget && + constraints.maxHeight >= clearance + _kTapTarget; + + final showReaction = + reaction != null && + (props.showReaction ?? style?.showReaction ?? defaults.showReaction) && + density.carriesTopToolbar && + constraints.maxWidth >= + topPadding.horizontal + + (showMore ? _kTapTarget : 0) + + reactionSpan && + constraints.maxHeight >= + clearance + math.max(showMore ? _kTapTarget : 0, reactionSpan); + + return Stack( + fit: StackFit.expand, + children: [ + // No RepaintBoundary between here and the label pill: the pill's + // backdrop filter samples this subtree, and a boundary would hand it an + // empty backdrop and silently drop the blur. + _buildVideo(context), + if (showMore || showReaction) + PositionedDirectional( + top: 0, + start: 0, + end: 0, + child: RepaintBoundary( + child: _TopToolbar( + actions: showMore ? actions : const [], + reaction: showReaction ? reaction : null, + style: style, + defaults: defaults, + ), + ), + ), + if (showLabel || showIndicator) + PositionedDirectional( + start: 0, + end: 0, + bottom: 0, + child: _BottomToolbar( + participant: participant, + showLabel: showLabel, + showName: density.showsName, + showIndicator: showIndicator, + style: style, + defaults: defaults, + ), + ), + ], + ); + } + + Widget _buildVideo(BuildContext context) { + final call = props.call; + final participant = props.participant; + final rendererScopePrefix = props.rendererScopePrefix; + + final rendererBuilder = props.videoRendererBuilder; + if (rendererBuilder != null) { + return rendererBuilder(context, call, participant); + } + + final placeholderBuilder = props.videoPlaceholderBuilder; + + return StreamParticipantVideo( + call: call, + participant: participant, + rendererScopePrefix: rendererScopePrefix, + onSizeChanged: props.onSizeChanged, + videoFit: props.videoFit ?? style?.videoFit ?? defaults.videoFit, + placeholderBuilder: (context) { + if (placeholderBuilder != null) { + return placeholderBuilder(context, call, participant); + } + return StreamParticipantPlaceholder( + call: call, + participant: participant, + // No default of its own: the placeholder merges an incoming + // style over the defaults it owns, so restating them here would + // only be a second copy to keep in step. + style: style?.placeholderStyle, + ); + }, + ); + } +} + +class _TopToolbar extends StatelessWidget { + const _TopToolbar({ + required this.actions, + required this.reaction, + required this.style, + required this.defaults, + }); + + final List actions; + final CallReaction? reaction; + final StreamParticipantTileStyle? style; + final _StreamParticipantTileStyleDefaults defaults; + + @override + Widget build(BuildContext context) { + return Padding( + padding: style?.topToolbarPadding ?? defaults.topToolbarPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (actions.isNotEmpty) + _MoreMenuButton(actions: actions, style: style), + const Spacer(), + if (reaction != null) + // Loose, so a glyph that measures wider than the size it was drawn + // at clips instead of overflowing the row. Emoji advance widths are + // a property of the platform's font, which the tile cannot know + // when it decides whether the reaction fits. + Flexible( + child: Padding( + // Measured from the tile edge, so the toolbar's own inset comes + // off the designed distance. + padding: EdgeInsets.all( + _reactionPadding(context, style: style, defaults: defaults), ), - ); - }; - - var videoRendererBuilder = props.videoRendererBuilder; - videoRendererBuilder ??= (context, call, participant) { - return Stack( - children: [ - StreamVideoRenderer( - key: ValueKey( - '${rendererScopePrefix ?? ''}${participant.uniqueParticipantKey}-video', - ), - rendererScopePrefix: rendererScopePrefix, - call: call, - participant: participant, - videoTrackType: SfuTrackType.video, - onSizeChanged: onSizeChanged, - placeholderBuilder: (context) { - return videoPlaceholderBuilder!( - context, - call, - participant, - ); - }, - videoFit: videoFit, - ), - if (participant.reaction != null) - Align( - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.all(8), - child: Text( - theme.callControlsTheme.callReactions - .firstWhereOrNull( - (e) => - e.emojiCode == - participant.reaction?.emojiCode, - ) - ?.icon ?? - '', - style: const TextStyle( - fontSize: 24, - ), - ), - ), - ), - ], - ); - }; - - return Stack( - children: [ - videoRendererBuilder(context, call, participant), - if (showParticipantLabel) - Align( - alignment: participantLabelAlignment, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - StreamParticipantLabel.fromParticipant( - participant: participant, - audioLevelIndicatorColor: audioLevelIndicatorColor, - disabledMicrophoneColor: disabledMicrophoneColor, - enabledMicrophoneColor: enabledMicrophoneColor, - pausedVideoIndicatorColor: pausedVideoIndicatorColor, - participantLabelTextStyle: participantLabelTextStyle, - ), - ], - ), - ), - if (showConnectionQualityIndicator) - Align( - alignment: connectionLevelAlignment, - child: StreamConnectionQualityIndicator( - connectionQuality: participant.connectionQuality, - activeColor: connectionLevelActiveColor, - inactiveColor: connectionLevelInactiveColor, - ), - ), - ], - ); - }, + child: _ReactionIndicator( + reaction: reaction!, + size: style?.reactionSize ?? defaults.reactionSize, + ), + ), + ), + ], + ), + ); + } +} + +// The tap target the overflow button reserves around itself. +const _kTapTarget = kMinInteractiveDimension; + +// The reaction's own inset, less whatever the toolbar already insets it by. +double _reactionPadding( + BuildContext context, { + required StreamParticipantTileStyle? style, + required _StreamParticipantTileStyleDefaults defaults, +}) { + final inset = style?.reactionInset ?? defaults.reactionInset; + final toolbarInset = (style?.topToolbarPadding ?? defaults.topToolbarPadding) + .resolve(Directionality.maybeOf(context)) + .top; + return math.max(0, inset - toolbarInset); +} + +// How much room the bottom toolbar takes, so the top one can keep clear of it. +// +// Both of its parts are resolved from their own styles rather than assumed: +// either can be themed to a different size, and a tile that guessed would put +// the overflow button back on top of the name pill. +double _bottomChromeHeight( + BuildContext context, { + required StreamParticipantTileStyle? style, + required _StreamParticipantTileStyleDefaults defaults, + required bool showLabel, + required bool showIndicator, +}) { + if (!showLabel && !showIndicator) return 0; + + var content = 0.0; + if (showLabel) { + content = math.max( + content, + participantLabelHeight(context, style: style?.labelStyle), + ); + } + if (showIndicator) { + content = math.max( + content, + connectionQualityIndicatorSize( + context, + style: style?.connectionQualityIndicatorStyle, + ), + ); + } + + final padding = (style?.toolbarPadding ?? defaults.toolbarPadding).resolve( + Directionality.maybeOf(context), + ); + return padding.vertical + content; +} + +class _ReactionIndicator extends StatelessWidget { + const _ReactionIndicator({required this.reaction, required this.size}); + + final CallReaction reaction; + final double size; + + @override + Widget build(BuildContext context) { + final icon = StreamVideoTheme.of(context).callControlsTheme.callReactions + .firstWhereOrNull((it) => it.emojiCode == reaction.emojiCode) + ?.icon; + + if (icon == null) return const SizedBox.shrink(); + + return Text(icon, style: TextStyle(fontSize: size)); + } +} + +class _BottomToolbar extends StatelessWidget { + const _BottomToolbar({ + required this.participant, + required this.showLabel, + required this.showName, + required this.showIndicator, + required this.style, + required this.defaults, + }); + + final CallParticipantState participant; + final bool showLabel; + final bool showName; + final bool showIndicator; + final StreamParticipantTileStyle? style; + final _StreamParticipantTileStyleDefaults defaults; + + @override + Widget build(BuildContext context) { + // What the pill needs to draw everything this participant gives it. A muted + // camera-off participant carries two icons the density ladder's widths know + // nothing about, and the pill lays its icons out at their full size rather + // than shrinking them. + final minLabelWidth = participantLabelMinWidth( + context, + showName: showName, + showMicrophoneOff: !participant.isAudioEnabled, + showVideoOff: !participant.isVideoEnabled, + style: style?.labelStyle, + ); + + return Padding( + padding: style?.toolbarPadding ?? defaults.toolbarPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Expanded, not Flexible plus a Spacer: two flex children would split + // the free space between them and cap the pill at half the row. This + // hands the label region exactly what is left after the indicator and + // the gap, which is what keeps a long name from reaching the + // indicator at any tile size. + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: showLabel + ? LayoutBuilder( + // The tile-level density check sizes the chrome against + // the tile. What actually reaches the pill is whatever is + // left after the indicator, which a replaced indicator can + // shrink further. Below the pill's own fixed width there + // is nothing left to truncate, so drop it rather than + // overflow. + builder: (context, constraints) => + constraints.maxWidth < minLabelWidth + ? const SizedBox.shrink() + : StreamParticipantLabel.fromParticipant( + participant: participant, + showName: showName, + style: style?.labelStyle, + ), + ) + : const SizedBox.shrink(), + ), + ), + if (showIndicator) ...[ + // Only between the two of them. With no pill beside it the gap + // separates the indicator from nothing, and the tile's narrowest + // band has no room to spare for it. + if (showLabel) + SizedBox(width: style?.toolbarSpacing ?? defaults.toolbarSpacing), + RepaintBoundary( + child: StreamConnectionQualityIndicator( + connectionQuality: participant.connectionQuality, + style: style?.connectionQualityIndicatorStyle, + ), + ), + ], + ], + ), + ); + } +} + +// The overflow button and the menu it anchors. +// +// Stateful because the menu has to be closed from outside a tap: a menu left +// open while its tile scrolls away floats free of the tile it belongs to, and +// one left open while tiles are recycled would act on the wrong participant. +class _MoreMenuButton extends StatefulWidget { + const _MoreMenuButton({required this.actions, required this.style}); + + final List actions; + final StreamParticipantTileStyle? style; + + @override + State<_MoreMenuButton> createState() => _MoreMenuButtonState(); +} + +class _MoreMenuButtonState extends State<_MoreMenuButton> { + final _controller = MenuController(); + ScrollPosition? _scrollPosition; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final position = Scrollable.maybeOf(context)?.position; + if (position == _scrollPosition) return; + _scrollPosition?.isScrollingNotifier.removeListener(_closeOnScroll); + _scrollPosition = position + ?..isScrollingNotifier.addListener(_closeOnScroll); + } + + @override + void didUpdateWidget(_MoreMenuButton oldWidget) { + super.didUpdateWidget(oldWidget); + // By value: an actionsBuilder returns a fresh list every build, so + // comparing identity would close the menu on the next rebuild of the call — + // which, with participant state streaming in, is immediately. + if (!listEquals(oldWidget.actions, widget.actions)) _controller.close(); + } + + @override + void dispose() { + _scrollPosition?.isScrollingNotifier.removeListener(_closeOnScroll); + super.dispose(); + } + + void _closeOnScroll() { + if (_scrollPosition?.isScrollingNotifier.value ?? false) { + _controller.close(); + } + } + + @override + Widget build(BuildContext context) { + return StreamContextMenuAnchor( + controller: _controller, + alignmentOffset: Offset(0, context.streamSpacing.xxs), + menuChildren: [ + for (final action in widget.actions) + StreamContextMenuAction( + enabled: action.enabled, + isDestructive: action.isDestructive, + leading: Icon(action.icon), + // The menu sizes itself to its widest item, so a long label has to + // truncate rather than stretch the panel. + label: Text( + action.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + // A MenuAnchor panel is an overlay rather than a route, so + // selecting an item does not dismiss it on its own. + _controller.close(); + action.onPressed(); + }, + ), + ], + builder: (context, controller, child) => StreamTapTargetPadding( + minSize: const Size.square(kMinInteractiveDimension), + alignment: AlignmentDirectional.topStart, + child: StreamButton.icon( + style: .secondary, + size: .small, + themeStyle: widget.style?.moreButtonStyle, + icon: Icon(context.streamIcons.moreHorizontal), + onPressed: () => + controller.isOpen ? controller.close() : controller.open(), ), ), ); } } +// Maps the deprecated avatar theme onto the placeholder's style. +// +// Only the properties the design-system avatar has an equivalent for carry +// across: a size taken from the tightest constraint, and the initials text +// style. The rest — per-corner radii, the selection ring — has no counterpart +// and is dropped. +StreamParticipantPlaceholderStyle? _placeholderStyleOf( + StreamUserAvatarThemeData? theme, +) { + if (theme == null) return null; + + return StreamParticipantPlaceholderStyle( + avatarTheme: StreamAvatarThemeData( + size: avatarSizeFromConstraints(theme.constraints), + ), + ); +} + +// Default style values for [StreamParticipantTile]. +class _StreamParticipantTileStyleDefaults extends StreamParticipantTileStyle { + _StreamParticipantTileStyleDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + + @override + VideoFit get videoFit => defaultVideoFit; + + @override + Color get backgroundColor => _colorScheme.backgroundSurfaceSubtle; + + @override + BorderRadius get borderRadius => BorderRadius.all(_radius.xxl); + + @override + BoxBorder get border => Border.all(color: _colorScheme.borderDefault); + + @override + BoxBorder get speakingBorder => + Border.all(color: _colorScheme.accentPrimary, width: 2); + + @override + bool get showSpeakerBorder => true; + + @override + bool get showParticipantLabel => true; + + @override + bool get showConnectionQualityIndicator => true; + + @override + bool get showMoreButton => true; + + @override + bool get showReaction => true; + + @override + EdgeInsetsGeometry get toolbarPadding => EdgeInsets.all(_spacing.sm); + + @override + double get toolbarSpacing => _spacing.sm; + + @override + EdgeInsetsGeometry get topToolbarPadding => EdgeInsets.all(_spacing.xxs); + + @override + double get reactionSize => 48; + + @override + double get reactionInset => _spacing.sm; +} + /// A widget that represents a single participant in a call. /// /// Kept as a thin wrapper around [DefaultStreamParticipantTile] so existing @@ -471,14 +882,26 @@ class StreamCallParticipant extends StatelessWidget { Color? speakerBorderColor, bool? showParticipantLabel, TextStyle? participantLabelTextStyle, + @Deprecated( + 'The participant label is laid out in the tile toolbar and no longer ' + 'takes an alignment. This parameter has no effect.', + ) AlignmentGeometry? participantLabelAlignment, Color? audioLevelIndicatorColor, + @Deprecated( + 'Only a muted microphone draws an icon now, so there is nothing for this ' + 'to color. This parameter has no effect.', + ) Color? enabledMicrophoneColor, Color? disabledMicrophoneColor, Color? pausedVideoIndicatorColor, bool? showConnectionQualityIndicator, Color? connectionLevelActiveColor, Color? connectionLevelInactiveColor, + @Deprecated( + 'The connection quality indicator is laid out in the tile toolbar and no ' + 'longer takes an alignment. This parameter has no effect.', + ) AlignmentGeometry? connectionLevelAlignment, VideoPlaceholderBuilder? videoPlaceholderBuilder, VideoRendererBuilder? videoRendererBuilder, @@ -488,33 +911,90 @@ class StreamCallParticipant extends StatelessWidget { participant: participant, rendererScopePrefix: rendererScopePrefix, videoFit: videoFit, - backgroundColor: backgroundColor, - borderRadius: borderRadius, - userAvatarTheme: userAvatarTheme, showSpeakerBorder: showSpeakerBorder, - speakerBorderThickness: speakerBorderThickness, - speakerBorderColor: speakerBorderColor, showParticipantLabel: showParticipantLabel, - participantLabelTextStyle: participantLabelTextStyle, - participantLabelAlignment: participantLabelAlignment, - audioLevelIndicatorColor: audioLevelIndicatorColor, - enabledMicrophoneColor: enabledMicrophoneColor, - disabledMicrophoneColor: disabledMicrophoneColor, - pausedVideoIndicatorColor: pausedVideoIndicatorColor, showConnectionQualityIndicator: showConnectionQualityIndicator, - connectionLevelActiveColor: connectionLevelActiveColor, - connectionLevelInactiveColor: connectionLevelInactiveColor, - connectionLevelAlignment: connectionLevelAlignment, videoPlaceholderBuilder: videoPlaceholderBuilder, videoRendererBuilder: videoRendererBuilder, onSizeChanged: onSizeChanged, - ); + ), + _backgroundColor = backgroundColor, + _borderRadius = borderRadius, + _userAvatarTheme = userAvatarTheme, + _speakerBorderThickness = speakerBorderThickness, + _speakerBorderColor = speakerBorderColor, + _participantLabelTextStyle = participantLabelTextStyle, + _audioLevelIndicatorColor = audioLevelIndicatorColor, + _disabledMicrophoneColor = disabledMicrophoneColor, + _pausedVideoIndicatorColor = pausedVideoIndicatorColor, + _connectionLevelActiveColor = connectionLevelActiveColor, + _connectionLevelInactiveColor = connectionLevelInactiveColor; /// The properties that configure this participant tile. final StreamParticipantTileProps props; + final Color? _backgroundColor; + final BorderRadius? _borderRadius; + final StreamUserAvatarThemeData? _userAvatarTheme; + final double? _speakerBorderThickness; + final Color? _speakerBorderColor; + final TextStyle? _participantLabelTextStyle; + final Color? _audioLevelIndicatorColor; + final Color? _disabledMicrophoneColor; + final Color? _pausedVideoIndicatorColor; + final Color? _connectionLevelActiveColor; + final Color? _connectionLevelInactiveColor; + @override Widget build(BuildContext context) { - return DefaultStreamParticipantTile(props: props); + // Built here rather than in the initializer list: a thickness given without + // a color (or the reverse) still needs the other half of the border, and + // that half comes from the theme. + final speakingBorder = + (_speakerBorderColor != null || _speakerBorderThickness != null) + ? Border.all( + color: + _speakerBorderColor ?? context.streamColorScheme.accentPrimary, + width: _speakerBorderThickness ?? 2, + ) + : null; + + return DefaultStreamParticipantTile( + props: props.copyWith( + style: StreamParticipantTileStyle( + backgroundColor: _backgroundColor, + borderRadius: _borderRadius, + speakingBorder: speakingBorder, + placeholderStyle: _placeholderStyleOf(_userAvatarTheme), + // Only built when this widget was actually given something to say. + // The generated merge would leave an all-null style alone anyway, so + // this is about keeping the props readable rather than correctness. + labelStyle: + (_participantLabelTextStyle != null || + _audioLevelIndicatorColor != null || + _disabledMicrophoneColor != null || + _pausedVideoIndicatorColor != null) + ? StreamParticipantLabelStyle( + nameTextStyle: _participantLabelTextStyle, + speakingColor: _audioLevelIndicatorColor, + microphoneOffColor: _disabledMicrophoneColor, + videoOffIconColor: _pausedVideoIndicatorColor, + ) + : null, + connectionQualityIndicatorStyle: + (_connectionLevelActiveColor != null || + _connectionLevelInactiveColor != null) + ? StreamConnectionQualityIndicatorStyle( + // The indicator now colors each level apart. A single legacy + // color spreads across all three, so an override still lands. + poorColor: _connectionLevelActiveColor, + fairColor: _connectionLevelActiveColor, + greatColor: _connectionLevelActiveColor, + inactiveColor: _connectionLevelInactiveColor, + ) + : null, + ), + ), + ); } } diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_tile_action.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_tile_action.dart new file mode 100644 index 000000000..16b176d13 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_tile_action.dart @@ -0,0 +1,102 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_video/stream_video.dart'; + +/// A single entry in a participant tile's overflow menu. +/// +/// The SDK ships no actions of its own: a tile renders exactly what it is +/// given, and hides its overflow button entirely when the list is empty. What +/// an action does is up to the integrator — pinning, muting, blocking and +/// removing all live on [Call], and anything else an app wants to offer works +/// just as well. +/// +/// {@tool snippet} +/// +/// ```dart +/// StreamParticipantTile( +/// call: call, +/// participant: participant, +/// actionsBuilder: (context, participant) => [ +/// StreamParticipantTileAction( +/// icon: context.streamIcons.pin, +/// label: participant.isPinned ? 'Unpin' : 'Pin', +/// onPressed: () => call.setParticipantPinnedLocally( +/// sessionId: participant.sessionId, +/// pinned: !participant.isPinned, +/// ), +/// ), +/// StreamParticipantTileAction( +/// icon: context.streamIcons.userRemove, +/// label: 'Remove', +/// isDestructive: true, +/// onPressed: () => call.removeMembers([participant.userId]), +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamParticipantTileActionsBuilder], for actions that depend on the +/// participant they are shown for. +@immutable +class StreamParticipantTileAction { + /// Creates a participant tile action. + const StreamParticipantTileAction({ + required this.icon, + required this.label, + required this.onPressed, + this.isDestructive = false, + this.enabled = true, + }); + + /// The icon shown before [label]. + final IconData icon; + + /// The text describing the action. + final String label; + + /// Called when the action is selected. + final VoidCallback onPressed; + + /// Whether the action is presented as destructive. + /// + /// Destructive actions are colored apart and sorted below the rest. + final bool isDestructive; + + /// Whether the action can be selected. + /// + /// A disabled action is still listed, so the menu does not change shape as + /// state changes; it just cannot be chosen. + final bool enabled; + + /// Whether this action presents the same entry as [other]. + /// + /// [onPressed] is deliberately left out. Actions are usually built inline, so + /// the callback is a fresh closure on every build and would make no two + /// actions ever equal — which is the opposite of what callers comparing + /// action lists want to know, namely whether the menu still offers the same + /// entries. + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is StreamParticipantTileAction && + other.icon == icon && + other.label == label && + other.isDestructive == isDestructive && + other.enabled == enabled; + } + + @override + int get hashCode => Object.hash(icon, label, isDestructive, enabled); +} + +/// Builds the overflow menu actions for [participant]. +/// +/// Called during build, so the actions can reflect the participant's current +/// state — pinned or not, muted or not — and the app's own permissions. +typedef StreamParticipantTileActionsBuilder = + List Function( + BuildContext context, + CallParticipantState participant, + ); diff --git a/packages/stream_video_flutter/lib/src/call_participants/participant_video.dart b/packages/stream_video_flutter/lib/src/call_participants/participant_video.dart new file mode 100644 index 000000000..5b984e3c7 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/participant_video.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +import '../../stream_video_flutter.dart'; + +/// The video of a participant, with a placeholder for when there is none. +/// +/// A thin component around [StreamVideoRenderer] so the video area of a +/// participant tile can be replaced app-wide: register a `participantVideo` +/// builder with [streamVideoComponentBuilders] on a [StreamComponentFactory]. +/// When no builder is registered, [DefaultStreamParticipantVideo] is used. +/// +/// A replacement is responsible for reporting the size it renders at through +/// [StreamParticipantVideoProps.onSizeChanged] — see that property. +/// +/// See also: +/// +/// * [StreamParticipantPlaceholder], shown when there is no picture. +class StreamParticipantVideo extends StatelessWidget { + /// Creates a participant video. + StreamParticipantVideo({ + super.key, + required Call call, + required CallParticipantState participant, + SfuTrackTypeVideo? videoTrackType, + String? rendererScopePrefix, + VideoFit? videoFit, + ValueSetter? onSizeChanged, + WidgetBuilder? placeholderBuilder, + }) : props = .new( + call: call, + participant: participant, + videoTrackType: videoTrackType ?? SfuTrackType.video, + rendererScopePrefix: rendererScopePrefix, + videoFit: videoFit, + onSizeChanged: onSizeChanged, + placeholderBuilder: placeholderBuilder, + ); + + /// The properties that configure this video. + final StreamParticipantVideoProps props; + + @override + Widget build(BuildContext context) { + final builder = context + .videoComponentBuilder(); + return builder?.call(context, props) ?? + DefaultStreamParticipantVideo(props: props); + } +} + +/// Properties for configuring a [StreamParticipantVideo]. +/// +/// See also: +/// +/// * [StreamParticipantVideo], which uses these properties. +/// * [DefaultStreamParticipantVideo], the default implementation. +@immutable +class StreamParticipantVideoProps { + /// Creates properties for a participant video. + const StreamParticipantVideoProps({ + required this.call, + required this.participant, + SfuTrackTypeVideo? videoTrackType, + this.rendererScopePrefix, + this.videoFit, + this.onSizeChanged, + this.placeholderBuilder, + }) : _videoTrackType = videoTrackType; + + final SfuTrackTypeVideo? _videoTrackType; + + /// Represents a call. + final Call call; + + /// The participant whose video is shown. + final CallParticipantState participant; + + /// Which of the participant's tracks to render. + /// + /// Defaults to their camera. + SfuTrackTypeVideo get videoTrackType => _videoTrackType ?? SfuTrackType.video; + + /// Optional prefix to scope renderer keys (e.g. PiP vs main view). + final String? rendererScopePrefix; + + /// How the video fills the space it is given. + final VideoFit? videoFit; + + /// Reports the size the video is rendered at. + /// + /// This drives dynascale: the size decides which quality layer is requested + /// from the SFU, so a replacement that does not report it leaves the call + /// negotiating against stale dimensions. Forward it, or accept that every + /// participant is subscribed to at whatever was last measured. + final ValueSetter? onSizeChanged; + + /// Builds what is shown while there is no picture. + /// + /// Defaults to a [StreamParticipantPlaceholder]. + final WidgetBuilder? placeholderBuilder; + + /// Creates a copy of these properties with the given fields replaced. + StreamParticipantVideoProps copyWith({ + Call? call, + CallParticipantState? participant, + SfuTrackTypeVideo? videoTrackType, + String? rendererScopePrefix, + VideoFit? videoFit, + ValueSetter? onSizeChanged, + WidgetBuilder? placeholderBuilder, + }) { + return StreamParticipantVideoProps( + call: call ?? this.call, + participant: participant ?? this.participant, + videoTrackType: videoTrackType ?? _videoTrackType, + rendererScopePrefix: rendererScopePrefix ?? this.rendererScopePrefix, + videoFit: videoFit ?? this.videoFit, + onSizeChanged: onSizeChanged ?? this.onSizeChanged, + placeholderBuilder: placeholderBuilder ?? this.placeholderBuilder, + ); + } +} + +/// The default implementation of [StreamParticipantVideo]. +class DefaultStreamParticipantVideo extends StatelessWidget { + /// Creates the default participant video. + const DefaultStreamParticipantVideo({super.key, required this.props}); + + /// The properties that configure this video. + final StreamParticipantVideoProps props; + + @override + Widget build(BuildContext context) { + final participant = props.participant; + final prefix = props.rendererScopePrefix ?? ''; + + // SfuTrackType carries no name; camera versus screen share is the only + // distinction the key needs. + final track = props.videoTrackType.isScreenShare ? 'screenShare' : 'video'; + + return StreamVideoRenderer( + key: ValueKey('$prefix${participant.uniqueParticipantKey}-$track'), + rendererScopePrefix: props.rendererScopePrefix, + call: props.call, + participant: participant, + videoTrackType: props.videoTrackType, + onSizeChanged: props.onSizeChanged, + videoFit: props.videoFit, + placeholderBuilder: + props.placeholderBuilder ?? + (context) => StreamParticipantPlaceholder( + call: props.call, + participant: participant, + ), + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/call_participants/regular_call_participants_content.dart b/packages/stream_video_flutter/lib/src/call_participants/regular_call_participants_content.dart index 5145c105b..432b57275 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/regular_call_participants_content.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/regular_call_participants_content.dart @@ -51,8 +51,6 @@ class RegularCallParticipantsContent extends StatelessWidget { @override Widget build(BuildContext context) { - final participantsTheme = StreamCallParticipantTheme.of(context); - final remoteParticipants = participants.where((e) => !e.isLocal); final localParticipant = participants.where((e) => e.isLocal).firstOrNull; @@ -87,9 +85,6 @@ class RegularCallParticipantsContent extends StatelessWidget { call: call, participants: gridParticipants, itemBuilder: callParticipantBuilder, - padding: participantsTheme.participantsGridPadding, - mainAxisSpacing: participantsTheme.participantsGridMainAxisSpacing, - crossAxisSpacing: participantsTheme.participantsGridCrossAxisSpacing, ); if (showLocalVideo && localParticipant != null) { diff --git a/packages/stream_video_flutter/lib/src/call_screen/lobby_video.dart b/packages/stream_video_flutter/lib/src/call_screen/lobby_video.dart index 96abb2e8a..164ba3532 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/lobby_video.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/lobby_video.dart @@ -1,10 +1,9 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../stream_video_flutter.dart'; -import '../call_participants/participant_label.dart'; -import 'dart:math' as math; /// A widget that can be shown before joining a call. Measures latencies /// and selects the best SFU. This speeds up the process of joining when @@ -34,7 +33,20 @@ class StreamLobbyVideo extends StatefulWidget { /// Theme for the avatar. final StreamUserAvatarThemeData? userAvatarTheme; + /// Called with the microphone track whenever it is created, and with null + /// when it is stopped. + /// + /// The track becomes the caller's to manage: this widget never stops one it + /// has handed over, because the track may well outlive it. Passing it to + /// `CallConnectOptions.microphone` as a [TrackOption.provided] carries a + /// warmed-up microphone into the call rather than opening a second one. + /// Whoever takes it is responsible for stopping it. final FutureOr Function(RtcLocalAudioTrack?)? onMicrophoneTrackSet; + + /// Called with the camera track whenever it is created, and with null when it + /// is stopped. + /// + /// The track becomes the caller's to manage — see [onMicrophoneTrackSet]. final FutureOr Function(RtcLocalCameraTrack?)? onCameraTrackSet; final List Function(BuildContext, Call)? additionalActionsBuilder; @@ -169,18 +181,17 @@ class _StreamLobbyVideoState extends State { else placeHolderBuilder(context), Align( - alignment: Alignment.bottomLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - StreamParticipantLabel( - isAudioEnabled: microphoneEnabled, - isSpeaking: false, - isTrackPaused: false, - participantName: currentUser.name, - ), - ], + alignment: AlignmentDirectional.bottomStart, + child: Padding( + padding: EdgeInsets.all( + context.streamSpacing.sm, + ), + child: StreamParticipantLabel( + name: currentUser.name, + isAudioEnabled: microphoneEnabled, + isSpeaking: false, + isVideoEnabled: cameraEnabled, + ), ), ), ], diff --git a/packages/stream_video_flutter/lib/src/components/stream_video_component_builders.dart b/packages/stream_video_flutter/lib/src/components/stream_video_component_builders.dart index 0b8d1fa5b..a9a66b07a 100644 --- a/packages/stream_video_flutter/lib/src/components/stream_video_component_builders.dart +++ b/packages/stream_video_flutter/lib/src/components/stream_video_component_builders.dart @@ -6,10 +6,33 @@ import '../../stream_video_flutter.dart'; Iterable> streamVideoComponentBuilders({ // ── Call participants ──────────────────────────────────────────────────── StreamComponentBuilder? participantTile, + StreamComponentBuilder? + floatingParticipantTile, + StreamComponentBuilder? participantVideo, + StreamComponentBuilder? + participantPlaceholder, + StreamComponentBuilder? participantLabel, + StreamComponentBuilder? + connectionQualityIndicator, + + // ── Shared ─────────────────────────────────────────────────────────────── + StreamComponentBuilder? userAvatar, }) { final builders = [ if (participantTile != null) StreamComponentBuilderExtension(builder: participantTile), + if (floatingParticipantTile != null) + StreamComponentBuilderExtension(builder: floatingParticipantTile), + if (participantVideo != null) + StreamComponentBuilderExtension(builder: participantVideo), + if (participantPlaceholder != null) + StreamComponentBuilderExtension(builder: participantPlaceholder), + if (participantLabel != null) + StreamComponentBuilderExtension(builder: participantLabel), + if (connectionQualityIndicator != null) + StreamComponentBuilderExtension(builder: connectionQualityIndicator), + if (userAvatar != null) + StreamComponentBuilderExtension(builder: userAvatar), ]; return builders; diff --git a/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart b/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart index a24a56052..9c8df83ac 100644 --- a/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart +++ b/packages/stream_video_flutter/lib/src/livestream/livestream_content.dart @@ -211,9 +211,11 @@ class _LivestreamContentState extends State { rendererScopePrefix: 'livecontent', call: call, participant: participant, - backgroundColor: StreamVideoTheme.of( - context, - ).colorTheme.livestreamBackground, + style: StreamParticipantTileStyle( + backgroundColor: StreamVideoTheme.of( + context, + ).colorTheme.livestreamBackground, + ), showConnectionQualityIndicator: false, showParticipantLabel: false, showSpeakerBorder: false, diff --git a/packages/stream_video_flutter/lib/src/theme/call_participant_theme.dart b/packages/stream_video_flutter/lib/src/theme/call_participant_theme.dart index 9039e5590..1351010a0 100644 --- a/packages/stream_video_flutter/lib/src/theme/call_participant_theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/call_participant_theme.dart @@ -1,14 +1,33 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../stream_video_flutter.dart'; +import '../widgets/avatar_size_from_constraints.dart'; /// Defines default property values for [StreamParticipantTile] widgets. +/// +/// Nothing reads this any more. It survives so existing code keeps compiling, +/// and is translated onto the component themes once — in the +/// [StreamVideoTheme] factory. Reaching it any other way, through +/// [StreamVideoTheme.copyWith] or the [StreamCallParticipantTheme] widget, +/// sets the value without restyling anything. +@Deprecated( + 'Use participantTileTheme, participantLabelTheme, ' + 'connectionQualityIndicatorTheme and callParticipantsGridTheme instead. ' + 'Will be removed in the next major version.', +) @immutable class StreamCallParticipantThemeData with Diagnosticable { /// Creates a new instance of [StreamCallParticipantThemeData]. + @Deprecated( + 'Use participantTileTheme, participantLabelTheme, ' + 'connectionQualityIndicatorTheme and callParticipantsGridTheme instead. ' + 'Will be removed in the next major version.', + ) const StreamCallParticipantThemeData({ this.videoFit, this.backgroundColor = const Color(0xffB4B7BB), @@ -121,9 +140,9 @@ class StreamCallParticipantThemeData with Diagnosticable { Color? backgroundColor, BorderRadius? borderRadius, StreamUserAvatarThemeData? userAvatarTheme, - bool? showDominantSpeakerBorder, - double? dominantSpeakerBorderThickness, - Color? dominantSpeakerBorderColor, + bool? showSpeakerBorder, + double? speakerBorderThickness, + Color? speakerBorderColor, bool? showParticipantLabel, TextStyle? participantLabelTextStyle, AlignmentGeometry? participantLabelAlignment, @@ -144,10 +163,10 @@ class StreamCallParticipantThemeData with Diagnosticable { backgroundColor: backgroundColor ?? this.backgroundColor, borderRadius: borderRadius ?? this.borderRadius, userAvatarTheme: userAvatarTheme ?? this.userAvatarTheme, - showSpeakerBorder: showDominantSpeakerBorder ?? showSpeakerBorder, + showSpeakerBorder: showSpeakerBorder ?? this.showSpeakerBorder, speakerBorderThickness: - dominantSpeakerBorderThickness ?? speakerBorderThickness, - speakerBorderColor: dominantSpeakerBorderColor ?? speakerBorderColor, + speakerBorderThickness ?? this.speakerBorderThickness, + speakerBorderColor: speakerBorderColor ?? this.speakerBorderColor, showParticipantLabel: showParticipantLabel ?? this.showParticipantLabel, participantLabelTextStyle: participantLabelTextStyle ?? this.participantLabelTextStyle, @@ -342,24 +361,11 @@ class StreamCallParticipantThemeData with Diagnosticable { ..add(DiagnosticsProperty('backgroundColor', backgroundColor)) ..add(DiagnosticsProperty('borderRadius', borderRadius)) ..add(DiagnosticsProperty('userAvatarTheme', userAvatarTheme)) + ..add(DiagnosticsProperty('showSpeakerBorder', showSpeakerBorder)) ..add( - DiagnosticsProperty( - 'showDominantSpeakerBorder', - showSpeakerBorder, - ), - ) - ..add( - DiagnosticsProperty( - 'dominantSpeakerBorderThickness', - speakerBorderThickness, - ), - ) - ..add( - DiagnosticsProperty( - 'dominantSpeakerBorderColor', - speakerBorderColor, - ), + DiagnosticsProperty('speakerBorderThickness', speakerBorderThickness), ) + ..add(DiagnosticsProperty('speakerBorderColor', speakerBorderColor)) ..add(DiagnosticsProperty('showParticipantLabel', showParticipantLabel)) ..add( DiagnosticsProperty( @@ -446,15 +452,16 @@ class StreamCallParticipantThemeData with Diagnosticable { backgroundColor: other.backgroundColor, borderRadius: other.borderRadius, userAvatarTheme: other.userAvatarTheme, - showDominantSpeakerBorder: other.showSpeakerBorder, - dominantSpeakerBorderThickness: other.speakerBorderThickness, - dominantSpeakerBorderColor: other.speakerBorderColor, + showSpeakerBorder: other.showSpeakerBorder, + speakerBorderThickness: other.speakerBorderThickness, + speakerBorderColor: other.speakerBorderColor, showParticipantLabel: other.showParticipantLabel, participantLabelTextStyle: other.participantLabelTextStyle, participantLabelAlignment: other.participantLabelAlignment, audioLevelIndicatorColor: other.audioLevelIndicatorColor, enabledMicrophoneColor: other.enabledMicrophoneColor, disabledMicrophoneColor: other.disabledMicrophoneColor, + pausedVideoIndicatorColor: other.pausedVideoIndicatorColor, showConnectionQualityIndicator: other.showConnectionQualityIndicator, connectionLevelActiveColor: other.connectionLevelActiveColor, connectionLevelInactiveColor: other.connectionLevelInactiveColor, @@ -464,12 +471,105 @@ class StreamCallParticipantThemeData with Diagnosticable { participantsGridCrossAxisSpacing: other.participantsGridCrossAxisSpacing, ); } + + // ── Migration to the component themes ────────────────────────────────────── + // + // `StreamVideoTheme.callParticipantTheme` is null unless an app sets one, so + // a value reaching here means somebody deliberately styled the tile through + // the deprecated shape. Everything it carries is translated, defaults + // included — an app that wants the redesigned tile stops setting it rather + // than setting parts of it. + // + // The translation runs in the `StreamVideoTheme` factory, which is where a + // theme is built. Reaching the deprecated shape any other way — through + // `copyWith`, or through the `StreamCallParticipantTheme` widget — sets the + // field without restyling anything. + + static const _defaults = StreamCallParticipantThemeData(); + + /// The subset of this theme that describes the participant tile. + StreamParticipantTileThemeData toParticipantTileThemeData() { + return StreamParticipantTileThemeData( + style: StreamParticipantTileStyle( + videoFit: videoFit, + backgroundColor: backgroundColor, + borderRadius: borderRadius, + speakingBorder: Border.all( + color: speakerBorderColor, + width: speakerBorderThickness, + ), + showSpeakerBorder: showSpeakerBorder, + showParticipantLabel: showParticipantLabel, + showConnectionQualityIndicator: showConnectionQualityIndicator, + placeholderStyle: StreamParticipantPlaceholderStyle( + avatarTheme: StreamAvatarThemeData( + size: avatarSizeFromConstraints(userAvatarTheme.constraints), + backgroundColor: userAvatarTheme.initialsBackground, + foregroundColor: userAvatarTheme.initialsTextStyle.color, + ), + ), + ), + ); + } + + /// The subset of this theme that describes the participant name pill. + StreamParticipantLabelThemeData toParticipantLabelThemeData() { + return StreamParticipantLabelThemeData( + style: StreamParticipantLabelStyle( + nameTextStyle: participantLabelTextStyle, + speakingColor: audioLevelIndicatorColor, + microphoneOffColor: disabledMicrophoneColor, + videoOffIconColor: pausedVideoIndicatorColor, + ), + ); + } + + /// The subset of this theme that describes the connection quality indicator. + StreamConnectionQualityIndicatorThemeData + toConnectionQualityIndicatorThemeData() { + return StreamConnectionQualityIndicatorThemeData( + style: StreamConnectionQualityIndicatorStyle( + // The indicator colours each level apart now. The single colour this + // theme carries spreads across all three, so it still reads as one + // flat colour the way it used to. + poorColor: connectionLevelActiveColor, + fairColor: connectionLevelActiveColor, + greatColor: connectionLevelActiveColor, + inactiveColor: connectionLevelInactiveColor, + ), + ); + } + + /// The subset of this theme that describes the participants grid layout. + StreamCallParticipantsGridThemeData toCallParticipantsGridThemeData() { + return StreamCallParticipantsGridThemeData( + padding: participantsGridPadding, + mainAxisSpacing: participantsGridMainAxisSpacing, + crossAxisSpacing: participantsGridCrossAxisSpacing, + ); + } } /// Applies a call participant theme to descendant [StreamParticipantTile] /// widgets. +/// +/// The tile and its parts no longer read this, so wrapping a subtree in one +/// restyles nothing. Scope the component themes instead: [ +/// StreamParticipantTileTheme], [StreamParticipantLabelTheme], +/// [StreamConnectionQualityIndicatorTheme] and +/// [StreamCallParticipantsGridTheme] each wrap a subtree the same way. +@Deprecated( + 'Use StreamParticipantTileTheme, StreamParticipantLabelTheme, ' + 'StreamConnectionQualityIndicatorTheme or StreamCallParticipantsGridTheme ' + 'instead. Will be removed in the next major version.', +) class StreamCallParticipantTheme extends InheritedWidget { /// Creates a new instance of [StreamCallParticipantTheme]. + @Deprecated( + 'Use StreamParticipantTileTheme, StreamParticipantLabelTheme, ' + 'StreamConnectionQualityIndicatorTheme or StreamCallParticipantsGridTheme ' + 'instead. Will be removed in the next major version.', + ) const StreamCallParticipantTheme({ super.key, required this.data, @@ -480,13 +580,18 @@ class StreamCallParticipantTheme extends InheritedWidget { final StreamCallParticipantThemeData data; /// Returns the configuration [data] from the closest - /// [StreamCallParticipantTheme] ancestor. If there is no ancestor, - /// it returns [StreamVideoTheme.callParticipantTheme]. + /// [StreamCallParticipantTheme] ancestor. + /// + /// Falls back to [StreamVideoTheme.callParticipantTheme], and then to this + /// class's own defaults — which nothing reads any more. The tile and its + /// parts resolve their appearance from the component themes; this survives + /// only so existing calls keep compiling. static StreamCallParticipantThemeData of(BuildContext context) { final callParticipantTheme = context .dependOnInheritedWidgetOfExactType(); return callParticipantTheme?.data ?? - StreamVideoTheme.of(context).callParticipantTheme; + StreamVideoTheme.of(context).callParticipantTheme ?? + StreamCallParticipantThemeData._defaults; } @override diff --git a/packages/stream_video_flutter/lib/src/theme/components/call_participants_grid_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/call_participants_grid_theme.dart new file mode 100644 index 000000000..6568bea80 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/call_participants_grid_theme.dart @@ -0,0 +1,88 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../../stream_video_flutter.dart'; + +part 'call_participants_grid_theme.g.theme.dart'; + +/// Applies a grid theme to descendant `CallParticipantsGridView` widgets. +/// +/// See also: +/// +/// * [StreamCallParticipantsGridThemeData], which describes the theme. +class StreamCallParticipantsGridTheme extends InheritedTheme { + /// Creates a participants grid theme. + const StreamCallParticipantsGridTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The grid theme data for descendant widgets. + final StreamCallParticipantsGridThemeData data; + + /// Returns the [StreamCallParticipantsGridThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamCallParticipantsGridTheme] ancestor + /// take precedence over the global values from + /// [StreamVideoTheme.callParticipantsGridTheme]. + static StreamCallParticipantsGridThemeData of(BuildContext context) { + final localTheme = context + .dependOnInheritedWidgetOfExactType(); + return StreamVideoTheme.of( + context, + ).callParticipantsGridTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamCallParticipantsGridTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamCallParticipantsGridTheme oldWidget) => + data != oldWidget.data; +} + +/// Theme data for customizing how participant tiles are spaced in a grid. +/// +/// Unlike the tile's own theme this carries its properties directly rather than +/// through a nested style: they are layout values with no reuse elsewhere. +/// +/// See also: +/// +/// * [StreamCallParticipantsGridTheme], for overriding it in a subtree. +@themeGen +@immutable +class StreamCallParticipantsGridThemeData + with _$StreamCallParticipantsGridThemeData { + /// Creates participants grid theme data. + const StreamCallParticipantsGridThemeData({ + this.padding, + this.mainAxisSpacing, + this.crossAxisSpacing, + }); + + /// The inset around the grid. + /// + /// Defaults to `spacing.xs` on every side. + final EdgeInsetsGeometry? padding; + + /// The gap between tiles along the main axis. + /// + /// Defaults to `spacing.xs`. + final double? mainAxisSpacing; + + /// The gap between tiles along the cross axis. + /// + /// Defaults to `spacing.xs`. + final double? crossAxisSpacing; + + /// Linearly interpolate between two theme data objects. + static StreamCallParticipantsGridThemeData? lerp( + StreamCallParticipantsGridThemeData? a, + StreamCallParticipantsGridThemeData? b, + double t, + ) => _$StreamCallParticipantsGridThemeData.lerp(a, b, t); +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/call_participants_grid_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/call_participants_grid_theme.g.theme.dart new file mode 100644 index 000000000..72d2544fc --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/call_participants_grid_theme.g.theme.dart @@ -0,0 +1,102 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'call_participants_grid_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamCallParticipantsGridThemeData { + bool get canMerge => true; + + static StreamCallParticipantsGridThemeData? lerp( + StreamCallParticipantsGridThemeData? a, + StreamCallParticipantsGridThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamCallParticipantsGridThemeData( + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + mainAxisSpacing: lerpDouble$(a.mainAxisSpacing, b.mainAxisSpacing, t), + crossAxisSpacing: lerpDouble$(a.crossAxisSpacing, b.crossAxisSpacing, t), + ); + } + + StreamCallParticipantsGridThemeData copyWith({ + EdgeInsetsGeometry? padding, + double? mainAxisSpacing, + double? crossAxisSpacing, + }) { + final _this = (this as StreamCallParticipantsGridThemeData); + + return StreamCallParticipantsGridThemeData( + padding: padding ?? _this.padding, + mainAxisSpacing: mainAxisSpacing ?? _this.mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing ?? _this.crossAxisSpacing, + ); + } + + StreamCallParticipantsGridThemeData merge( + StreamCallParticipantsGridThemeData? other, + ) { + final _this = (this as StreamCallParticipantsGridThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + padding: other.padding, + mainAxisSpacing: other.mainAxisSpacing, + crossAxisSpacing: other.crossAxisSpacing, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamCallParticipantsGridThemeData); + final _other = (other as StreamCallParticipantsGridThemeData); + + return _other.padding == _this.padding && + _other.mainAxisSpacing == _this.mainAxisSpacing && + _other.crossAxisSpacing == _this.crossAxisSpacing; + } + + @override + int get hashCode { + final _this = (this as StreamCallParticipantsGridThemeData); + + return Object.hash( + runtimeType, + _this.padding, + _this.mainAxisSpacing, + _this.crossAxisSpacing, + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/components.dart b/packages/stream_video_flutter/lib/src/theme/components/components.dart new file mode 100644 index 000000000..71f689eda --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/components.dart @@ -0,0 +1,5 @@ +export 'call_participants_grid_theme.dart'; +export 'connection_quality_indicator_theme.dart'; +export 'floating_participant_tile_theme.dart'; +export 'participant_label_theme.dart'; +export 'participant_tile_theme.dart'; diff --git a/packages/stream_video_flutter/lib/src/theme/components/connection_quality_indicator_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/connection_quality_indicator_theme.dart new file mode 100644 index 000000000..c5a56b283 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/connection_quality_indicator_theme.dart @@ -0,0 +1,170 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../../stream_video_flutter.dart'; + +part 'connection_quality_indicator_theme.g.theme.dart'; + +/// Applies a connection quality indicator theme to descendant +/// `StreamConnectionQualityIndicator` widgets. +/// +/// Wrap a subtree with [StreamConnectionQualityIndicatorTheme] to override the +/// indicator's styling. +/// +/// {@tool snippet} +/// +/// Paint the indicator's bars in a single color rather than one per level: +/// +/// ```dart +/// StreamConnectionQualityIndicatorTheme( +/// data: StreamConnectionQualityIndicatorThemeData( +/// style: StreamConnectionQualityIndicatorStyle( +/// poorColor: Colors.white, +/// fairColor: Colors.white, +/// greatColor: Colors.white, +/// ), +/// ), +/// child: child, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamConnectionQualityIndicatorThemeData], which describes the theme. +/// * [StreamConnectionQualityIndicatorStyle], the visual style it carries. +class StreamConnectionQualityIndicatorTheme extends InheritedTheme { + /// Creates a connection quality indicator theme. + const StreamConnectionQualityIndicatorTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The indicator theme data for descendant widgets. + final StreamConnectionQualityIndicatorThemeData data; + + /// Returns the [StreamConnectionQualityIndicatorThemeData] merged from local + /// and global themes. + /// + /// Local values from the nearest [StreamConnectionQualityIndicatorTheme] + /// ancestor take precedence over the global values from + /// [StreamVideoTheme.connectionQualityIndicatorTheme]. This allows partial + /// overrides: setting only [StreamConnectionQualityIndicatorStyle.size] + /// leaves the colors coming from the global theme. + static StreamConnectionQualityIndicatorThemeData of(BuildContext context) { + final localTheme = context + .dependOnInheritedWidgetOfExactType< + StreamConnectionQualityIndicatorTheme + >(); + return StreamVideoTheme.of( + context, + ).connectionQualityIndicatorTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamConnectionQualityIndicatorTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamConnectionQualityIndicatorTheme oldWidget) => + data != oldWidget.data; +} + +/// Theme data for customizing `StreamConnectionQualityIndicator` widgets. +/// +/// Wraps a [StreamConnectionQualityIndicatorStyle] so it can be served by +/// [StreamConnectionQualityIndicatorTheme] and slotted into [StreamVideoTheme] +/// alongside the other component theme data classes. +/// +/// See also: +/// +/// * [StreamConnectionQualityIndicatorStyle], the style embedded here. +/// * [StreamConnectionQualityIndicatorTheme], for overriding it in a subtree. +@themeGen +@immutable +class StreamConnectionQualityIndicatorThemeData + with _$StreamConnectionQualityIndicatorThemeData { + /// Creates connection quality indicator theme data. + const StreamConnectionQualityIndicatorThemeData({this.style}); + + /// Visual styling for the indicator. + final StreamConnectionQualityIndicatorStyle? style; + + /// Linearly interpolate between two theme data objects. + static StreamConnectionQualityIndicatorThemeData? lerp( + StreamConnectionQualityIndicatorThemeData? a, + StreamConnectionQualityIndicatorThemeData? b, + double t, + ) => _$StreamConnectionQualityIndicatorThemeData.lerp(a, b, t); +} + +/// Visual styling properties for a `StreamConnectionQualityIndicator`. +/// +/// The indicator is a round chip holding three bars. How many bars are lit +/// reflects the reported quality, and the color of the lit bars reflects it as +/// well: [poorColor], [fairColor] and [greatColor] are separate so a glance at +/// the color is enough, without counting bars. +/// +/// Exposed separately from [StreamConnectionQualityIndicatorThemeData] so other +/// theme data classes can embed an indicator style via a typed field — see +/// [StreamParticipantTileStyle.connectionQualityIndicatorStyle]. +@themeGen +@immutable +class StreamConnectionQualityIndicatorStyle + with _$StreamConnectionQualityIndicatorStyle { + /// Creates an indicator style with optional property overrides. + const StreamConnectionQualityIndicatorStyle({ + this.size, + this.backgroundColor, + this.iconSize, + this.poorColor, + this.fairColor, + this.greatColor, + this.inactiveColor, + }); + + /// The diameter of the chip. + /// + /// Defaults to 32. + final double? size; + + /// The fill behind the bars. + /// + /// Defaults to `colorScheme.backgroundOverlayDarkStrong`, which stays legible + /// on top of video. + final Color? backgroundColor; + + /// The side length of the bars glyph inside the chip. + /// + /// Defaults to 24. + final double? iconSize; + + /// The color of the lit bars at the weakest quality level. + /// + /// Defaults to `colorScheme.accentError`. + final Color? poorColor; + + /// The color of the lit bars at the middle quality level. + /// + /// Defaults to `colorScheme.accentWarning`. + final Color? fairColor; + + /// The color of the lit bars at the strongest quality level. + /// + /// Defaults to `colorScheme.accentSuccess`. + final Color? greatColor; + + /// The color of the bars above the reported quality level. + /// + /// Defaults to `colorScheme.textOnAccent` at reduced opacity. + final Color? inactiveColor; + + /// Linearly interpolate between two styles. + static StreamConnectionQualityIndicatorStyle? lerp( + StreamConnectionQualityIndicatorStyle? a, + StreamConnectionQualityIndicatorStyle? b, + double t, + ) => _$StreamConnectionQualityIndicatorStyle.lerp(a, b, t); +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/connection_quality_indicator_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/connection_quality_indicator_theme.g.theme.dart new file mode 100644 index 000000000..ac39d8767 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/connection_quality_indicator_theme.g.theme.dart @@ -0,0 +1,201 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'connection_quality_indicator_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamConnectionQualityIndicatorThemeData { + bool get canMerge => true; + + static StreamConnectionQualityIndicatorThemeData? lerp( + StreamConnectionQualityIndicatorThemeData? a, + StreamConnectionQualityIndicatorThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamConnectionQualityIndicatorThemeData( + style: StreamConnectionQualityIndicatorStyle.lerp(a.style, b.style, t), + ); + } + + StreamConnectionQualityIndicatorThemeData copyWith({ + StreamConnectionQualityIndicatorStyle? style, + }) { + final _this = (this as StreamConnectionQualityIndicatorThemeData); + + return StreamConnectionQualityIndicatorThemeData( + style: style ?? _this.style, + ); + } + + StreamConnectionQualityIndicatorThemeData merge( + StreamConnectionQualityIndicatorThemeData? other, + ) { + final _this = (this as StreamConnectionQualityIndicatorThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamConnectionQualityIndicatorThemeData); + final _other = (other as StreamConnectionQualityIndicatorThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamConnectionQualityIndicatorThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamConnectionQualityIndicatorStyle { + bool get canMerge => true; + + static StreamConnectionQualityIndicatorStyle? lerp( + StreamConnectionQualityIndicatorStyle? a, + StreamConnectionQualityIndicatorStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamConnectionQualityIndicatorStyle( + size: lerpDouble$(a.size, b.size, t), + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + iconSize: lerpDouble$(a.iconSize, b.iconSize, t), + poorColor: Color.lerp(a.poorColor, b.poorColor, t), + fairColor: Color.lerp(a.fairColor, b.fairColor, t), + greatColor: Color.lerp(a.greatColor, b.greatColor, t), + inactiveColor: Color.lerp(a.inactiveColor, b.inactiveColor, t), + ); + } + + StreamConnectionQualityIndicatorStyle copyWith({ + double? size, + Color? backgroundColor, + double? iconSize, + Color? poorColor, + Color? fairColor, + Color? greatColor, + Color? inactiveColor, + }) { + final _this = (this as StreamConnectionQualityIndicatorStyle); + + return StreamConnectionQualityIndicatorStyle( + size: size ?? _this.size, + backgroundColor: backgroundColor ?? _this.backgroundColor, + iconSize: iconSize ?? _this.iconSize, + poorColor: poorColor ?? _this.poorColor, + fairColor: fairColor ?? _this.fairColor, + greatColor: greatColor ?? _this.greatColor, + inactiveColor: inactiveColor ?? _this.inactiveColor, + ); + } + + StreamConnectionQualityIndicatorStyle merge( + StreamConnectionQualityIndicatorStyle? other, + ) { + final _this = (this as StreamConnectionQualityIndicatorStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + size: other.size, + backgroundColor: other.backgroundColor, + iconSize: other.iconSize, + poorColor: other.poorColor, + fairColor: other.fairColor, + greatColor: other.greatColor, + inactiveColor: other.inactiveColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamConnectionQualityIndicatorStyle); + final _other = (other as StreamConnectionQualityIndicatorStyle); + + return _other.size == _this.size && + _other.backgroundColor == _this.backgroundColor && + _other.iconSize == _this.iconSize && + _other.poorColor == _this.poorColor && + _other.fairColor == _this.fairColor && + _other.greatColor == _this.greatColor && + _other.inactiveColor == _this.inactiveColor; + } + + @override + int get hashCode { + final _this = (this as StreamConnectionQualityIndicatorStyle); + + return Object.hash( + runtimeType, + _this.size, + _this.backgroundColor, + _this.iconSize, + _this.poorColor, + _this.fairColor, + _this.greatColor, + _this.inactiveColor, + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/floating_participant_tile_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/floating_participant_tile_theme.dart new file mode 100644 index 000000000..bb28d4a82 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/floating_participant_tile_theme.dart @@ -0,0 +1,157 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../../stream_video_flutter.dart'; + +part 'floating_participant_tile_theme.g.theme.dart'; + +/// Applies a floating participant tile theme to descendant +/// `StreamFloatingParticipantTile` widgets. +/// +/// See also: +/// +/// * [StreamFloatingParticipantTileThemeData], which describes the theme. +/// * [StreamFloatingParticipantTileStyle], the visual style it carries. +class StreamFloatingParticipantTileTheme extends InheritedTheme { + /// Creates a floating participant tile theme. + const StreamFloatingParticipantTileTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The floating tile theme data for descendant widgets. + final StreamFloatingParticipantTileThemeData data; + + /// Returns the [StreamFloatingParticipantTileThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamFloatingParticipantTileTheme] + /// ancestor take precedence over the global values from + /// [StreamVideoTheme.floatingParticipantTileTheme]. + static StreamFloatingParticipantTileThemeData of(BuildContext context) { + final localTheme = context + .dependOnInheritedWidgetOfExactType< + StreamFloatingParticipantTileTheme + >(); + return StreamVideoTheme.of( + context, + ).floatingParticipantTileTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamFloatingParticipantTileTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamFloatingParticipantTileTheme oldWidget) => + data != oldWidget.data; +} + +/// Theme data for customizing `StreamFloatingParticipantTile` widgets. +/// +/// See also: +/// +/// * [StreamFloatingParticipantTileStyle], the style embedded here. +/// * [StreamFloatingParticipantTileTheme], for overriding it in a subtree. +@themeGen +@immutable +class StreamFloatingParticipantTileThemeData + with _$StreamFloatingParticipantTileThemeData { + /// Creates floating participant tile theme data. + const StreamFloatingParticipantTileThemeData({this.style}); + + /// Visual styling for the floating tile. + final StreamFloatingParticipantTileStyle? style; + + /// Linearly interpolate between two theme data objects. + static StreamFloatingParticipantTileThemeData? lerp( + StreamFloatingParticipantTileThemeData? a, + StreamFloatingParticipantTileThemeData? b, + double t, + ) => _$StreamFloatingParticipantTileThemeData.lerp(a, b, t); +} + +/// Visual styling properties for a `StreamFloatingParticipantTile`. +/// +/// The floating tile is the draggable self-view that sits on top of the call. +/// It is a participant tile plus the state that only a floating surface has: +/// where it starts, whether it snaps to a corner, and how far it lifts off the +/// content below. +@themeGen +@immutable +class StreamFloatingParticipantTileStyle + with _$StreamFloatingParticipantTileStyle { + /// Creates a floating tile style with optional property overrides. + const StreamFloatingParticipantTileStyle({ + this.size, + this.padding, + this.borderRadius, + this.border, + this.elevation, + this.shadowColor, + this.initialAlignment, + this.enableSnapping, + this.tileStyle, + }); + + /// The dimensions of the floating view. + /// + /// Defaults to 140x228. + final Size? size; + + /// The inset between the floating view and the edges of its container. + /// + /// Defaults to `spacing.md`. + final double? padding; + + /// The corner radius of the floating view. + /// + /// Smaller than a grid tile's — defaults to `radius.lg`. + final BorderRadius? borderRadius; + + /// The hairline around the floating view. + /// + /// Translucent by default (`colorScheme.borderOpacitySubtle`) rather than the + /// grid tile's opaque border, because the floating view sits on top of video + /// rather than on a surface. + final BoxBorder? border; + + /// How far the floating view lifts off the content below it. + /// + /// Rendered through a a `Material` rather than a hand-painted shadow, so it + /// matches every other elevated Stream surface. Defaults to + /// `elevation.level2`. + final double? elevation; + + /// The color of the elevation shadow. + /// + /// Defaults to the host app's `ThemeData.shadowColor`. + final Color? shadowColor; + + /// The corner the floating view starts in. + /// + /// Defaults to [FloatingViewAlignment.topRight]. + final FloatingViewAlignment? initialAlignment; + + /// Whether the floating view snaps to the nearest corner when released. + /// + /// Defaults to true. + final bool? enableSnapping; + + /// Overrides applied to the participant tile rendered inside. + /// + /// Merged over the ambient [StreamParticipantTileTheme] style, so an app-wide + /// tile customization still reaches the self-view. Defaults to a tile with no + /// name pill, no overflow button and no speaking border — at this size only + /// the connection quality indicator is legible. + final StreamParticipantTileStyle? tileStyle; + + /// Linearly interpolate between two styles. + static StreamFloatingParticipantTileStyle? lerp( + StreamFloatingParticipantTileStyle? a, + StreamFloatingParticipantTileStyle? b, + double t, + ) => _$StreamFloatingParticipantTileStyle.lerp(a, b, t); +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/floating_participant_tile_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/floating_participant_tile_theme.g.theme.dart new file mode 100644 index 000000000..ab803207d --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/floating_participant_tile_theme.g.theme.dart @@ -0,0 +1,211 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'floating_participant_tile_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamFloatingParticipantTileThemeData { + bool get canMerge => true; + + static StreamFloatingParticipantTileThemeData? lerp( + StreamFloatingParticipantTileThemeData? a, + StreamFloatingParticipantTileThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamFloatingParticipantTileThemeData( + style: StreamFloatingParticipantTileStyle.lerp(a.style, b.style, t), + ); + } + + StreamFloatingParticipantTileThemeData copyWith({ + StreamFloatingParticipantTileStyle? style, + }) { + final _this = (this as StreamFloatingParticipantTileThemeData); + + return StreamFloatingParticipantTileThemeData(style: style ?? _this.style); + } + + StreamFloatingParticipantTileThemeData merge( + StreamFloatingParticipantTileThemeData? other, + ) { + final _this = (this as StreamFloatingParticipantTileThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamFloatingParticipantTileThemeData); + final _other = (other as StreamFloatingParticipantTileThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamFloatingParticipantTileThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamFloatingParticipantTileStyle { + bool get canMerge => true; + + static StreamFloatingParticipantTileStyle? lerp( + StreamFloatingParticipantTileStyle? a, + StreamFloatingParticipantTileStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamFloatingParticipantTileStyle( + size: Size.lerp(a.size, b.size, t), + padding: lerpDouble$(a.padding, b.padding, t), + borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t), + border: BoxBorder.lerp(a.border, b.border, t), + elevation: lerpDouble$(a.elevation, b.elevation, t), + shadowColor: Color.lerp(a.shadowColor, b.shadowColor, t), + initialAlignment: t < 0.5 ? a.initialAlignment : b.initialAlignment, + enableSnapping: t < 0.5 ? a.enableSnapping : b.enableSnapping, + tileStyle: StreamParticipantTileStyle.lerp(a.tileStyle, b.tileStyle, t), + ); + } + + StreamFloatingParticipantTileStyle copyWith({ + Size? size, + double? padding, + BorderRadius? borderRadius, + BoxBorder? border, + double? elevation, + Color? shadowColor, + FloatingViewAlignment? initialAlignment, + bool? enableSnapping, + StreamParticipantTileStyle? tileStyle, + }) { + final _this = (this as StreamFloatingParticipantTileStyle); + + return StreamFloatingParticipantTileStyle( + size: size ?? _this.size, + padding: padding ?? _this.padding, + borderRadius: borderRadius ?? _this.borderRadius, + border: border ?? _this.border, + elevation: elevation ?? _this.elevation, + shadowColor: shadowColor ?? _this.shadowColor, + initialAlignment: initialAlignment ?? _this.initialAlignment, + enableSnapping: enableSnapping ?? _this.enableSnapping, + tileStyle: tileStyle ?? _this.tileStyle, + ); + } + + StreamFloatingParticipantTileStyle merge( + StreamFloatingParticipantTileStyle? other, + ) { + final _this = (this as StreamFloatingParticipantTileStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + size: other.size, + padding: other.padding, + borderRadius: other.borderRadius, + border: other.border, + elevation: other.elevation, + shadowColor: other.shadowColor, + initialAlignment: other.initialAlignment, + enableSnapping: other.enableSnapping, + tileStyle: _this.tileStyle?.merge(other.tileStyle) ?? other.tileStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamFloatingParticipantTileStyle); + final _other = (other as StreamFloatingParticipantTileStyle); + + return _other.size == _this.size && + _other.padding == _this.padding && + _other.borderRadius == _this.borderRadius && + _other.border == _this.border && + _other.elevation == _this.elevation && + _other.shadowColor == _this.shadowColor && + _other.initialAlignment == _this.initialAlignment && + _other.enableSnapping == _this.enableSnapping && + _other.tileStyle == _this.tileStyle; + } + + @override + int get hashCode { + final _this = (this as StreamFloatingParticipantTileStyle); + + return Object.hash( + runtimeType, + _this.size, + _this.padding, + _this.borderRadius, + _this.border, + _this.elevation, + _this.shadowColor, + _this.initialAlignment, + _this.enableSnapping, + _this.tileStyle, + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart new file mode 100644 index 000000000..b917d36b3 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.dart @@ -0,0 +1,216 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../../stream_video_flutter.dart'; + +part 'participant_label_theme.g.theme.dart'; + +/// Applies a participant label theme to descendant `StreamParticipantLabel` +/// widgets. +/// +/// Wrap a subtree with [StreamParticipantLabelTheme] to override the styling of +/// the name pill shown on a participant tile. +/// +/// {@tool snippet} +/// +/// Drop the blur behind the pill, which costs a render layer per tile: +/// +/// ```dart +/// StreamParticipantLabelTheme( +/// data: StreamParticipantLabelThemeData( +/// style: StreamParticipantLabelStyle(blurSigma: 0), +/// ), +/// child: child, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamParticipantLabelThemeData], which describes the theme. +/// * [StreamParticipantLabelStyle], the visual style it carries. +class StreamParticipantLabelTheme extends InheritedTheme { + /// Creates a participant label theme. + const StreamParticipantLabelTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The label theme data for descendant widgets. + final StreamParticipantLabelThemeData data; + + /// Returns the [StreamParticipantLabelThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamParticipantLabelTheme] ancestor take + /// precedence over the global values from + /// [StreamVideoTheme.participantLabelTheme]. This allows partial overrides: + /// setting only [StreamParticipantLabelStyle.nameTextStyle] leaves the + /// remaining properties coming from the global theme. + static StreamParticipantLabelThemeData of(BuildContext context) { + final localTheme = context + .dependOnInheritedWidgetOfExactType(); + return StreamVideoTheme.of( + context, + ).participantLabelTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamParticipantLabelTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamParticipantLabelTheme oldWidget) => + data != oldWidget.data; +} + +/// Theme data for customizing `StreamParticipantLabel` widgets. +/// +/// Wraps a [StreamParticipantLabelStyle] so it can be served by +/// [StreamParticipantLabelTheme] and slotted into [StreamVideoTheme] alongside +/// the other component theme data classes. +/// +/// See also: +/// +/// * [StreamParticipantLabelStyle], the style embedded here. +/// * [StreamParticipantLabelTheme], for overriding it in a subtree. +@themeGen +@immutable +class StreamParticipantLabelThemeData with _$StreamParticipantLabelThemeData { + /// Creates participant label theme data. + const StreamParticipantLabelThemeData({this.style}); + + /// Visual styling for the label. + final StreamParticipantLabelStyle? style; + + /// Linearly interpolate between two theme data objects. + static StreamParticipantLabelThemeData? lerp( + StreamParticipantLabelThemeData? a, + StreamParticipantLabelThemeData? b, + double t, + ) => _$StreamParticipantLabelThemeData.lerp(a, b, t); +} + +/// Visual styling properties for a `StreamParticipantLabel`. +/// +/// The label is a pill holding the participant's name, a camera-off icon while +/// their video is off, and an audio indicator. It sits on top of video, so its +/// fill is an overlay rather than a surface color. +/// +/// Exposed separately from [StreamParticipantLabelThemeData] so other theme +/// data classes can embed a label style via a typed field — see +/// [StreamParticipantTileStyle.labelStyle]. +@themeGen +@immutable +class StreamParticipantLabelStyle with _$StreamParticipantLabelStyle { + /// Creates a label style with optional property overrides. + const StreamParticipantLabelStyle({ + this.backgroundColor, + this.borderRadius, + this.padding, + this.spacing, + this.blurSigma, + this.nameTextStyle, + this.videoOffIconColor, + this.videoOffIconSize, + this.audioIndicatorSize, + this.audioIndicatorBorderRadius, + this.audioIndicatorBackgroundColor, + this.audioIndicatorIconSize, + this.speakingColor, + this.microphoneIconSize, + this.microphoneOffColor, + }); + + /// The pill's fill. + /// + /// Defaults to `colorScheme.backgroundOverlayDarkStrong`, which stays legible + /// on top of video. + final Color? backgroundColor; + + /// The pill's corner radius. + /// + /// Defaults to `radius.lg`. + final BorderRadius? borderRadius; + + /// The inset around the pill's content. + /// + /// Asymmetric by default — the audio indicator carries its own padding, so it + /// sits closer to the trailing edge than the name does to the leading one. + final EdgeInsetsGeometry? padding; + + /// The gap between the name, the camera-off icon and the audio indicator. + /// + /// Defaults to `spacing.xs`. + final double? spacing; + + /// The blur applied to whatever sits behind the pill. + /// + /// Defaults to 12.5. Set to `0` to skip the blur entirely: it costs one + /// render layer per tile, which is measurable on a full grid. `null` is not + /// the way to switch it off — like every property here it means "no + /// override", and leaves the default in place. + final double? blurSigma; + + /// The text style of the participant's name. + /// + /// Defaults to `textTheme.metadataDefault` in `colorScheme.textOnAccent`. + final TextStyle? nameTextStyle; + + /// The color of the camera-off icon. + /// + /// Defaults to the color of [nameTextStyle]. + final Color? videoOffIconColor; + + /// The side length of the camera-off icon. + /// + /// Defaults to 20. + final double? videoOffIconSize; + + /// The side length of the audio indicator's box. + /// + /// Defaults to 24. + final double? audioIndicatorSize; + + /// The corner radius of the audio indicator's box. + /// + /// Defaults to `radius.md`. + final BorderRadius? audioIndicatorBorderRadius; + + /// The fill of the audio indicator's box. + /// + /// Defaults to [backgroundColor]. + final Color? audioIndicatorBackgroundColor; + + /// The side length of the bars inside the audio indicator. + /// + /// Defaults to 10. + final double? audioIndicatorIconSize; + + /// The color of the sound indicator's bars. + /// + /// Defaults to `colorScheme.brand.shade300`. + final Color? speakingColor; + + /// The side length of the muted-microphone icon. + /// + /// Defaults to 20. + final double? microphoneIconSize; + + /// The color of the microphone icon while the participant is muted. + /// + /// Only the muted state draws an icon, so there is no unmuted counterpart. + /// Defaults to the color of [nameTextStyle]: on a tile the muted state is + /// information rather than a warning, and the red used for the mute control + /// in the call bar would read as an error here. + final Color? microphoneOffColor; + + /// Linearly interpolate between two styles. + static StreamParticipantLabelStyle? lerp( + StreamParticipantLabelStyle? a, + StreamParticipantLabelStyle? b, + double t, + ) => _$StreamParticipantLabelStyle.lerp(a, b, t); +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart new file mode 100644 index 000000000..2dfe1df44 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/participant_label_theme.g.theme.dart @@ -0,0 +1,279 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'participant_label_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamParticipantLabelThemeData { + bool get canMerge => true; + + static StreamParticipantLabelThemeData? lerp( + StreamParticipantLabelThemeData? a, + StreamParticipantLabelThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamParticipantLabelThemeData( + style: StreamParticipantLabelStyle.lerp(a.style, b.style, t), + ); + } + + StreamParticipantLabelThemeData copyWith({ + StreamParticipantLabelStyle? style, + }) { + final _this = (this as StreamParticipantLabelThemeData); + + return StreamParticipantLabelThemeData(style: style ?? _this.style); + } + + StreamParticipantLabelThemeData merge( + StreamParticipantLabelThemeData? other, + ) { + final _this = (this as StreamParticipantLabelThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamParticipantLabelThemeData); + final _other = (other as StreamParticipantLabelThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamParticipantLabelThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamParticipantLabelStyle { + bool get canMerge => true; + + static StreamParticipantLabelStyle? lerp( + StreamParticipantLabelStyle? a, + StreamParticipantLabelStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamParticipantLabelStyle( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + blurSigma: lerpDouble$(a.blurSigma, b.blurSigma, t), + nameTextStyle: TextStyle.lerp(a.nameTextStyle, b.nameTextStyle, t), + videoOffIconColor: Color.lerp( + a.videoOffIconColor, + b.videoOffIconColor, + t, + ), + videoOffIconSize: lerpDouble$(a.videoOffIconSize, b.videoOffIconSize, t), + audioIndicatorSize: lerpDouble$( + a.audioIndicatorSize, + b.audioIndicatorSize, + t, + ), + audioIndicatorBorderRadius: BorderRadius.lerp( + a.audioIndicatorBorderRadius, + b.audioIndicatorBorderRadius, + t, + ), + audioIndicatorBackgroundColor: Color.lerp( + a.audioIndicatorBackgroundColor, + b.audioIndicatorBackgroundColor, + t, + ), + audioIndicatorIconSize: lerpDouble$( + a.audioIndicatorIconSize, + b.audioIndicatorIconSize, + t, + ), + speakingColor: Color.lerp(a.speakingColor, b.speakingColor, t), + microphoneIconSize: lerpDouble$( + a.microphoneIconSize, + b.microphoneIconSize, + t, + ), + microphoneOffColor: Color.lerp( + a.microphoneOffColor, + b.microphoneOffColor, + t, + ), + ); + } + + StreamParticipantLabelStyle copyWith({ + Color? backgroundColor, + BorderRadius? borderRadius, + EdgeInsetsGeometry? padding, + double? spacing, + double? blurSigma, + TextStyle? nameTextStyle, + Color? videoOffIconColor, + double? videoOffIconSize, + double? audioIndicatorSize, + BorderRadius? audioIndicatorBorderRadius, + Color? audioIndicatorBackgroundColor, + double? audioIndicatorIconSize, + Color? speakingColor, + double? microphoneIconSize, + Color? microphoneOffColor, + }) { + final _this = (this as StreamParticipantLabelStyle); + + return StreamParticipantLabelStyle( + backgroundColor: backgroundColor ?? _this.backgroundColor, + borderRadius: borderRadius ?? _this.borderRadius, + padding: padding ?? _this.padding, + spacing: spacing ?? _this.spacing, + blurSigma: blurSigma ?? _this.blurSigma, + nameTextStyle: nameTextStyle ?? _this.nameTextStyle, + videoOffIconColor: videoOffIconColor ?? _this.videoOffIconColor, + videoOffIconSize: videoOffIconSize ?? _this.videoOffIconSize, + audioIndicatorSize: audioIndicatorSize ?? _this.audioIndicatorSize, + audioIndicatorBorderRadius: + audioIndicatorBorderRadius ?? _this.audioIndicatorBorderRadius, + audioIndicatorBackgroundColor: + audioIndicatorBackgroundColor ?? _this.audioIndicatorBackgroundColor, + audioIndicatorIconSize: + audioIndicatorIconSize ?? _this.audioIndicatorIconSize, + speakingColor: speakingColor ?? _this.speakingColor, + microphoneIconSize: microphoneIconSize ?? _this.microphoneIconSize, + microphoneOffColor: microphoneOffColor ?? _this.microphoneOffColor, + ); + } + + StreamParticipantLabelStyle merge(StreamParticipantLabelStyle? other) { + final _this = (this as StreamParticipantLabelStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + borderRadius: other.borderRadius, + padding: other.padding, + spacing: other.spacing, + blurSigma: other.blurSigma, + nameTextStyle: + _this.nameTextStyle?.merge(other.nameTextStyle) ?? + other.nameTextStyle, + videoOffIconColor: other.videoOffIconColor, + videoOffIconSize: other.videoOffIconSize, + audioIndicatorSize: other.audioIndicatorSize, + audioIndicatorBorderRadius: other.audioIndicatorBorderRadius, + audioIndicatorBackgroundColor: other.audioIndicatorBackgroundColor, + audioIndicatorIconSize: other.audioIndicatorIconSize, + speakingColor: other.speakingColor, + microphoneIconSize: other.microphoneIconSize, + microphoneOffColor: other.microphoneOffColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamParticipantLabelStyle); + final _other = (other as StreamParticipantLabelStyle); + + return _other.backgroundColor == _this.backgroundColor && + _other.borderRadius == _this.borderRadius && + _other.padding == _this.padding && + _other.spacing == _this.spacing && + _other.blurSigma == _this.blurSigma && + _other.nameTextStyle == _this.nameTextStyle && + _other.videoOffIconColor == _this.videoOffIconColor && + _other.videoOffIconSize == _this.videoOffIconSize && + _other.audioIndicatorSize == _this.audioIndicatorSize && + _other.audioIndicatorBorderRadius == _this.audioIndicatorBorderRadius && + _other.audioIndicatorBackgroundColor == + _this.audioIndicatorBackgroundColor && + _other.audioIndicatorIconSize == _this.audioIndicatorIconSize && + _other.speakingColor == _this.speakingColor && + _other.microphoneIconSize == _this.microphoneIconSize && + _other.microphoneOffColor == _this.microphoneOffColor; + } + + @override + int get hashCode { + final _this = (this as StreamParticipantLabelStyle); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.borderRadius, + _this.padding, + _this.spacing, + _this.blurSigma, + _this.nameTextStyle, + _this.videoOffIconColor, + _this.videoOffIconSize, + _this.audioIndicatorSize, + _this.audioIndicatorBorderRadius, + _this.audioIndicatorBackgroundColor, + _this.audioIndicatorIconSize, + _this.speakingColor, + _this.microphoneIconSize, + _this.microphoneOffColor, + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/participant_tile_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_tile_theme.dart new file mode 100644 index 000000000..9bebc1092 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/participant_tile_theme.dart @@ -0,0 +1,267 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../../stream_video_flutter.dart'; + +part 'participant_tile_theme.g.theme.dart'; + +/// Applies a participant tile theme to descendant [StreamParticipantTile] +/// widgets. +/// +/// Wrap a subtree with [StreamParticipantTileTheme] to override the tile's +/// styling — for example to drop the name pill in a livestream layout while +/// leaving the grid untouched. +/// +/// {@tool snippet} +/// +/// ```dart +/// StreamParticipantTileTheme( +/// data: StreamParticipantTileThemeData( +/// style: StreamParticipantTileStyle(showParticipantLabel: false), +/// ), +/// child: child, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamParticipantTileThemeData], which describes the theme. +/// * [StreamParticipantTileStyle], the visual style it carries. +class StreamParticipantTileTheme extends InheritedTheme { + /// Creates a participant tile theme. + const StreamParticipantTileTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The tile theme data for descendant widgets. + final StreamParticipantTileThemeData data; + + /// Returns the [StreamParticipantTileThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamParticipantTileTheme] ancestor take + /// precedence over the global values from + /// [StreamVideoTheme.participantTileTheme]. This allows partial overrides: + /// setting only [StreamParticipantTileStyle.borderRadius] leaves the + /// remaining properties coming from the global theme. + static StreamParticipantTileThemeData of(BuildContext context) { + final localTheme = context + .dependOnInheritedWidgetOfExactType(); + return StreamVideoTheme.of( + context, + ).participantTileTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamParticipantTileTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamParticipantTileTheme oldWidget) => + data != oldWidget.data; +} + +/// Theme data for customizing [StreamParticipantTile] widgets. +/// +/// Wraps a [StreamParticipantTileStyle] so it can be served by +/// [StreamParticipantTileTheme] and slotted into [StreamVideoTheme] alongside +/// the other component theme data classes. +/// +/// See also: +/// +/// * [StreamParticipantTileStyle], the style embedded here. +/// * [StreamParticipantTileTheme], for overriding it in a subtree. +@themeGen +@immutable +class StreamParticipantTileThemeData with _$StreamParticipantTileThemeData { + /// Creates participant tile theme data. + const StreamParticipantTileThemeData({this.style}); + + /// Visual styling for the tile. + final StreamParticipantTileStyle? style; + + /// Linearly interpolate between two theme data objects. + static StreamParticipantTileThemeData? lerp( + StreamParticipantTileThemeData? a, + StreamParticipantTileThemeData? b, + double t, + ) => _$StreamParticipantTileThemeData.lerp(a, b, t); +} + +/// Visual styling properties for a [StreamParticipantTile]. +/// +/// A tile is a rounded surface holding a participant's video, a top toolbar +/// carrying the overflow button and any live reaction, and a bottom toolbar +/// carrying the name pill and the connection quality indicator. +/// +/// The nested [placeholderStyle], [labelStyle] and +/// [connectionQualityIndicatorStyle] are handed to their components through +/// scoped themes, so a component supplied through the component factory picks +/// up the tile's styling without having to thread it manually. +/// +/// Exposed separately from [StreamParticipantTileThemeData] so other theme data +/// classes can embed a tile style via a typed field — see +/// [StreamFloatingParticipantTileStyle.tileStyle]. +@themeGen +@immutable +class StreamParticipantTileStyle with _$StreamParticipantTileStyle { + /// Creates a tile style with optional property overrides. + const StreamParticipantTileStyle({ + this.videoFit, + this.backgroundColor, + this.borderRadius, + this.border, + this.speakingBorder, + this.showSpeakerBorder, + this.showParticipantLabel, + this.showConnectionQualityIndicator, + this.showMoreButton, + this.showReaction, + this.toolbarPadding, + this.toolbarSpacing, + this.topToolbarPadding, + this.moreButtonStyle, + this.reactionSize, + this.reactionInset, + this.placeholderStyle, + this.labelStyle, + this.connectionQualityIndicatorStyle, + }); + + /// How the participant's video fills the tile. + /// + /// Defaults to [VideoFit.adaptive] on web and desktop, [VideoFit.cover] on + /// mobile. + final VideoFit? videoFit; + + /// The fill behind the video. + /// + /// Visible while the participant's camera is off. Defaults to + /// `colorScheme.backgroundSurfaceSubtle`. + final Color? backgroundColor; + + /// The corner radius of the tile. + /// + /// Defaults to `radius.xxl`. + final BorderRadius? borderRadius; + + /// The border drawn around a tile that is showing no video. + /// + /// A tile showing video needs no outline — the video itself defines the + /// edge — so this is only painted while the placeholder is visible. Defaults + /// to a hairline in `colorScheme.borderDefault`. + final BoxBorder? border; + + /// The border drawn around the tile while the participant is speaking. + /// + /// Replaces [border] rather than stacking with it, and is suppressed entirely + /// when [showSpeakerBorder] resolves to false. Defaults to a 2px outline in + /// `colorScheme.accentPrimary`. + final BoxBorder? speakingBorder; + + /// Whether [speakingBorder] is drawn while the participant is speaking. + /// + /// Defaults to true. + final bool? showSpeakerBorder; + + /// Whether the name pill is shown in the bottom toolbar. + /// + /// Defaults to true. + final bool? showParticipantLabel; + + /// Whether the connection quality indicator is shown in the bottom toolbar. + /// + /// Defaults to true. + final bool? showConnectionQualityIndicator; + + /// Whether the overflow button is shown in the top toolbar. + /// + /// Has no effect on a tile with no actions: the button is hidden whenever the + /// resolved action list is empty. Defaults to true. + final bool? showMoreButton; + + /// Whether the participant's live reaction is shown in the top toolbar. + /// + /// Defaults to true. + final bool? showReaction; + + /// The inset around the bottom toolbar's content. + /// + /// Defaults to `spacing.sm` on every side. The toolbar takes its height from + /// its content, so this also sets how far the pill sits from the tile edge. + final EdgeInsetsGeometry? toolbarPadding; + + /// The gap between the name pill and the connection quality indicator. + /// + /// Defaults to `spacing.sm`. Also the minimum distance a long name is kept + /// from the indicator before it ellipsizes. + final double? toolbarSpacing; + + /// The inset around the top toolbar's content. + /// + /// Defaults to `spacing.xxs`, which lets the overflow button's tap target + /// reach close to the tile corner while its visual stays inset. + final EdgeInsetsGeometry? topToolbarPadding; + + /// The button style applied to the overflow button. + /// + /// Applied through a scoped [StreamButtonTheme], so a [StreamButton] supplied + /// by a custom component builder picks it up too. + final StreamButtonThemeStyle? moreButtonStyle; + + /// The font size of the reaction emoji. + /// + /// Defaults to 48. + final double? reactionSize; + + /// The inset of the reaction from the tile's top and trailing edges. + /// + /// Measured from the tile edge rather than from [topToolbarPadding]. + /// Defaults to `spacing.sm`. + final double? reactionInset; + + /// Styling for the widget shown while the participant's camera is off. + final StreamParticipantPlaceholderStyle? placeholderStyle; + + /// Styling for the participant name pill. + final StreamParticipantLabelStyle? labelStyle; + + /// Styling for the connection quality indicator. + final StreamConnectionQualityIndicatorStyle? connectionQualityIndicatorStyle; + + /// Linearly interpolate between two styles. + static StreamParticipantTileStyle? lerp( + StreamParticipantTileStyle? a, + StreamParticipantTileStyle? b, + double t, + ) => _$StreamParticipantTileStyle.lerp(a, b, t); +} + +/// Visual styling for the widget shown while a participant's camera is off. +/// +/// The placeholder centers the participant's avatar over the tile's background. +@themeGen +@immutable +class StreamParticipantPlaceholderStyle + with _$StreamParticipantPlaceholderStyle { + /// Creates a placeholder style with optional property overrides. + const StreamParticipantPlaceholderStyle({this.avatarTheme}); + + /// Configuration for the avatar at the center of the placeholder. + /// + /// Handed down through a scoped [StreamAvatarTheme], so it also reaches an + /// avatar supplied through the component factory. Defaults to + /// [StreamAvatarSize.xxl] with a 2px `colorScheme.borderOnInverse` ring. + final StreamAvatarThemeData? avatarTheme; + + /// Linearly interpolate between two styles. + static StreamParticipantPlaceholderStyle? lerp( + StreamParticipantPlaceholderStyle? a, + StreamParticipantPlaceholderStyle? b, + double t, + ) => _$StreamParticipantPlaceholderStyle.lerp(a, b, t); +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/participant_tile_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/participant_tile_theme.g.theme.dart new file mode 100644 index 000000000..7fcce1d95 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/participant_tile_theme.g.theme.dart @@ -0,0 +1,386 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'participant_tile_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamParticipantTileThemeData { + bool get canMerge => true; + + static StreamParticipantTileThemeData? lerp( + StreamParticipantTileThemeData? a, + StreamParticipantTileThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamParticipantTileThemeData( + style: StreamParticipantTileStyle.lerp(a.style, b.style, t), + ); + } + + StreamParticipantTileThemeData copyWith({StreamParticipantTileStyle? style}) { + final _this = (this as StreamParticipantTileThemeData); + + return StreamParticipantTileThemeData(style: style ?? _this.style); + } + + StreamParticipantTileThemeData merge(StreamParticipantTileThemeData? other) { + final _this = (this as StreamParticipantTileThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamParticipantTileThemeData); + final _other = (other as StreamParticipantTileThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamParticipantTileThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamParticipantTileStyle { + bool get canMerge => true; + + static StreamParticipantTileStyle? lerp( + StreamParticipantTileStyle? a, + StreamParticipantTileStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamParticipantTileStyle( + videoFit: t < 0.5 ? a.videoFit : b.videoFit, + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t), + border: BoxBorder.lerp(a.border, b.border, t), + speakingBorder: BoxBorder.lerp(a.speakingBorder, b.speakingBorder, t), + showSpeakerBorder: t < 0.5 ? a.showSpeakerBorder : b.showSpeakerBorder, + showParticipantLabel: t < 0.5 + ? a.showParticipantLabel + : b.showParticipantLabel, + showConnectionQualityIndicator: t < 0.5 + ? a.showConnectionQualityIndicator + : b.showConnectionQualityIndicator, + showMoreButton: t < 0.5 ? a.showMoreButton : b.showMoreButton, + showReaction: t < 0.5 ? a.showReaction : b.showReaction, + toolbarPadding: EdgeInsetsGeometry.lerp( + a.toolbarPadding, + b.toolbarPadding, + t, + ), + toolbarSpacing: lerpDouble$(a.toolbarSpacing, b.toolbarSpacing, t), + topToolbarPadding: EdgeInsetsGeometry.lerp( + a.topToolbarPadding, + b.topToolbarPadding, + t, + ), + moreButtonStyle: StreamButtonThemeStyle.lerp( + a.moreButtonStyle, + b.moreButtonStyle, + t, + ), + reactionSize: lerpDouble$(a.reactionSize, b.reactionSize, t), + reactionInset: lerpDouble$(a.reactionInset, b.reactionInset, t), + placeholderStyle: StreamParticipantPlaceholderStyle.lerp( + a.placeholderStyle, + b.placeholderStyle, + t, + ), + labelStyle: StreamParticipantLabelStyle.lerp( + a.labelStyle, + b.labelStyle, + t, + ), + connectionQualityIndicatorStyle: + StreamConnectionQualityIndicatorStyle.lerp( + a.connectionQualityIndicatorStyle, + b.connectionQualityIndicatorStyle, + t, + ), + ); + } + + StreamParticipantTileStyle copyWith({ + VideoFit? videoFit, + Color? backgroundColor, + BorderRadius? borderRadius, + BoxBorder? border, + BoxBorder? speakingBorder, + bool? showSpeakerBorder, + bool? showParticipantLabel, + bool? showConnectionQualityIndicator, + bool? showMoreButton, + bool? showReaction, + EdgeInsetsGeometry? toolbarPadding, + double? toolbarSpacing, + EdgeInsetsGeometry? topToolbarPadding, + StreamButtonThemeStyle? moreButtonStyle, + double? reactionSize, + double? reactionInset, + StreamParticipantPlaceholderStyle? placeholderStyle, + StreamParticipantLabelStyle? labelStyle, + StreamConnectionQualityIndicatorStyle? connectionQualityIndicatorStyle, + }) { + final _this = (this as StreamParticipantTileStyle); + + return StreamParticipantTileStyle( + videoFit: videoFit ?? _this.videoFit, + backgroundColor: backgroundColor ?? _this.backgroundColor, + borderRadius: borderRadius ?? _this.borderRadius, + border: border ?? _this.border, + speakingBorder: speakingBorder ?? _this.speakingBorder, + showSpeakerBorder: showSpeakerBorder ?? _this.showSpeakerBorder, + showParticipantLabel: showParticipantLabel ?? _this.showParticipantLabel, + showConnectionQualityIndicator: + showConnectionQualityIndicator ?? + _this.showConnectionQualityIndicator, + showMoreButton: showMoreButton ?? _this.showMoreButton, + showReaction: showReaction ?? _this.showReaction, + toolbarPadding: toolbarPadding ?? _this.toolbarPadding, + toolbarSpacing: toolbarSpacing ?? _this.toolbarSpacing, + topToolbarPadding: topToolbarPadding ?? _this.topToolbarPadding, + moreButtonStyle: moreButtonStyle ?? _this.moreButtonStyle, + reactionSize: reactionSize ?? _this.reactionSize, + reactionInset: reactionInset ?? _this.reactionInset, + placeholderStyle: placeholderStyle ?? _this.placeholderStyle, + labelStyle: labelStyle ?? _this.labelStyle, + connectionQualityIndicatorStyle: + connectionQualityIndicatorStyle ?? + _this.connectionQualityIndicatorStyle, + ); + } + + StreamParticipantTileStyle merge(StreamParticipantTileStyle? other) { + final _this = (this as StreamParticipantTileStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + videoFit: other.videoFit, + backgroundColor: other.backgroundColor, + borderRadius: other.borderRadius, + border: other.border, + speakingBorder: other.speakingBorder, + showSpeakerBorder: other.showSpeakerBorder, + showParticipantLabel: other.showParticipantLabel, + showConnectionQualityIndicator: other.showConnectionQualityIndicator, + showMoreButton: other.showMoreButton, + showReaction: other.showReaction, + toolbarPadding: other.toolbarPadding, + toolbarSpacing: other.toolbarSpacing, + topToolbarPadding: other.topToolbarPadding, + moreButtonStyle: + _this.moreButtonStyle?.merge(other.moreButtonStyle) ?? + other.moreButtonStyle, + reactionSize: other.reactionSize, + reactionInset: other.reactionInset, + placeholderStyle: + _this.placeholderStyle?.merge(other.placeholderStyle) ?? + other.placeholderStyle, + labelStyle: _this.labelStyle?.merge(other.labelStyle) ?? other.labelStyle, + connectionQualityIndicatorStyle: + _this.connectionQualityIndicatorStyle?.merge( + other.connectionQualityIndicatorStyle, + ) ?? + other.connectionQualityIndicatorStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamParticipantTileStyle); + final _other = (other as StreamParticipantTileStyle); + + return _other.videoFit == _this.videoFit && + _other.backgroundColor == _this.backgroundColor && + _other.borderRadius == _this.borderRadius && + _other.border == _this.border && + _other.speakingBorder == _this.speakingBorder && + _other.showSpeakerBorder == _this.showSpeakerBorder && + _other.showParticipantLabel == _this.showParticipantLabel && + _other.showConnectionQualityIndicator == + _this.showConnectionQualityIndicator && + _other.showMoreButton == _this.showMoreButton && + _other.showReaction == _this.showReaction && + _other.toolbarPadding == _this.toolbarPadding && + _other.toolbarSpacing == _this.toolbarSpacing && + _other.topToolbarPadding == _this.topToolbarPadding && + _other.moreButtonStyle == _this.moreButtonStyle && + _other.reactionSize == _this.reactionSize && + _other.reactionInset == _this.reactionInset && + _other.placeholderStyle == _this.placeholderStyle && + _other.labelStyle == _this.labelStyle && + _other.connectionQualityIndicatorStyle == + _this.connectionQualityIndicatorStyle; + } + + @override + int get hashCode { + final _this = (this as StreamParticipantTileStyle); + + return Object.hash( + runtimeType, + _this.videoFit, + _this.backgroundColor, + _this.borderRadius, + _this.border, + _this.speakingBorder, + _this.showSpeakerBorder, + _this.showParticipantLabel, + _this.showConnectionQualityIndicator, + _this.showMoreButton, + _this.showReaction, + _this.toolbarPadding, + _this.toolbarSpacing, + _this.topToolbarPadding, + _this.moreButtonStyle, + _this.reactionSize, + _this.reactionInset, + _this.placeholderStyle, + _this.labelStyle, + _this.connectionQualityIndicatorStyle, + ); + } +} + +mixin _$StreamParticipantPlaceholderStyle { + bool get canMerge => true; + + static StreamParticipantPlaceholderStyle? lerp( + StreamParticipantPlaceholderStyle? a, + StreamParticipantPlaceholderStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamParticipantPlaceholderStyle( + avatarTheme: StreamAvatarThemeData.lerp(a.avatarTheme, b.avatarTheme, t), + ); + } + + StreamParticipantPlaceholderStyle copyWith({ + StreamAvatarThemeData? avatarTheme, + }) { + final _this = (this as StreamParticipantPlaceholderStyle); + + return StreamParticipantPlaceholderStyle( + avatarTheme: avatarTheme ?? _this.avatarTheme, + ); + } + + StreamParticipantPlaceholderStyle merge( + StreamParticipantPlaceholderStyle? other, + ) { + final _this = (this as StreamParticipantPlaceholderStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + avatarTheme: + _this.avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamParticipantPlaceholderStyle); + final _other = (other as StreamParticipantPlaceholderStyle); + + return _other.avatarTheme == _this.avatarTheme; + } + + @override + int get hashCode { + final _this = (this as StreamParticipantPlaceholderStyle); + + return Object.hash(runtimeType, _this.avatarTheme); + } +} diff --git a/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart b/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart index 37fdd0d4e..40c454d90 100644 --- a/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart +++ b/packages/stream_video_flutter/lib/src/theme/stream_video_theme.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart' hide TextTheme; -import '../utils/device_segmentation.dart'; import '../widgets/floating_view/floating_view_alignment.dart'; import 'themes.dart'; @@ -19,10 +18,22 @@ class StreamVideoTheme extends ThemeExtension { StreamCallControlsThemeData? callControlsTheme, StreamUserAvatarThemeData? userAvatarTheme, StreamLobbyViewThemeData? lobbyViewTheme, + @Deprecated( + 'Use participantTileTheme, participantLabelTheme, ' + 'connectionQualityIndicatorTheme and callParticipantsGridTheme instead. ' + 'A theme set here is still applied in full, which also means the tile ' + 'keeps its pre-redesign styling; stop setting it to pick up the new ' + 'design. Will be removed in the next major version.', + ) StreamCallParticipantThemeData? callParticipantTheme, StreamLocalVideoThemeData? localVideoTheme, StreamIncomingOutgoingCallThemeData? incomingCallTheme, StreamIncomingOutgoingCallThemeData? outgoingCallTheme, + StreamParticipantTileThemeData? participantTileTheme, + StreamFloatingParticipantTileThemeData? floatingParticipantTileTheme, + StreamParticipantLabelThemeData? participantLabelTheme, + StreamConnectionQualityIndicatorThemeData? connectionQualityIndicatorTheme, + StreamCallParticipantsGridThemeData? callParticipantsGridTheme, StreamLivestreamThemeData? livestreamTheme, }) { final isDark = brightness == Brightness.dark; @@ -38,6 +49,13 @@ class StreamVideoTheme extends ThemeExtension { textTheme, ); + // A legacy participant theme is translated into the component themes it was + // split into, so code written against either shape keeps working. A theme + // given in the new shape replaces it outright rather than merging: the two + // describe the same tile, and blending them would produce a look neither + // one asked for. + final legacy = callParticipantTheme; + final customizedTheme = defaultTheme.copyWith( textTheme: textTheme, colorTheme: colorTheme, @@ -49,6 +67,17 @@ class StreamVideoTheme extends ThemeExtension { localVideoTheme: localVideoTheme, incomingCallTheme: incomingCallTheme, outgoingCallTheme: outgoingCallTheme, + participantTileTheme: + participantTileTheme ?? legacy?.toParticipantTileThemeData(), + floatingParticipantTileTheme: floatingParticipantTileTheme, + participantLabelTheme: + participantLabelTheme ?? legacy?.toParticipantLabelThemeData(), + connectionQualityIndicatorTheme: + connectionQualityIndicatorTheme ?? + legacy?.toConnectionQualityIndicatorThemeData(), + callParticipantsGridTheme: + callParticipantsGridTheme ?? + legacy?.toCallParticipantsGridThemeData(), livestreamTheme: livestreamTheme, ); @@ -70,11 +99,24 @@ class StreamVideoTheme extends ThemeExtension { required this.callControlsTheme, required this.userAvatarTheme, required this.lobbyViewTheme, - required this.callParticipantTheme, + @Deprecated( + 'Use participantTileTheme, participantLabelTheme, ' + 'connectionQualityIndicatorTheme and callParticipantsGridTheme instead. ' + 'Will be removed in the next major version.', + ) + this.callParticipantTheme, required this.localVideoTheme, required this.incomingCallTheme, required this.callContentTheme, required this.outgoingCallTheme, + this.participantTileTheme = const StreamParticipantTileThemeData(), + this.floatingParticipantTileTheme = + const StreamFloatingParticipantTileThemeData(), + this.participantLabelTheme = const StreamParticipantLabelThemeData(), + this.connectionQualityIndicatorTheme = + const StreamConnectionQualityIndicatorThemeData(), + this.callParticipantsGridTheme = + const StreamCallParticipantsGridThemeData(), required this.livestreamTheme, }); @@ -146,34 +188,6 @@ class StreamVideoTheme extends ThemeExtension { selectionThickness: 4, ), ), - callParticipantTheme: StreamCallParticipantThemeData( - showSpeakerBorder: true, - borderRadius: isDesktopDevice - ? const BorderRadius.all(Radius.circular(12)) - : BorderRadius.zero, - speakerBorderColor: colorTheme.accentPrimary, - speakerBorderThickness: 4, - backgroundColor: colorTheme.disabled, - userAvatarTheme: StreamUserAvatarThemeData( - constraints: const BoxConstraints.tightFor( - height: 100, - width: 100, - ), - borderRadius: const BorderRadius.all(Radius.circular(50)), - initialsTextStyle: textTheme.title1.copyWith(color: Colors.white), - selectionColor: colorTheme.accentPrimary, - selectionThickness: 4, - ), - audioLevelIndicatorColor: colorTheme.accentPrimary, - participantLabelTextStyle: textTheme.footnote.copyWith( - color: Colors.white, - ), - disabledMicrophoneColor: colorTheme.accentError, - pausedVideoIndicatorColor: Colors.white, - enabledMicrophoneColor: Colors.white, - connectionLevelActiveColor: colorTheme.accentPrimary, - connectionLevelInactiveColor: Colors.white, - ), localVideoTheme: const StreamLocalVideoThemeData( localVideoHeight: 150, localVideoWidth: 125, @@ -350,7 +364,16 @@ class StreamVideoTheme extends ThemeExtension { final StreamLobbyViewThemeData lobbyViewTheme; /// Theme for the call participant widget. - final StreamCallParticipantThemeData callParticipantTheme; + /// + /// `null` unless an app sets one: the participant tile takes its defaults + /// from the widgets themselves now, so a populated value here means somebody + /// asked for the deprecated shape. + @Deprecated( + 'Use participantTileTheme, participantLabelTheme, ' + 'connectionQualityIndicatorTheme and callParticipantsGridTheme instead. ' + 'Will be removed in the next major version.', + ) + final StreamCallParticipantThemeData? callParticipantTheme; /// Theme for the local video widget. final StreamLocalVideoThemeData localVideoTheme; @@ -364,6 +387,22 @@ class StreamVideoTheme extends ThemeExtension { /// Theme for the outgoing call widget. final StreamIncomingOutgoingCallThemeData outgoingCallTheme; + /// Theme for the participant tile. + final StreamParticipantTileThemeData participantTileTheme; + + /// Theme for the floating self-view. + final StreamFloatingParticipantTileThemeData floatingParticipantTileTheme; + + /// Theme for the participant tile's name pill. + final StreamParticipantLabelThemeData participantLabelTheme; + + /// Theme for the connection quality indicator. + final StreamConnectionQualityIndicatorThemeData + connectionQualityIndicatorTheme; + + /// Theme for the participants grid layout. + final StreamCallParticipantsGridThemeData callParticipantsGridTheme; + /// Theme for the outgoing call widget. final StreamLivestreamThemeData livestreamTheme; @@ -381,6 +420,11 @@ class StreamVideoTheme extends ThemeExtension { StreamIncomingOutgoingCallThemeData? incomingCallTheme, StreamCallContentThemeData? callContentTheme, StreamIncomingOutgoingCallThemeData? outgoingCallTheme, + StreamParticipantTileThemeData? participantTileTheme, + StreamFloatingParticipantTileThemeData? floatingParticipantTileTheme, + StreamParticipantLabelThemeData? participantLabelTheme, + StreamConnectionQualityIndicatorThemeData? connectionQualityIndicatorTheme, + StreamCallParticipantsGridThemeData? callParticipantsGridTheme, StreamLivestreamThemeData? livestreamTheme, }) => StreamVideoTheme.raw( textTheme: this.textTheme.merge(textTheme), @@ -388,11 +432,26 @@ class StreamVideoTheme extends ThemeExtension { callControlsTheme: this.callControlsTheme.merge(callControlsTheme), userAvatarTheme: this.userAvatarTheme.merge(userAvatarTheme), lobbyViewTheme: this.lobbyViewTheme.merge(lobbyViewTheme), - callParticipantTheme: this.callParticipantTheme.merge(callParticipantTheme), + callParticipantTheme: + this.callParticipantTheme?.merge(callParticipantTheme) ?? + callParticipantTheme, localVideoTheme: this.localVideoTheme.merge(localVideoTheme), incomingCallTheme: this.incomingCallTheme.merge(incomingCallTheme), callContentTheme: this.callContentTheme.merge(callContentTheme), outgoingCallTheme: this.outgoingCallTheme.merge(outgoingCallTheme), + participantTileTheme: this.participantTileTheme.merge(participantTileTheme), + floatingParticipantTileTheme: this.floatingParticipantTileTheme.merge( + floatingParticipantTileTheme, + ), + participantLabelTheme: this.participantLabelTheme.merge( + participantLabelTheme, + ), + connectionQualityIndicatorTheme: this.connectionQualityIndicatorTheme.merge( + connectionQualityIndicatorTheme, + ), + callParticipantsGridTheme: this.callParticipantsGridTheme.merge( + callParticipantsGridTheme, + ), livestreamTheme: this.livestreamTheme.merge(livestreamTheme), ); @@ -402,15 +461,31 @@ class StreamVideoTheme extends ThemeExtension { return copyWith( textTheme: textTheme.merge(other.textTheme), colorTheme: colorTheme.merge(other.colorTheme), - callControlsTheme: callControlsTheme.merge(callControlsTheme), + callControlsTheme: callControlsTheme.merge(other.callControlsTheme), userAvatarTheme: userAvatarTheme.merge(other.userAvatarTheme), lobbyViewTheme: lobbyViewTheme.merge(other.lobbyViewTheme), - callParticipantTheme: callParticipantTheme.merge( - other.callParticipantTheme, - ), + callParticipantTheme: + callParticipantTheme?.merge(other.callParticipantTheme) ?? + other.callParticipantTheme, + localVideoTheme: localVideoTheme.merge(other.localVideoTheme), incomingCallTheme: incomingCallTheme.merge(other.incomingCallTheme), callContentTheme: callContentTheme.merge(other.callContentTheme), outgoingCallTheme: outgoingCallTheme.merge(other.outgoingCallTheme), + participantTileTheme: participantTileTheme.merge( + other.participantTileTheme, + ), + floatingParticipantTileTheme: floatingParticipantTileTheme.merge( + other.floatingParticipantTileTheme, + ), + participantLabelTheme: participantLabelTheme.merge( + other.participantLabelTheme, + ), + connectionQualityIndicatorTheme: connectionQualityIndicatorTheme.merge( + other.connectionQualityIndicatorTheme, + ), + callParticipantsGridTheme: callParticipantsGridTheme.merge( + other.callParticipantsGridTheme, + ), livestreamTheme: livestreamTheme.merge(other.livestreamTheme), ); } @@ -428,15 +503,50 @@ class StreamVideoTheme extends ThemeExtension { colorTheme: colorTheme.lerp(other.colorTheme, t), userAvatarTheme: userAvatarTheme.lerp(other.userAvatarTheme, t), lobbyViewTheme: lobbyViewTheme.lerp(other.lobbyViewTheme, t), - callParticipantTheme: callParticipantTheme.lerp( - other.callParticipantTheme, - t, - ), + callParticipantTheme: + callParticipantTheme != null && other.callParticipantTheme != null + ? callParticipantTheme!.lerp(other.callParticipantTheme!, t) + : (t < 0.5 ? callParticipantTheme : other.callParticipantTheme), localVideoTheme: localVideoTheme.lerp(other.localVideoTheme, t), callControlsTheme: callControlsTheme.lerp(other.callControlsTheme, t), incomingCallTheme: incomingCallTheme.lerp(other.incomingCallTheme, t), callContentTheme: callContentTheme.lerp(other.callContentTheme, t), outgoingCallTheme: outgoingCallTheme.lerp(other.outgoingCallTheme, t), + participantTileTheme: + StreamParticipantTileThemeData.lerp( + participantTileTheme, + other.participantTileTheme, + t, + ) ?? + participantTileTheme, + floatingParticipantTileTheme: + StreamFloatingParticipantTileThemeData.lerp( + floatingParticipantTileTheme, + other.floatingParticipantTileTheme, + t, + ) ?? + floatingParticipantTileTheme, + participantLabelTheme: + StreamParticipantLabelThemeData.lerp( + participantLabelTheme, + other.participantLabelTheme, + t, + ) ?? + participantLabelTheme, + connectionQualityIndicatorTheme: + StreamConnectionQualityIndicatorThemeData.lerp( + connectionQualityIndicatorTheme, + other.connectionQualityIndicatorTheme, + t, + ) ?? + connectionQualityIndicatorTheme, + callParticipantsGridTheme: + StreamCallParticipantsGridThemeData.lerp( + callParticipantsGridTheme, + other.callParticipantsGridTheme, + t, + ) ?? + callParticipantsGridTheme, livestreamTheme: livestreamTheme.lerp(other.livestreamTheme, t), ); } diff --git a/packages/stream_video_flutter/lib/src/theme/themes.dart b/packages/stream_video_flutter/lib/src/theme/themes.dart index 1bb1e0188..37b1f112d 100644 --- a/packages/stream_video_flutter/lib/src/theme/themes.dart +++ b/packages/stream_video_flutter/lib/src/theme/themes.dart @@ -1,6 +1,7 @@ export 'call_content_theme.dart'; export 'call_controls_theme.dart'; export 'call_participant_theme.dart'; +export 'components/components.dart'; export 'incoming_outgoing_call_theme.dart'; export 'lobby_view_theme.dart'; export 'local_video_theme.dart'; diff --git a/packages/stream_video_flutter/lib/src/widgets/avatar_size_from_constraints.dart b/packages/stream_video_flutter/lib/src/widgets/avatar_size_from_constraints.dart new file mode 100644 index 000000000..679e88af6 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/widgets/avatar_size_from_constraints.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../stream_video_flutter.dart'; + +/// The design-system avatar diameter that covers [constraints]. +/// +/// The deprecated themes size an avatar with box constraints; the design system +/// has a fixed set of diameters. Round up to the first one that fits, so an +/// avatar never comes out smaller than it was asked to be. +/// +/// Constraints wider than the largest diameter land on that one instead: there +/// is nothing bigger to round up to, so a legacy theme asking for 100px gets +/// [StreamAvatarSize.xxl] at 80px. +@internal +StreamAvatarSize avatarSizeFromConstraints(BoxConstraints constraints) { + final diameter = constraints.constrain(Size.infinite).shortestSide; + return StreamAvatarSize.values.firstWhere( + (it) => it.value >= diameter, + orElse: () => StreamAvatarSize.xxl, + ); +} diff --git a/packages/stream_video_flutter/lib/src/widgets/stream_user_avatar.dart b/packages/stream_video_flutter/lib/src/widgets/stream_user_avatar.dart index 02b9c9e9a..b9d2f19a6 100644 --- a/packages/stream_video_flutter/lib/src/widgets/stream_user_avatar.dart +++ b/packages/stream_video_flutter/lib/src/widgets/stream_user_avatar.dart @@ -1,38 +1,8 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../stream_video_flutter.dart'; import '../utils/extensions.dart'; - -/// Builder function used to build an image widget for the user avatar. -typedef ImageWidgetBuilder = - Widget Function( - BuildContext context, - UserInfo user, - ImageProvider imageProvider, - ); - -/// Builder function used to build a placeholder widget. -typedef PlaceholderWidgetBuilder = - Widget Function( - BuildContext context, - UserInfo user, - ); - -/// Builder function used to build an error widget. -typedef ErrorWidgetBuilder = - Widget Function( - BuildContext context, - UserInfo user, - Object error, - ); - -/// Builder function used to build a widget with the user initials. -typedef FallbackWidgetBuilder = - Widget Function( - BuildContext context, - UserInfo user, - ); +import 'avatar_size_from_constraints.dart'; /// The action to perform when the user avatar is tapped. typedef OnUserAvatarTap = void Function(UserInfo); @@ -41,231 +11,127 @@ typedef OnUserAvatarTap = void Function(UserInfo); typedef OnUserAvatarLongPress = void Function(UserInfo); /// Displays a user's avatar. +/// +/// Every avatar in the SDK goes through here, so registering a `userAvatar` +/// builder with [streamVideoComponentBuilders] on a [StreamComponentFactory] +/// changes all of them at once — in a participant tile, the lobby, the +/// participants list and the incoming and outgoing call screens. The builder +/// receives the whole [UserInfo], so an avatar can be drawn from fields the SDK +/// itself never reads: a team badge, a role ring, an identicon derived from +/// `extraData`. +/// +/// When no builder is registered, [DefaultStreamUserAvatar] is used. +/// +/// See also: +/// +/// * [StreamAvatarTheme], for customizing its size, colors and border. class StreamUserAvatar extends StatelessWidget { /// Creates a new instance of [StreamUserAvatar]. - const StreamUserAvatar({ + StreamUserAvatar({ super.key, + required UserInfo user, + OnUserAvatarTap? onTap, + OnUserAvatarLongPress? onLongPress, + }) : props = .new(user: user, onTap: onTap, onLongPress: onLongPress); + + /// The properties that configure this avatar. + final StreamUserAvatarProps props; + + @override + Widget build(BuildContext context) { + final builder = context.videoComponentBuilder(); + return builder?.call(context, props) ?? + DefaultStreamUserAvatar(props: props); + } +} + +/// Properties for configuring a [StreamUserAvatar]. +/// +/// See also: +/// +/// * [StreamUserAvatar], which uses these properties. +/// * [DefaultStreamUserAvatar], the default implementation. +@immutable +class StreamUserAvatarProps { + /// Creates properties for a user avatar. + const StreamUserAvatarProps({ required this.user, - this.selected = false, this.onTap, this.onLongPress, - this.imageBuilder, - this.placeholderBuilder, - this.errorBuilder, - this.fallbackBuilder, - this.constraints, - this.borderRadius, - this.initialsTextStyle, - this.initialsBackground, - this.selectionColor, - this.selectionThickness, }); - /// User whose avatar is to be displayed. + /// The user whose avatar is displayed. + /// + /// The whole record rather than just an image URL, so a replacement can draw + /// from anything the user carries. final UserInfo user; - /// Flag for if avatar is selected. Defaults to `false`. - final bool selected; - - /// The action to perform when the user avatar is tapped. + /// Called when the avatar is tapped. final OnUserAvatarTap? onTap; - /// The action to perform when the user avatar is long-pressed. + /// Called when the avatar is long-pressed. final OnUserAvatarLongPress? onLongPress; - /// Builder function used to build an image widget for the user avatar. - final ImageWidgetBuilder? imageBuilder; - - /// Builder function used to build a placeholder widget. - final PlaceholderWidgetBuilder? placeholderBuilder; - - /// Builder function used to build an error widget. - final ErrorWidgetBuilder? errorBuilder; - - /// Builder function used to build a widget with the user initials. - final FallbackWidgetBuilder? fallbackBuilder; - - /// Sizing constraints of the avatar. - final BoxConstraints? constraints; - - /// [BorderRadius] of the image. - final BorderRadius? borderRadius; - - /// [TextStyle] for the initials text. - final TextStyle? initialsTextStyle; - - /// Background color for the initials. - final Color? initialsBackground; + /// Creates a copy of these properties with the given fields replaced. + StreamUserAvatarProps copyWith({ + UserInfo? user, + OnUserAvatarTap? onTap, + OnUserAvatarLongPress? onLongPress, + }) { + return StreamUserAvatarProps( + user: user ?? this.user, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + ); + } +} - /// Color of the selection. - final Color? selectionColor; +/// The default implementation of [StreamUserAvatar]. +/// +/// The design system's [StreamAvatar] showing the user's picture, falling back +/// to their initials. +class DefaultStreamUserAvatar extends StatelessWidget { + /// Creates the default user avatar. + const DefaultStreamUserAvatar({super.key, required this.props}); - /// Selection thickness around the avatar. - final double? selectionThickness; + /// The properties that configure this avatar. + final StreamUserAvatarProps props; @override Widget build(BuildContext context) { + final user = props.user; final imageUrl = user.image; - final hasImage = imageUrl != null && imageUrl.isNotEmpty; - - final theme = StreamUserAvatarTheme.of(context); - final constraints = this.constraints ?? theme.constraints; - final borderRadius = this.borderRadius ?? theme.borderRadius; - final initialsTextStyle = this.initialsTextStyle ?? theme.initialsTextStyle; - final initialsBackground = - this.initialsBackground ?? theme.initialsBackground; - final selectionColor = this.selectionColor ?? theme.selectionColor; - final selectionThickness = - this.selectionThickness ?? theme.selectionThickness; - Widget avatar = FittedBox( - fit: BoxFit.cover, - child: Container( - constraints: constraints, - child: hasImage - ? CachedNetworkImage( - fit: BoxFit.cover, - filterQuality: FilterQuality.high, - imageUrl: imageUrl, - errorWidget: (context, __, error) => errorBuilder != null - ? errorBuilder!(context, user, error) - : _InitialsUserAvatar( - user: user, - borderRadius: borderRadius, - initialsTextStyle: initialsTextStyle, - ), - placeholder: placeholderBuilder != null - ? (context, __) => placeholderBuilder!(context, user) - : null, - imageBuilder: (context, imageProvider) => imageBuilder != null - ? imageBuilder!(context, user, imageProvider) - : _ImageUserAvatar( - imageProvider: imageProvider, - borderRadius: borderRadius, - ), - ) - : fallbackBuilder != null - ? fallbackBuilder!(context, user) - : _InitialsUserAvatar( - user: user, - borderRadius: borderRadius, - initialsTextStyle: initialsTextStyle, - initialsBackground: initialsBackground, - ), - ), + // A scoped StreamAvatarTheme wins; the deprecated StreamUserAvatarTheme is + // read underneath it so the screens still wrapping avatars in one keep + // their sizing and colors. + final theme = StreamAvatarTheme.of(context); + final legacy = StreamUserAvatarTheme.of(context); + + final avatar = StreamAvatar( + imageUrl: imageUrl != null && imageUrl.isNotEmpty ? imageUrl : null, + size: theme.size ?? avatarSizeFromConstraints(legacy.constraints), + backgroundColor: theme.backgroundColor ?? legacy.initialsBackground, + foregroundColor: theme.foregroundColor ?? legacy.initialsTextStyle.color, + semanticsLabel: user.name.isNotEmpty ? user.name : user.id, + placeholder: (context) => Text(_initialsFor(user)), ); - if (selected) { - avatar = ClipRRect( - borderRadius: borderRadius + BorderRadius.circular(selectionThickness), - child: Container( - constraints: constraints, - color: selectionColor, - child: Padding( - padding: EdgeInsets.all(selectionThickness), - child: avatar, - ), - ), - ); - } + final onTap = props.onTap; + final onLongPress = props.onLongPress; + if (onTap == null && onLongPress == null) return avatar; + return GestureDetector( - onTap: onTap != null ? () => onTap!(user) : null, - onLongPress: onLongPress != null ? () => onLongPress!(user) : null, + onTap: onTap != null ? () => onTap(user) : null, + onLongPress: onLongPress != null ? () => onLongPress(user) : null, child: avatar, ); } -} - -/// Displays an avatar with the user picture. -class _ImageUserAvatar extends StatelessWidget { - /// Creates a new instance of [_ImageUserAvatar]. - const _ImageUserAvatar({ - required this.imageProvider, - required this.borderRadius, - }); - - /// The image to be painted into the decoration. - final ImageProvider imageProvider; - - /// [BorderRadius] of the image. - final BorderRadius borderRadius; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: borderRadius, - image: DecorationImage( - image: imageProvider, - fit: BoxFit.cover, - ), - ), - ); - } -} - -/// Displays an avatar with a color background and initials text. -class _InitialsUserAvatar extends StatelessWidget { - /// Creates a new instance of [_InitialsUserAvatar]. - const _InitialsUserAvatar({ - required this.user, - required this.borderRadius, - required this.initialsTextStyle, - this.initialsBackground, - }); - - /// User whose avatar is to be displayed. - final UserInfo user; - - /// [BorderRadius] of the image. - final BorderRadius borderRadius; - - /// [TextStyle] for the initials text. - final TextStyle? initialsTextStyle; - - /// Background color for the initials. - final Color? initialsBackground; - @override - Widget build(BuildContext context) { - final initials = user.name.isNotEmpty - ? user.name.initials() - : user.id.initials(); - - final avatarColorIndex = initials.hashCode.abs() % avatarColors.length; - final avatarColor = avatarColors[avatarColorIndex]; - - return DecoratedBox( - decoration: BoxDecoration( - color: initialsBackground ?? avatarColor, - borderRadius: borderRadius, - ), - child: Center( - child: Text( - initials, - style: initialsTextStyle, - ), - ), - ); + // A name of nothing but spaces has no initials, so fall through to the id + // rather than showing an empty circle. + static String _initialsFor(UserInfo user) { + final fromName = user.name.initials(); + return fromName.isNotEmpty ? fromName : user.id.initials(); } - - /// The list of available colors for avatars. - static const avatarColors = [ - Color(0xffb64e4e), - Color(0xffB4774B), - Color(0xffB4A34B), - Color(0xff9AB44B), - Color(0xff6EB44B), - Color(0xff4BB453), - Color(0xff4BB47F), - Color(0xff4BB4AC), - Color(0xff4B91B4), - Color(0xff4B65B4), - Color(0xff5C4BB4), - Color(0xff884BB4), - Color(0xffB44BB4), - Color(0xffB44B88), - Color(0xff926D73), - Color(0xff6E8B91), - ]; } diff --git a/packages/stream_video_flutter/lib/stream_video_flutter.dart b/packages/stream_video_flutter/lib/stream_video_flutter.dart index 8cf6e6f0b..a02c02e9b 100644 --- a/packages/stream_video_flutter/lib/stream_video_flutter.dart +++ b/packages/stream_video_flutter/lib/stream_video_flutter.dart @@ -29,10 +29,17 @@ export 'src/call_controls/controls/toggle_screen_sharing_option.dart'; export 'src/call_controls/controls/toggle_speakerphone_option.dart'; export 'src/call_participants/call_participants.dart'; export 'src/call_participants/call_participants_sorting_mixin.dart'; +export 'src/call_participants/floating_participant_tile.dart'; +export 'src/call_participants/indicators/audio_indicator.dart'; +export 'src/call_participants/indicators/connection_quality_indicator.dart'; export 'src/call_participants/layout/participant_layout_mode.dart'; export 'src/call_participants/livestream_hosts.dart'; export 'src/call_participants/local_video.dart'; +export 'src/call_participants/participant_label.dart'; +export 'src/call_participants/participant_placeholder.dart'; export 'src/call_participants/participant_tile.dart'; +export 'src/call_participants/participant_tile_action.dart'; +export 'src/call_participants/participant_video.dart'; export 'src/call_screen/call_container.dart'; export 'src/call_screen/call_content/call_app_bar.dart'; export 'src/call_screen/call_content/call_content.dart'; diff --git a/packages/stream_video_flutter/pubspec.yaml b/packages/stream_video_flutter/pubspec.yaml index 90bb72c56..2fab2e118 100644 --- a/packages/stream_video_flutter/pubspec.yaml +++ b/packages/stream_video_flutter/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: stream_core_flutter: ^0.4.1 stream_video: ^1.4.3 stream_webrtc_flutter: ^3.0.1 + theme_extensions_builder_annotation: ^7.1.0 visibility_detector: ^0.4.0+2 dev_dependencies: @@ -40,6 +41,7 @@ dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.0 + theme_extensions_builder: ^7.2.0 platforms: android: diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_connection_quality_indicator_dark.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_connection_quality_indicator_dark.png new file mode 100644 index 000000000..6ebe966a8 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_connection_quality_indicator_dark.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_connection_quality_indicator_light.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_connection_quality_indicator_light.png new file mode 100644 index 000000000..942d6b735 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_connection_quality_indicator_light.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_floating_participant_tile_dark.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_floating_participant_tile_dark.png new file mode 100644 index 000000000..37b4fcb28 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_floating_participant_tile_dark.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_floating_participant_tile_light.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_floating_participant_tile_light.png new file mode 100644 index 000000000..8aaf3b233 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_floating_participant_tile_light.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_label_dark.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_label_dark.png new file mode 100644 index 000000000..07158b866 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_label_dark.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_label_light.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_label_light.png new file mode 100644 index 000000000..ec2a86dc7 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_label_light.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_dark.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_dark.png new file mode 100644 index 000000000..b4715d7c2 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_dark.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_light.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_light.png new file mode 100644 index 000000000..3130ee03f Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_light.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_long_name_dark.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_long_name_dark.png new file mode 100644 index 000000000..0020aa5c3 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_long_name_dark.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_long_name_light.png b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_long_name_light.png new file mode 100644 index 000000000..d9406b986 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/goldens/ci/stream_participant_tile_long_name_light.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/participant_parts_golden_test.dart b/packages/stream_video_flutter/test/src/call_participants/participant_parts_golden_test.dart new file mode 100644 index 000000000..bce1933ba --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_participants/participant_parts_golden_test.dart @@ -0,0 +1,161 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/goldens.dart'; +import '../mocks.dart'; + +// The label pill and the connection quality indicator sit on top of video, so +// they are snapshotted over a mid-grey rather than the page background — on a +// white one their overlay fill would be indistinguishable from a solid chip. +Widget _onVideo(Widget child) => ColoredBox( + color: const Color(0xFF6E7A8A), + child: Padding(padding: const EdgeInsets.all(12), child: child), +); + +MockCallParticipantState _participant({ + String name = 'Katie Miler', + bool isSpeaking = false, + bool isAudioEnabled = true, + bool isVideoEnabled = true, + SfuConnectionQuality quality = SfuConnectionQuality.excellent, +}) { + final participant = MockCallParticipantState(); + when(() => participant.name).thenReturn(name); + when(() => participant.image).thenReturn(null); + when(() => participant.isSpeaking).thenReturn(isSpeaking); + when(() => participant.isAudioEnabled).thenReturn(isAudioEnabled); + when(() => participant.isVideoEnabled).thenReturn(isVideoEnabled); + when(() => participant.connectionQuality).thenReturn(quality); + when(() => participant.reaction).thenReturn(null); + return participant; +} + +Future _pumpPastTheFirstFrame(WidgetTester tester) async { + // onlyPumpAndSettle never returns while the sound indicator is animating. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +} + +void main() { + for (final brightness in Brightness.values) { + streamGoldenTest( + 'StreamParticipantLabel renders its states', + fileName: 'stream_participant_label', + brightness: brightness, + pumpBeforeTest: _pumpPastTheFirstFrame, + builder: () => GoldenTestGroup( + columns: 2, + children: [ + GoldenTestScenario( + name: 'idle', + child: _onVideo( + StreamParticipantLabel.fromParticipant( + participant: _participant(), + ), + ), + ), + GoldenTestScenario( + name: 'speaking', + child: _onVideo( + StreamParticipantLabel.fromParticipant( + participant: _participant(isSpeaking: true), + ), + ), + ), + GoldenTestScenario( + name: 'muted, camera off', + child: _onVideo( + StreamParticipantLabel.fromParticipant( + participant: _participant( + isAudioEnabled: false, + isVideoEnabled: false, + ), + ), + ), + ), + GoldenTestScenario( + name: 'no name', + child: _onVideo( + StreamParticipantLabel.fromParticipant( + participant: _participant(isAudioEnabled: false), + showName: false, + ), + ), + ), + ], + ), + ); + + streamGoldenTest( + 'StreamConnectionQualityIndicator renders every level', + fileName: 'stream_connection_quality_indicator', + brightness: brightness, + builder: () => GoldenTestGroup( + columns: 4, + scenarioConstraints: const BoxConstraints.tightFor(width: 80), + children: [ + for (final quality in SfuConnectionQuality.values) + GoldenTestScenario( + name: quality.name, + child: _onVideo( + Center( + child: StreamConnectionQualityIndicator( + connectionQuality: quality, + ), + ), + ), + ), + ], + ), + ); + + streamGoldenTest( + 'StreamFloatingParticipantTile renders the self-view', + fileName: 'stream_floating_participant_tile', + brightness: brightness, + pumpBeforeTest: _pumpPastTheFirstFrame, + builder: () => GoldenTestGroup( + columns: 2, + scenarioConstraints: const BoxConstraints.tightFor( + width: 180, + height: 268, + ), + children: [ + GoldenTestScenario( + name: 'video on', + child: Center( + child: StreamFloatingParticipantTile( + call: MockCall(), + participant: _participant(), + // A real renderer needs a live call. + participantBuilder: (_, _, _) => + const ColoredBox(color: Color(0xFF6E7A8A)), + ), + ), + ), + GoldenTestScenario( + name: 'default tile inside', + child: Center( + child: StreamFloatingParticipantTile( + call: MockCall(), + participant: _participant(), + participantBuilder: (context, call, participant) => + StreamParticipantTile( + call: call, + participant: participant, + showParticipantLabel: false, + showSpeakerBorder: false, + videoRendererBuilder: (_, _, _) => + const ColoredBox(color: Color(0xFF6E7A8A)), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_video_flutter/test/src/call_participants/participant_tile_golden_test.dart b/packages/stream_video_flutter/test/src/call_participants/participant_tile_golden_test.dart new file mode 100644 index 000000000..ff1c4d3a3 --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_participants/participant_tile_golden_test.dart @@ -0,0 +1,129 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/goldens.dart'; +import '../mocks.dart'; + +MockCallParticipantState _participant({ + String name = 'Katie Miler', + bool isSpeaking = false, + bool isAudioEnabled = true, + bool isVideoEnabled = true, + SfuConnectionQuality quality = SfuConnectionQuality.excellent, +}) { + final participant = MockCallParticipantState(); + when(() => participant.name).thenReturn(name); + when(() => participant.image).thenReturn(null); + when(() => participant.isSpeaking).thenReturn(isSpeaking); + when(() => participant.isAudioEnabled).thenReturn(isAudioEnabled); + when(() => participant.isVideoEnabled).thenReturn(isVideoEnabled); + when(() => participant.connectionQuality).thenReturn(quality); + when(() => participant.reaction).thenReturn(null); + return participant; +} + +Widget _tile({ + required CallParticipantState participant, + List? actions, +}) { + return StreamParticipantTile( + call: MockCall(), + participant: participant, + actions: actions, + // A real renderer needs a live call. A flat fill stands in for video, and + // keeps the snapshot from depending on a decoded frame. + videoRendererBuilder: (_, _, _) => + const ColoredBox(color: Color(0xFF6E7A8A)), + ); +} + +void main() { + for (final brightness in Brightness.values) { + streamGoldenTest( + 'StreamParticipantTile renders its states', + fileName: 'stream_participant_tile', + brightness: brightness, + // onlyPumpAndSettle never returns while the speaking indicator's + // controller repeats. Pump a fixed distance into it instead; it always + // starts at zero, so the frame is deterministic. + pumpBeforeTest: (tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + }, + builder: () => GoldenTestGroup( + columns: 3, + scenarioConstraints: const BoxConstraints.tightFor( + width: 200, + height: 320, + ), + children: [ + GoldenTestScenario( + name: 'video on', + child: _tile(participant: _participant()), + ), + GoldenTestScenario( + name: 'speaking', + child: _tile(participant: _participant(isSpeaking: true)), + ), + GoldenTestScenario( + name: 'muted', + child: _tile(participant: _participant(isAudioEnabled: false)), + ), + // The camera-off icon in the pill. The avatar placeholder behind it + // cannot be snapshotted here: the stubbed renderer replaces the whole + // renderer, placeholder included. + GoldenTestScenario( + name: 'camera off', + child: _tile(participant: _participant(isVideoEnabled: false)), + ), + GoldenTestScenario( + name: 'poor connection', + child: _tile( + participant: _participant(quality: SfuConnectionQuality.poor), + ), + ), + GoldenTestScenario( + name: 'with actions', + child: _tile( + participant: _participant(), + actions: [ + StreamParticipantTileAction( + icon: Icons.push_pin, + label: 'Pin', + onPressed: () {}, + ), + ], + ), + ), + ], + ), + ); + + streamGoldenTest( + 'StreamParticipantTile keeps a long name clear of the indicator', + fileName: 'stream_participant_tile_long_name', + brightness: brightness, + pumpBeforeTest: (tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + }, + builder: () => GoldenTestGroup( + columns: 3, + children: [ + for (final width in [240.0, 160.0, 120.0]) + GoldenTestScenario( + name: '${width.toInt()}px', + constraints: BoxConstraints.tightFor(width: width, height: 200), + child: _tile( + participant: _participant( + name: 'Bartholomew Fitzgerald-Montgomery III', + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_video_flutter/test/src/call_participants/participant_tile_layout_test.dart b/packages/stream_video_flutter/test/src/call_participants/participant_tile_layout_test.dart new file mode 100644 index 000000000..63397b6d5 --- /dev/null +++ b/packages/stream_video_flutter/test/src/call_participants/participant_tile_layout_test.dart @@ -0,0 +1,651 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/test_wrapper.dart'; +import '../mocks.dart'; + +final _icons = StreamTheme.light().icons; + +const _longName = + 'Bartholomew Fitzgerald-Montgomery the Third of Northumberland'; + +MockCallParticipantState _participant({ + String name = 'Rene Floor', + bool isSpeaking = false, + bool isAudioEnabled = true, + bool isVideoEnabled = true, + SfuConnectionQuality quality = SfuConnectionQuality.excellent, + bool hasReaction = false, +}) { + final participant = MockCallParticipantState(); + when(() => participant.name).thenReturn(name); + when(() => participant.isSpeaking).thenReturn(isSpeaking); + when(() => participant.isAudioEnabled).thenReturn(isAudioEnabled); + when(() => participant.isVideoEnabled).thenReturn(isVideoEnabled); + when(() => participant.connectionQuality).thenReturn(quality); + when(() => participant.reaction).thenReturn( + hasReaction ? _reaction : null, + ); + return participant; +} + +// The default theme's ':like:' reaction, whose icon the tile draws. +final _reaction = CallReaction( + type: 'reaction', + emojiCode: ':like:', + user: CallUser.empty(), +); + +const _reactionIcon = '\u{1F44D}'; + +StreamParticipantTileAction _pin() => StreamParticipantTileAction( + icon: Icons.push_pin, + label: 'Pin', + onPressed: () {}, +); + +Widget _tile({ + required CallParticipantState participant, + required double width, + required double height, + List? actions, + StreamParticipantTileActionsBuilder? actionsBuilder, +}) { + return TestWrapper( + child: Center( + child: SizedBox( + width: width, + height: height, + child: StreamParticipantTile( + call: MockCall(), + participant: participant, + actions: actions, + actionsBuilder: actionsBuilder, + // The renderer needs a live call to publish tracks. + videoRendererBuilder: (_, _, _) => + const ColoredBox(color: Color(0xFF102030)), + ), + ), + ), + ); +} + +void main() { + group('bottom toolbar', () { + // The defect this layout exists to fix: the name used to be a Stack child + // aligned bottom-left and the indicator one aligned bottom-right, so a long + // name slid underneath the indicator instead of truncating. + for (final width in [400.0, 320.0, 240.0, 200.0, 160.0, 152.0]) { + testWidgets('a long name never reaches the indicator at ${width}px', ( + tester, + ) async { + await tester.pumpWidget( + _tile( + participant: _participant(name: _longName), + width: width, + height: 300, + ), + ); + + expect(tester.takeException(), isNull); + + final label = tester.getRect( + find.byType(DefaultStreamParticipantLabel), + ); + final indicator = tester.getRect( + find.byType(DefaultStreamConnectionQualityIndicator), + ); + + expect( + label.right, + lessThanOrEqualTo(indicator.left), + reason: 'the name pill overlaps the connection quality indicator', + ); + }); + } + + testWidgets('a long name ellipsizes rather than overflowing', ( + tester, + ) async { + await tester.pumpWidget( + _tile( + participant: _participant(name: _longName), + width: 200, + height: 300, + ), + ); + + expect(tester.takeException(), isNull); + + final paragraph = tester.renderObject( + find.text(_longName), + ); + expect(paragraph.didExceedMaxLines, isTrue); + }); + + testWidgets('a short name leaves the indicator at the trailing edge', ( + tester, + ) async { + await tester.pumpWidget( + _tile(participant: _participant(name: 'Al'), width: 300, height: 300), + ); + + final tile = tester.getRect(find.byType(DefaultStreamParticipantTile)); + final indicator = tester.getRect( + find.byType(DefaultStreamConnectionQualityIndicator), + ); + + // Within the toolbar's own inset of the tile edge. + expect(tile.right - indicator.right, lessThan(20)); + }); + }); + + group('density', () { + testWidgets('drops the name but keeps the pill on a narrow tile', ( + tester, + ) async { + await tester.pumpWidget( + _tile(participant: _participant(), width: 130, height: 160), + ); + + expect(find.text('Rene Floor'), findsNothing); + expect(find.byType(DefaultStreamParticipantLabel), findsOneWidget); + expect( + find.byType(DefaultStreamConnectionQualityIndicator), + findsOneWidget, + ); + }); + + testWidgets('drops the pill on a very narrow tile', (tester) async { + await tester.pumpWidget( + _tile(participant: _participant(), width: 90, height: 120), + ); + + expect(find.byType(DefaultStreamParticipantLabel), findsNothing); + expect( + find.byType(DefaultStreamConnectionQualityIndicator), + findsOneWidget, + ); + }); + + testWidgets('drops both toolbars on a tile with no room for them', ( + tester, + ) async { + await tester.pumpWidget( + _tile(participant: _participant(), width: 40, height: 40), + ); + + expect(find.byType(DefaultStreamParticipantLabel), findsNothing); + expect( + find.byType(DefaultStreamConnectionQualityIndicator), + findsNothing, + ); + }); + + // The gap before the indicator used to be emitted whether or not there was + // a pill on the other side of it, so the narrowest band the ladder allows + // was 12px short of what the row needed. + for (final width in [56.0, 60.0, 66.0]) { + testWidgets('fits the indicator alone at ${width}px', (tester) async { + await tester.pumpWidget( + _tile(participant: _participant(), width: width, height: 120), + ); + + expect(tester.takeException(), isNull); + expect( + find.byType(DefaultStreamConnectionQualityIndicator), + findsOneWidget, + ); + }); + } + + // The pill lays its state icons out at full size, so a muted camera-off + // participant needs far more room than a name alone. The ladder's widths + // know nothing about that; the tile measures the pill instead. + testWidgets('drops the pill when state icons widen it past the room', ( + tester, + ) async { + await tester.pumpWidget( + _tile( + participant: _participant( + isAudioEnabled: false, + isVideoEnabled: false, + ), + width: 164, + height: 300, + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(DefaultStreamParticipantLabel), findsNothing); + expect( + find.byType(DefaultStreamConnectionQualityIndicator), + findsOneWidget, + ); + }); + + testWidgets('keeps the pill once the state icons do fit', (tester) async { + await tester.pumpWidget( + _tile( + participant: _participant( + isAudioEnabled: false, + isVideoEnabled: false, + ), + width: 180, + height: 300, + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(DefaultStreamParticipantLabel), findsOneWidget); + expect(find.byIcon(_icons.voiceOffFill), findsOneWidget); + expect(find.byIcon(_icons.videoOffFill), findsOneWidget); + }); + }); + + group('top toolbar', () { + // A reaction arrives mid-call, so a tile that only fits the overflow button + // would start overflowing the moment someone reacted. + testWidgets('drops the reaction when it does not fit beside the button', ( + tester, + ) async { + await tester.pumpWidget( + _tile( + participant: _participant(hasReaction: true), + width: 110, + height: 300, + actions: [_pin()], + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text(_reactionIcon), findsNothing); + expect(find.byType(StreamButton), findsOneWidget); + }); + + testWidgets('keeps the reaction once it does fit', (tester) async { + await tester.pumpWidget( + _tile( + participant: _participant(hasReaction: true), + width: 130, + height: 300, + actions: [_pin()], + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text(_reactionIcon), findsOneWidget); + }); + + // The two toolbars hang off opposite edges of a Stack, so a tile too short + // for both does not overflow — the button lands on top of the name pill. + testWidgets('drops the button on a tile too short to clear the pill', ( + tester, + ) async { + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 104, + actions: [_pin()], + ), + ); + + expect(find.byType(StreamButton), findsNothing); + expect(find.byType(DefaultStreamParticipantLabel), findsOneWidget); + }); + + testWidgets('keeps the button clear of the pill when both are shown', ( + tester, + ) async { + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 120, + actions: [_pin()], + ), + ); + + final button = tester.getRect(find.byType(StreamButton)); + final label = tester.getRect( + find.byType(DefaultStreamParticipantLabel), + ); + + expect(button.overlaps(label), isFalse); + }); + }); + + group('audio indicator', () { + // The sound indicator never leaves, so the pill keeps its shape as someone + // starts and stops talking. Only the muted state adds an icon. + testWidgets('is shown while speaking', (tester) async { + await tester.pumpWidget( + _tile( + participant: _participant(isSpeaking: true), + width: 300, + height: 300, + ), + ); + + expect(find.byType(StreamAudioIndicator), findsOneWidget); + expect(find.byIcon(_icons.voiceOffFill), findsNothing); + }); + + testWidgets('is shown while silent, with no microphone icon', ( + tester, + ) async { + await tester.pumpWidget( + _tile(participant: _participant(), width: 300, height: 300), + ); + + expect(find.byType(StreamAudioIndicator), findsOneWidget); + expect(find.byIcon(_icons.voiceOffFill), findsNothing); + }); + + testWidgets('is shown alongside the muted icon while muted', ( + tester, + ) async { + await tester.pumpWidget( + _tile( + participant: _participant(isAudioEnabled: false), + width: 300, + height: 300, + ), + ); + + expect(find.byType(StreamAudioIndicator), findsOneWidget); + expect(find.byIcon(_icons.voiceOffFill), findsOneWidget); + }); + + testWidgets('rests when the participant is not speaking', (tester) async { + await tester.pumpWidget( + _tile(participant: _participant(), width: 300, height: 300), + ); + + // A resting indicator runs no animation, so the tree settles. A speaking + // one never would. + await tester.pumpAndSettle(); + }); + }); + + group('overflow menu', () { + StreamParticipantTileAction action(String label) => + StreamParticipantTileAction( + icon: Icons.push_pin, + label: label, + onPressed: () {}, + ); + + testWidgets('is hidden when no actions are given', (tester) async { + await tester.pumpWidget( + _tile(participant: _participant(), width: 300, height: 300), + ); + + expect(find.byType(StreamButton), findsNothing); + }); + + testWidgets('is hidden when the action list is empty', (tester) async { + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 300, + actions: const [], + ), + ); + + expect(find.byType(StreamButton), findsNothing); + }); + + testWidgets('is hidden when the actions builder returns nothing', ( + tester, + ) async { + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 300, + actionsBuilder: (_, _) => const [], + ), + ); + + expect(find.byType(StreamButton), findsNothing); + }); + + testWidgets('opens a menu listing every action', (tester) async { + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 300, + actions: [action('Pin'), action('Block')], + ), + ); + + expect(find.byType(StreamButton), findsOneWidget); + + await tester.tap(find.byType(StreamButton)); + await tester.pumpAndSettle(); + + expect(find.text('Pin'), findsOneWidget); + expect(find.text('Block'), findsOneWidget); + }); + + testWidgets('runs the action and closes the menu on tap', (tester) async { + var pinned = 0; + + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 300, + actions: [ + StreamParticipantTileAction( + icon: Icons.push_pin, + label: 'Pin', + onPressed: () => pinned++, + ), + ], + ), + ); + + await tester.tap(find.byType(StreamButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pin')); + await tester.pumpAndSettle(); + + expect(pinned, 1); + // A MenuAnchor panel is an overlay rather than a route, so it does not + // dismiss itself when an item is chosen. + expect(find.text('Pin'), findsNothing); + }); + + testWidgets('stays open when a rebuild yields an equal action list', ( + tester, + ) async { + // actionsBuilder returns a fresh list every build, so comparing identity + // would close the menu on the next rebuild — which, with participant + // state streaming in during a call, is immediately. + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 300, + actionsBuilder: (_, _) => [action('Pin')], + ), + ); + + await tester.tap(find.byType(StreamButton)); + await tester.pumpAndSettle(); + expect(find.text('Pin'), findsOneWidget); + + // Rebuild with an equal — but not identical — list. + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 300, + actionsBuilder: (_, _) => [action('Pin')], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Pin'), findsOneWidget); + }); + + testWidgets('closes when the actions actually change', (tester) async { + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 300, + actionsBuilder: (_, _) => [action('Pin')], + ), + ); + + await tester.tap(find.byType(StreamButton)); + await tester.pumpAndSettle(); + expect(find.text('Pin'), findsOneWidget); + + await tester.pumpWidget( + _tile( + participant: _participant(), + width: 300, + height: 300, + actionsBuilder: (_, _) => [action('Unpin')], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Unpin'), findsNothing); + }); + + testWidgets('prefers the actions builder over the action list', ( + tester, + ) async { + CallParticipantState? received; + + final participant = _participant(); + await tester.pumpWidget( + _tile( + participant: participant, + width: 300, + height: 300, + actions: [action('From the list')], + actionsBuilder: (_, it) { + received = it; + return [action('From the builder')]; + }, + ), + ); + + await tester.tap(find.byType(StreamButton)); + await tester.pumpAndSettle(); + + expect(find.text('From the builder'), findsOneWidget); + expect(find.text('From the list'), findsNothing); + expect(received, same(participant)); + }); + }); + + group('component factory', () { + testWidgets('uses a registered participant label builder', (tester) async { + await tester.pumpWidget( + StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamVideoComponentBuilders( + participantLabel: (context, props) => Text('label:${props.name}'), + ), + ), + child: _tile(participant: _participant(), width: 300, height: 300), + ), + ); + + expect(find.text('label:Rene Floor'), findsOneWidget); + expect(find.byType(DefaultStreamParticipantLabel), findsNothing); + }); + + testWidgets('uses a registered participant video builder', (tester) async { + await tester.pumpWidget( + StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamVideoComponentBuilders( + participantVideo: (context, props) => const Text('video'), + ), + ), + child: TestWrapper( + child: SizedBox( + width: 300, + height: 300, + child: StreamParticipantTile( + call: MockCall(), + participant: _participant(), + ), + ), + ), + ), + ); + + expect(find.text('video'), findsOneWidget); + expect(find.byType(DefaultStreamParticipantVideo), findsNothing); + }); + + testWidgets('prefers a per-instance renderer over the registered one', ( + tester, + ) async { + await tester.pumpWidget( + StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamVideoComponentBuilders( + participantVideo: (context, props) => const Text('registered'), + ), + ), + child: TestWrapper( + child: SizedBox( + width: 300, + height: 300, + child: StreamParticipantTile( + call: MockCall(), + participant: _participant(), + videoRendererBuilder: (_, _, _) => const Text('per instance'), + ), + ), + ), + ), + ); + + // A call site that named a renderer said something more specific than an + // app-wide default. + expect(find.text('per instance'), findsOneWidget); + expect(find.text('registered'), findsNothing); + }); + + testWidgets('uses a registered connection quality indicator builder', ( + tester, + ) async { + await tester.pumpWidget( + StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamVideoComponentBuilders( + connectionQualityIndicator: (context, props) => SizedBox.square( + dimension: 32, + child: Text('q:${props.connectionQuality.name}'), + ), + ), + ), + child: _tile(participant: _participant(), width: 300, height: 300), + ), + ); + + expect(find.text('q:excellent'), findsOneWidget); + expect( + find.byType(DefaultStreamConnectionQualityIndicator), + findsNothing, + ); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/call_participants/participant_tile_test.dart b/packages/stream_video_flutter/test/src/call_participants/participant_tile_test.dart index 27c476235..7dafb9fa7 100644 --- a/packages/stream_video_flutter/test/src/call_participants/participant_tile_test.dart +++ b/packages/stream_video_flutter/test/src/call_participants/participant_tile_test.dart @@ -54,6 +54,7 @@ void main() { tester, ) async { when(() => participant.isSpeaking).thenReturn(false); + when(() => participant.isVideoEnabled).thenReturn(true); await tester.pumpWidget( TestWrapper( @@ -73,10 +74,66 @@ void main() { }); }); + group('StreamFloatingParticipantTile', () { + // Both floating goldens hand in their own participantBuilder, so the + // default composition — a StreamParticipantTile inside the surface — is + // only covered here. + testWidgets('clips the tile to the surface radius it was given', ( + tester, + ) async { + final participant = MockCallParticipantState(); + when(() => participant.name).thenReturn('Rene Floor'); + when(() => participant.isSpeaking).thenReturn(false); + when(() => participant.isAudioEnabled).thenReturn(true); + when(() => participant.isVideoEnabled).thenReturn(true); + when( + () => participant.connectionQuality, + ).thenReturn(SfuConnectionQuality.excellent); + when(() => participant.reaction).thenReturn(null); + + const radius = BorderRadius.all(Radius.circular(24)); + + await tester.pumpWidget( + StreamComponentFactory( + // Replaces the renderer, not the tile: the tile's own clip is what + // this is about. + builders: StreamComponentBuilders( + extensions: streamVideoComponentBuilders( + participantVideo: (context, props) => + const ColoredBox(color: Color(0xFF102030)), + ), + ), + child: TestWrapper( + child: StreamFloatingParticipantTile( + call: MockCall(), + participant: participant, + style: const StreamFloatingParticipantTileStyle( + borderRadius: radius, + ), + ), + ), + ), + ); + + // A surface rounded further than the tile inside it leaves transparent + // notches where the tighter clip stops short of the corner. + final clip = tester.widget( + find + .descendant( + of: find.byType(DefaultStreamParticipantTile), + matching: find.byType(ClipRRect), + ) + .first, + ); + expect(clip.borderRadius, radius); + }); + }); + group('StreamCallParticipant (deprecated)', () { testWidgets('renders the default participant tile', (tester) async { final participant = MockCallParticipantState(); when(() => participant.isSpeaking).thenReturn(false); + when(() => participant.isVideoEnabled).thenReturn(true); await tester.pumpWidget( TestWrapper( diff --git a/packages/stream_video_flutter/test/src/theme/call_participant_theme_bridge_test.dart b/packages/stream_video_flutter/test/src/theme/call_participant_theme_bridge_test.dart new file mode 100644 index 000000000..68d310d55 --- /dev/null +++ b/packages/stream_video_flutter/test/src/theme/call_participant_theme_bridge_test.dart @@ -0,0 +1,155 @@ +// ignore_for_file: deprecated_member_use_from_same_package + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +void main() { + group('StreamCallParticipantThemeData migration', () { + test('an app that sets no participant theme gets the redesign', () { + final theme = StreamVideoTheme.light(); + + // Nothing populates the deprecated theme any more, so the tile and its + // parts fall through to their own context-derived defaults. + expect(theme.callParticipantTheme, isNull); + expect(theme.participantTileTheme.style, isNull); + expect(theme.participantLabelTheme.style, isNull); + expect(theme.connectionQualityIndicatorTheme.style, isNull); + expect(theme.callParticipantsGridTheme.padding, isNull); + }); + + test('a participant theme carries across in full', () { + final theme = StreamVideoTheme( + brightness: Brightness.light, + callParticipantTheme: const StreamCallParticipantThemeData( + backgroundColor: Color(0xFF112233), + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ); + + final style = theme.participantTileTheme.style; + expect(style?.backgroundColor, const Color(0xFF112233)); + expect(style?.borderRadius, const BorderRadius.all(Radius.circular(16))); + // Setting the theme at all means keeping its shape, so the properties it + // did not name come across at their old defaults rather than picking up + // the redesign's. + expect(style?.showParticipantLabel, isTrue); + expect(theme.callParticipantsGridTheme.padding, const EdgeInsets.all(8)); + }); + + test('folds the speaker border colour and width into one border', () { + final theme = StreamVideoTheme( + brightness: Brightness.light, + callParticipantTheme: const StreamCallParticipantThemeData( + speakerBorderColor: Color(0xFF00FF00), + speakerBorderThickness: 6, + ), + ); + + final border = + theme.participantTileTheme.style?.speakingBorder as Border?; + expect(border?.top.color, const Color(0xFF00FF00)); + expect(border?.top.width, 6); + }); + + test('spreads one connection colour across all three levels', () { + final theme = StreamVideoTheme( + brightness: Brightness.light, + callParticipantTheme: const StreamCallParticipantThemeData( + connectionLevelActiveColor: Color(0xFF00FF00), + ), + ); + + final style = theme.connectionQualityIndicatorTheme.style; + expect(style?.poorColor, const Color(0xFF00FF00)); + expect(style?.fairColor, const Color(0xFF00FF00)); + expect(style?.greatColor, const Color(0xFF00FF00)); + }); + + test('ignores the legacy theme entirely once the new one is given', () { + final theme = StreamVideoTheme( + brightness: Brightness.light, + callParticipantTheme: const StreamCallParticipantThemeData( + backgroundColor: Color(0xFF112233), + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + participantTileTheme: const StreamParticipantTileThemeData( + style: StreamParticipantTileStyle( + backgroundColor: Color(0xFF445566), + ), + ), + ); + + final style = theme.participantTileTheme.style; + expect(style?.backgroundColor, const Color(0xFF445566)); + // Not blended: the radius the legacy theme carried is gone rather than + // showing through underneath. + expect(style?.borderRadius, isNull); + // The themes the new shape said nothing about still come from the legacy + // one — each is replaced on its own, not as a set. + expect(theme.callParticipantsGridTheme.padding, const EdgeInsets.all(8)); + }); + + test('carries the avatar configuration onto the placeholder', () { + final theme = StreamVideoTheme( + brightness: Brightness.light, + callParticipantTheme: const StreamCallParticipantThemeData( + userAvatarTheme: StreamUserAvatarThemeData( + constraints: BoxConstraints.tightFor(height: 48, width: 48), + initialsBackground: Color(0xFF223344), + ), + ), + ); + + final avatar = + theme.participantTileTheme.style?.placeholderStyle?.avatarTheme; + // Box constraints round up to the nearest design-system diameter. + expect(avatar?.size, StreamAvatarSize.xl); + expect(avatar?.backgroundColor, const Color(0xFF223344)); + }); + + test('maps every property an app is likely to have set', () { + final theme = StreamVideoTheme( + brightness: Brightness.light, + callParticipantTheme: const StreamCallParticipantThemeData( + borderRadius: BorderRadius.all(Radius.circular(16)), + backgroundColor: Color(0xFF223344), + audioLevelIndicatorColor: Color(0xFF334455), + participantLabelTextStyle: TextStyle(fontSize: 11), + disabledMicrophoneColor: Color(0xFF445566), + pausedVideoIndicatorColor: Color(0xFF556677), + connectionLevelActiveColor: Color(0xFF00FF00), + connectionLevelInactiveColor: Color(0xFF667788), + participantsGridPadding: EdgeInsets.all(4), + participantsGridMainAxisSpacing: 4, + participantsGridCrossAxisSpacing: 4, + ), + ); + + expect( + theme.participantTileTheme.style?.borderRadius, + const BorderRadius.all(Radius.circular(16)), + ); + expect( + theme.participantTileTheme.style?.backgroundColor, + const Color(0xFF223344), + ); + + final label = theme.participantLabelTheme.style; + expect(label?.nameTextStyle?.fontSize, 11); + expect(label?.speakingColor, const Color(0xFF334455)); + expect(label?.microphoneOffColor, const Color(0xFF445566)); + expect(label?.videoOffIconColor, const Color(0xFF556677)); + + expect( + theme.connectionQualityIndicatorTheme.style?.inactiveColor, + const Color(0xFF667788), + ); + + final grid = theme.callParticipantsGridTheme; + expect(grid.padding, const EdgeInsets.all(4)); + expect(grid.mainAxisSpacing, 4); + expect(grid.crossAxisSpacing, 4); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/theme/call_participant_theme_test.dart b/packages/stream_video_flutter/test/src/theme/call_participant_theme_test.dart new file mode 100644 index 000000000..580aec9bb --- /dev/null +++ b/packages/stream_video_flutter/test/src/theme/call_participant_theme_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +void main() { + group('StreamCallParticipantThemeData', () { + test('copyWith replaces the speaker border properties', () { + const theme = StreamCallParticipantThemeData(); + + final copy = theme.copyWith( + showSpeakerBorder: false, + speakerBorderThickness: 2, + speakerBorderColor: const Color(0xFF00FF00), + ); + + expect(copy.showSpeakerBorder, isFalse); + expect(copy.speakerBorderThickness, 2); + expect(copy.speakerBorderColor, const Color(0xFF00FF00)); + }); + + test('copyWith keeps properties it was not given', () { + const theme = StreamCallParticipantThemeData( + speakerBorderThickness: 6, + speakerBorderColor: Color(0xFF0000FF), + ); + + final copy = theme.copyWith(showSpeakerBorder: false); + + expect(copy.speakerBorderThickness, 6); + expect(copy.speakerBorderColor, const Color(0xFF0000FF)); + }); + + test('merge carries every property of the other theme', () { + const theme = StreamCallParticipantThemeData(); + const other = StreamCallParticipantThemeData( + speakerBorderThickness: 2, + speakerBorderColor: Color(0xFF00FF00), + // Regression: merge used to drop this one silently. + pausedVideoIndicatorColor: Color(0xFFFF0000), + connectionLevelActiveColor: Color(0xFF123456), + ); + + final merged = theme.merge(other); + + expect(merged.speakerBorderThickness, 2); + expect(merged.speakerBorderColor, const Color(0xFF00FF00)); + expect(merged.pausedVideoIndicatorColor, const Color(0xFFFF0000)); + expect(merged.connectionLevelActiveColor, const Color(0xFF123456)); + }); + }); + + group('StreamVideoTheme', () { + test('merge carries the call controls theme of the other theme', () { + final theme = StreamVideoTheme.light(); + final other = theme.copyWith( + callControlsTheme: const StreamCallControlsThemeData(elevation: 12), + ); + + // Regression: merge passed its own callControlsTheme to itself, so the + // other theme's value was discarded. + expect(theme.merge(other).callControlsTheme.elevation, 12); + }); + + test('merge carries the local video theme of the other theme', () { + final theme = StreamVideoTheme.light(); + final other = theme.copyWith( + localVideoTheme: const StreamLocalVideoThemeData(localVideoWidth: 200), + ); + + // Regression: localVideoTheme was missing from merge entirely. + expect(theme.merge(other).localVideoTheme.localVideoWidth, 200); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/theme/participant_tile_theme_test.dart b/packages/stream_video_flutter/test/src/theme/participant_tile_theme_test.dart new file mode 100644 index 000000000..1ae26b233 --- /dev/null +++ b/packages/stream_video_flutter/test/src/theme/participant_tile_theme_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/test_wrapper.dart'; + +void main() { + group('StreamParticipantTileTheme', () { + testWidgets('resolves the global theme when no ancestor is present', ( + tester, + ) async { + late StreamParticipantTileThemeData resolved; + + await tester.pumpWidget( + MaterialApp( + theme: streamTestTheme().copyWith( + extensions: [ + StreamTheme.light(), + StreamVideoTheme.light().copyWith( + participantTileTheme: const StreamParticipantTileThemeData( + style: StreamParticipantTileStyle( + showParticipantLabel: false, + ), + ), + ), + ], + ), + home: Builder( + builder: (context) { + resolved = StreamParticipantTileTheme.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(resolved.style?.showParticipantLabel, isFalse); + }); + + testWidgets('merges a local override over the global theme', ( + tester, + ) async { + late StreamParticipantTileThemeData resolved; + + await tester.pumpWidget( + MaterialApp( + theme: streamTestTheme().copyWith( + extensions: [ + StreamTheme.light(), + StreamVideoTheme.light().copyWith( + participantTileTheme: const StreamParticipantTileThemeData( + style: StreamParticipantTileStyle( + showParticipantLabel: false, + showMoreButton: false, + ), + ), + ), + ], + ), + home: StreamParticipantTileTheme( + data: const StreamParticipantTileThemeData( + style: StreamParticipantTileStyle(showMoreButton: true), + ), + child: Builder( + builder: (context) { + resolved = StreamParticipantTileTheme.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + // The local value wins... + expect(resolved.style?.showMoreButton, isTrue); + // ...and the global one it did not mention survives, rather than being + // replaced wholesale the way the deprecated theme behaved. + expect(resolved.style?.showParticipantLabel, isFalse); + }); + }); + + group('StreamVideoTheme', () { + test('carries the new component themes through copyWith', () { + final theme = StreamVideoTheme.light().copyWith( + participantLabelTheme: const StreamParticipantLabelThemeData( + style: StreamParticipantLabelStyle(blurSigma: 4), + ), + connectionQualityIndicatorTheme: + const StreamConnectionQualityIndicatorThemeData( + style: StreamConnectionQualityIndicatorStyle(size: 40), + ), + callParticipantsGridTheme: const StreamCallParticipantsGridThemeData( + mainAxisSpacing: 2, + ), + floatingParticipantTileTheme: + const StreamFloatingParticipantTileThemeData( + style: StreamFloatingParticipantTileStyle(elevation: 9), + ), + ); + + expect(theme.participantLabelTheme.style?.blurSigma, 4); + expect(theme.connectionQualityIndicatorTheme.style?.size, 40); + expect(theme.callParticipantsGridTheme.mainAxisSpacing, 2); + expect(theme.floatingParticipantTileTheme.style?.elevation, 9); + }); + + test('defaults every new component theme to an empty instance', () { + final theme = StreamVideoTheme.light(); + + // Empty means "no overrides": the widgets fall back to their own + // context-derived defaults rather than to values baked into the theme. + expect(theme.participantTileTheme.style, isNull); + expect(theme.participantLabelTheme.style, isNull); + expect(theme.connectionQualityIndicatorTheme.style, isNull); + expect(theme.floatingParticipantTileTheme.style, isNull); + expect(theme.callParticipantsGridTheme.padding, isNull); + }); + + test('lerp interpolates the new component themes', () { + final a = StreamVideoTheme.light().copyWith( + connectionQualityIndicatorTheme: + const StreamConnectionQualityIndicatorThemeData( + style: StreamConnectionQualityIndicatorStyle(size: 20), + ), + ); + final b = StreamVideoTheme.light().copyWith( + connectionQualityIndicatorTheme: + const StreamConnectionQualityIndicatorThemeData( + style: StreamConnectionQualityIndicatorStyle(size: 40), + ), + ); + + final mid = a.lerp(b, 0.5) as StreamVideoTheme; + + expect(mid.connectionQualityIndicatorTheme.style?.size, 30); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/widgets/stream_user_avatar_test.dart b/packages/stream_video_flutter/test/src/widgets/stream_user_avatar_test.dart new file mode 100644 index 000000000..68198fcee --- /dev/null +++ b/packages/stream_video_flutter/test/src/widgets/stream_user_avatar_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/test_wrapper.dart'; +import '../mocks.dart'; + +const _user = UserInfo(id: 'katie', name: 'Katie Miler'); + +void main() { + group('StreamUserAvatar', () { + testWidgets('falls back to the initials of the name', (tester) async { + await tester.pumpWidget( + TestWrapper(child: StreamUserAvatar(user: _user)), + ); + + expect(find.text('KM'), findsOneWidget); + }); + + testWidgets('falls back to the id when there is no name', (tester) async { + await tester.pumpWidget( + TestWrapper( + child: StreamUserAvatar(user: const UserInfo(id: 'ab')), + ), + ); + + expect(find.text('A'), findsOneWidget); + }); + + testWidgets('falls back to the id when the name is only spaces', ( + tester, + ) async { + await tester.pumpWidget( + TestWrapper( + child: StreamUserAvatar( + user: const UserInfo(id: 'ab', name: ' '), + ), + ), + ); + + // A name with no letters in it has no initials, and an empty circle says + // nothing about who is behind it. + expect(find.text('A'), findsOneWidget); + }); + + testWidgets('reports taps with the user', (tester) async { + UserInfo? tapped; + + await tester.pumpWidget( + TestWrapper( + child: StreamUserAvatar(user: _user, onTap: (it) => tapped = it), + ), + ); + + await tester.tap(find.byType(StreamUserAvatar)); + expect(tapped, _user); + }); + + testWidgets('takes its size from an ambient StreamAvatarTheme', ( + tester, + ) async { + await tester.pumpWidget( + TestWrapper( + child: Center( + child: StreamAvatarTheme( + data: const StreamAvatarThemeData(size: StreamAvatarSize.xxl), + child: StreamUserAvatar(user: _user), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(StreamAvatar)).width, + StreamAvatarSize.xxl.value, + ); + }); + + testWidgets('honours the deprecated theme when nothing else sizes it', ( + tester, + ) async { + await tester.pumpWidget( + TestWrapper( + child: Center( + // ignore: deprecated_member_use_from_same_package + child: StreamUserAvatarTheme( + data: const StreamUserAvatarThemeData( + constraints: BoxConstraints.tightFor(height: 80, width: 80), + ), + child: StreamUserAvatar(user: _user), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(StreamAvatar)).width, 80); + }); + + testWidgets('one registered builder reaches every avatar in the SDK', ( + tester, + ) async { + // The point of the slot: an app replaces the avatar once and every avatar + // follows, including the one a participant tile shows in place of video. + final participant = MockCallParticipantState(); + when(participant.toUserInfo).thenReturn(_user); + + await tester.pumpWidget( + StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamVideoComponentBuilders( + userAvatar: (context, props) => Text('avatar:${props.user.name}'), + ), + ), + child: TestWrapper( + child: Column( + children: [ + StreamUserAvatar(user: _user), + StreamParticipantPlaceholder( + call: MockCall(), + participant: participant, + ), + ], + ), + ), + ), + ); + + expect(find.text('avatar:Katie Miler'), findsNWidgets(2)); + expect(find.byType(DefaultStreamUserAvatar), findsNothing); + }); + + testWidgets('a participant tile shows the placeholder in place of video', ( + tester, + ) async { + final participant = MockCallParticipantState(); + when(participant.toUserInfo).thenReturn(_user); + when(() => participant.name).thenReturn(_user.name); + when(() => participant.isSpeaking).thenReturn(false); + when(() => participant.isAudioEnabled).thenReturn(true); + when(() => participant.isVideoEnabled).thenReturn(false); + when( + () => participant.connectionQuality, + ).thenReturn(SfuConnectionQuality.excellent); + when(() => participant.reaction).thenReturn(null); + + await tester.pumpWidget( + TestWrapper( + child: SizedBox( + width: 300, + height: 300, + child: StreamParticipantTile( + call: MockCall(), + participant: participant, + // The renderer needs a live call, so it stands in for itself and + // hands back what it would show when there is no picture. + videoRendererBuilder: (context, call, participant) => + StreamParticipantPlaceholder( + call: call, + participant: participant, + ), + ), + ), + ), + ); + + expect(find.byType(DefaultStreamParticipantPlaceholder), findsOneWidget); + expect(find.byType(DefaultStreamUserAvatar), findsOneWidget); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 64a61325d..62bde2f61 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2007,8 +2007,8 @@ packages: dependency: "direct overridden" description: path: "packages/stream_core_flutter" - ref: "13ef444220a6b38bbbe934a12d19c59820792c8e" - resolved-ref: "13ef444220a6b38bbbe934a12d19c59820792c8e" + ref: "31c96f67b1c7b81d7dabb95348bbb8136e3713cf" + resolved-ref: "31c96f67b1c7b81d7dabb95348bbb8136e3713cf" url: "https://github.com/GetStream/stream-core-flutter.git" source: git version: "0.5.0" @@ -2092,6 +2092,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + theme_extensions_builder: + dependency: transitive + description: + name: theme_extensions_builder + sha256: e8ea8eb1d859716bc1a29058fb85db87519374eeb49930a07818d330fbf0f388 + url: "https://pub.dev" + source: hosted + version: "7.4.0" theme_extensions_builder_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 958736af5..c604a9d23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,7 @@ dependency_overrides: git: url: https://github.com/GetStream/stream-core-flutter.git path: packages/stream_core_flutter - ref: 13ef444220a6b38bbbe934a12d19c59820792c8e + ref: 31c96f67b1c7b81d7dabb95348bbb8136e3713cf melos: ignore: @@ -64,6 +64,7 @@ melos: stream_video_noise_cancellation: ^1.4.3 stream_video_push_notification: ^1.4.3 stream_video_screen_sharing: ^1.4.3 + theme_extensions_builder_annotation: ^7.1.0 hooks: # Keep streamVideoVersion in globals.dart in sync with the pubspec version.