diff --git a/dogfooding/lib/screens/call_screen.dart b/dogfooding/lib/screens/call_screen.dart index 31fd51305..391ca41bc 100644 --- a/dogfooding/lib/screens/call_screen.dart +++ b/dogfooding/lib/screens/call_screen.dart @@ -766,26 +766,55 @@ class __ShowChatButtonState extends State<_ShowChatButton> { Future _customDesktopScreenShareSelector( BuildContext context, ) { - final stateNotifier = ScreenSelectorStateNotifier( - sourceTypes: [SourceType.Screen], - ); - return showModalBottomSheet( context: context, - builder: (BuildContext context) { - return ValueListenableBuilder( - valueListenable: stateNotifier, - builder: - (BuildContext context, ScreenSelectorState value, Widget? child) => - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ThumbnailGrid( - sources: value.sources.values.toList(), - selectedSource: value.selectedSource, - onSelectSource: (source) => Navigator.pop(context, source), - ), - ), - ); - }, + builder: (context) => const _ScreenOnlySelectorSheet(), ); } + +class _ScreenOnlySelectorSheet extends StatefulWidget { + const _ScreenOnlySelectorSheet(); + + @override + State<_ScreenOnlySelectorSheet> createState() => + _ScreenOnlySelectorSheetState(); +} + +class _ScreenOnlySelectorSheetState extends State<_ScreenOnlySelectorSheet> { + late final _controller = ScreenShareSourceController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + final sources = [ + for (final source in state.sources) + if (source.type == SourceType.Screen) source, + ]; + + return GridView.builder( + padding: const EdgeInsets.all(16), + itemCount: sources.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + mainAxisExtent: 164, + ), + itemBuilder: (context, index) => StreamScreenShareThumbnail( + source: sources[index], + selected: false, + onTap: (source) => Navigator.pop(context, source), + ), + ); + }, + ); + } +} diff --git a/dogfooding/macos/Podfile b/dogfooding/macos/Podfile index 10dfe2343..86a124687 100644 --- a/dogfooding/macos/Podfile +++ b/dogfooding/macos/Podfile @@ -46,7 +46,7 @@ post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) target.build_configurations.each do |config| - config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.15' + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.0' end end end diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 9c70a21c9..829c148f1 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -2,6 +2,13 @@ ### ✅ Added +- Added `StreamModalDialog` and `showStreamModalDialog`, a centered modal surface with a title, header actions and a footer, over a blurred `StreamBlurScrim`. +- Added `StreamTabBar`, a row of equal-width tabs whose selected index the caller owns. It, `StreamModalDialog` and `StreamBlurScrim` are design-system candidates, living in `src/widgets/design_system_candidates` until they graduate to core. +- Added `StreamScreenShareDialog`, the desktop screen share picker as a widget, so it can be presented some way other than through `showDefaultScreenSelectionDialog`. +- Added `StreamScreenShareSelector`, the redesigned grid of screens and windows behind the desktop screen share picker, and `StreamScreenShareThumbnail`, one tile of it. +- Added `ScreenShareSourceController`, which holds the screens and windows on offer and the one that is picked. +- Added `StreamScreenShareSelectorThemeData` on `StreamVideoTheme`, and `StreamScreenShareSelectorTheme` to restyle the selector over a subtree. +- Added `desktopScreenShareRefresh` and `desktopScreenShareNoSources` to the localizations, in English and Dutch. - `StreamLayoutButton` draws the participant layout in effect and offers the rest through a `StreamAdaptiveMenuAnchor`. - `StreamLayoutButton.defaultLayouts` is `auto` and `speakerBottom`, so the button toggles unless it is given more. - Added layout strings to the localizations, in English and Dutch: `layoutMenuTitle`, `layoutSelectTooltip`, `layoutDefault`, `layoutGrid`, `layoutSpeakerTop`, `layoutSpeakerBottom`, `layoutSpeakerLeft`, `layoutSpeakerRight` and `layoutSpeakerOneToOne`. @@ -204,6 +211,8 @@ ### ⚠️ Breaking +- The desktop screen share picker is rebuilt on the design system. `TabbedScreenSelectWidget`, `ThumbnailGrid`, `ScreenSelectorStateNotifier` and `ScreenSelectorState` are gone; `StreamScreenShareSelector` and `ScreenShareSourceController` replace them. `showDefaultScreenSelectionDialog` keeps its signature. +- `ScreenShareThumbnailWidget` is `StreamScreenShareThumbnail` now, and takes the thumbnail from the source it is given rather than subscribing for one. - `ParticipantLayoutMode.auto` is the default layout of `StreamCallContent`, `StreamCallParticipants` and `RegularCallParticipantsContent`, and renders what `grid` used to. The livestream widgets still default to `grid`. - `ParticipantLayoutMode.grid` gives the local participant a tile of its own instead of floating them over the grid. - `ParticipantLayoutMode.auto` floats the self-view on mobile only while at most two other people are in the call, and gives the local participant a tile beyond that. @@ -268,6 +277,9 @@ ### 🔄 Changed +- The desktop screen share picker reads the platform's screens and windows once, and again on its refresh button, instead of re-enumerating and re-capturing all of them every two seconds. +- The picker asks the platform for 480x300 thumbnails where the platform honours a size; macOS captures at its own. +- The picker's sources are released whichever way it is dismissed, including the escape key and a tap outside. - `StreamLobbyView` is restyled onto the design system — its typography, spacing and icons come from `StreamTheme`, and the close action is a ghost `StreamButton` instead of a Material `IconButton`. - Requires `stream_core_flutter` 0.5.0 for the button styles, error badge and theme accessors the components above use. 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 d9767b914..d3ed8a91d 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 @@ -19,6 +19,14 @@ "@desktopScreenShareWindow": { "description": "Tab to select a single window to share" }, + "desktopScreenShareRefresh": "Refresh", + "@desktopScreenShareRefresh": { + "description": "Tooltip of the action that re-reads the screens and windows on offer" + }, + "desktopScreenShareNoSources": "Nothing to share here.", + "@desktopScreenShareNoSources": { + "description": "Shown in place of the grid when the platform offers no screen or window of the selected type" + }, "layoutMenuTitle": "Layout", "@layoutMenuTitle": { "description": "Title of the sheet that picks how participants are laid out" 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 5d885657e..8d8f087b1 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 @@ -4,6 +4,8 @@ "desktopScreenShareChooseDialogCancel": "Annuleren", "desktopScreenShareEntireScreen": "Volledig scherm", "desktopScreenShareWindow": "Venster", + "desktopScreenShareRefresh": "Vernieuwen", + "desktopScreenShareNoSources": "Hier valt niets te delen.", "layoutMenuTitle": "Indeling", "layoutSelectTooltip": "Indeling wijzigen", "layoutDefault": "Standaard", 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 6054e6c02..1cb36277e 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 @@ -131,6 +131,18 @@ abstract class StreamVideoFlutterLocalizations { /// **'Window'** String get desktopScreenShareWindow; + /// Tooltip of the action that re-reads the screens and windows on offer + /// + /// In en, this message translates to: + /// **'Refresh'** + String get desktopScreenShareRefresh; + + /// Shown in place of the grid when the platform offers no screen or window of the selected type + /// + /// In en, this message translates to: + /// **'Nothing to share here.'** + String get desktopScreenShareNoSources; + /// Title of the sheet that picks how participants are laid out /// /// 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 1ac4e4d72..b8737b957 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 @@ -24,6 +24,12 @@ class StreamVideoFlutterLocalizationsEn @override String get desktopScreenShareWindow => 'Window'; + @override + String get desktopScreenShareRefresh => 'Refresh'; + + @override + String get desktopScreenShareNoSources => 'Nothing to share here.'; + @override String get layoutMenuTitle => 'Layout'; 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 bed363915..4bca0594c 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 @@ -24,6 +24,12 @@ class StreamVideoFlutterLocalizationsNl @override String get desktopScreenShareWindow => 'Venster'; + @override + String get desktopScreenShareRefresh => 'Vernieuwen'; + + @override + String get desktopScreenShareNoSources => 'Hier valt niets te delen.'; + @override String get layoutMenuTitle => 'Indeling'; diff --git a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart index 8120c0b66..5d09f9316 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/desktop_screen_selector.dart @@ -1,163 +1,261 @@ -// ignore_for_file: comment_references - import 'package:flutter/material.dart'; import '../../stream_video_flutter.dart'; import '../l10n/localization_extension.dart'; +import 'screen_share_selector_defaults.dart'; +/// Picks the screen or window to share, on a platform that offers no chooser +/// of its own. typedef DesktopScreenSelectorBuilder = Future Function( BuildContext context, ); -/// Default screen selection dialog. This shows a dialog with 2 tabs for screens and windows. -/// Can be styled using overlay from [StreamColorTheme]; body, bodyBold and tabBar from [StreamTextTheme]. +/// Shows the default screen selection dialog: the screens and windows on +/// offer, in two tabs, over a blurred scrim. +/// +/// Resolves to the picked source, or null when the dialog was cancelled or +/// dismissed. /// -/// For more customizations you can use [TabbedScreenSelectWidget] or [ThumbnailGrid] directly. +/// Style it through [StreamScreenShareSelectorTheme]. For a picker of a +/// different shape, build one out of [StreamScreenShareDialog], +/// [StreamScreenShareSelector] or [StreamScreenShareThumbnail] and pass it to +/// [StreamScreenShareButton.desktopScreenSelectorBuilder]. Future showDefaultScreenSelectionDialog( BuildContext context, ) { - final streamVideoTheme = StreamVideoTheme.of(context); - final screenSelectorState = ScreenSelectorStateNotifier(); - final translations = context.translations; - - return showDialog( + return showStreamModalDialog( context: context, - builder: (context) => AlertDialog( - title: Text(translations.desktopScreenShareChooseDialogTitle), - backgroundColor: streamVideoTheme.colorTheme.overlay, - content: TabbedScreenSelectWidget( - screenSelectorState: screenSelectorState, - ), - actions: [ - TextButton( - child: Text(translations.desktopScreenShareChooseDialogCancel), - onPressed: () { - Navigator.pop(context); - screenSelectorState.dispose(); - }, - ), - ElevatedButton( - child: Text(translations.desktopScreenShareChooseDialogShare), - onPressed: () { - Navigator.pop( - context, - screenSelectorState.value.selectedSource, - ); - screenSelectorState.dispose(); - }, - ), - ], - ), + builder: (context) => const StreamScreenShareDialog(), ); } -class TabbedScreenSelectWidget extends StatelessWidget { - const TabbedScreenSelectWidget({ - required ScreenSelectorStateNotifier screenSelectorState, +/// The default screen share picker, as a widget: a [StreamModalDialog] around +/// a [StreamScreenShareSelector], with a refresh action in the header and +/// Cancel and Share in the footer. +/// +/// Pops the [Navigator] with the picked source, or with nothing when +/// cancelled. [showDefaultScreenSelectionDialog] shows it over a scrim; use +/// this directly to present it some other way. +class StreamScreenShareDialog extends StatefulWidget { + /// Creates a screen share dialog. + const StreamScreenShareDialog({super.key, this.controller}); + + /// Holds the sources on offer and the one that is picked. + /// + /// Null builds one — and disposes it — for the life of the dialog, which is + /// what [showDefaultScreenSelectionDialog] does. A controller passed here + /// belongs to the caller, who disposes it. + final ScreenShareSourceController? controller; + + @override + State createState() => + _StreamScreenShareDialogState(); +} + +class _StreamScreenShareDialogState extends State { + ScreenShareSourceController? _ownedController; + + ScreenShareSourceController get _controller => + widget.controller ?? _ownedController!; + + @override + void initState() { + super.initState(); + // Built here rather than lazily on first build: a controller starts + // reading the platform as soon as it exists, which is not something to do + // as a side effect of building. + if (widget.controller == null) { + _ownedController = ScreenShareSourceController(); + } + } + + @override + void didUpdateWidget(StreamScreenShareDialog oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == oldWidget.controller) return; + + if (widget.controller == null) { + _ownedController ??= ScreenShareSourceController(); + } else { + // A controller arriving where the dialog had been making its own leaves + // the owned one with nothing to drive. + _ownedController?.dispose(); + _ownedController = null; + } + } + + @override + void dispose() { + _ownedController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final translations = context.translations; + final controller = _controller; + + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, _) => StreamModalDialog( + title: Text(translations.desktopScreenShareChooseDialogTitle), + headerActions: [ + StreamButton.icon( + icon: Icon(context.streamIcons.refresh), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + tooltip: translations.desktopScreenShareRefresh, + onPressed: state.isLoading ? null : controller.refresh, + ), + ], + actions: [ + StreamButton( + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () => Navigator.pop(context), + child: Text(translations.desktopScreenShareChooseDialogCancel), + ), + StreamButton( + onPressed: switch (state.selectedSource) { + final source? => () => Navigator.pop( + context, + source, + ), + null => null, + }, + child: Text(translations.desktopScreenShareChooseDialogShare), + ), + ], + child: StreamScreenShareSelector(controller: controller), + ), + ); + } +} + +/// The body of the screen share picker: a tab per source type over a grid of +/// [StreamScreenShareThumbnail]s. +/// +/// Reports a pick to the [controller], which the caller reads to find out what +/// to share. The caller owns the controller and disposes it. +/// +/// {@tool snippet} +/// +/// ```dart +/// StreamScreenShareSelector(controller: _controller) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [showDefaultScreenSelectionDialog], which shows this in a dialog. +/// * [StreamScreenShareSelectorTheme], for restyling it over a subtree. +class StreamScreenShareSelector extends StatelessWidget { + /// Creates a screen share selector. + const StreamScreenShareSelector({ super.key, - }) : _screenSelectorState = screenSelectorState; - final ScreenSelectorStateNotifier _screenSelectorState; - Map get _sources => - _screenSelectorState.value.sources; - DesktopCapturerSource? get _selectedSource => - _screenSelectorState.value.selectedSource; + required this.controller, + this.style, + }); + + /// Holds the sources on offer and the one that is picked. + final ScreenShareSourceController controller; + + /// Overrides for the selector's styling. + /// + /// Merged over the ambient [StreamScreenShareSelectorTheme]. + final StreamScreenShareSelectorStyle? style; @override Widget build(BuildContext context) { final translations = context.translations; - return SizedBox( - width: 640, - height: 560, - child: ValueListenableBuilder( - valueListenable: _screenSelectorState, - builder: (context, state, _) { - final streamVideoTheme = StreamVideoTheme.of(context); - final textTheme = streamVideoTheme.textTheme; - - return DefaultTabController( - length: 2, - child: Column( - children: [ - TabBar( - onTap: (value) => _screenSelectorState.setSourceType( - [if (value == 0) SourceType.Screen else SourceType.Window], - ), - tabs: - [ - translations.desktopScreenShareEntireScreen, - translations.desktopScreenShareWindow, - ] - .map( - (e) => Tab(child: Text(e, style: textTheme.tabBar)), - ) - .toList(), - ), - Expanded( - child: TabBarView( - children: [ - ThumbnailGrid( - sources: _sources.values - .where( - (element) => element.type == SourceType.Screen, - ) - .toList(), - selectedSource: _selectedSource, - onSelectSource: _screenSelectorState.setSelectedSource, - ), - ThumbnailGrid( - crossAxisCount: 3, - sources: _sources.values - .where( - (element) => element.type == SourceType.Window, - ) - .toList(), - selectedSource: _selectedSource, - onSelectSource: _screenSelectorState.setSelectedSource, - ), - ], - ), - ), - ], + const types = [SourceType.Screen, SourceType.Window]; + final labels = [ + translations.desktopScreenShareEntireScreen, + translations.desktopScreenShareWindow, + ]; + + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, _) => Column( + children: [ + StreamTabBar( + selectedIndex: types.indexOf(state.sourceType), + onSelected: (index) => controller.setSourceType(types[index]), + tabs: [ + for (final label in labels) StreamTabBarItem(label: label), + ], + ), + Expanded( + child: _SourceGrid( + state: state, + style: style, + onSelectSource: controller.setSelectedSource, ), - ); - }, + ), + ], ), ); } } -class ThumbnailGrid extends StatelessWidget { - const ThumbnailGrid({ - required this.sources, - this.crossAxisCount = 2, - this.crossAxisSpacing = 8, - required this.selectedSource, +class _SourceGrid extends StatelessWidget { + const _SourceGrid({ + required this.state, + required this.style, required this.onSelectSource, - super.key, }); - final List sources; - final DesktopCapturerSource? selectedSource; + final ScreenShareSourceState state; + final StreamScreenShareSelectorStyle? style; final OnThumbnailTapped onSelectSource; - final double crossAxisSpacing; - final int crossAxisCount; @override Widget build(BuildContext context) { - return GridView.count( - crossAxisSpacing: crossAxisSpacing, - crossAxisCount: crossAxisCount, - children: sources - .map( - (e) => ScreenShareThumbnailWidget( - onTap: onSelectSource, - source: e, - selected: selectedSource?.id == e.id, + final style = resolveScreenShareSelectorStyle(context, this.style); + final sources = state.visibleSources; + + if (sources.isEmpty) { + // A first load has nothing to show yet; a refresh keeps the grid it has. + if (state.isLoading) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + return Padding( + padding: style.padding, + child: Center( + child: Text( + context.translations.desktopScreenShareNoSources, + textAlign: TextAlign.center, + style: context.streamTextTheme.bodyDefault.copyWith( + color: context.streamColorScheme.textSecondary, ), - ) - .toList(), + ), + ), + ); + } + + return GridView.builder( + padding: style.padding, + itemCount: sources.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: style.crossAxisCount, + crossAxisSpacing: style.spacing, + mainAxisSpacing: style.spacing, + mainAxisExtent: style.tileHeight, + ), + itemBuilder: (context, index) { + final source = sources[index]; + return StreamScreenShareThumbnail( + key: ValueKey(source.id), + source: source, + thumbnail: state.thumbnailFor(source), + selected: state.selectedSourceId == source.id, + onTap: onSelectSource, + style: this.style, + ); + }, ); } } diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_selector_state_notifier.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_selector_state_notifier.dart deleted file mode 100644 index 5f541823b..000000000 --- a/packages/stream_video_flutter/lib/src/screen_share/screen_selector_state_notifier.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; - -import 'desktop_screen_selector.dart'; -import 'screen_share_logger.dart'; - -/// The [ScreenSelectorStateNotifier] is used to keep track of the available screens and windows to share. -/// This is used by [showDefaultScreenSelectionDialog], but can be used directly if you want to build a custom widget. -/// Can be used in combination with a [ValueListenableBuilder] to get stateful updates. -class ScreenSelectorStateNotifier extends ValueNotifier { - /// Constructor of the [ScreenSelectorStateNotifier]. The [sourceTypes] can be used to set which source types are loaded first. - ScreenSelectorStateNotifier({ - List sourceTypes = const [SourceType.Screen], - }) : super(ScreenSelectorState._(sourceTypes: sourceTypes)) { - _subscriptions.add( - desktopCapturer.onAdded.stream.listen((source) { - final map = Map.from(value.sources); - map[source.id] = source; - value = value._copyWith(sources: UnmodifiableMapView(map)); - }), - ); - - _subscriptions.add( - desktopCapturer.onRemoved.stream.listen((source) { - final map = Map.from(value.sources); - map.remove(source.id); - value = value._copyWith(sources: UnmodifiableMapView(map)); - }), - ); - - _subscriptions.add( - desktopCapturer.onThumbnailChanged.stream.listen((source) { - value = value._copyWith(); - }), - ); - - _getSources(); - } - - final List> _subscriptions = []; - Timer? _timer; - - /// Update the sourceTypes. It's recommended to only show [SourceType.Screen] or - /// [SourceType.Window], but it is possible to show both. - void setSourceType(List sourceTypes) { - if (listEquals(sourceTypes, value.sourceTypes)) return; - - value = value._copyWith(sources: {}, sourceTypes: sourceTypes); - _getSources(); - } - - /// Updates the current selected source. Has no real effect other than the - /// option to show the selection in the UI. - void setSelectedSource(DesktopCapturerSource source) { - value = value._copyWith(selectedSource: source); - } - - @override - void dispose() { - _timer?.cancel(); - for (final subscription in _subscriptions) { - subscription.cancel(); - } - super.dispose(); - } - - Future _getSources() async { - try { - _timer?.cancel(); - final capturerSources = await desktopCapturer.getSources( - types: value.sourceTypes, - ); - _timer = Timer.periodic(const Duration(seconds: 2), (timer) { - desktopCapturer.updateSources(types: value.sourceTypes); - }); - final sources = {}; - for (final capturerSource in capturerSources) { - sources[capturerSource.id] = capturerSource; - } - - value = value._copyWith(sources: sources); - } catch (e) { - screenShareLogger.e(() => '[_getSources] failed: $e'); - } - } -} - -@immutable -class ScreenSelectorState { - const ScreenSelectorState._({ - this.sources = const {}, - required this.sourceTypes, - this.selectedSource, - }); - final Map sources; - final List sourceTypes; - final DesktopCapturerSource? selectedSource; - - ScreenSelectorState _copyWith({ - Map? sources, - List? sourceTypes, - DesktopCapturerSource? selectedSource, - }) { - return ScreenSelectorState._( - sources: sources ?? this.sources, - sourceTypes: sourceTypes ?? this.sourceTypes, - selectedSource: selectedSource ?? this.selectedSource, - ); - } -} diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share.dart index 6aa28e439..0eb2d1e03 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/screen_share.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share.dart @@ -2,5 +2,5 @@ export 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' show DesktopCapturerSource, SourceType; export 'desktop_screen_selector.dart'; -export 'screen_selector_state_notifier.dart'; +export 'screen_share_source_controller.dart'; export 'screen_share_thumbnail_widget.dart'; diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share_selector_defaults.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share_selector_defaults.dart new file mode 100644 index 000000000..7dfaa9ac7 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share_selector_defaults.dart @@ -0,0 +1,102 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../stream_video_flutter.dart'; + +/// Resolves the style a [StreamScreenShareSelector] draws itself with. +/// +/// Merges the ambient [StreamScreenShareSelectorTheme] with [style] and fills +/// in whatever neither supplied, so callers can read every property without a +/// fallback of their own. +@internal +StreamScreenShareSelectorStyleDefaults resolveScreenShareSelectorStyle( + BuildContext context, + StreamScreenShareSelectorStyle? style, +) { + final themeStyle = StreamScreenShareSelectorTheme.of(context).style; + return StreamScreenShareSelectorStyleDefaults( + context, + themeStyle?.merge(style) ?? style, + ); +} + +/// Default style values for [StreamScreenShareSelector]. +/// +/// Shared with the thumbnails the grid is built from, so a default lives in +/// one place rather than once per widget that draws it. Deliberately not +/// exported; reach it through [resolveScreenShareSelectorStyle]. +@internal +class StreamScreenShareSelectorStyleDefaults + extends StreamScreenShareSelectorStyle { + /// Resolves the selector's defaults from the theme on the given context, + /// letting [_style] win wherever it has a value. + StreamScreenShareSelectorStyleDefaults(this._context, [this._style]); + + final BuildContext _context; + final StreamScreenShareSelectorStyle? _style; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + + @override + EdgeInsetsGeometry get padding => + _style?.padding ?? EdgeInsets.all(_spacing.xxl); + + @override + int get crossAxisCount => _style?.crossAxisCount ?? 3; + + @override + double get spacing => _style?.spacing ?? _spacing.md; + + @override + double get tileHeight => _style?.tileHeight ?? 164; + + @override + EdgeInsetsGeometry get tilePadding => + _style?.tilePadding ?? EdgeInsets.all(_spacing.xs); + + @override + double get tileSpacing => _style?.tileSpacing ?? _spacing.xs; + + @override + BorderRadius get tileBorderRadius => + _style?.tileBorderRadius ?? BorderRadius.all(_radius.xl); + + @override + BorderRadius get imageBorderRadius => + _style?.imageBorderRadius ?? BorderRadius.all(_radius.md); + + @override + Color get borderColor => _style?.borderColor ?? _colorScheme.borderDefault; + + @override + double get borderWidth => _style?.borderWidth ?? 1; + + @override + Color get selectedBorderColor => + _style?.selectedBorderColor ?? _colorScheme.accentPrimary; + + @override + double get selectedBorderWidth => _style?.selectedBorderWidth ?? 2; + + @override + Color get placeholderColor => + _style?.placeholderColor ?? _colorScheme.backgroundSurfaceSubtle; + + @override + TextStyle get labelTextStyle => + _style?.labelTextStyle ?? _textTheme.captionEmphasis; + + @override + Color get labelColor => _style?.labelColor ?? _colorScheme.textTertiary; + + @override + Color get selectedLabelColor => + _style?.selectedLabelColor ?? _colorScheme.accentPrimary; + + @override + Size get thumbnailSize => + _style?.thumbnailSize ?? ScreenShareSourceController.defaultThumbnailSize; +} diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart new file mode 100644 index 000000000..326609a27 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share_source_controller.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; + +import 'screen_share_logger.dart'; + +/// The screens and windows a desktop user can share, loaded once and held +/// until asked to reload. +/// +/// Used by `showDefaultScreenSelectionDialog`, and directly by a custom +/// picker. Pair it with a [ValueListenableBuilder] to rebuild as the load +/// finishes. +/// +/// A load makes the platform enumerate every screen and window and capture a +/// bitmap of each, which is expensive enough to be visible, so the list is a +/// snapshot: it is read on construction and again on [refresh], and never on a +/// timer. A source opened afterwards appears once the user asks for it. +class ScreenShareSourceController + extends ValueNotifier { + /// Creates a controller and starts loading. + /// + /// [sourceType] is the type shown first. Both types are loaded either way, + /// so switching between them costs nothing. + ScreenShareSourceController({ + DesktopCapturer? capturer, + SourceType sourceType = SourceType.Screen, + Size thumbnailSize = defaultThumbnailSize, + }) : _capturer = capturer ?? desktopCapturer, + _thumbnailSize = thumbnailSize, + super(ScreenShareSourceState(sourceType: sourceType)) { + // Subscribed before the first load, because the enumeration raises its + // events while it runs. These getters have no implementation to fall back + // on in the platform interface, so a capturer that reports its bitmaps + // inline and raises no events need not provide them. + try { + _events.addAll([ + _capturer.onAdded.stream.listen(_onThumbnail), + _capturer.onThumbnailChanged.stream.listen(_onThumbnail), + ]); + // The platform interface reports "this capturer has no such stream" by + // throwing from the getter, so there is nothing else to catch here. + // ignore: avoid_catching_errors + } on UnimplementedError catch (e) { + screenShareLogger.w(() => '[init] capturer posts no thumbnails: $e'); + } + + unawaited(refresh()); + } + + /// The resolution asked of the platform for each thumbnail. + /// + /// Honoured where the platform offers a choice; macOS captures at its own + /// fixed size whatever this says. + static const defaultThumbnailSize = Size(480, 300); + + static const _types = [SourceType.Screen, SourceType.Window]; + + final DesktopCapturer _capturer; + final Size _thumbnailSize; + final List> _events = []; + bool _disposed = false; + + /// Shows the sources of [sourceType], leaving the loaded list alone. + /// + /// Both types are already in [ScreenShareSourceState.sources], so this is a + /// filter rather than a reload. + void setSourceType(SourceType sourceType) { + if (sourceType == value.sourceType) return; + value = value.copyWith(sourceType: sourceType); + } + + /// Marks [source] as the one to share. + void setSelectedSource(DesktopCapturerSource source) { + value = value.copyWith(selectedSourceId: source.id); + } + + /// Re-reads the screens and windows from the platform. + /// + /// Does nothing while a load is already running. A selection that is no + /// longer on offer is dropped. + Future refresh() async { + if (value.isLoading) return; + value = value.copyWith(isLoading: true, error: null); + + try { + final sources = await _capturer.getSources( + types: _types, + thumbnailSize: ThumbnailSize( + _thumbnailSize.width.round(), + _thumbnailSize.height.round(), + ), + ); + + if (_disposed) return; + value = value._withSources(List.unmodifiable(sources)); + + // Not every platform puts the bitmaps in the `getSources` result — + // macOS leaves them out and reports them through the events the + // enumeration raises, which is what the subscriptions above are for. + // Only if something is still missing is it worth asking for a capture + // pass, since that recaptures every screen and window. Deliberately not + // on a timer: the old picker's two-second one spent exactly this to + // redraw what was already on screen. + if (value.sources.any((it) => value.thumbnailFor(it) == null)) { + await _capturer.updateSources(types: _types); + } + } catch (e, stk) { + screenShareLogger.e(() => '[refresh] failed: $e, $stk'); + if (_disposed) return; + value = value.copyWith(isLoading: false, error: e); + } + } + + void _onThumbnail(DesktopCapturerSource source) { + final thumbnail = source.thumbnail; + if (_disposed || thumbnail == null) return; + + value = value.copyWith( + thumbnails: {...value.thumbnails, source.id: thumbnail}, + ); + } + + @override + void dispose() { + _disposed = true; + for (final subscription in _events) { + unawaited(subscription.cancel()); + } + super.dispose(); + } +} + +/// The state a [ScreenShareSourceController] holds. +@immutable +class ScreenShareSourceState { + /// Creates a screen share source state. + const ScreenShareSourceState({ + required this.sourceType, + this.sources = const [], + this.thumbnails = const {}, + this.selectedSourceId, + this.isLoading = false, + this.error, + }); + + /// Every screen and window the last load found, in the order the platform + /// reported them. + final List sources; + + /// The bitmap of each source that has reported one, by source id. + /// + /// Held here rather than read off the source because the platform delivers + /// thumbnails after the source list, and a reload replaces the source + /// objects. + final Map thumbnails; + + /// The type of source being shown. + final SourceType sourceType; + + /// The id of the source the user picked, if any. + final String? selectedSourceId; + + /// Whether a load is running. + final bool isLoading; + + /// What the last load failed with, if it did. + final Object? error; + + /// The sources of [sourceType]. + List get visibleSources => [ + for (final source in sources) + if (source.type == sourceType) source, + ]; + + /// The source the user picked, or null when nothing is picked or the pick is + /// no longer on offer. + DesktopCapturerSource? get selectedSource { + for (final source in sources) { + if (source.id == selectedSourceId) return source; + } + return null; + } + + /// The bitmap to draw for [source], if there is one yet. + Uint8List? thumbnailFor(DesktopCapturerSource source) => + thumbnails[source.id] ?? source.thumbnail; + + /// A copy holding [sources], with the thumbnails of sources that are no + /// longer on offer dropped and the load marked finished. + ScreenShareSourceState _withSources(List sources) { + final ids = {for (final source in sources) source.id}; + + return ScreenShareSourceState( + sources: sources, + thumbnails: { + for (final entry in thumbnails.entries) + if (ids.contains(entry.key)) entry.key: entry.value, + }, + sourceType: sourceType, + selectedSourceId: selectedSourceId, + ); + } + + /// Creates a copy of this state with the given fields replaced. + /// + /// [error] is cleared by passing null explicitly; the other nullable fields + /// are left alone when omitted. + ScreenShareSourceState copyWith({ + List? sources, + Map? thumbnails, + SourceType? sourceType, + String? selectedSourceId, + bool? isLoading, + Object? error = _unchanged, + }) { + return ScreenShareSourceState( + sources: sources ?? this.sources, + thumbnails: thumbnails ?? this.thumbnails, + sourceType: sourceType ?? this.sourceType, + selectedSourceId: selectedSourceId ?? this.selectedSourceId, + isLoading: isLoading ?? this.isLoading, + error: identical(error, _unchanged) ? this.error : error, + ); + } + + static const _unchanged = Object(); +} diff --git a/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart b/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart index 8937634a7..b383e466b 100644 --- a/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart +++ b/packages/stream_video_flutter/lib/src/screen_share/screen_share_thumbnail_widget.dart @@ -1,112 +1,111 @@ -import 'dart:async'; -import 'dart:typed_data'; - import 'package:flutter/material.dart'; -import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; -import '../theme/stream_video_theme.dart'; +import '../../stream_video_flutter.dart'; import 'screen_share_logger.dart'; +import 'screen_share_selector_defaults.dart'; +/// Called with the source a thumbnail stands for. typedef OnThumbnailTapped = void Function(DesktopCapturerSource); -class ScreenShareThumbnailWidget extends StatefulWidget { - const ScreenShareThumbnailWidget({ +/// One screen or window in a [StreamScreenShareSelector]: a still of the +/// source over its name, outlined in the accent color while it is picked. +/// +/// Mirrors the `Web / Screen Share Thumbnail` component from the design. +/// +/// The still is the bitmap the platform captured when the source was loaded, +/// which is what [ScreenShareSourceController] holds. It is not a live preview +/// and does not update on its own. +class StreamScreenShareThumbnail extends StatelessWidget { + /// Creates a screen share thumbnail. + const StreamScreenShareThumbnail({ super.key, required this.source, required this.selected, required this.onTap, + this.thumbnail, + this.style, }); - final DesktopCapturerSource source; - final bool selected; - final OnThumbnailTapped onTap; - @override - State createState() => - _ScreenShareThumbnailWidgetState(); -} - -class _ScreenShareThumbnailWidgetState - extends State { - final List> _subscriptions = []; - Uint8List? _thumbnail; - @override - void initState() { - super.initState(); - _subscribe(); - } + /// The screen or window this stands for. + final DesktopCapturerSource source; - @override - void didUpdateWidget(covariant ScreenShareThumbnailWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.source.id != oldWidget.source.id) { - _unsubscribe(); - _subscribe(); - } - } + /// Whether this is the source the user picked. + final bool selected; - @override - void dispose() { - _unsubscribe(); - super.dispose(); - } + /// Called with [source] when the thumbnail is tapped. + final OnThumbnailTapped onTap; - void _subscribe() { - _subscriptions.add( - widget.source.onThumbnailChanged.stream.listen((event) { - setState(() { - _thumbnail = event; - }); - }), - ); - _subscriptions.add( - widget.source.onNameChanged.stream.listen((event) { - setState(() {}); - }), - ); - } + /// The bitmap to draw. + /// + /// Defaults to the one on [source]. Pass it explicitly where the platform + /// reports thumbnails separately from the source list, as + /// [ScreenShareSourceState.thumbnailFor] does. + final Uint8List? thumbnail; - void _unsubscribe() { - for (final element in _subscriptions) { - element.cancel(); - } - _subscriptions.clear(); - } + /// Overrides for the thumbnail's styling. + /// + /// Merged over the ambient [StreamScreenShareSelectorTheme]. + final StreamScreenShareSelectorStyle? style; @override Widget build(BuildContext context) { - final theme = StreamVideoTheme.of(context); - final textTheme = theme.textTheme; + final style = resolveScreenShareSelectorStyle(context, this.style); + final thumbnail = this.thumbnail ?? source.thumbnail; - return Column( - children: [ - Expanded( - child: DecoratedBox( - decoration: widget.selected - ? BoxDecoration( - border: Border.all(width: 2, color: Colors.blueAccent), - ) - : const BoxDecoration(), - child: InkWell( - onTap: () { - screenShareLogger.d( - () => 'Selected source id => ${widget.source.id}', - ); - widget.onTap(widget.source); - }, - child: _thumbnail != null - ? Image.memory( - _thumbnail!, - gaplessPlayback: true, - ) - : Container(), + return Semantics( + selected: selected, + button: true, + label: source.name, + child: InkWell( + onTap: () { + screenShareLogger.d(() => 'Selected source id => ${source.id}'); + onTap(source); + }, + borderRadius: style.tileBorderRadius, + child: Container( + padding: style.tilePadding, + // A foreground border paints inside the tile, so the extra pixel a + // selected tile's border carries does not resize it. + foregroundDecoration: BoxDecoration( + borderRadius: style.tileBorderRadius, + border: Border.all( + color: selected ? style.selectedBorderColor : style.borderColor, + width: selected ? style.selectedBorderWidth : style.borderWidth, ), ), + child: Column( + spacing: style.tileSpacing, + children: [ + Expanded( + child: ClipRRect( + borderRadius: style.imageBorderRadius, + child: ColoredBox( + color: style.placeholderColor, + child: thumbnail == null + ? const SizedBox.expand() + : SizedBox.expand( + child: Image.memory( + thumbnail, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + ), + ), + ), + Text( + source.name, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: style.labelTextStyle.copyWith( + color: selected ? style.selectedLabelColor : style.labelColor, + ), + ), + ], + ), ), - Text( - widget.source.name, - style: widget.selected ? textTheme.bodyBold : textTheme.body, - ), - ], + ), ); } } diff --git a/packages/stream_video_flutter/lib/src/theme/components/components.dart b/packages/stream_video_flutter/lib/src/theme/components/components.dart index 2f1b1f398..eb0cc8162 100644 --- a/packages/stream_video_flutter/lib/src/theme/components/components.dart +++ b/packages/stream_video_flutter/lib/src/theme/components/components.dart @@ -5,3 +5,4 @@ export 'floating_participant_tile_theme.dart'; export 'lobby_view_theme.dart'; export 'participant_label_theme.dart'; export 'participant_tile_theme.dart'; +export 'screen_share_selector_theme.dart'; diff --git a/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.dart b/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.dart new file mode 100644 index 000000000..a3b60ca4c --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.dart @@ -0,0 +1,223 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../../stream_video_flutter.dart'; + +part 'screen_share_selector_theme.g.theme.dart'; + +/// Applies a screen share selector theme to descendant +/// [StreamScreenShareSelector] widgets. +/// +/// Wrap a subtree with [StreamScreenShareSelectorTheme] to override the +/// styling of the grid a desktop user picks a screen or window from. +/// +/// {@tool snippet} +/// +/// Draw the sources two to a row, in taller tiles: +/// +/// ```dart +/// StreamScreenShareSelectorTheme( +/// data: StreamScreenShareSelectorThemeData( +/// style: StreamScreenShareSelectorStyle( +/// crossAxisCount: 2, +/// tileHeight: 220, +/// ), +/// ), +/// child: child, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamScreenShareSelectorThemeData], which describes the theme. +/// * [StreamScreenShareSelectorStyle], the visual style it carries. +class StreamScreenShareSelectorTheme extends InheritedTheme { + /// Creates a screen share selector theme. + const StreamScreenShareSelectorTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The selector theme data for descendant widgets. + final StreamScreenShareSelectorThemeData data; + + /// Returns the [StreamScreenShareSelectorThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamScreenShareSelectorTheme] ancestor + /// take precedence over the global values from + /// [StreamVideoTheme.screenShareSelectorTheme]. This allows partial + /// overrides: setting only [StreamScreenShareSelectorStyle.crossAxisCount] + /// leaves the remaining properties coming from the global theme. + static StreamScreenShareSelectorThemeData of(BuildContext context) { + final localTheme = context + .dependOnInheritedWidgetOfExactType(); + return StreamVideoTheme.of( + context, + ).screenShareSelectorTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamScreenShareSelectorTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamScreenShareSelectorTheme oldWidget) => + data != oldWidget.data; +} + +/// Theme data for customizing [StreamScreenShareSelector] widgets. +/// +/// Wraps a [StreamScreenShareSelectorStyle] so it can be served by +/// [StreamScreenShareSelectorTheme] and slotted into [StreamVideoTheme] +/// alongside the other component theme data classes. +/// +/// See also: +/// +/// * [StreamScreenShareSelectorStyle], the style embedded here. +/// * [StreamScreenShareSelectorTheme], for overriding it in a subtree. +@themeGen +@immutable +class StreamScreenShareSelectorThemeData + with _$StreamScreenShareSelectorThemeData { + /// Creates screen share selector theme data. + const StreamScreenShareSelectorThemeData({this.style}); + + /// Visual styling for the selector. + final StreamScreenShareSelectorStyle? style; + + /// Linearly interpolate between two theme data objects. + static StreamScreenShareSelectorThemeData? lerp( + StreamScreenShareSelectorThemeData? a, + StreamScreenShareSelectorThemeData? b, + double t, + ) => _$StreamScreenShareSelectorThemeData.lerp(a, b, t); +} + +/// Visual styling properties for a [StreamScreenShareSelector]. +/// +/// The selector is a grid of thumbnails, one per screen or window the platform +/// offers, with the picked one outlined in the accent color. +@themeGen +@immutable +class StreamScreenShareSelectorStyle with _$StreamScreenShareSelectorStyle { + /// Creates a selector style with optional property overrides. + const StreamScreenShareSelectorStyle({ + this.padding, + this.crossAxisCount, + this.spacing, + this.tileHeight, + this.tilePadding, + this.tileSpacing, + this.tileBorderRadius, + this.imageBorderRadius, + this.borderColor, + this.borderWidth, + this.selectedBorderColor, + this.selectedBorderWidth, + this.placeholderColor, + this.labelTextStyle, + this.labelColor, + this.selectedLabelColor, + this.thumbnailSize, + }); + + /// The inset around the grid. + /// + /// Defaults to `spacing.xxl` on every side. + final EdgeInsetsGeometry? padding; + + /// How many tiles fit in a row. + /// + /// Defaults to 3. + final int? crossAxisCount; + + /// The gap between tiles, in both directions. + /// + /// Defaults to `spacing.md`. + final double? spacing; + + /// The height of a tile, thumbnail and label together. + /// + /// Defaults to 164. + final double? tileHeight; + + /// The inset between a tile's border and its contents. + /// + /// Defaults to `spacing.xs` on every side. + final EdgeInsetsGeometry? tilePadding; + + /// The gap between a tile's thumbnail and its label. + /// + /// Defaults to `spacing.xs`. + final double? tileSpacing; + + /// The corner radius of a tile. + /// + /// Defaults to `radius.xl`. + final BorderRadius? tileBorderRadius; + + /// The corner radius of the thumbnail inside a tile. + /// + /// Defaults to `radius.md`. + final BorderRadius? imageBorderRadius; + + /// The color of an unselected tile's border. + /// + /// Defaults to `colorScheme.borderDefault`. + final Color? borderColor; + + /// The width of an unselected tile's border. + /// + /// Defaults to 1. Both border widths paint inside the tile, so a tile does + /// not resize as it is selected. + final double? borderWidth; + + /// The color of the selected tile's border. + /// + /// Defaults to `colorScheme.accentPrimary`. + final Color? selectedBorderColor; + + /// The width of the selected tile's border. + /// + /// Defaults to 2. + final double? selectedBorderWidth; + + /// The fill drawn where a source has no thumbnail yet. + /// + /// Defaults to `colorScheme.backgroundSurfaceSubtle`. + final Color? placeholderColor; + + /// The text style of a tile's label. + /// + /// Defaults to `textTheme.captionEmphasis`. + final TextStyle? labelTextStyle; + + /// The color of an unselected tile's label. + /// + /// Defaults to `colorScheme.textTertiary`. + final Color? labelColor; + + /// The color of the selected tile's label. + /// + /// Defaults to `colorScheme.accentPrimary`. + final Color? selectedLabelColor; + + /// The resolution asked of the platform for each thumbnail. + /// + /// Defaults to 480x300. The platform captures and encodes one bitmap per + /// screen and window at this size, and the grid decodes all of them, so a + /// larger value costs on both sides for detail a tile this size cannot + /// show. + final Size? thumbnailSize; + + /// Linearly interpolate between two styles. + static StreamScreenShareSelectorStyle? lerp( + StreamScreenShareSelectorStyle? a, + StreamScreenShareSelectorStyle? b, + double t, + ) => _$StreamScreenShareSelectorStyle.lerp(a, b, t); +} diff --git a/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.g.theme.dart b/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_theme.g.theme.dart new file mode 100644 index 000000000..767952328 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/theme/components/screen_share_selector_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 'screen_share_selector_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamScreenShareSelectorThemeData { + bool get canMerge => true; + + static StreamScreenShareSelectorThemeData? lerp( + StreamScreenShareSelectorThemeData? a, + StreamScreenShareSelectorThemeData? 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 StreamScreenShareSelectorThemeData( + style: StreamScreenShareSelectorStyle.lerp(a.style, b.style, t), + ); + } + + StreamScreenShareSelectorThemeData copyWith({ + StreamScreenShareSelectorStyle? style, + }) { + final _this = (this as StreamScreenShareSelectorThemeData); + + return StreamScreenShareSelectorThemeData(style: style ?? _this.style); + } + + StreamScreenShareSelectorThemeData merge( + StreamScreenShareSelectorThemeData? other, + ) { + final _this = (this as StreamScreenShareSelectorThemeData); + + 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 StreamScreenShareSelectorThemeData); + final _other = (other as StreamScreenShareSelectorThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamScreenShareSelectorThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamScreenShareSelectorStyle { + bool get canMerge => true; + + static StreamScreenShareSelectorStyle? lerp( + StreamScreenShareSelectorStyle? a, + StreamScreenShareSelectorStyle? 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 StreamScreenShareSelectorStyle( + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + crossAxisCount: t < 0.5 ? a.crossAxisCount : b.crossAxisCount, + spacing: lerpDouble$(a.spacing, b.spacing, t), + tileHeight: lerpDouble$(a.tileHeight, b.tileHeight, t), + tilePadding: EdgeInsetsGeometry.lerp(a.tilePadding, b.tilePadding, t), + tileSpacing: lerpDouble$(a.tileSpacing, b.tileSpacing, t), + tileBorderRadius: BorderRadius.lerp( + a.tileBorderRadius, + b.tileBorderRadius, + t, + ), + imageBorderRadius: BorderRadius.lerp( + a.imageBorderRadius, + b.imageBorderRadius, + t, + ), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + borderWidth: lerpDouble$(a.borderWidth, b.borderWidth, t), + selectedBorderColor: Color.lerp( + a.selectedBorderColor, + b.selectedBorderColor, + t, + ), + selectedBorderWidth: lerpDouble$( + a.selectedBorderWidth, + b.selectedBorderWidth, + t, + ), + placeholderColor: Color.lerp(a.placeholderColor, b.placeholderColor, t), + labelTextStyle: TextStyle.lerp(a.labelTextStyle, b.labelTextStyle, t), + labelColor: Color.lerp(a.labelColor, b.labelColor, t), + selectedLabelColor: Color.lerp( + a.selectedLabelColor, + b.selectedLabelColor, + t, + ), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamScreenShareSelectorStyle copyWith({ + EdgeInsetsGeometry? padding, + int? crossAxisCount, + double? spacing, + double? tileHeight, + EdgeInsetsGeometry? tilePadding, + double? tileSpacing, + BorderRadius? tileBorderRadius, + BorderRadius? imageBorderRadius, + Color? borderColor, + double? borderWidth, + Color? selectedBorderColor, + double? selectedBorderWidth, + Color? placeholderColor, + TextStyle? labelTextStyle, + Color? labelColor, + Color? selectedLabelColor, + Size? thumbnailSize, + }) { + final _this = (this as StreamScreenShareSelectorStyle); + + return StreamScreenShareSelectorStyle( + padding: padding ?? _this.padding, + crossAxisCount: crossAxisCount ?? _this.crossAxisCount, + spacing: spacing ?? _this.spacing, + tileHeight: tileHeight ?? _this.tileHeight, + tilePadding: tilePadding ?? _this.tilePadding, + tileSpacing: tileSpacing ?? _this.tileSpacing, + tileBorderRadius: tileBorderRadius ?? _this.tileBorderRadius, + imageBorderRadius: imageBorderRadius ?? _this.imageBorderRadius, + borderColor: borderColor ?? _this.borderColor, + borderWidth: borderWidth ?? _this.borderWidth, + selectedBorderColor: selectedBorderColor ?? _this.selectedBorderColor, + selectedBorderWidth: selectedBorderWidth ?? _this.selectedBorderWidth, + placeholderColor: placeholderColor ?? _this.placeholderColor, + labelTextStyle: labelTextStyle ?? _this.labelTextStyle, + labelColor: labelColor ?? _this.labelColor, + selectedLabelColor: selectedLabelColor ?? _this.selectedLabelColor, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamScreenShareSelectorStyle merge(StreamScreenShareSelectorStyle? other) { + final _this = (this as StreamScreenShareSelectorStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + padding: other.padding, + crossAxisCount: other.crossAxisCount, + spacing: other.spacing, + tileHeight: other.tileHeight, + tilePadding: other.tilePadding, + tileSpacing: other.tileSpacing, + tileBorderRadius: other.tileBorderRadius, + imageBorderRadius: other.imageBorderRadius, + borderColor: other.borderColor, + borderWidth: other.borderWidth, + selectedBorderColor: other.selectedBorderColor, + selectedBorderWidth: other.selectedBorderWidth, + placeholderColor: other.placeholderColor, + labelTextStyle: + _this.labelTextStyle?.merge(other.labelTextStyle) ?? + other.labelTextStyle, + labelColor: other.labelColor, + selectedLabelColor: other.selectedLabelColor, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamScreenShareSelectorStyle); + final _other = (other as StreamScreenShareSelectorStyle); + + return _other.padding == _this.padding && + _other.crossAxisCount == _this.crossAxisCount && + _other.spacing == _this.spacing && + _other.tileHeight == _this.tileHeight && + _other.tilePadding == _this.tilePadding && + _other.tileSpacing == _this.tileSpacing && + _other.tileBorderRadius == _this.tileBorderRadius && + _other.imageBorderRadius == _this.imageBorderRadius && + _other.borderColor == _this.borderColor && + _other.borderWidth == _this.borderWidth && + _other.selectedBorderColor == _this.selectedBorderColor && + _other.selectedBorderWidth == _this.selectedBorderWidth && + _other.placeholderColor == _this.placeholderColor && + _other.labelTextStyle == _this.labelTextStyle && + _other.labelColor == _this.labelColor && + _other.selectedLabelColor == _this.selectedLabelColor && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamScreenShareSelectorStyle); + + return Object.hash( + runtimeType, + _this.padding, + _this.crossAxisCount, + _this.spacing, + _this.tileHeight, + _this.tilePadding, + _this.tileSpacing, + _this.tileBorderRadius, + _this.imageBorderRadius, + _this.borderColor, + _this.borderWidth, + _this.selectedBorderColor, + _this.selectedBorderWidth, + _this.placeholderColor, + _this.labelTextStyle, + _this.labelColor, + _this.selectedLabelColor, + _this.thumbnailSize, + ); + } +} 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 d14eab5d6..d933bf436 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 @@ -42,6 +42,7 @@ class StreamVideoTheme extends ThemeExtension { StreamParticipantLabelThemeData? participantLabelTheme, StreamConnectionQualityIndicatorThemeData? connectionQualityIndicatorTheme, StreamCallParticipantsGridThemeData? callParticipantsGridTheme, + StreamScreenShareSelectorThemeData? screenShareSelectorTheme, StreamLivestreamThemeData? livestreamTheme, }) { final isDark = brightness == Brightness.dark; @@ -87,6 +88,7 @@ class StreamVideoTheme extends ThemeExtension { callParticipantsGridTheme: callParticipantsGridTheme ?? legacy?.toCallParticipantsGridThemeData(), + screenShareSelectorTheme: screenShareSelectorTheme, livestreamTheme: livestreamTheme, ); @@ -131,6 +133,7 @@ class StreamVideoTheme extends ThemeExtension { const StreamConnectionQualityIndicatorThemeData(), this.callParticipantsGridTheme = const StreamCallParticipantsGridThemeData(), + this.screenShareSelectorTheme = const StreamScreenShareSelectorThemeData(), required this.livestreamTheme, }); @@ -414,6 +417,9 @@ class StreamVideoTheme extends ThemeExtension { /// Theme for the participants grid layout. final StreamCallParticipantsGridThemeData callParticipantsGridTheme; + /// Theme for the desktop screen share selector. + final StreamScreenShareSelectorThemeData screenShareSelectorTheme; + /// Theme for the outgoing call widget. final StreamLivestreamThemeData livestreamTheme; @@ -454,6 +460,7 @@ class StreamVideoTheme extends ThemeExtension { StreamParticipantLabelThemeData? participantLabelTheme, StreamConnectionQualityIndicatorThemeData? connectionQualityIndicatorTheme, StreamCallParticipantsGridThemeData? callParticipantsGridTheme, + StreamScreenShareSelectorThemeData? screenShareSelectorTheme, StreamLivestreamThemeData? livestreamTheme, }) => StreamVideoTheme.raw( textTheme: this.textTheme.merge(textTheme), @@ -482,6 +489,9 @@ class StreamVideoTheme extends ThemeExtension { callParticipantsGridTheme: this.callParticipantsGridTheme.merge( callParticipantsGridTheme, ), + screenShareSelectorTheme: this.screenShareSelectorTheme.merge( + screenShareSelectorTheme, + ), livestreamTheme: this.livestreamTheme.merge(livestreamTheme), ); @@ -517,6 +527,9 @@ class StreamVideoTheme extends ThemeExtension { callParticipantsGridTheme: callParticipantsGridTheme.merge( other.callParticipantsGridTheme, ), + screenShareSelectorTheme: screenShareSelectorTheme.merge( + other.screenShareSelectorTheme, + ), livestreamTheme: livestreamTheme.merge(other.livestreamTheme), ); } @@ -591,6 +604,13 @@ class StreamVideoTheme extends ThemeExtension { t, ) ?? callParticipantsGridTheme, + screenShareSelectorTheme: + StreamScreenShareSelectorThemeData.lerp( + screenShareSelectorTheme, + other.screenShareSelectorTheme, + t, + ) ?? + screenShareSelectorTheme, livestreamTheme: livestreamTheme.lerp(other.livestreamTheme, t), ); } diff --git a/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_modal_dialog.dart b/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_modal_dialog.dart new file mode 100644 index 000000000..93e087718 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_modal_dialog.dart @@ -0,0 +1,291 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// Shows [builder] as a modal dialog over a blurred scrim. +/// +/// The dialog itself is typically a [StreamModalDialog], which supplies the +/// surface, the header and the footer. +/// +/// Mirrors the `Web / Blur Scrim` component from the design: the barrier is +/// the scrim color over a backdrop blur, rather than the flat translucent +/// black Material's [showDialog] paints. +/// +/// Returns the value the dialog was popped with, or null when it was +/// dismissed. +Future showStreamModalDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + String? barrierLabel, + bool useRootNavigator = true, + RouteSettings? routeSettings, +}) { + final scrimColor = context.streamColorScheme.backgroundScrim; + + return showGeneralDialog( + context: context, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + barrierDismissible: barrierDismissible, + barrierLabel: + barrierLabel ?? + MaterialLocalizations.of(context).modalBarrierDismissLabel, + // The scrim is drawn as part of the transition instead, so that it can + // carry the blur. A colored barrier here would paint a second, flat one + // underneath it. + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 150), + pageBuilder: (context, animation, secondaryAnimation) => builder(context), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final curve = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + + return FadeTransition( + opacity: curve, + child: Stack( + children: [ + // Behind the dialog and out of the way of the modal barrier that + // sits below, which is what a tap outside dismisses. + Positioned.fill( + child: IgnorePointer(child: StreamBlurScrim(color: scrimColor)), + ), + ScaleTransition( + scale: Tween(begin: 0.96, end: 1).animate(curve), + child: child, + ), + ], + ), + ); + }, + ); +} + +/// The scrim a modal surface sits on: a translucent fill over a blur of +/// whatever is behind it. +/// +/// Mirrors the `Web / Blur Scrim` component from the design. +/// +/// This is a design-system candidate: it lives in this SDK until the +/// component is finalized and can graduate to stream_core_flutter. +class StreamBlurScrim extends StatelessWidget { + /// Creates a blur scrim. + const StreamBlurScrim({super.key, this.color, this.blurSigma = 12.5}); + + /// The fill drawn over the blur. + /// + /// Defaults to `colorScheme.backgroundScrim`. + final Color? color; + + /// The blur applied to whatever sits behind the scrim. + /// + /// Defaults to 12.5. Set to `0` to skip the blur, which costs a render + /// layer the size of the window. + final double blurSigma; + + @override + Widget build(BuildContext context) { + final fill = ColoredBox( + color: color ?? context.streamColorScheme.backgroundScrim, + ); + + if (blurSigma <= 0) return fill; + + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), + child: fill, + ); + } +} + +/// A centered modal surface with a title, an optional row of header actions +/// and an optional row of footer actions. +/// +/// Mirrors the `Web / Modal Dialog Header` and `Web / Modal Dialog Footer` +/// components from the design, on an elevation-1 surface. +/// +/// Pass it to [showStreamModalDialog], which supplies the scrim: +/// +/// {@tool snippet} +/// +/// ```dart +/// final confirmed = await showStreamModalDialog( +/// context: context, +/// builder: (context) => StreamModalDialog( +/// title: const Text('Leave the call?'), +/// actions: [ +/// StreamButton( +/// style: StreamButtonStyle.secondary, +/// type: StreamButtonType.ghost, +/// onPressed: () => Navigator.pop(context, false), +/// child: const Text('Stay'), +/// ), +/// StreamButton( +/// style: StreamButtonStyle.destructive, +/// onPressed: () => Navigator.pop(context, true), +/// child: const Text('Leave'), +/// ), +/// ], +/// child: const Text('Everyone else stays in the call.'), +/// ), +/// ); +/// ``` +/// {@end-tool} +/// +/// This is a design-system candidate: it lives in this SDK until the +/// component is finalized and can graduate to stream_core_flutter. +class StreamModalDialog extends StatelessWidget { + /// Creates a modal dialog. + const StreamModalDialog({ + super.key, + this.title, + this.headerActions = const [], + this.showCloseButton = true, + this.onClose, + this.actions = const [], + this.constraints = defaultConstraints, + required this.child, + }); + + /// The size the design gives a modal window. + /// + /// A maximum rather than a fixed size: the dialog shrinks with the window, + /// and takes only the height its content needs. + static const defaultConstraints = BoxConstraints( + maxWidth: 720, + maxHeight: 640, + ); + + /// The dialog's title, drawn at the leading edge of the header. + final Widget? title; + + /// Buttons drawn in the header, before the close button. + /// + /// Typically [StreamButton.icon]s. + final List headerActions; + + /// Whether the header draws a close button after [headerActions]. + final bool showCloseButton; + + /// Called when the close button is pressed. + /// + /// Defaults to popping the dialog with no value. + final VoidCallback? onClose; + + /// Buttons drawn in the footer, aligned to the trailing edge. + /// + /// An empty list draws no footer at all. + final List actions; + + /// The bounds of the dialog. + /// + /// Defaults to [defaultConstraints]. + final BoxConstraints constraints; + + /// The dialog's body, between the header and the footer. + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + + return Dialog( + backgroundColor: colorScheme.backgroundElevation1, + surfaceTintColor: Colors.transparent, + elevation: context.streamElevation.level4, + clipBehavior: Clip.antiAlias, + insetPadding: EdgeInsets.all(spacing.xxl), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(context.streamRadius.xl), + ), + child: ConstrainedBox( + constraints: constraints, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ModalDialogHeader( + title: title, + actions: [ + ...headerActions, + if (showCloseButton) + StreamButton.icon( + icon: Icon(context.streamIcons.xmark), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + onPressed: onClose ?? () => Navigator.of(context).pop(), + ), + ], + ), + Flexible(child: child), + if (actions.isNotEmpty) _ModalDialogFooter(actions: actions), + ], + ), + ), + ); + } +} + +class _ModalDialogHeader extends StatelessWidget { + const _ModalDialogHeader({required this.title, required this.actions}); + + final Widget? title; + final List actions; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Padding( + padding: EdgeInsets.all(spacing.xl), + child: Row( + spacing: spacing.md, + children: [ + Expanded( + child: DefaultTextStyle.merge( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.streamTextTheme.headingSm.copyWith( + color: context.streamColorScheme.textPrimary, + ), + child: title ?? const SizedBox.shrink(), + ), + ), + if (actions.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xs, + children: actions, + ), + ], + ), + ); + } +} + +class _ModalDialogFooter extends StatelessWidget { + const _ModalDialogFooter({required this.actions}); + + final List actions; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Padding( + padding: EdgeInsets.all(spacing.xl), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: spacing.xs, + children: actions, + ), + ); + } +} diff --git a/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_tab_bar.dart b/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_tab_bar.dart new file mode 100644 index 000000000..1b07250c9 --- /dev/null +++ b/packages/stream_video_flutter/lib/src/widgets/design_system_candidates/stream_tab_bar.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// A row of tabs, each taking an equal share of the width, marking the +/// selected one with an accent label over an accent underline. +/// +/// Mirrors the `Web / Tab Bar` component from the design. +/// +/// Unlike Material's [TabBar] this carries no controller and no page view: +/// [selectedIndex] comes from the caller and [onSelected] reports a tap. That +/// keeps a tab switch a plain state change, which is what a tab bar over +/// already-loaded content wants — there is nothing to animate between and +/// nothing to reload. +/// +/// This is a design-system candidate: it lives in this SDK until the +/// component is finalized and can graduate to stream_core_flutter. +class StreamTabBar extends StatelessWidget { + /// Creates a tab bar. + const StreamTabBar({ + super.key, + required this.tabs, + required this.selectedIndex, + required this.onSelected, + }) : assert(tabs.length > 0, 'A tab bar needs at least one tab.'); + + /// The tabs to draw, in order. + final List tabs; + + /// The index into [tabs] of the selected tab. + final int selectedIndex; + + /// Called with the index of the tapped tab. + final ValueChanged onSelected; + + /// The height of the bar, underline included. + static const height = 48.0; + + static const _indicatorWeight = 2.0; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: Row( + children: [ + for (final (index, tab) in tabs.indexed) + Expanded( + child: _StreamTab( + item: tab, + selected: index == selectedIndex, + onPressed: () => onSelected(index), + ), + ), + ], + ), + ); + } +} + +/// One tab of a [StreamTabBar]. +@immutable +class StreamTabBarItem { + /// Creates a tab. + const StreamTabBarItem({required this.label, this.icon}); + + /// The tab's label. + final String label; + + /// An icon drawn before [label]. + final Widget? icon; +} + +class _StreamTab extends StatelessWidget { + const _StreamTab({ + required this.item, + required this.selected, + required this.onPressed, + }); + + final StreamTabBarItem item; + final bool selected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + + final color = selected + ? colorScheme.accentPrimary + : colorScheme.textSecondary; + + return Semantics( + selected: selected, + button: true, + child: InkWell( + onTap: onPressed, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: StreamTabBar._indicatorWeight, + color: selected + ? colorScheme.accentPrimary + : colorScheme.borderDefault, + ), + ), + ), + child: Padding( + padding: EdgeInsets.all(spacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: spacing.xs, + children: [ + if (item.icon case final icon?) + IconTheme.merge( + data: IconThemeData(color: color), + child: icon, + ), + Flexible( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.streamTextTheme.captionEmphasis.copyWith( + color: color, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/stream_video_flutter/lib/stream_video_flutter.dart b/packages/stream_video_flutter/lib/stream_video_flutter.dart index 73434db15..28e5a04c3 100644 --- a/packages/stream_video_flutter/lib/stream_video_flutter.dart +++ b/packages/stream_video_flutter/lib/stream_video_flutter.dart @@ -87,8 +87,10 @@ export 'src/utils/screen_size.dart'; export 'src/widgets/design_system_candidates/stream_adaptive_menu_anchor.dart'; export 'src/widgets/design_system_candidates/stream_context_menu_anchor.dart'; export 'src/widgets/design_system_candidates/stream_context_menu_heading.dart'; +export 'src/widgets/design_system_candidates/stream_modal_dialog.dart'; export 'src/widgets/design_system_candidates/stream_radio_indicator.dart'; export 'src/widgets/design_system_candidates/stream_select_input.dart'; +export 'src/widgets/design_system_candidates/stream_tab_bar.dart'; export 'src/widgets/floating_view/floating_view_alignment.dart'; export 'src/widgets/floating_view/floating_view_container.dart'; export 'src/widgets/partial_call_state_builder.dart'; diff --git a/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart new file mode 100644 index 000000000..3cfab19df --- /dev/null +++ b/packages/stream_video_flutter/test/src/screen_share/desktop_screen_selector_test.dart @@ -0,0 +1,260 @@ +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'; +import 'fake_desktop_capturer.dart'; + +void main() { + late FakeDesktopCapturer capturer; + + final screen1 = FakeDesktopCapturerSource( + id: 'screen-1', + name: 'Screen 1', + type: SourceType.Screen, + thumbnail: blueThumbnail, + ); + final screen2 = FakeDesktopCapturerSource( + id: 'screen-2', + name: 'Screen 2', + type: SourceType.Screen, + thumbnail: greyThumbnail, + ); + final window = FakeDesktopCapturerSource( + id: 'window-1', + name: 'Notes', + type: SourceType.Window, + thumbnail: greyThumbnail, + ); + + setUp(() { + capturer = FakeDesktopCapturer(sources: [screen1, screen2, window]); + addTearDown(capturer.close); + }); + + Future pumpSelector(WidgetTester tester) async { + final controller = ScreenShareSourceController(capturer: capturer); + addTearDown(controller.dispose); + + await tester.pumpWidget( + TestWrapper( + child: SizedBox( + width: 720, + height: 500, + child: StreamScreenShareSelector(controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + return controller; + } + + group('StreamScreenShareSelector', () { + testWidgets('shows the screens of the selected tab', (tester) async { + await pumpSelector(tester); + + expect(find.text('Screen 1'), findsOneWidget); + expect(find.text('Screen 2'), findsOneWidget); + expect(find.text('Notes'), findsNothing); + }); + + testWidgets('switching to the window tab shows windows, and reloads ' + 'nothing', (tester) async { + await pumpSelector(tester); + + await tester.tap(find.text('Window')); + await tester.pumpAndSettle(); + + expect(find.text('Notes'), findsOneWidget); + expect(find.text('Screen 1'), findsNothing); + expect( + capturer.getSourcesCalls, + hasLength(1), + reason: 'both types were loaded up front', + ); + }); + + testWidgets('tapping a thumbnail selects its source', (tester) async { + final controller = await pumpSelector(tester); + + await tester.tap(find.text('Screen 2')); + await tester.pumpAndSettle(); + + expect(controller.value.selectedSource, screen2); + }); + + testWidgets('says so when the platform offers nothing', (tester) async { + capturer.sources = []; + await pumpSelector(tester); + + expect(find.text('Nothing to share here.'), findsOneWidget); + }); + + testWidgets('never polls the platform while it is open', (tester) async { + await pumpSelector(tester); + // These sources came back with their bitmaps, so there is nothing left + // to capture. The old picker asked for a full capture pass every two + // seconds regardless. A leaked Timer.periodic would also fail the test + // outright, at teardown; this says which one it was. + expect(capturer.updateSourcesCallCount, 0); + + await tester.pump(const Duration(seconds: 10)); + + expect(capturer.updateSourcesCallCount, 0); + }); + }); + + group('StreamScreenShareDialog', () { + Future pumpDialog(WidgetTester tester) async { + final controller = ScreenShareSourceController(capturer: capturer); + addTearDown(controller.dispose); + + await tester.pumpWidget( + TestWrapper( + child: StreamScreenShareDialog(controller: controller), + ), + ); + await tester.pumpAndSettle(); + return controller; + } + + testWidgets('the refresh action re-reads the platform', (tester) async { + await pumpDialog(tester); + expect(capturer.getSourcesCalls, hasLength(1)); + + await tester.tap(find.byIcon(const StreamIcons().refresh)); + await tester.pumpAndSettle(); + + expect(capturer.getSourcesCalls, hasLength(2)); + }); + + testWidgets('Share is disabled until a source is picked', (tester) async { + await pumpDialog(tester); + + StreamButton shareButton() => tester.widget( + find.ancestor( + of: find.text('Share'), + matching: find.byType(StreamButton), + ), + ); + + expect(shareButton().props.onPressed, isNull); + + await tester.tap(find.text('Screen 1')); + await tester.pumpAndSettle(); + + expect(shareButton().props.onPressed, isNotNull); + }); + }); + + group('showDefaultScreenSelectionDialog', () { + testWidgets('opens the modal and cancels with nothing', (tester) async { + // The real entry point, on the real global capturer: there is no + // platform behind it under `flutter test`, so the load fails and the + // grid says it has nothing — which is enough to prove the dialog opens, + // dismisses, and returns null. + DesktopCapturerSource? result; + var popped = false; + + await tester.pumpWidget( + TestWrapper( + child: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showDefaultScreenSelectionDialog(context); + popped = true; + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + // Explicit pumps rather than pumpAndSettle: the loading spinner repeats + // forever, and there is no platform here to finish the load. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Choose what to share'), findsOneWidget); + expect(find.text('Entire Screen'), findsOneWidget); + expect(find.text('Window'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(popped, isTrue); + expect(result, isNull); + }); + + testWidgets('shares the picked source and cancels with nothing', ( + tester, + ) async { + // The dialog builds its own controller off the global capturer, which a + // test cannot reach, so the dialog chrome is exercised around a selector + // driven by the fake. + final controller = ScreenShareSourceController(capturer: capturer); + addTearDown(controller.dispose); + + DesktopCapturerSource? result; + var popped = false; + + await tester.pumpWidget( + TestWrapper( + child: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showStreamModalDialog( + context: context, + builder: (context) => ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, _) => StreamModalDialog( + title: const Text('Choose what to share'), + actions: [ + StreamButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + StreamButton( + onPressed: state.selectedSource == null + ? null + : () => Navigator.pop( + context, + state.selectedSource, + ), + child: const Text('Share'), + ), + ], + child: StreamScreenShareSelector(controller: controller), + ), + ), + ); + popped = true; + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Choose what to share'), findsOneWidget); + + // Nothing picked yet, so Share does nothing. + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + expect(popped, isFalse); + + await tester.tap(find.text('Screen 1')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Share')); + await tester.pumpAndSettle(); + + expect(popped, isTrue); + expect(result, screen1); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart b/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart new file mode 100644 index 000000000..e460cc6a1 --- /dev/null +++ b/packages/stream_video_flutter/test/src/screen_share/fake_desktop_capturer.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:stream_video_flutter/stream_video_flutter.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; + +/// A [DesktopCapturer] that hands back the sources it was built with and +/// counts what was asked of it. +/// +/// The real one is a global reached through `desktopCapturer`, which is why +/// [ScreenShareSourceController] takes one. +/// +/// Models how the platform actually delivers bitmaps: macOS leaves them out +/// of the `getSources` result and posts them as events instead. A source built +/// with a bitmap of its own stands for the platforms that do return them +/// inline; one listed in [pendingThumbnails] only gets its bitmap once an +/// update asks for it. +class FakeDesktopCapturer extends DesktopCapturer { + FakeDesktopCapturer({ + this.sources = const [], + this.pendingThumbnails = const {}, + }); + + /// What the next [getSources] resolves to. + List sources; + + /// Bitmaps the platform hands over only on an update, by source id. + Map pendingThumbnails; + + /// The `types` of every [getSources] call, in order. + final List> getSourcesCalls = []; + + /// The `thumbnailSize` of every [getSources] call, in order. + final List requestedThumbnailSizes = []; + + /// How many times [updateSources] was called. + int updateSourcesCallCount = 0; + + @override + final StreamController onAdded = + StreamController.broadcast(sync: true); + + @override + final StreamController onThumbnailChanged = + StreamController.broadcast(sync: true); + + @override + Future> getSources({ + required List types, + ThumbnailSize? thumbnailSize, + }) async { + getSourcesCalls.add(types); + requestedThumbnailSizes.add(thumbnailSize); + return sources; + } + + @override + Future updateSources({required List types}) async { + updateSourcesCallCount++; + for (final source in sources) { + final pending = pendingThumbnails[source.id]; + if (pending != null && source is FakeDesktopCapturerSource) { + source.thumbnail = pending; + } + if (source.thumbnail != null) onThumbnailChanged.add(source); + } + return true; + } + + /// Releases the event controllers. + Future close() async { + await onAdded.close(); + await onThumbnailChanged.close(); + } +} + +/// A [DesktopCapturerSource] with fixed values and, like the native one, a +/// bitmap the platform can fill in later. +class FakeDesktopCapturerSource extends DesktopCapturerSource { + FakeDesktopCapturerSource({ + required this.id, + required this.name, + required this.type, + this.thumbnail, + }); + + @override + final String id; + + @override + final String name; + + @override + final SourceType type; + + @override + Uint8List? thumbnail; + + @override + ThumbnailSize get thumbnailSize => ThumbnailSize(480, 300); +} + +/// A 2x2 PNG in the design's accent blue. +final Uint8List blueThumbnail = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVR4nGNgiP8PQhAKACJaBXnC' + '+/yRAAAAAElFTkSuQmCC', +); + +/// A 2x2 PNG in a neutral grey. +final Uint8List greyThumbnail = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGPIKG4FIgYIBQAmGgWB' + '3tA7ugAAAABJRU5ErkJggg==', +); diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_dark.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_dark.png new file mode 100644 index 000000000..67ce7acf1 Binary files /dev/null and b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_dark.png differ diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_light.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_light.png new file mode 100644 index 000000000..1f428709c Binary files /dev/null and b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_modal_light.png differ diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_dark.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_dark.png new file mode 100644 index 000000000..2630be9ad Binary files /dev/null and b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_dark.png differ diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_light.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_light.png new file mode 100644 index 000000000..c54fb513d Binary files /dev/null and b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_screens_light.png differ diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_dark.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_dark.png new file mode 100644 index 000000000..61c154643 Binary files /dev/null and b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_dark.png differ diff --git a/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_light.png b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_light.png new file mode 100644 index 000000000..4f7401196 Binary files /dev/null and b/packages/stream_video_flutter/test/src/screen_share/goldens/ci/screen_share_selector_windows_light.png differ diff --git a/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart b/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart new file mode 100644 index 000000000..920050a7c --- /dev/null +++ b/packages/stream_video_flutter/test/src/screen_share/screen_share_selector_golden_test.dart @@ -0,0 +1,139 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../../test_utils/goldens.dart'; +import 'fake_desktop_capturer.dart'; + +// The selector is snapshotted on its own rather than through +// `showDefaultScreenSelectionDialog`: the CI capture path drops anything +// painted into an Overlay, so a dialog comes out blank. The dialog chrome is +// asserted in stream_modal_dialog_test.dart instead. +void main() { + final sources = [ + FakeDesktopCapturerSource( + id: 'screen-1', + name: 'Screen 1', + type: SourceType.Screen, + thumbnail: blueThumbnail, + ), + FakeDesktopCapturerSource( + id: 'screen-2', + name: 'Screen 2', + type: SourceType.Screen, + thumbnail: greyThumbnail, + ), + FakeDesktopCapturerSource( + id: 'window-1', + name: 'A window with a name too long to fit in its tile', + type: SourceType.Window, + thumbnail: greyThumbnail, + ), + FakeDesktopCapturerSource( + id: 'window-2', + name: 'Notes', + type: SourceType.Window, + thumbnail: blueThumbnail, + ), + ]; + + Widget selector(SourceType sourceType) => _DisposingSelector( + controller: ScreenShareSourceController( + capturer: FakeDesktopCapturer(sources: sources), + sourceType: sourceType, + ), + ); + + for (final brightness in Brightness.values) { + streamGoldenTest( + 'the screen share modal, header and footer included', + fileName: 'screen_share_modal', + brightness: brightness, + // A dialog built inline rather than shown as a route, so the snapshot + // catches it: the CI capture drops overlay content. + constraints: const BoxConstraints.tightFor(width: 800, height: 720), + pumpBeforeTest: (tester) => _settle(tester, tap: 'Screen 1'), + builder: () => _DisposingSelector( + controller: ScreenShareSourceController( + capturer: FakeDesktopCapturer(sources: sources), + ), + asModal: true, + ), + ); + + streamGoldenTest( + 'StreamScreenShareSelector outlines the picked screen', + fileName: 'screen_share_selector_screens', + brightness: brightness, + // The selection is made by tapping, which is also what proves the + // selected tile looks different from its neighbour. + pumpBeforeTest: (tester) => _settle(tester, tap: 'Screen 1'), + builder: () => selector(SourceType.Screen), + ); + + streamGoldenTest( + 'StreamScreenShareSelector ellipsises a long window name', + fileName: 'screen_share_selector_windows', + brightness: brightness, + pumpBeforeTest: _settle, + builder: () => selector(SourceType.Window), + ); + } +} + +Future _settle(WidgetTester tester, {String? tap}) async { + await tester.pumpAndSettle(); + if (tap != null) { + await tester.tap(find.text(tap)); + await tester.pumpAndSettle(); + } + await precacheImages(tester); + await tester.pumpAndSettle(); +} + +/// Sizes the selector to the body of a 720x640 modal — its 88px header and +/// 88px footer taken off — and disposes the controller when the test is over. +/// +/// With [asModal] it draws the whole modal around it instead. +class _DisposingSelector extends StatefulWidget { + const _DisposingSelector({required this.controller, this.asModal = false}); + + final ScreenShareSourceController controller; + final bool asModal; + + @override + State<_DisposingSelector> createState() => _DisposingSelectorState(); +} + +class _DisposingSelectorState extends State<_DisposingSelector> { + @override + void dispose() { + widget.controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final selector = StreamScreenShareSelector(controller: widget.controller); + + if (!widget.asModal) { + return SizedBox(width: 720, height: 464, child: selector); + } + + // On the scrim over a filled backdrop, the way it is seen in a call — a + // white modal on the wrapper's white page has no visible edge. The blur is + // a no-op under `flutter test`, so the scrim snapshots as a flat fill. + return Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: context.streamColorScheme.backgroundInverse), + const StreamBlurScrim(), + _modal(context), + ], + ); + } + + Widget _modal(BuildContext context) => + StreamScreenShareDialog(controller: widget.controller); +} diff --git a/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart b/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart new file mode 100644 index 000000000..50f7af5d1 --- /dev/null +++ b/packages/stream_video_flutter/test/src/screen_share/screen_share_source_controller_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; + +import 'fake_desktop_capturer.dart'; + +void main() { + late FakeDesktopCapturer capturer; + + final screen = FakeDesktopCapturerSource( + id: 'screen-1', + name: 'Screen 1', + type: SourceType.Screen, + ); + final window = FakeDesktopCapturerSource( + id: 'window-1', + name: 'A window', + type: SourceType.Window, + ); + + setUp(() { + screen.thumbnail = null; + window.thumbnail = null; + capturer = FakeDesktopCapturer(sources: [screen, window]); + addTearDown(capturer.close); + }); + + ScreenShareSourceController controller({ + SourceType sourceType = SourceType.Screen, + }) { + final controller = ScreenShareSourceController( + capturer: capturer, + sourceType: sourceType, + ); + addTearDown(controller.dispose); + return controller; + } + + group('ScreenShareSourceController', () { + test('loads both source types in a single call', () async { + final subject = controller(); + await pumpEventQueue(); + + expect(capturer.getSourcesCalls, [ + [SourceType.Screen, SourceType.Window], + ]); + expect(subject.value.sources, [screen, window]); + expect(subject.value.isLoading, isFalse); + }); + + test('caps the resolution it asks the platform to capture', () async { + controller(); + await pumpEventQueue(); + + final size = capturer.requestedThumbnailSizes.single; + expect(size, isNotNull); + expect( + size!.width, + ScreenShareSourceController.defaultThumbnailSize.width, + ); + expect( + size.height, + ScreenShareSourceController.defaultThumbnailSize.height, + ); + }); + + test( + 'skips the capture pass when the sources carry their bitmaps', + () async { + screen.thumbnail = blueThumbnail; + window.thumbnail = greyThumbnail; + + final subject = controller(); + await pumpEventQueue(); + + expect(capturer.updateSourcesCallCount, 0); + expect(subject.value.thumbnailFor(screen), blueThumbnail); + }, + ); + + test('asks for the thumbnails once per load, never on a timer', () async { + controller(); + await pumpEventQueue(); + + expect(capturer.updateSourcesCallCount, 1); + + // Whatever the old picker's two-second timer would have fired by now. + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + await pumpEventQueue(); + } + + expect(capturer.updateSourcesCallCount, 1); + }); + + test( + 'picks up a thumbnail the platform posts after the source list', + () async { + // macOS leaves the bitmaps out of the getSources result entirely. + capturer.pendingThumbnails = {screen.id: blueThumbnail}; + final subject = controller(); + + expect(subject.value.thumbnailFor(screen), isNull); + + await pumpEventQueue(); + + expect(subject.value.thumbnailFor(screen), blueThumbnail); + }, + ); + + test('drops the thumbnail of a source that is gone', () async { + capturer.pendingThumbnails = {screen.id: blueThumbnail}; + final subject = controller(); + await pumpEventQueue(); + expect(subject.value.thumbnails, hasLength(1)); + + capturer + ..sources = [window] + ..pendingThumbnails = {}; + await subject.refresh(); + await pumpEventQueue(); + + expect(subject.value.thumbnails, isEmpty); + }); + + test('switching source type filters rather than reloading', () async { + final subject = controller(); + await pumpEventQueue(); + + expect(subject.value.visibleSources, [screen]); + + subject.setSourceType(SourceType.Window); + await pumpEventQueue(); + + expect(subject.value.visibleSources, [window]); + expect(capturer.getSourcesCalls, hasLength(1)); + }); + + test('refresh reads the platform once more', () async { + final subject = controller(); + await pumpEventQueue(); + + await subject.refresh(); + + expect(capturer.getSourcesCalls, hasLength(2)); + }); + + test('drops a selection the platform no longer offers', () async { + final subject = controller(); + await pumpEventQueue(); + + subject.setSelectedSource(screen); + expect(subject.value.selectedSource, screen); + + capturer.sources = [window]; + await subject.refresh(); + + expect(subject.value.selectedSource, isNull); + }); + + test('reports a failed load without throwing', () async { + final failing = _FailingDesktopCapturer(); + addTearDown(failing.close); + final subject = ScreenShareSourceController(capturer: failing); + addTearDown(subject.dispose); + await pumpEventQueue(); + + expect(subject.value.error, isNotNull); + expect(subject.value.isLoading, isFalse); + expect(subject.value.sources, isEmpty); + }); + }); +} + +class _FailingDesktopCapturer extends FakeDesktopCapturer { + @override + Future> getSources({ + required List types, + ThumbnailSize? thumbnailSize, + }) async => throw Exception('no capturer here'); +} diff --git a/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_modal_dialog_test.dart b/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_modal_dialog_test.dart new file mode 100644 index 000000000..1a67969be --- /dev/null +++ b/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_modal_dialog_test.dart @@ -0,0 +1,99 @@ +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() { + Future openDialog( + WidgetTester tester, { + required Widget Function(BuildContext context) builder, + }) async { + String? result; + + await tester.pumpWidget( + TestWrapper( + child: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showStreamModalDialog( + context: context, + builder: builder, + ); + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return result; + } + + group('StreamModalDialog', () { + testWidgets('draws the title, the header actions and the footer actions', ( + tester, + ) async { + await openDialog( + tester, + builder: (context) => StreamModalDialog( + title: const Text('Choose what to share'), + headerActions: [ + StreamButton.icon( + icon: Icon(context.streamIcons.refresh), + onPressed: () {}, + ), + ], + actions: [ + StreamButton(onPressed: () {}, child: const Text('Cancel')), + StreamButton(onPressed: () {}, child: const Text('Share')), + ], + child: const Text('body'), + ), + ); + + expect(find.text('Choose what to share'), findsOneWidget); + expect(find.byIcon(const StreamIcons().refresh), findsOneWidget); + expect(find.text('body'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); + }); + + testWidgets('the close button dismisses with no value', (tester) async { + await openDialog( + tester, + builder: (context) => const StreamModalDialog(child: Text('body')), + ); + + await tester.tap(find.byIcon(const StreamIcons().xmark)); + await tester.pumpAndSettle(); + + expect(find.text('body'), findsNothing); + }); + + testWidgets('draws no footer without actions', (tester) async { + await openDialog( + tester, + builder: (context) => const StreamModalDialog(child: Text('body')), + ); + + expect(find.byType(StreamButton), findsOneWidget); // the close button + }); + + testWidgets('a tap outside dismisses it through the scrim', (tester) async { + await openDialog( + tester, + builder: (context) => const StreamModalDialog(child: Text('body')), + ); + + expect(find.byType(StreamBlurScrim), findsOneWidget); + + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + expect(find.text('body'), findsNothing); + }); + }); +} diff --git a/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_tab_bar_test.dart b/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_tab_bar_test.dart new file mode 100644 index 000000000..0e6cd092d --- /dev/null +++ b/packages/stream_video_flutter/test/src/widgets/design_system_candidates/stream_tab_bar_test.dart @@ -0,0 +1,65 @@ +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() { + Future pumpTabBar( + WidgetTester tester, { + required int selectedIndex, + ValueChanged? onSelected, + }) { + return tester.pumpWidget( + TestWrapper( + child: SizedBox( + width: 400, + child: StreamTabBar( + selectedIndex: selectedIndex, + onSelected: onSelected ?? (_) {}, + tabs: const [ + StreamTabBarItem(label: 'Entire Screen'), + StreamTabBarItem(label: 'Window'), + ], + ), + ), + ), + ); + } + + Color labelColorOf(WidgetTester tester, String label) => + tester.widget(find.text(label)).style!.color!; + + group('StreamTabBar', () { + testWidgets('marks the selected tab with the accent color', (tester) async { + await pumpTabBar(tester, selectedIndex: 0); + + final colorScheme = StreamTheme.of( + tester.element(find.text('Entire Screen')), + ).colorScheme; + + expect(labelColorOf(tester, 'Entire Screen'), colorScheme.accentPrimary); + expect(labelColorOf(tester, 'Window'), colorScheme.textSecondary); + }); + + testWidgets('reports the index of the tapped tab', (tester) async { + final tapped = []; + await pumpTabBar(tester, selectedIndex: 0, onSelected: tapped.add); + + await tester.tap(find.text('Window')); + await tester.pumpAndSettle(); + + expect(tapped, [1]); + }); + + testWidgets('gives every tab an equal share of the width', (tester) async { + await pumpTabBar(tester, selectedIndex: 0); + + expect(tester.getSize(find.text('Entire Screen')).width, lessThan(200)); + expect( + tester.getTopLeft(find.text('Window')).dx, + greaterThanOrEqualTo(200), + ); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index ae4f729a2..ceba69e78 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1955,14 +1955,13 @@ packages: source: hosted version: "10.4.0" stream_core: - dependency: "direct overridden" + dependency: transitive description: - path: "packages/stream_core" - ref: f83b5d4d706a79fc429de2d27aead4394b83c1fb - resolved-ref: f83b5d4d706a79fc429de2d27aead4394b83c1fb - url: "https://github.com/GetStream/stream-core-flutter.git" - source: git - version: "0.4.0" + name: stream_core + sha256: "25c19466b96050354e9a64fb13767956b48b9405576472005598733502ef219c" + url: "https://pub.dev" + source: hosted + version: "0.5.0" stream_core_flutter: dependency: "direct overridden" description: