diff --git a/CLAUDE.md b/CLAUDE.md index e9dddc416..cdd292a46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,18 @@ 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. +## Right-to-left + +Every widget has to work right to left. Lay out by start and end, not left and +right: `EdgeInsetsDirectional`, `PositionedDirectional`, +`AlignmentDirectional`. A public padding or alignment is typed as the +`…Geometry` base, so a caller can pass the directional form. Code that needs +numbers out of one resolves it against `Directionality.of(context)` first, and +from then on `left` and `right` are physical sides already swapped for the +direction. A chevron or arrow that points along the reading direction swaps +its glyph under RTL. Each layout gets at least one widget test under +`TextDirection.rtl`, with a start and end that differ. + ## Tokens Read them from the context extensions — `context.streamColorScheme`, diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index bfa2faf5f..fca8849c9 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -182,9 +182,13 @@ - Added `StreamCallDurationBadgeThemeData` on `StreamVideoTheme`, and `StreamCallDurationBadgeTheme` to restyle the badge over a subtree. - Added `callEncryptedTooltip`, `callRecordingTooltip` and `callScreenSharingTooltip` to the localizations, in English and Dutch. - Added `callDurationSpoken`, `callDurationHours`, `callDurationMinutes` and `callDurationSeconds` to the localizations, in English and Dutch. +- Added `participantsPrevious` and `participantsNext` to the localizations, in English and Dutch. ### 🔄 Changed +- The participants bar in `CallParticipantsSpotlightView` carries a button at either end for the tiles it cannot fit. +- `CallParticipantsGridView` pages with the same button as the participants bar. +- Above the small breakpoint, the grid's page buttons sit either side of the grid rather than over it. - The badge on the call control buttons is amber with no border, where it used to be red with one. - `accentWarning` is a lighter amber, which also repaints the fair bars on `StreamConnectionQualityIndicator`. - `CallAppBar` is laid out by `StreamToolbar` at the design system's 72 with `spacing.sm` edge padding, matching `CallControlBar`'s horizontal inset. 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 9cab1e881..ef9a39163 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 @@ -1,7 +1,11 @@ +import 'dart:math' as math; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../../../stream_video_flutter.dart'; +import '../../l10n/localization_extension.dart'; +import 'participants_navigation_button.dart'; /// Arranges every participant in a grid, a page at a time. /// @@ -46,7 +50,7 @@ class CallParticipantsGridView extends StatefulWidget { /// Padding around the grid. /// /// Overrides [StreamCallParticipantsGridThemeData.padding]. - final EdgeInsets? padding; + final EdgeInsetsGeometry? padding; @override State createState() => @@ -77,9 +81,9 @@ class _CallParticipantsGridViewState extends State { final screenSize = context.streamScreenSize; final padding = - widget.padding ?? - theme.padding?.resolve(Directionality.maybeOf(context)) ?? - EdgeInsets.all(spacing.xs); + (widget.padding ?? theme.padding ?? EdgeInsets.all(spacing.xs)).resolve( + Directionality.maybeOf(context), + ); final mainAxisSpacing = widget.mainAxisSpacing ?? theme.mainAxisSpacing ?? spacing.xs; final crossAxisSpacing = @@ -106,82 +110,127 @@ class _CallParticipantsGridViewState extends State { }); } - return Padding( - padding: padding, - child: ValueListenableBuilder( - valueListenable: _currentPage, - builder: (context, value, child) { - if (pages.length <= 1) return child!; + return ValueListenableBuilder( + valueListenable: _currentPage, + builder: (context, value, child) { + final content = child!; + final grid = Padding(padding: padding, child: content); + if (pages.length <= 1) return grid; + + final currentPage = value.clamp(0, lastPage); + final icons = context.streamIcons; + final translations = context.translations; + final isRtl = Directionality.of(context) == TextDirection.rtl; + + // A hidden button keeps its place, so the grid is the same size on + // every page. + Widget button({required bool isBack, required bool visible}) { + final pointsLeft = isBack != isRtl; + + return ParticipantsNavigationButton( + icon: pointsLeft ? icons.chevronLeft : icons.chevronRight, + tooltip: isBack + ? translations.participantsPrevious + : translations.participantsNext, + visible: visible, + onPressed: () => + _goToPage(isBack ? currentPage - 1 : currentPage + 1), + ); + } + + final back = button(isBack: true, visible: currentPage > 0); + final forward = button( + isBack: false, + visible: currentPage < lastPage, + ); - final currentPage = value.clamp(0, lastPage); + // Insets are to the button's visual, less the tap inset. + final tapInset = participantsNavigationButtonTapInset; + // A narrow window has no width to spare, so its buttons sit over the + // grid, a gap inside its padding. Wider ones set the grid in between + // them instead, each button a gap out from the grid and the padding + // in from the edge. + if (screenSize == StreamScreenSize.small) { return Stack( children: [ - child!, - Center( - child: Row( - children: [ - AnimatedScale( - scale: currentPage > 0 ? 1 : 0, - duration: kThemeAnimationDuration, - child: PageNavigationButton( - icon: Icon(context.streamIcons.chevronLeft), - onPressed: () => _goToPage(currentPage - 1), - ), - ), - const Spacer(), - AnimatedScale( - scale: currentPage < lastPage ? 1 : 0, - duration: kThemeAnimationDuration, - child: PageNavigationButton( - icon: Icon(context.streamIcons.chevronRight), - onPressed: () => _goToPage(currentPage + 1), - ), - ), - ], + grid, + Positioned.fill( + child: Padding( + padding: EdgeInsets.only( + left: math.max(0, padding.left + spacing.xs - tapInset), + right: math.max(0, padding.right + spacing.xs - tapInset), + ), + child: Center( + child: Row(children: [back, const Spacer(), forward]), + ), ), ), ], ); + } + + return Padding( + padding: EdgeInsets.only( + left: math.max(0, padding.left - tapInset), + right: math.max(0, padding.right - tapInset), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: math.max(0, spacing.xs - tapInset), + children: [ + Center(widthFactor: 1, child: back), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: padding.top, + bottom: padding.bottom, + ), + child: content, + ), + ), + Center(widthFactor: 1, child: forward), + ], + ), + ); + }, + child: LayoutBuilder( + builder: (context, constraints) { + return PageView.builder( + itemCount: pages.length, + controller: _pageController, + // A page at a time rather than the platform's own physics, so a + // drag, a wheel or a trackpad settles on a page boundary. + physics: const PageScrollPhysics(), + onPageChanged: (page) => _currentPage.value = page, + itemBuilder: (context, index) { + final page = pages[index]; + + final details = StreamParticipantGridDetails( + box: constraints.biggest, + count: page.length, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + maxTileAspectRatio: maxTileAspectRatio, + screenSize: screenSize, + ); + + final columns = theme.columnResolver?.call(details); + final arrangement = columns == null + ? solveParticipantGrid(details) + : arrangeParticipantGrid(details, columns); + + return _GridPage( + call: widget.call, + participants: page, + itemBuilder: widget.itemBuilder, + arrangement: arrangement, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + ); + }, + ); }, - child: LayoutBuilder( - builder: (context, constraints) { - return PageView.builder( - itemCount: pages.length, - controller: _pageController, - // A page at a time rather than the platform's own physics, so a - // drag, a wheel or a trackpad settles on a page boundary. - physics: const PageScrollPhysics(), - onPageChanged: (page) => _currentPage.value = page, - itemBuilder: (context, index) { - final page = pages[index]; - - final details = StreamParticipantGridDetails( - box: constraints.biggest, - count: page.length, - mainAxisSpacing: mainAxisSpacing, - crossAxisSpacing: crossAxisSpacing, - maxTileAspectRatio: maxTileAspectRatio, - screenSize: screenSize, - ); - - final columns = theme.columnResolver?.call(details); - final arrangement = columns == null - ? solveParticipantGrid(details) - : arrangeParticipantGrid(details, columns); - - return _GridPage( - call: widget.call, - participants: page, - itemBuilder: widget.itemBuilder, - arrangement: arrangement, - mainAxisSpacing: mainAxisSpacing, - crossAxisSpacing: crossAxisSpacing, - ); - }, - ); - }, - ), ), ); } @@ -233,42 +282,3 @@ class _GridPage extends StatelessWidget { ); } } - -class PageNavigationButton extends StatelessWidget { - const PageNavigationButton({ - super.key, - required this.icon, - this.iconColor, - this.iconSize, - this.onPressed, - }); - - final Widget icon; - final Color? iconColor; - final double? iconSize; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - final streamVideoTheme = StreamVideoTheme.of(context); - final colorTheme = streamVideoTheme.colorTheme; - return ElevatedButton( - onPressed: onPressed, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - minimumSize: const Size(48, 48), - padding: EdgeInsets.zero, - backgroundColor: colorTheme.barsBg, - ), - child: IconTheme.merge( - data: IconThemeData( - size: iconSize, - color: iconColor ?? colorTheme.textHighEmphasis, - ), - child: icon, - ), - ); - } -} diff --git a/packages/stream_video_flutter/lib/src/call_participants/layout/call_participants_spotlight_view.dart b/packages/stream_video_flutter/lib/src/call_participants/layout/call_participants_spotlight_view.dart index bbde9d686..673e509ea 100644 --- a/packages/stream_video_flutter/lib/src/call_participants/layout/call_participants_spotlight_view.dart +++ b/packages/stream_video_flutter/lib/src/call_participants/layout/call_participants_spotlight_view.dart @@ -3,6 +3,8 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../stream_video_flutter.dart'; +import '../../l10n/localization_extension.dart'; +import 'participants_navigation_button.dart'; /// Defines the alignment of the participants bar. enum ParticipantsBarAlignment { top, bottom, left, right } @@ -18,7 +20,8 @@ enum ParticipantsBarAlignment { top, bottom, left, right } /// The bar's tiles have a size of their own rather than a share of the view, /// scaled down only where they would otherwise take more than a third of it. /// They are centred while they fit, and once they do not the bar runs to the -/// edge of the view and scrolls. +/// edge of the view and scrolls, with a button at either end of it for the +/// tiles that way. class CallParticipantsSpotlightView extends StatelessWidget { const CallParticipantsSpotlightView({ super.key, @@ -201,8 +204,13 @@ class CallParticipantsSpotlightView extends StatelessWidget { return SizedBox( width: isHorizontal ? constraints.maxWidth : tileSize.width, height: isHorizontal ? tileSize.height : constraints.maxHeight, - child: ListView.separated( - padding: isHorizontal + child: _ParticipantsBar( + call: call, + participants: tiles, + participantBuilder: participantBuilder, + tileSize: tileSize, + spacing: spacing, + listPadding: isHorizontal ? EdgeInsets.only( left: math.max(start, slack), right: math.max(end, slack), @@ -211,17 +219,7 @@ class CallParticipantsSpotlightView extends StatelessWidget { top: math.max(start, slack), bottom: math.max(end, slack), ), - itemCount: tiles.length, - scrollDirection: isHorizontal ? Axis.horizontal : Axis.vertical, - separatorBuilder: (context, index) => - SizedBox.square(dimension: spacing), - itemBuilder: (context, index) { - final participant = tiles[index]; - return SizedBox.fromSize( - size: tileSize, - child: participantBuilder.call(context, call, participant), - ); - }, + isHorizontal: isHorizontal, ), ); } @@ -249,6 +247,224 @@ class CallParticipantsSpotlightView extends StatelessWidget { } } +/// Which ends of the bar have tiles beyond them. +typedef _BarEdges = ({bool start, bool end}); + +/// The bar's tiles, with a button at either end for the ones it hides. +/// +/// Each button appears while there is something further that way and +/// scrolls towards it. +class _ParticipantsBar extends StatefulWidget { + const _ParticipantsBar({ + required this.call, + required this.participants, + required this.participantBuilder, + required this.tileSize, + required this.spacing, + required this.listPadding, + required this.isHorizontal, + }); + + final Call call; + final List participants; + final CallParticipantBuilder participantBuilder; + final Size tileSize; + final double spacing; + final EdgeInsets listPadding; + final bool isHorizontal; + + @override + State<_ParticipantsBar> createState() => _ParticipantsBarState(); +} + +class _ParticipantsBarState extends State<_ParticipantsBar> { + final _controller = ScrollController(); + final _edges = ValueNotifier<_BarEdges>((start: false, end: false)); + + @override + void dispose() { + _controller.dispose(); + _edges.dispose(); + super.dispose(); + } + + // Returns false so the notification carries on to the listeners above. + bool _syncEdges(ScrollMetrics metrics) { + _edges.value = ( + start: metrics.extentBefore > 0, + end: metrics.extentAfter > 0, + ); + return false; + } + + /// Scrolls towards the end of the list, or towards its start when + /// [forward] is false, by up to a viewport. + /// + /// Forward brings the tile cut off at the end to the start of the view, + /// and back brings the one cut off at the start to its end, each as far in + /// from the edge as the list's padding. + Future _scroll({required bool forward}) async { + if (!_controller.hasClients) return; + + final position = _controller.position; + final pixels = position.pixels; + final viewport = position.viewportDimension; + + final padding = widget.listPadding; + final isRtl = Directionality.of(context) == TextDirection.rtl; + final (leading, trailing) = switch ((widget.isHorizontal, isRtl)) { + (false, _) => (padding.top, padding.bottom), + (true, false) => (padding.left, padding.right), + (true, true) => (padding.right, padding.left), + }; + final tile = widget.isHorizontal + ? widget.tileSize.width + : widget.tileSize.height; + final stride = tile + widget.spacing; + final count = widget.participants.length; + + double tileStart(int index) => leading + index * stride; + + double? target; + if (forward) { + for (var i = 0; i < count; i++) { + if (tileStart(i) + tile > pixels + viewport) { + target = tileStart(i) - leading; + break; + } + } + } else { + for (var i = count - 1; i >= 0; i--) { + if (tileStart(i) < pixels) { + target = tileStart(i) + tile + trailing - viewport; + break; + } + } + } + + // A tile longer than the view never fits, so it moves a whole viewport. + final fallback = forward ? pixels + viewport : pixels - viewport; + final moves = + target != null && (forward ? target > pixels : target < pixels); + + await _controller.animateTo( + (moves ? target : fallback).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final inset = context.streamSpacing.sm; + final isRtl = Directionality.of(context) == TextDirection.rtl; + + final (startIcon, endIcon) = switch ((widget.isHorizontal, isRtl)) { + (false, _) => (icons.chevronUp, icons.chevronDown), + (true, false) => (icons.chevronLeft, icons.chevronRight), + (true, true) => (icons.chevronRight, icons.chevronLeft), + }; + + return ValueListenableBuilder<_BarEdges>( + valueListenable: _edges, + builder: (context, edges, child) => Stack( + children: [ + Positioned.fill(child: child!), + _buildButton( + icon: startIcon, + inset: inset, + isStart: true, + edges: edges, + ), + _buildButton( + icon: endIcon, + inset: inset, + isStart: false, + edges: edges, + ), + ], + ), + child: _buildList(), + ); + } + + Widget _buildList() { + // Metrics notifications cover layout changes, such as a participant + // joining or the view resizing, and scroll notifications cover scrolling. + return NotificationListener( + onNotification: (notification) => _syncEdges(notification.metrics), + child: NotificationListener( + onNotification: (notification) => _syncEdges(notification.metrics), + child: ListView.separated( + controller: _controller, + padding: widget.listPadding, + itemCount: widget.participants.length, + scrollDirection: widget.isHorizontal + ? Axis.horizontal + : Axis.vertical, + separatorBuilder: (context, index) => + SizedBox.square(dimension: widget.spacing), + itemBuilder: (context, index) { + final participant = widget.participants[index]; + return SizedBox.fromSize( + size: widget.tileSize, + child: widget.participantBuilder.call( + context, + widget.call, + participant, + ), + ); + }, + ), + ), + ); + } + + Widget _buildButton({ + required IconData icon, + required double inset, + required bool isStart, + required _BarEdges edges, + }) { + // The design insets the button's visual, which sits inside a wider tap + // target. + final offset = inset - participantsNavigationButtonTapInset; + + final translations = context.translations; + + final button = ParticipantsNavigationButton( + icon: icon, + tooltip: isStart + ? translations.participantsPrevious + : translations.participantsNext, + visible: isStart ? edges.start : edges.end, + onPressed: () => _scroll(forward: !isStart), + ); + + if (widget.isHorizontal) { + return PositionedDirectional( + start: isStart ? offset : null, + end: isStart ? null : offset, + top: 0, + bottom: 0, + child: Center(widthFactor: 1, child: button), + ); + } + + return Positioned( + top: isStart ? offset : null, + bottom: isStart ? null : offset, + left: 0, + right: 0, + child: Center(heightFactor: 1, child: button), + ); + } +} + extension on ParticipantsBarAlignment { Axis toAxis() { switch (this) { diff --git a/packages/stream_video_flutter/lib/src/call_participants/layout/participants_navigation_button.dart b/packages/stream_video_flutter/lib/src/call_participants/layout/participants_navigation_button.dart new file mode 100644 index 000000000..24ebe16dc --- /dev/null +++ b/packages/stream_video_flutter/lib/src/call_participants/layout/participants_navigation_button.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import '../../../stream_video_flutter.dart'; + +/// The size of the navigation buttons' visual: [StreamButton]'s default. +const participantsNavigationButtonSize = StreamButtonSize.medium; + +/// How far the button's tap target reaches past its visual, on each side. +/// +/// The button's box is [kMinInteractiveDimension], with the visual centred +/// inside it. A layout positioning the button to the design takes this off +/// its own inset. +final participantsNavigationButtonTapInset = + (kMinInteractiveDimension - participantsNavigationButtonSize.value) / 2; + +/// A chevron on an elevated circle that moves the participants bar or grid +/// along. +/// +/// While [visible] is false the button is scaled away, keeping its place in +/// the layout, and can neither be tapped nor read out. +class ParticipantsNavigationButton extends StatelessWidget { + const ParticipantsNavigationButton({ + super.key, + required this.icon, + required this.tooltip, + this.visible = true, + this.onPressed, + }); + + /// The chevron the button carries. + final IconData icon; + + /// Names the button for a screen reader, and on hover. + final String tooltip; + + /// Whether the button is shown. + final bool visible; + + /// Called when the button is pressed. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + // A zero transform cannot be inverted, so a scaled-away button also drops + // out of the hit test. + return ExcludeSemantics( + excluding: !visible, + child: AnimatedScale( + scale: visible ? 1 : 0, + duration: kThemeAnimationDuration, + child: StreamButton.icon( + icon: Icon(icon), + style: .secondary, + type: .ghost, + isFloating: true, + tooltip: tooltip, + onPressed: onPressed, + themeStyle: StreamButtonThemeStyle(iconSize: .all(16)), + ), + ), + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_en.arb b/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_en.arb index 888e3112d..ee8c5c2b9 100644 --- a/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_en.arb +++ b/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_en.arb @@ -71,6 +71,14 @@ "@layoutSpeakerOneToOne": { "description": "The participant layout showing only the speaker and the local participant" }, + "participantsPrevious": "Previous participants", + "@participantsPrevious": { + "description": "Tooltip of the button that moves a participant layout back — the bar towards its first tile, the grid to the page before" + }, + "participantsNext": "Next participants", + "@participantsNext": { + "description": "Tooltip of the button that moves a participant layout on — the bar towards its last tile, the grid to the page after" + }, "livestreamBackstageStartingSoon": "Livestream will start soon", "@livestreamBackstageStartingSoon": { "description": "Label for livestream backstage when live stream will soon start" diff --git a/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_nl.arb b/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_nl.arb index 9bda4ae2a..dea454ee6 100644 --- a/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_nl.arb +++ b/packages/stream_video_flutter/lib/src/l10n/arb/stream_video_flutter_nl.arb @@ -17,6 +17,8 @@ "layoutSpeakerLeft": "Spreker (links)", "layoutSpeakerRight": "Spreker (rechts)", "layoutSpeakerOneToOne": "Spreker 1:1", + "participantsPrevious": "Vorige deelnemers", + "participantsNext": "Volgende deelnemers", "livestreamBackstageStartingSoon": "Livestream begint binnenkort", "livestreamBackstageStartingIn": "Livestream begint over:", "livestreamBackstageParticipants": "{count, plural, zero{Nog geen deelnemers zijn} one{Eén deelnemer is} other{{count} deelnemers zijn}} vroeg aanwezig", diff --git a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations.dart b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations.dart index 097038e32..449f297bb 100644 --- a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations.dart +++ b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations.dart @@ -209,6 +209,18 @@ abstract class StreamVideoFlutterLocalizations { /// **'Speaker 1:1'** String get layoutSpeakerOneToOne; + /// Tooltip of the button that moves a participant layout back — the bar towards its first tile, the grid to the page before + /// + /// In en, this message translates to: + /// **'Previous participants'** + String get participantsPrevious; + + /// Tooltip of the button that moves a participant layout on — the bar towards its last tile, the grid to the page after + /// + /// In en, this message translates to: + /// **'Next participants'** + String get participantsNext; + /// Label for livestream backstage when live stream will soon start /// /// In en, this message translates to: diff --git a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_en.dart b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_en.dart index 7ccb9dc1c..dcf10c6f8 100644 --- a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_en.dart +++ b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_en.dart @@ -64,6 +64,12 @@ class StreamVideoFlutterLocalizationsEn @override String get layoutSpeakerOneToOne => 'Speaker 1:1'; + @override + String get participantsPrevious => 'Previous participants'; + + @override + String get participantsNext => 'Next participants'; + @override String get livestreamBackstageStartingSoon => 'Livestream will start soon'; diff --git a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_nl.dart b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_nl.dart index 6e078dc52..5330b6f19 100644 --- a/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_nl.dart +++ b/packages/stream_video_flutter/lib/src/l10n/localizations/stream_video_flutter_localizations_nl.dart @@ -64,6 +64,12 @@ class StreamVideoFlutterLocalizationsNl @override String get layoutSpeakerOneToOne => 'Spreker 1:1'; + @override + String get participantsPrevious => 'Vorige deelnemers'; + + @override + String get participantsNext => 'Volgende deelnemers'; + @override String get livestreamBackstageStartingSoon => 'Livestream begint binnenkort'; diff --git a/packages/stream_video_flutter/test/src/call_participants/call_participants_grid_view_test.dart b/packages/stream_video_flutter/test/src/call_participants/call_participants_grid_view_test.dart index e4f7230b7..6123f34e4 100644 --- a/packages/stream_video_flutter/test/src/call_participants/call_participants_grid_view_test.dart +++ b/packages/stream_video_flutter/test/src/call_participants/call_participants_grid_view_test.dart @@ -5,6 +5,8 @@ import 'package:mocktail/mocktail.dart'; // content — so it is reached directly. // ignore: implementation_imports import 'package:stream_video_flutter/src/call_participants/layout/call_participants_grid_view.dart'; +// ignore: implementation_imports +import 'package:stream_video_flutter/src/call_participants/layout/participants_navigation_button.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart' hide Finder; import '../../test_utils/test_wrapper.dart'; @@ -47,8 +49,9 @@ void main() { WidgetTester tester, { required Size size, required List participants, - EdgeInsets? padding, + EdgeInsetsGeometry? padding, StreamCallParticipantsGridThemeData? theme, + TextDirection textDirection = TextDirection.ltr, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; @@ -66,7 +69,12 @@ void main() { } return tester.pumpWidget( - TestWrapper(child: SizedBox.expand(child: grid)), + TestWrapper( + child: Directionality( + textDirection: textDirection, + child: SizedBox.expand(child: grid), + ), + ), ); } @@ -235,7 +243,7 @@ void main() { participants: _participants(6), ); - expect(find.byType(PageNavigationButton), findsNothing); + expect(find.byType(ParticipantsNavigationButton), findsNothing); }); testWidgets('chevrons once there is a second page', (tester) async { @@ -245,7 +253,7 @@ void main() { participants: _participants(7), ); - expect(find.byType(PageNavigationButton), findsNWidgets(2)); + expect(find.byType(ParticipantsNavigationButton), findsNWidgets(2)); // Six of the seven are on the first page. expect(_tile('p5'), findsOneWidget); expect(_tile('p6'), findsNothing); @@ -258,7 +266,7 @@ void main() { participants: _participants(12), ); - expect(find.byType(PageNavigationButton), findsNothing); + expect(find.byType(ParticipantsNavigationButton), findsNothing); }); testWidgets('the last page is not stranded when people leave', ( @@ -270,7 +278,7 @@ void main() { participants: _participants(12), ); - await tester.tap(find.byType(PageNavigationButton).last); + await tester.tap(find.byType(ParticipantsNavigationButton).last); await tester.pumpAndSettle(); expect(_tile('p6'), findsOneWidget); @@ -283,7 +291,206 @@ void main() { await tester.pumpAndSettle(); expect(_tile('p0'), findsOneWidget); - expect(find.byType(PageNavigationButton), findsNothing); + expect(find.byType(ParticipantsNavigationButton), findsNothing); + }); + + /// The visual of the [index]th chevron, without the tap target that + /// reaches past it. + Rect chevron(WidgetTester tester, int index) => tester + .getRect(find.byType(ParticipantsNavigationButton).at(index)) + .deflate((kMinInteractiveDimension - 40) / 2); + + testWidgets('a narrow window keeps its width and is overlaid', ( + tester, + ) async { + // Three pages of six, moved onto the middle one so both chevrons are + // drawn — a hidden one is scaled to nothing and has no width to read. + await pump( + tester, + size: const Size(400, 672), + participants: _participants(18), + ); + await tester.tap(find.byType(ParticipantsNavigationButton).last); + await tester.pumpAndSettle(); + + // The design draws the chevron 16 in from the view: the grid's own 8 of + // padding and 8 more inside it. The tiles keep the full width and run + // underneath. + expect(chevron(tester, 0).left, 16); + expect(400 - chevron(tester, 1).right, 16); + expect(tester.getRect(_tile('p6')).left, 8); + }); + + testWidgets('a narrow window insets each chevron by its own side', ( + tester, + ) async { + await pump( + tester, + size: const Size(400, 672), + participants: _participants(18), + padding: const EdgeInsetsDirectional.fromSTEB(24, 8, 8, 8), + textDirection: TextDirection.rtl, + ); + await tester.tap(find.byType(ParticipantsNavigationButton).last); + await tester.pumpAndSettle(); + + // Right to left, back sits at the start, on the right: 24 of padding + // and 8 inside it. Forward gets the 8 at the end, on the left. + expect(400 - chevron(tester, 0).right, 32); + expect(chevron(tester, 1).left, 16); + }); + + testWidgets('a wider window sets the grid between the chevrons', ( + tester, + ) async { + // Three pages of twelve at this width, again on the middle one. + await pump( + tester, + size: const Size(768, 880), + participants: _participants(36), + ); + await tester.tap(find.byType(ParticipantsNavigationButton).last); + await tester.pumpAndSettle(); + + // 8 of padding, the 40 chevron, then 8 before the tiles start. + final back = chevron(tester, 0); + expect(back.left, 8); + expect(back.width, 40); + expect(768 - chevron(tester, 1).right, 8); + expect( + tester.getRect(_tile('p12')).left, + greaterThanOrEqualTo(back.right + 8), + ); + }); + + testWidgets('the chevrons name themselves for a screen reader', ( + tester, + ) async { + // Disposed inline: a tear-down runs after the check that every handle + // was let go of. + final semantics = tester.ensureSemantics(); + + // The middle page of three, so both are shown. + await pump( + tester, + size: const Size(400, 672), + participants: _participants(18), + ); + await tester.tap(find.byType(ParticipantsNavigationButton).last); + await tester.pumpAndSettle(); + + for (final (icon, label) in [ + (StreamIconData.chevronLeft, 'Previous participants'), + (StreamIconData.chevronRight, 'Next participants'), + ]) { + final button = find.ancestor( + of: find.byIcon(icon), + matching: find.byTooltip(label), + ); + expect(button, findsOneWidget, reason: '$label is on the wrong button'); + expect(tester.getSemantics(button).tooltip, label); + } + + semantics.dispose(); + }); + + testWidgets('only offer the way on from the first page', (tester) async { + await pump( + tester, + size: const Size(400, 672), + participants: _participants(12), + ); + + expect(_chevronScales(tester), [0.0, 1.0]); + }); + + testWidgets('only offer the way back from the last page', (tester) async { + await pump( + tester, + size: const Size(400, 672), + participants: _participants(12), + ); + await tester.tap(find.byType(ParticipantsNavigationButton).last); + await tester.pumpAndSettle(); + + expect(_tile('p6'), findsOneWidget); + expect(_chevronScales(tester), [1.0, 0.0]); + }); + + testWidgets('go back a page', (tester) async { + await pump( + tester, + size: const Size(400, 672), + participants: _participants(12), + ); + await tester.tap(find.byType(ParticipantsNavigationButton).last); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ParticipantsNavigationButton).first); + await tester.pumpAndSettle(); + + expect(_tile('p0'), findsOneWidget); + expect(_tile('p6'), findsNothing); + expect(_chevronScales(tester), [0.0, 1.0]); + }); + + testWidgets('a hidden chevron is not read out', (tester) async { + // Disposed inline: a tear-down runs after the check that every handle + // was let go of. + final semantics = tester.ensureSemantics(); + + await pump( + tester, + size: const Size(400, 672), + participants: _participants(12), + ); + + SemanticsFinder tooltip(String label) => + find.semantics.byPredicate((node) => node.tooltip == label); + + expect(tooltip('Previous participants'), findsNothing); + expect(tooltip('Next participants'), findsOne); + + semantics.dispose(); + }); + + testWidgets('lead the other way when the grid reads right to left', ( + tester, + ) async { + await pump( + tester, + size: const Size(400, 672), + participants: _participants(12), + textDirection: TextDirection.rtl, + ); + + // On the first page only the way on shows, on the left, pointing left. + final on = find.ancestor( + of: find.byIcon(StreamIconData.chevronLeft), + matching: find.byType(ParticipantsNavigationButton), + ); + expect(tester.getCenter(on).dx, lessThan(200)); + expect(_chevronScales(tester), [0.0, 1.0]); + + await tester.tap(on); + await tester.pumpAndSettle(); + + expect(_tile('p6'), findsOneWidget); + }); + + testWidgets('a wider window keeps the grid the same size on every page', ( + tester, + ) async { + await pump( + tester, + size: const Size(768, 880), + participants: _participants(24), + ); + final first = tester.getRect(_tile('p0')); + + await tester.tap(find.byType(ParticipantsNavigationButton).last); + await tester.pumpAndSettle(); + + expect(tester.getRect(_tile('p12')), first); }); testWidgets('a page that comes back is reachable again', (tester) async { @@ -293,9 +500,9 @@ void main() { size: const Size(400, 672), participants: _participants(18), ); - await tester.tap(find.byType(PageNavigationButton).last); + await tester.tap(find.byType(ParticipantsNavigationButton).last); await tester.pumpAndSettle(); - await tester.tap(find.byType(PageNavigationButton).last); + await tester.tap(find.byType(ParticipantsNavigationButton).last); await tester.pumpAndSettle(); expect(_tile('p12'), findsOneWidget); @@ -352,7 +559,7 @@ void main() { theme: const StreamCallParticipantsGridThemeData(pageSize: 4), ); - expect(find.byType(PageNavigationButton), findsNWidgets(2)); + expect(find.byType(ParticipantsNavigationButton), findsNWidgets(2)); expect(_tile('p3'), findsOneWidget); expect(_tile('p4'), findsNothing); }); @@ -365,7 +572,7 @@ void main() { theme: const StreamCallParticipantsGridThemeData(compactPageSize: 2), ); - expect(find.byType(PageNavigationButton), findsNWidgets(2)); + expect(find.byType(ParticipantsNavigationButton), findsNWidgets(2)); expect(_tile('p1'), findsOneWidget); expect(_tile('p2'), findsNothing); }); diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_grid_view_golden_test.dart b/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_grid_view_golden_test.dart index 831c9b046..3682d56a1 100644 --- a/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_grid_view_golden_test.dart +++ b/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_grid_view_golden_test.dart @@ -20,12 +20,14 @@ const _names = [ 'Alex Cole', ]; -MockCallParticipantState _participant(String name) { +/// A participant called [name], identified by [id] where the names run out. +MockCallParticipantState _participant(String name, {String? id}) { final participant = MockCallParticipantState(); + final key = id ?? name; when(() => participant.name).thenReturn(name); - when(() => participant.userId).thenReturn(name); - when(() => participant.sessionId).thenReturn(name); - when(() => participant.uniqueParticipantKey).thenReturn(name); + when(() => participant.userId).thenReturn(key); + when(() => participant.sessionId).thenReturn(key); + when(() => participant.uniqueParticipantKey).thenReturn(key); when(() => participant.image).thenReturn(null); when(() => participant.isLocal).thenReturn(false); when(() => participant.isSpeaking).thenReturn(false); @@ -59,7 +61,10 @@ Widget _grid(Size size, {required int count}) => MediaQuery( size: size, child: CallParticipantsGridView( call: MockCall(), - participants: [for (var i = 0; i < count; i++) _participant(_names[i])], + participants: [ + for (var i = 0; i < count; i++) + _participant(_names[i % _names.length], id: 'p$i'), + ], itemBuilder: _tile, ), ), @@ -115,6 +120,28 @@ void main() { ), ); + streamGoldenTest( + 'CallParticipantsGridView offers the pages it cannot fit', + fileName: 'stream_call_participants_grid_paged', + brightness: brightness, + pumpBeforeTest: pumpBeforeTest, + builder: () => GoldenTestGroup( + columns: 2, + children: [ + // More people than a page holds, so the chevrons come in: over the + // grid while the window is narrow, either side of it once it is not. + GoldenTestScenario( + name: '7 on a narrow window', + child: _grid(const Size(400, 560), count: 7), + ), + GoldenTestScenario( + name: '13 on a wider one', + child: _grid(const Size(768, 560), count: 13), + ), + ], + ), + ); + streamGoldenTest( 'CallParticipantsGridView puts four in a row when the window is short', fileName: 'stream_call_participants_grid_short', diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_spotlight_view_golden_test.dart b/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_spotlight_view_golden_test.dart index 8df0b5a66..1670b5aa9 100644 --- a/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_spotlight_view_golden_test.dart +++ b/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_spotlight_view_golden_test.dart @@ -125,6 +125,32 @@ void main() { ), ); + streamGoldenTest( + 'CallParticipantsSpotlightView offers the bar it cannot fit', + fileName: 'stream_call_participants_spotlight_bar_buttons', + brightness: brightness, + pumpBeforeTest: pumpBeforeTest, + builder: () => GoldenTestGroup( + columns: 2, + children: [ + // More people than either bar has room for, so each carries the + // button that leads to the rest of them. + GoldenTestScenario( + name: 'bar below', + child: _layout(const Size(560, 520), others: 5), + ), + GoldenTestScenario( + name: 'bar to the right', + child: _layout( + const Size(768, 400), + others: 5, + barAlignment: ParticipantsBarAlignment.right, + ), + ), + ], + ), + ); + streamGoldenTest( 'CallParticipantsSpotlightView stops the stage at 16:9', fileName: 'stream_call_participants_spotlight_large', diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_spotlight_view_test.dart b/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_spotlight_view_test.dart index 5025e8d2b..27f4424c6 100644 --- a/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_spotlight_view_test.dart +++ b/packages/stream_video_flutter/test/src/call_participants/layout/call_participants_spotlight_view_test.dart @@ -34,6 +34,21 @@ Widget _stageBox(BuildContext _, Call __, CallParticipantState participant) => Finder _stageTile(String id) => find.byKey(ValueKey('stage-$id')); +/// The target scale of the [AnimatedScale] around the button carrying [icon]. +/// +/// The bar's buttons are scaled away rather than taken out, so this reads what +/// the button is meant to be doing without waiting on the animation. +double _buttonScale(WidgetTester tester, IconData icon) => tester + .widget( + find + .ancestor(of: find.byIcon(icon), matching: find.byType(AnimatedScale)) + .first, + ) + .scale; + +double _scrolled(WidgetTester tester) => + tester.state(find.byType(Scrollable)).position.pixels; + void main() { // StreamScreenSize reads MediaQuery.sizeOf, so the case's width has to come // from the surface itself — a SizedBox inside the default 800x600 one leaves @@ -46,6 +61,8 @@ void main() { StreamCallParticipantsSpotlightStyle? style, EdgeInsetsGeometry? padding, CallParticipantBuilder? spotlightBuilder, + CallParticipantBuilder participantBuilder = _box, + TextDirection textDirection = TextDirection.ltr, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; @@ -57,7 +74,7 @@ void main() { participants: [ for (var i = 0; i < barParticipants; i++) _participant('bar$i'), ], - participantBuilder: _box, + participantBuilder: participantBuilder, spotlightBuilder: spotlightBuilder, barAlignment: barAlignment, padding: padding, @@ -70,7 +87,14 @@ void main() { ); } - return tester.pumpWidget(TestWrapper(child: SizedBox.expand(child: view))); + return tester.pumpWidget( + TestWrapper( + child: Directionality( + textDirection: textDirection, + child: SizedBox.expand(child: view), + ), + ), + ); } group('the stage', () { @@ -310,6 +334,313 @@ void main() { }); }); + group("the bar's scroll buttons", () { + const overflowing = Size(400, 656); + + Future pumpBar( + WidgetTester tester, { + int barParticipants = 5, + Size size = overflowing, + ParticipantsBarAlignment barAlignment = ParticipantsBarAlignment.bottom, + TextDirection textDirection = TextDirection.ltr, + }) async { + await pump( + tester, + size: size, + barParticipants: barParticipants, + barAlignment: barAlignment, + textDirection: textDirection, + ); + // The list reports its metrics in a microtask after it lays out, so the + // buttons come in a frame behind the first one, and then scale up. + await tester.pumpAndSettle(); + } + + testWidgets('stay away while the tiles fit', (tester) async { + await pumpBar(tester, barParticipants: 1); + + expect(_buttonScale(tester, StreamIconData.chevronLeft), 0); + expect(_buttonScale(tester, StreamIconData.chevronRight), 0); + }); + + testWidgets('offer the way on once they do not', (tester) async { + await pumpBar(tester); + + // Nothing behind the bar to start with, and the rest of it ahead. + expect(_buttonScale(tester, StreamIconData.chevronLeft), 0); + expect(_buttonScale(tester, StreamIconData.chevronRight), 1); + }); + + testWidgets('bring the tile cut off at the end to the start', ( + tester, + ) async { + await pumpBar(tester); + + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + + // The second 222 tile ran from 238 to 460, past the 400 the view is + // wide. It now starts the 8 of padding in, where the first one did. + expect(_scrolled(tester), 230); + expect(tester.getRect(_tile('bar1')).left, 8); + }); + + testWidgets('bring the tile cut off at the start to the end', ( + tester, + ) async { + await pumpBar(tester); + + for (var i = 0; i < 2; i++) { + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + } + await tester.tap(find.byIcon(StreamIconData.chevronLeft)); + await tester.pumpAndSettle(); + + // Two steps on, at 460, the third tile starts the view and the second, + // at 238 to 460, sits just before it. Back ends that one 8 short of + // the far edge. + expect(tester.getRect(_tile('bar1')).right, 392); + }); + + testWidgets('scroll back to the start', (tester) async { + await pumpBar(tester); + + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(StreamIconData.chevronLeft)); + await tester.pumpAndSettle(); + + expect(_scrolled(tester), 0); + }); + + testWidgets('stop at the end rather than overshooting it', (tester) async { + await pumpBar(tester, barParticipants: 3); + + for (var i = 0; i < 2; i++) { + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + } + + // 3 x 222 and two 8px gaps is 682, plus the 16 of padding, less the 400 + // the view is wide. + expect(_scrolled(tester), 298); + }); + + testWidgets('turn around at the end of the bar', (tester) async { + await pumpBar(tester, barParticipants: 3); + + for (var i = 0; i < 2; i++) { + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + } + + expect(_buttonScale(tester, StreamIconData.chevronRight), 0); + expect(_buttonScale(tester, StreamIconData.chevronLeft), 1); + }); + + testWidgets('both show in the middle of the bar', (tester) async { + await pumpBar(tester); + + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + + // 5 tiles run to 1142, so 400 in leaves something either way. + expect(_buttonScale(tester, StreamIconData.chevronLeft), 1); + expect(_buttonScale(tester, StreamIconData.chevronRight), 1); + }); + + testWidgets('sit at the ends of the bar, inside it', (tester) async { + // Both of them, so the one at the start is drawn rather than scaled to + // nothing — a zero transform leaves it no position to read. + await pumpBar(tester); + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + + final bar = tester.getRect(find.byType(ListView)); + final back = tester.getCenter(find.byIcon(StreamIconData.chevronLeft)); + final on = tester.getCenter(find.byIcon(StreamIconData.chevronRight)); + + // 12 of inset and half of the 40-wide button. + expect(back.dx - bar.left, 32); + expect(bar.right - on.dx, 32); + expect(back.dy, bar.center.dy); + expect(on.dy, bar.center.dy); + }); + + for (final alignment in const [ + ParticipantsBarAlignment.top, + ParticipantsBarAlignment.bottom, + ]) { + testWidgets('run along a bar aligned ${alignment.name}', (tester) async { + await pumpBar(tester, barAlignment: alignment); + + expect(_buttonScale(tester, StreamIconData.chevronRight), 1); + + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + + expect(_scrolled(tester), 230); + }); + } + + for (final alignment in const [ + ParticipantsBarAlignment.left, + ParticipantsBarAlignment.right, + ]) { + testWidgets('run up and down a bar aligned ${alignment.name}', ( + tester, + ) async { + // 8 tiles of 125 and seven 8px gaps is 1056 against the 656 the view + // is tall, so 400 of the bar hangs below it. + await pumpBar( + tester, + size: const Size(1024, 656), + barParticipants: 8, + barAlignment: alignment, + ); + + expect(_buttonScale(tester, StreamIconData.chevronUp), 0); + expect(_buttonScale(tester, StreamIconData.chevronDown), 1); + + await tester.tap(find.byIcon(StreamIconData.chevronDown)); + await tester.pumpAndSettle(); + + expect(_scrolled(tester), 400); + expect(_buttonScale(tester, StreamIconData.chevronUp), 1); + expect(_buttonScale(tester, StreamIconData.chevronDown), 0); + }); + } + + testWidgets('name themselves for a screen reader', (tester) async { + // Disposed inline: a tear-down runs after the check that every handle + // was let go of. + final semantics = tester.ensureSemantics(); + + await pumpBar(tester); + + // Both of them, so the one at the start is named as well as the one on. + await tester.tap(find.byIcon(StreamIconData.chevronRight)); + await tester.pumpAndSettle(); + + // The chevron each label belongs to, so they cannot be the wrong way + // round, and then the semantics the label actually reaches. + for (final (icon, label) in [ + (StreamIconData.chevronLeft, 'Previous participants'), + (StreamIconData.chevronRight, 'Next participants'), + ]) { + final button = find.ancestor( + of: find.byIcon(icon), + matching: find.byTooltip(label), + ); + expect(button, findsOneWidget, reason: '$label is on the wrong button'); + expect(tester.getSemantics(button).tooltip, label); + } + + semantics.dispose(); + }); + + testWidgets('lead the other way when the bar reads right to left', ( + tester, + ) async { + await pumpBar(tester, textDirection: TextDirection.rtl); + + // The bar starts at its right-hand edge, so the button pointing further + // along it is the one on the left. + expect(_buttonScale(tester, StreamIconData.chevronLeft), 1); + expect(_buttonScale(tester, StreamIconData.chevronRight), 0); + + await tester.tap(find.byIcon(StreamIconData.chevronLeft)); + await tester.pumpAndSettle(); + + // Measured from the right, the second tile now starts where the first + // did. + expect(_scrolled(tester), 230); + expect(400 - tester.getRect(_tile('bar1')).right, 8); + }); + + testWidgets('sit at the ends of a bar that reads right to left', ( + tester, + ) async { + await pumpBar(tester, textDirection: TextDirection.rtl); + await tester.tap(find.byIcon(StreamIconData.chevronLeft)); + await tester.pumpAndSettle(); + + // The start is on the right, so the chevron back along the bar is the + // right-hand one. + final bar = tester.getRect(find.byType(ListView)); + final back = tester.getCenter(find.byIcon(StreamIconData.chevronRight)); + final on = tester.getCenter(find.byIcon(StreamIconData.chevronLeft)); + + expect(bar.right - back.dx, 32); + expect(on.dx - bar.left, 32); + }); + + testWidgets('sit at the ends of a vertical bar', (tester) async { + await pumpBar( + tester, + size: const Size(1024, 656), + barParticipants: 8, + barAlignment: ParticipantsBarAlignment.right, + ); + await tester.tap(find.byIcon(StreamIconData.chevronDown)); + await tester.pumpAndSettle(); + + final bar = tester.getRect(find.byType(ListView)); + final up = tester.getCenter(find.byIcon(StreamIconData.chevronUp)); + final down = tester.getCenter(find.byIcon(StreamIconData.chevronDown)); + + expect(up.dy - bar.top, 32); + expect(bar.bottom - down.dy, 32); + expect(up.dx, bar.center.dx); + }); + + testWidgets('come in when a participant joins a bar that fit', ( + tester, + ) async { + await pumpBar(tester, barParticipants: 1); + expect(_buttonScale(tester, StreamIconData.chevronRight), 0); + + // Nothing scrolls: the list only changes length. + await pumpBar(tester); + + expect(_buttonScale(tester, StreamIconData.chevronRight), 1); + }); + + testWidgets('go away when the window grows to fit the bar', ( + tester, + ) async { + await pumpBar(tester, barParticipants: 3); + expect(_buttonScale(tester, StreamIconData.chevronRight), 1); + + await pumpBar(tester, barParticipants: 3, size: const Size(1024, 656)); + + expect(_buttonScale(tester, StreamIconData.chevronRight), 0); + }); + + testWidgets('a hidden button lets a tap through to the tile', ( + tester, + ) async { + var tapped = 0; + await pump( + tester, + size: overflowing, + barParticipants: 5, + participantBuilder: (context, call, participant) => GestureDetector( + onTap: () => tapped++, + child: _box(context, call, participant), + ), + ); + await tester.pumpAndSettle(); + + // Where the back button would be, were there anything before the bar. + final bar = tester.getRect(find.byType(ListView)); + await tester.tapAt(Offset(bar.left + 32, bar.center.dy)); + + expect(tapped, 1); + }); + }); + group('the padding argument', () { testWidgets('overrides the style', (tester) async { await pump( diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_grid_paged_dark.png b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_grid_paged_dark.png new file mode 100644 index 000000000..b15dc73dc Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_grid_paged_dark.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_grid_paged_light.png b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_grid_paged_light.png new file mode 100644 index 000000000..0e5cfd8c9 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_grid_paged_light.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_bar_buttons_dark.png b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_bar_buttons_dark.png new file mode 100644 index 000000000..3672007a7 Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_bar_buttons_dark.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_bar_buttons_light.png b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_bar_buttons_light.png new file mode 100644 index 000000000..6c2ead22e Binary files /dev/null and b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_bar_buttons_light.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_small_dark.png b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_small_dark.png index 059d6e3c6..aadb9e3b1 100644 Binary files a/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_small_dark.png and b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_small_dark.png differ diff --git a/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_small_light.png b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_small_light.png index a99656854..28fecdbc9 100644 Binary files a/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_small_light.png and b/packages/stream_video_flutter/test/src/call_participants/layout/goldens/ci/stream_call_participants_spotlight_small_light.png differ