diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index d05ee5e5ca..2d81b70520 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -19,7 +19,11 @@ dependencies: sdk: flutter record: ^6.2.0 stream_chat_flutter: ^10.0.1 - stream_core_flutter: ^0.3.0 + stream_core_flutter: + git: + url: https://github.com/GetStream/stream-core-flutter + ref: 5353033ea55b88e8f651c604389f0b7b0a839ebe + path: packages/stream_core_flutter dev_dependencies: alchemist: ^0.14.0 diff --git a/docs/docs_screenshots/test/localization/goldens/macos/localization_support.png b/docs/docs_screenshots/test/localization/goldens/macos/localization_support.png index 42ede493df..7450f2fe64 100644 Binary files a/docs/docs_screenshots/test/localization/goldens/macos/localization_support.png and b/docs/docs_screenshots/test/localization/goldens/macos/localization_support.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png index f925026a53..81d40313ad 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png differ diff --git a/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart b/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart index 5270a18acc..6653c22c13 100644 --- a/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart +++ b/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart @@ -116,8 +116,8 @@ Widget _buildVoiceRecordingComposerScaffold({ children: [ Expanded(child: Container()), Builder( - builder: (context) { - return Material( + builder: (context) => StreamSnackbarPopup( + child: Material( child: DecoratedBox( decoration: BoxDecoration( color: context.streamColorScheme.backgroundElevation1, @@ -132,8 +132,8 @@ Widget _buildVoiceRecordingComposerScaffold({ ), ), ), - ); - }, + ), + ), ), ], ), diff --git a/melos.yaml b/melos.yaml index d0d8f64afd..b164f74d4f 100644 --- a/melos.yaml +++ b/melos.yaml @@ -98,7 +98,12 @@ command: stream_chat_persistence: ^10.0.1 streaming_shared_preferences: ^2.0.0 svg_icon_widget: ^0.0.1 - stream_core_flutter: ^0.3.0 + # Pinned to PR #118 (feat: add StreamSnackbar) until released. + stream_core_flutter: + git: + url: https://github.com/GetStream/stream-core-flutter + ref: 5353033ea55b88e8f651c604389f0b7b0a839ebe + path: packages/stream_core_flutter synchronized: ^3.4.0 thumblr: ^0.0.4 url_launcher: ^6.3.2 diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 224960c385..62496e7111 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,14 @@ +## Upcoming + +✅ Added + +- `StreamMessageComposer` now surfaces the hold-to-record hint through `StreamSnackbar` anchored above the composer, and `StreamChat` provides an app-wide `StreamSnackbarScope` fallback. + +⚠️ Deprecated + +- `StreamAudioRecorderController.showInfo` is now deprecated. Show your own snackbar via `StreamSnackbarMessenger.of(context).show(StreamSnackbar(...))` instead. +- `RecordStateIdle.message` is now deprecated; the composer no longer reads it. + ## 10.0.1 🐞 Fixed diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart index ba5fec625f..e6d514f1d8 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart @@ -174,6 +174,7 @@ class StreamAudioRecorderController extends ValueNotifier { /// Shows an info message to the user for the given [duration]. /// /// This is useful for showing messages like "Hold to record" or "Recording". + @Deprecated('Use StreamSnackbar via StreamSnackbarMessenger instead.') void showInfo( String message, { Duration duration = const Duration(seconds: 3), @@ -198,6 +199,18 @@ class StreamAudioRecorderController extends ValueNotifier { } } + /// Cancels any pending info-message timer and clears the message on + /// [RecordStateIdle]. Counterpart to [showInfo]. + @Deprecated('Use StreamSnackbar via StreamSnackbarMessenger instead.') + void hideInfo() { + // Cancel the info timer. + _infoTimer?.cancel(); + _infoTimer = null; + + // Clear the info message if it is currently being shown. + if (value case RecordStateIdle()) value = const RecordStateIdle(); + } + Future _getOutputFilePath(AudioEncoder encoder) async { // Ignored on web platform. if (CurrentPlatform.isWeb) return ''; @@ -232,6 +245,8 @@ class StreamAudioRecorderController extends ValueNotifier { void dispose() { _durationTimer?.cancel(); _durationTimer = null; + _infoTimer?.cancel(); + _infoTimer = null; _recorderAmplitudeSubscription?.cancel(); _recorder.dispose(); super.dispose(); diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart index 96bb1f00cc..1d496f59ac 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart @@ -9,17 +9,13 @@ sealed class AudioRecorderState { /// {@template recordStateIdle} /// The audio recorder is currently idle and not recording any audio track. -/// -/// Optionally, provide a [message] to display when the recorder is idle. -/// -/// For example, when the user has not long pressed the record button long -/// enough to start recording. /// {@endtemplate} final class RecordStateIdle extends AudioRecorderState { /// {@macro recordStateIdle} const RecordStateIdle({this.message}) : super._(); /// The optional message to display when the recorder is idle. + @Deprecated('Use StreamSnackbar via StreamSnackbarMessenger instead.') final String? message; } diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart index d0015a6179..acfdf90cc3 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart @@ -109,6 +109,10 @@ class _StreamChatMessageInputState extends State { void initState() { super.initState(); _initController(); + widget.audioRecorderController?.addListener(_onAudioRecorderChanged); + // Update the snackbar based on the initial state of the audio recorder controller + // after the first frame is rendered, to ensure that the BuildContext is fully available for showing the snackbar. + WidgetsBinding.instance.addPostFrameCallback((_) => _onAudioRecorderChanged()); } @override @@ -118,10 +122,16 @@ class _StreamChatMessageInputState extends State { if (oldWidget.controller == null) _controller.dispose(); _initController(); } + if (widget.audioRecorderController != oldWidget.audioRecorderController) { + oldWidget.audioRecorderController?.removeListener(_onAudioRecorderChanged); + widget.audioRecorderController?.addListener(_onAudioRecorderChanged); + _onAudioRecorderChanged(); // Update the snackbar based on the new controller's state immediately. + } } @override void dispose() { + widget.audioRecorderController?.removeListener(_onAudioRecorderChanged); if (widget.controller == null) _controller.dispose(); super.dispose(); } @@ -130,6 +140,27 @@ class _StreamChatMessageInputState extends State { _controller = widget.controller ?? StreamMessageComposerController(); } + // Listens to changes in the audio recorder controller and shows/hides a snackbar + // with the current message, if any. + void _onAudioRecorderChanged() { + if (!mounted) return; + + final state = widget.audioRecorderController?.value; + final message = state is RecordStateIdle ? state.message : null; + final messenger = StreamSnackbarMessenger.maybeOf(context); + + if (message == null || message.isEmpty) return messenger?.removeCurrent(); + final controller = messenger?.show(StreamSnackbar(message: Text(message)), replace: true); + if (controller == null) return; + + // Notify the recorder controller when the snackbar is closed, so it can clear + // the message and avoid showing stale messages if the user triggers the recorder again. + controller.closed.then((_) { + if (!mounted) return; + return widget.audioRecorderController?.hideInfo(); + }); + } + @override Widget build(BuildContext context) { final audioRecorderController = widget.audioRecorderController; @@ -149,30 +180,18 @@ class _StreamChatMessageInputState extends State { const targetAlignment = AlignmentDirectional.topEnd; const followerAlignment = AlignmentDirectional.bottomEnd; - final idleMessage = state is RecordStateIdle ? state.message : null; - final showIdleTooltip = idleMessage != null && idleMessage.isNotEmpty; - return PortalTarget( - visible: showIdleTooltip, anchor: Aligned( - target: Alignment.topCenter, - follower: Alignment.bottomCenter, - offset: Offset(0, -streamSpacing.md), + target: targetAlignment.resolve(textDirection), + follower: followerAlignment.resolve(textDirection), + offset: Offset(-streamSpacing.md, -streamSpacing.md).directional(textDirection), ), - portalFollower: showIdleTooltip ? HoldToRecordInfoTooltip(message: idleMessage) : const SizedBox.shrink(), - child: PortalTarget( - anchor: Aligned( - target: targetAlignment.resolve(textDirection), - follower: followerAlignment.resolve(textDirection), - offset: Offset(-streamSpacing.md, -streamSpacing.md).directional(textDirection), - ), - visible: state is RecordStateRecording, - portalFollower: SwipeToLockButton(isLocked: state is RecordStateRecordingLocked), - child: _StreamChatMessageInputContent( - widget: widget, - inputController: _controller, - audioRecorderState: state, - ), + visible: state is RecordStateRecording, + portalFollower: SwipeToLockButton(isLocked: state is RecordStateRecordingLocked), + child: _StreamChatMessageInputContent( + widget: widget, + inputController: _controller, + audioRecorderState: state, ), ); }, diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 8a14a72e1e..0bcd4a68f9 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -95,7 +95,7 @@ class StreamMessageComposer extends StatelessWidget { TextCapitalization textCapitalization = TextCapitalization.sentences, bool autofocus = false, bool autoCorrect = true, - }) : props = MessageComposerProps( + }) : props = .new( onMessageSent: onMessageSent, preMessageSending: preMessageSending, messageComposerController: messageComposerController, @@ -147,8 +147,8 @@ class StreamMessageComposer extends StatelessWidget { @override Widget build(BuildContext context) { final builder = context.chatComponentBuilder(); - if (builder != null) return builder(context, props); - return DefaultStreamMessageComposer(props: props); + final composer = builder?.call(context, props) ?? DefaultStreamMessageComposer(props: props); + return StreamSnackbarPopup(child: composer); } } diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index 62b9d5bbc4..032fa10513 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -199,7 +199,7 @@ class StreamChatState extends State { onBackgroundEventReceived: widget.onBackgroundEventReceived, backgroundKeepAlive: widget.backgroundKeepAlive, connectivityStream: widget.connectivityStream, - child: widget.child ?? const Empty(), + child: StreamSnackbarScope(child: widget.child ?? const Empty()), ); final theme = widget.themeData ?? StreamChatThemeData(); diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 2e1154ffe0..a434f1a6e5 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -98,6 +98,20 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamSheetTheme, StreamSheetThemeData, StreamSheetTransition, + StreamSnackbar, + StreamSnackbarAction, + StreamSnackbarClosedReason, + StreamSnackbarController, + StreamSnackbarHost, + StreamSnackbarMessenger, + StreamSnackbarPopup, + StreamSnackbarPopupPlacement, + StreamSnackbarProps, + StreamSnackbarScope, + StreamSnackbarStyle, + StreamSnackbarTheme, + StreamSnackbarThemeData, + StreamSnackbarVariant, StreamSwitch, StreamStepper, StreamStepperProps, diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 555b1c430a..9a9b14e18d 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -57,7 +57,11 @@ dependencies: share_plus: ">=12.0.2 <14.0.0" shimmer: ^3.0.0 stream_chat_flutter_core: ^10.0.1 - stream_core_flutter: ^0.3.0 + stream_core_flutter: + git: + url: https://github.com/GetStream/stream-core-flutter + ref: 5353033ea55b88e8f651c604389f0b7b0a839ebe + path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.4.0 theme_extensions_builder_annotation: ^7.1.0 diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart index 82fb045413..21c059639e 100644 --- a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart @@ -215,6 +215,36 @@ void main() { }); }); + group('hideInfo', () { + test('clears the info message immediately', () { + controller.showInfo('Test Message'); + expect((controller.value as RecordStateIdle).message, 'Test Message'); + + controller.hideInfo(); + + expect((controller.value as RecordStateIdle).message, isNull); + }); + + test('cancels the pending auto-clear timer', () async { + controller.showInfo('Test Message', duration: const Duration(milliseconds: 100)); + expect((controller.value as RecordStateIdle).message, 'Test Message'); + + controller.hideInfo(); + controller.showInfo('Other Message', duration: const Duration(seconds: 5)); + expect((controller.value as RecordStateIdle).message, 'Other Message'); + + // Wait past the original (now-canceled) timer; the new message must + // still be present. + await Future.delayed(const Duration(milliseconds: 150)); + expect((controller.value as RecordStateIdle).message, 'Other Message'); + }); + + test('is a no-op when no info message is being shown', () { + controller.hideInfo(); + expect((controller.value as RecordStateIdle).message, isNull); + }); + }); + group('amplitude changes', () { setUp(() { when(() => mockRecorder.hasPermission(request: false)).thenAnswer((_) async => true); diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/composer_hold_to_record_snackbar.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/composer_hold_to_record_snackbar.png new file mode 100644 index 0000000000..bbad6562b0 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/composer_hold_to_record_snackbar.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart index 6f2c045cfc..7fdd31ffed 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart @@ -89,8 +89,14 @@ void main() { ), ); - // Expect an empty box - expect(find.byType(SizedBox), findsOneWidget); + // Expect an empty box rendered by the attachment list itself. + expect( + find.descendant( + of: find.byType(StreamMessageComposerAttachmentList), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); }, ); }); @@ -184,8 +190,14 @@ void main() { ), ); - // Expect an empty box - expect(find.byType(SizedBox), findsOneWidget); + // Expect an empty box rendered by the media attachments widget itself. + expect( + find.descendant( + of: find.byType(MessageInputMediaAttachments), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); }, ); diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart index 1b39237008..30d4ea706e 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart @@ -2,17 +2,22 @@ import 'dart:async'; +import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:record/record.dart'; import 'package:stream_chat_flutter/src/message_input/dm_checkbox_list_tile.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../fakes.dart'; import '../mocks.dart'; +class _MockAudioRecorder extends Mock implements AudioRecorder {} + /// TODO: remove skip once we have a proper message input test. void main() { final originalRecordPlatform = RecordPlatform.instance; @@ -1037,9 +1042,272 @@ void main() { ); }); }); + + group('StreamChatMessageInput hold-to-record snackbar', () { + late _MockAudioRecorder mockRecorder; + late StreamAudioRecorderController audioRecorderController; + + setUpAll(() => registerFallbackValue(Duration.zero)); + + setUp(() { + PathProviderPlatform.instance = FakePathProviderPlatform(); + mockRecorder = _MockAudioRecorder(); + // The production AudioRecorder spins up a 100ms periodic amplitude + // timer; mocking with an empty stream keeps the test binding free + // of pending timers. + when(() => mockRecorder.onAmplitudeChanged(any())).thenAnswer((_) => const Stream.empty()); + when(() => mockRecorder.dispose()).thenAnswer((_) async {}); + + audioRecorderController = StreamAudioRecorderController.raw( + recorder: mockRecorder, + config: const RecordConfig(numChannels: 1), + ); + }); + + testWidgets( + 'long-press cancel on mic shows the hold-to-record snackbar', + (WidgetTester tester) async { + await tester.pumpWidget( + buildWidget( + StreamChatMessageInput( + onSendPressed: () {}, + audioRecorderController: audioRecorderController, + ), + ), + ); + await tester.pumpAndSettle(); + + const holdLabel = 'Hold to record. Release to save.'; + expect(find.text(holdLabel), findsNothing); + + await _cancelMicLongPress(tester); + + expect(find.text(holdLabel), findsOneWidget); + expect(find.byType(StreamSnackbar), findsOneWidget); + + // Dispose in body: showInfo's 3s timer outlives the widget tree, and + // `tearDown` / `addTearDown` run after the binding's pending-timer + // check — only a body-side dispose cancels it in time. + audioRecorderController.dispose(); + }, + ); + + testWidgets( + 'invokes onRecordStartCancel feedback before showing the snackbar', + (WidgetTester tester) async { + final feedback = _RecordingFeedbackSpy(); + + await tester.pumpWidget( + buildWidget( + StreamChatMessageInput( + onSendPressed: () {}, + audioRecorderController: audioRecorderController, + feedback: feedback, + ), + ), + ); + await tester.pumpAndSettle(); + + await _cancelMicLongPress(tester); + + expect(feedback.cancelCount, 1); + expect(find.text('Hold to record. Release to save.'), findsOneWidget); + + audioRecorderController.dispose(); + }, + ); + + testWidgets( + 'rapid cancels do not enqueue duplicate snackbars', + (WidgetTester tester) async { + await tester.pumpWidget( + buildWidget( + StreamChatMessageInput( + onSendPressed: () {}, + audioRecorderController: audioRecorderController, + ), + ), + ); + await tester.pumpAndSettle(); + + await _cancelMicLongPress(tester); + await _cancelMicLongPress(tester); + await _cancelMicLongPress(tester); + + expect(find.byType(StreamSnackbar), findsOneWidget); + + audioRecorderController.dispose(); + }, + ); + + testWidgets( + 'starting a hold clears the in-flight hold-to-record snackbar', + (WidgetTester tester) async { + // Mock the recorder so startRecord can transition to RecordStateRecordingHold. + const config = RecordConfig(numChannels: 1); + when(() => mockRecorder.hasPermission(request: false)).thenAnswer((_) async => true); + when(() => mockRecorder.start(config, path: any(named: 'path'))).thenAnswer((_) async {}); + + await tester.pumpWidget( + buildWidget( + StreamChatMessageInput( + onSendPressed: () {}, + audioRecorderController: audioRecorderController, + ), + ), + ); + await tester.pumpAndSettle(); + + await _cancelMicLongPress(tester); + expect(find.byType(StreamSnackbar), findsOneWidget); + + // Long-press the mic. startRecord transitions to RecordStateRecordingHold; + // the listener should react and remove the in-flight hint. + final mic = find.byKey(const ValueKey('microphone_key')); + tester.widget(mic).onLongPress!(); + // pump < 1s so the recorder's periodic duration timer doesn't tick; + // microtasks drain and the state transition + listener fire complete. + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(StreamSnackbar), findsNothing); + expect(audioRecorderController.value, isA()); + + audioRecorderController.dispose(); + }, + ); + + testWidgets( + 'composer subtree resolves a StreamSnackbarMessenger via context', + (WidgetTester tester) async { + await tester.pumpWidget( + buildWidget( + StreamChatMessageInput( + onSendPressed: () {}, + audioRecorderController: audioRecorderController, + ), + ), + ); + await tester.pumpAndSettle(); + + final micContext = tester.element(find.byKey(const ValueKey('microphone_key'))); + expect(StreamSnackbarMessenger.maybeOf(micContext), isNotNull); + }, + ); + + testWidgets( + 'swiping the snackbar away clears the recorder state.message', + (WidgetTester tester) async { + await tester.pumpWidget( + buildWidget( + StreamChatMessageInput( + onSendPressed: () {}, + audioRecorderController: audioRecorderController, + ), + ), + ); + await tester.pumpAndSettle(); + + // ignore: deprecated_member_use + audioRecorderController.showInfo('Hint'); + await tester.pumpAndSettle(); + expect(find.text('Hint'), findsOneWidget); + expect( + // ignore: deprecated_member_use + (audioRecorderController.value as RecordStateIdle).message, + 'Hint', + ); + + await tester.fling(find.text('Hint'), const Offset(0, 300), 1000); + await tester.pumpAndSettle(); + + expect(find.byType(StreamSnackbar), findsNothing); + // The listener should have called hideInfo() on dismissal, so the + // recorder no longer thinks it's showing 'Hint'. A subsequent + // showInfo('Hint') should fire a fresh snackbar. + expect( + // ignore: deprecated_member_use + (audioRecorderController.value as RecordStateIdle).message, + isNull, + ); + + // ignore: deprecated_member_use + audioRecorderController.showInfo('Hint'); + await tester.pumpAndSettle(); + expect(find.text('Hint'), findsOneWidget); + + audioRecorderController.dispose(); + }, + ); + }); + + group('StreamChat global snackbar scope', () { + testWidgets( + 'descendants without a nearer popup find a fallback messenger', + (WidgetTester tester) async { + StreamSnackbarMessenger? captured; + + await tester.pumpWidget( + buildWidget( + Builder( + builder: (context) { + captured = StreamSnackbarMessenger.maybeOf(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(captured, isNotNull); + }, + ); + }); + + goldenTest( + 'composer hold-to-record snackbar', + fileName: 'composer_hold_to_record_snackbar', + constraints: const BoxConstraints.tightFor(width: 400, height: 240), + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + final mic = find.byKey(const ValueKey('microphone_key')); + final gd = tester.widget(mic); + gd.onLongPressCancel!(); + await tester.pumpAndSettle(); + }, + builder: () => buildWidget( + Column( + children: [ + const Expanded(child: SizedBox()), + StreamMessageComposer(), + ], + ), + ), + ); +} + +Future _cancelMicLongPress(WidgetTester tester) async { + final mic = find.byKey(const ValueKey('microphone_key')); + expect(mic, findsOneWidget); + // Invoking the callback directly avoids depending on gesture-arena + // timing — onLongPressCancel needs a sibling tap to race the long press, + // which is brittle to reproduce in widget tests. + final gestureDetector = tester.widget(mic); + gestureDetector.onLongPressCancel!(); + await tester.pumpAndSettle(); +} + +class _RecordingFeedbackSpy extends AudioRecorderFeedback { + _RecordingFeedbackSpy() : super(); + + int cancelCount = 0; + + @override + Future onRecordStartCancel(BuildContext context) async { + cancelCount++; + } } -MaterialApp buildWidget(StreamMessageComposer input) { +MaterialApp buildWidget(Widget input) { final client = MockClient(); final clientState = MockClientState(); final channel = MockChannel(); @@ -1094,6 +1362,7 @@ MaterialApp buildWidget(StreamMessageComposer input) { return MaterialApp( home: StreamChat( client: client, + connectivityStream: Stream.value([ConnectivityResult.mobile]), child: StreamChannel( channel: channel, child: Scaffold(body: input),