diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index d197b940..80f0c538 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -1,4 +1,4 @@ -## 0.5.0 +## Upcoming ### ✨ Features @@ -11,6 +11,10 @@ - Added `excludeHeaderSemantics` to `StreamAppBar` and `StreamSheetHeader` for opting out of the default heading role and route naming on the title. - Added `onVisible` callback to `StreamSnackbar` — fires after the entrance animation completes (or synchronously when a screen reader is active). +### 🐞 Fixed + +- Fixed `StreamButton` icons picking up the host app's `ElevatedButtonThemeData.iconColor` instead of the button's own `foregroundColor`. + ## 0.4.0 ### ✨ Features diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart index 9fd9ecff..e454bb52 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart @@ -414,6 +414,7 @@ class _DefaultStreamButtonState extends State { elevation: effectiveElevation, backgroundColor: effectiveBackgroundColor, foregroundColor: effectiveForegroundColor, + iconColor: effectiveForegroundColor, overlayColor: effectiveOverlayColor, fixedSize: effectiveFixedSize, minimumSize: effectiveMinimumSize, diff --git a/packages/stream_core_flutter/test/components/buttons/stream_button_color_resolution_test.dart b/packages/stream_core_flutter/test/components/buttons/stream_button_color_resolution_test.dart deleted file mode 100644 index 197877b9..00000000 --- a/packages/stream_core_flutter/test/components/buttons/stream_button_color_resolution_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -// Repro for stream-chat-flutter issue #2786: -// "v10: composer / attachment-picker icons render white (invisible) in light -// mode despite a light StreamColorScheme". -// -// The user supplies a light StreamTheme via ThemeData.extensions, then renders -// StreamButton.icon(style: secondary, type: outline) (the composer's leading +). -// Expected: the icon's IconTheme color resolves to the supplied light scheme's -// textPrimary (a dark color, ~0xFF1A1B25). Actually observed in the bug report: -// the icon is white. -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_core_flutter/core.dart'; - -void main() { - group('StreamButton picks up StreamColorScheme from ThemeData extension', () { - testWidgets( - 'secondary outline icon button uses light textPrimary when supplied', - (tester) async { - final lightStreamTheme = StreamTheme( - colorScheme: StreamColorScheme.light(), - ); - final expectedTextPrimary = lightStreamTheme.colorScheme.textPrimary; - - Color? capturedIconColor; - await tester.pumpWidget( - MaterialApp( - themeMode: ThemeMode.light, - theme: ThemeData.light().copyWith(extensions: [lightStreamTheme]), - home: Scaffold( - body: Center( - child: StreamButton.icon( - icon: Builder( - builder: (context) { - capturedIconColor = IconTheme.of(context).color; - return const Icon(Icons.add); - }, - ), - style: StreamButtonStyle.secondary, - type: StreamButtonType.outline, - size: StreamButtonSize.large, - onPressed: () {}, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect( - capturedIconColor, - expectedTextPrimary, - reason: - 'StreamButton.icon should resolve its IconTheme color from the ' - 'supplied light StreamColorScheme.textPrimary, but got ' - '$capturedIconColor.', - ); - }, - ); - - testWidgets( - 'secondary ghost icon button uses light textPrimary when supplied', - (tester) async { - final lightStreamTheme = StreamTheme( - colorScheme: StreamColorScheme.light(), - ); - final expectedTextPrimary = lightStreamTheme.colorScheme.textPrimary; - - Color? capturedIconColor; - await tester.pumpWidget( - MaterialApp( - themeMode: ThemeMode.light, - theme: ThemeData.light().copyWith(extensions: [lightStreamTheme]), - home: Scaffold( - body: Center( - child: StreamButton.icon( - icon: Builder( - builder: (context) { - capturedIconColor = IconTheme.of(context).color; - return const Icon(Icons.send); - }, - ), - style: StreamButtonStyle.secondary, - type: StreamButtonType.ghost, - size: StreamButtonSize.small, - onPressed: () {}, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect( - capturedIconColor, - expectedTextPrimary, - reason: - 'StreamButton.icon (secondary/ghost) should resolve its IconTheme ' - 'color from the supplied light StreamColorScheme.textPrimary, but ' - 'got $capturedIconColor.', - ); - }, - ); - }); -} diff --git a/packages/stream_core_flutter/test/components/buttons/stream_button_test.dart b/packages/stream_core_flutter/test/components/buttons/stream_button_test.dart index 9c229efc..8af1e1ce 100644 --- a/packages/stream_core_flutter/test/components/buttons/stream_button_test.dart +++ b/packages/stream_core_flutter/test/components/buttons/stream_button_test.dart @@ -2,9 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_core_flutter/core.dart'; -Widget _withStreamTheme(Widget child) { +Widget _withStreamTheme( + Widget child, { + StreamTheme? streamTheme, + ElevatedButtonThemeData? elevatedButtonTheme, +}) { return MaterialApp( - theme: ThemeData(extensions: [StreamTheme()]), + theme: ThemeData( + extensions: [streamTheme ?? StreamTheme()], + elevatedButtonTheme: elevatedButtonTheme, + ), home: Scaffold(body: Center(child: child)), ); } @@ -154,4 +161,33 @@ void main() { handle.dispose(); }); }); + + // Test for https://github.com/GetStream/stream-chat-flutter/issues/2786 + testWidgets( + 'host-app ElevatedButtonThemeData.iconColor does not leak into StreamButton icons', + (tester) async { + const hostIconColor = Color(0xFF00FF00); + + Color? capturedIconColor; + await tester.pumpWidget( + _withStreamTheme( + elevatedButtonTheme: const ElevatedButtonThemeData( + style: ButtonStyle(iconColor: WidgetStatePropertyAll(hostIconColor)), + ), + StreamButton.icon( + onPressed: () {}, + icon: Builder( + builder: (context) { + capturedIconColor = IconTheme.of(context).color; + return const Icon(Icons.add); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(capturedIconColor, isNot(hostIconColor)); + }, + ); } diff --git a/packages/stream_core_flutter/test/components/buttons/stream_button_vs_icon_button_semantics_test.dart b/packages/stream_core_flutter/test/components/buttons/stream_button_vs_icon_button_semantics_test.dart deleted file mode 100644 index ec0e6612..00000000 --- a/packages/stream_core_flutter/test/components/buttons/stream_button_vs_icon_button_semantics_test.dart +++ /dev/null @@ -1,426 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_core_flutter/core.dart'; - -// Pretty-prints the merge-effective SemanticsData for direct comparison — -// strips rect/transform/traversalParentIdentifier noise, one field per line. -String _fmt(SemanticsData d) { - final lines = []; - void add(String key, Object? value) { - if (value == null) return; - if (value is String && value.isEmpty) return; - if (value is List && value.isEmpty) return; - lines.add(' $key: $value'); - } - - final actions = SemanticsAction.values.where((a) => (d.actions & a.index) != 0).map((a) => a.name).toList(); - final flags = d.flagsCollection.toStrings(); - - add('actions', actions); - add('flags', flags); - add('label', d.label); - add('value', d.value); - add('hint', d.hint); - add('tooltip', d.tooltip); - add('textDirection', d.textDirection?.name); - return '{\n${lines.join(',\n')}\n}'; -} - -Widget _withStreamTheme(Widget child, {bool useMaterial3 = true}) { - return MaterialApp( - theme: ThemeData( - useMaterial3: useMaterial3, - extensions: [StreamTheme()], - ), - home: Scaffold(body: Center(child: child)), - ); -} - -void main() { - group('Semantic tree comparison: IconButton vs StreamButton.icon', () { - testWidgets('with isSelected=true, tooltip, onPressed', (tester) async { - final handle = tester.ensureSemantics(); - - // Flutter Material IconButton - await tester.pumpWidget( - _withStreamTheme( - IconButton( - isSelected: true, - tooltip: 'Photo Gallery', - onPressed: () {}, - icon: const Icon(Icons.photo), - ), - ), - ); - - final iconButtonNode = tester.getSemantics(find.byType(IconButton)); - debugPrint('--- IconButton (isSelected: true) ---'); - debugPrint(_fmt(iconButtonNode.getSemanticsData())); - - // Stream's StreamButton.icon - await tester.pumpWidget( - _withStreamTheme( - StreamButton.icon( - isSelected: true, - tooltip: 'Photo Gallery', - onPressed: () {}, - icon: const Icon(Icons.photo), - ), - ), - ); - - final streamButtonNode = tester.getSemantics(find.byType(StreamButton)); - debugPrint('--- StreamButton.icon (isSelected: true) ---'); - debugPrint(_fmt(streamButtonNode.getSemanticsData())); - - handle.dispose(); - }); - - testWidgets('with isSelected=false, tooltip, onPressed', (tester) async { - final handle = tester.ensureSemantics(); - - await tester.pumpWidget( - _withStreamTheme( - IconButton( - isSelected: false, - tooltip: 'Photo Gallery', - onPressed: () {}, - icon: const Icon(Icons.photo), - ), - ), - ); - - debugPrint('--- IconButton (isSelected: false) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(IconButton)).getSemanticsData())); - - await tester.pumpWidget( - _withStreamTheme( - StreamButton.icon( - isSelected: false, - tooltip: 'Photo Gallery', - onPressed: () {}, - icon: const Icon(Icons.photo), - ), - ), - ); - - debugPrint('--- StreamButton.icon (isSelected: false) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(StreamButton)).getSemanticsData())); - - handle.dispose(); - }); - }); - - group('ToggleButtons (Flutter) behavior', () { - testWidgets('semantic tree for selected toggle button', (tester) async { - final handle = tester.ensureSemantics(); - - await tester.pumpWidget( - _withStreamTheme( - ToggleButtons( - isSelected: const [true, false, false], - onPressed: (_) {}, - children: const [ - Icon(Icons.photo), - Icon(Icons.camera_alt), - Icon(Icons.folder), - ], - ), - ), - ); - - debugPrint('--- ToggleButtons (first index selected) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(ToggleButtons)).getSemanticsData())); - debugPrint('--- First button: ---'); - debugPrint(_fmt(tester.getSemantics(find.byIcon(Icons.photo)).getSemanticsData())); - debugPrint('--- Second button: ---'); - debugPrint(_fmt(tester.getSemantics(find.byIcon(Icons.camera_alt)).getSemanticsData())); - - handle.dispose(); - }); - - testWidgets('captures events when tapping a toggle button', (tester) async { - final handle = tester.ensureSemantics(); - - final events = []; - tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler( - SystemChannels.accessibility, - (message) async { - if (message is Map) { - final type = message['type']?.toString(); - if (type != null) events.add(type); - } - return null; - }, - ); - - var pressedIndex = -1; - await tester.pumpWidget( - _withStreamTheme( - ToggleButtons( - isSelected: const [true, false, false], - onPressed: (i) => pressedIndex = i, - children: const [ - Icon(Icons.photo), - Icon(Icons.camera_alt), - Icon(Icons.folder), - ], - ), - ), - ); - - await tester.tap(find.byIcon(Icons.camera_alt)); - await tester.pumpAndSettle(); - - tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler( - SystemChannels.accessibility, - null, - ); - - debugPrint('--- ToggleButtons tap fired events: $events'); - debugPrint('--- pressedIndex: $pressedIndex'); - - handle.dispose(); - }); - }); - - group('Tap behavior: events fired on activation', () { - Future> capturedEvents( - WidgetTester tester, - Widget widget, - Finder finder, - ) async { - final events = []; - // Semantic events flow through SystemChannels.accessibility. - tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler( - SystemChannels.accessibility, - (message) async { - if (message is Map) { - final type = message['type']?.toString(); - if (type != null) events.add(type); - } - return null; - }, - ); - await tester.pumpWidget(widget); - await tester.tap(finder); - await tester.pumpAndSettle(); - tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler( - SystemChannels.accessibility, - null, - ); - return events; - } - - testWidgets('IconButton vs StreamButton.icon — events on tap', (tester) async { - final handle = tester.ensureSemantics(); - var iconButtonPresses = 0; - var streamButtonPresses = 0; - - final iconEvents = await capturedEvents( - tester, - _withStreamTheme( - IconButton( - isSelected: false, - tooltip: 'Photo Gallery', - onPressed: () => iconButtonPresses++, - icon: const Icon(Icons.photo), - ), - ), - find.byType(IconButton), - ); - - final streamEvents = await capturedEvents( - tester, - _withStreamTheme( - StreamButton.icon( - isSelected: false, - tooltip: 'Photo Gallery', - onPressed: () => streamButtonPresses++, - icon: const Icon(Icons.photo), - ), - ), - find.byType(StreamButton), - ); - - debugPrint('--- IconButton tap fired events: $iconEvents'); - debugPrint('--- StreamButton.icon tap fired events: $streamEvents'); - debugPrint('--- IconButton onPressed count: $iconButtonPresses'); - debugPrint('--- StreamButton onPressed count: $streamButtonPresses'); - - handle.dispose(); - }); - }); - - group('Material 3 variants', () { - testWidgets('IconButton M2 vs M3 (isSelected: true)', (tester) async { - final handle = tester.ensureSemantics(); - - await tester.pumpWidget( - _withStreamTheme( - useMaterial3: false, - IconButton( - isSelected: true, - tooltip: 'Photo Gallery', - onPressed: () {}, - icon: const Icon(Icons.photo), - ), - ), - ); - - debugPrint('--- IconButton M2 (isSelected: true) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(IconButton)).getSemanticsData())); - - await tester.pumpWidget( - _withStreamTheme( - IconButton( - isSelected: true, - tooltip: 'Photo Gallery', - onPressed: () {}, - icon: const Icon(Icons.photo), - ), - ), - ); - - debugPrint('--- IconButton M3 (isSelected: true) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(IconButton)).getSemanticsData())); - - handle.dispose(); - }); - - testWidgets('IconButton.filled M3 (toggle) vs StreamButton.icon', (tester) async { - final handle = tester.ensureSemantics(); - - await tester.pumpWidget( - _withStreamTheme( - IconButton.filled( - isSelected: true, - tooltip: 'Photo Gallery', - onPressed: () {}, - icon: const Icon(Icons.photo), - ), - ), - ); - - debugPrint('--- IconButton.filled M3 (isSelected: true) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(IconButton)).getSemanticsData())); - - await tester.pumpWidget( - _withStreamTheme( - StreamButton.icon( - isSelected: true, - tooltip: 'Photo Gallery', - onPressed: () {}, - icon: const Icon(Icons.photo), - ), - ), - ); - - debugPrint('--- StreamButton.icon (isSelected: true) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(StreamButton)).getSemanticsData())); - - handle.dispose(); - }); - - testWidgets('FilledButton M3 vs StreamButton (solid)', (tester) async { - final handle = tester.ensureSemantics(); - - await tester.pumpWidget( - _withStreamTheme( - FilledButton( - onPressed: () {}, - child: const Text('Submit'), - ), - ), - ); - - debugPrint('--- FilledButton M3 ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(FilledButton)).getSemanticsData())); - - await tester.pumpWidget( - _withStreamTheme( - StreamButton( - onPressed: () {}, - child: const Text('Submit'), - ), - ), - ); - - debugPrint('--- StreamButton (solid) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(StreamButton)).getSemanticsData())); - - handle.dispose(); - }); - }); - - group('Semantic tree comparison: ElevatedButton vs StreamButton', () { - testWidgets('with text child, onPressed', (tester) async { - final handle = tester.ensureSemantics(); - - await tester.pumpWidget( - _withStreamTheme( - ElevatedButton( - onPressed: () {}, - child: const Text('Submit'), - ), - ), - ); - - debugPrint('--- ElevatedButton ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(ElevatedButton)).getSemanticsData())); - - await tester.pumpWidget( - _withStreamTheme( - StreamButton( - onPressed: () {}, - child: const Text('Submit'), - ), - ), - ); - - debugPrint('--- StreamButton ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(StreamButton)).getSemanticsData())); - - handle.dispose(); - }); - - testWidgets('with text child, isSelected=true, onPressed', (tester) async { - final handle = tester.ensureSemantics(); - - // ElevatedButton has no isSelected param, so we apply Semantics(selected:) - // externally to compare apples-to-apples with StreamButton's built-in - // isSelected handling. - await tester.pumpWidget( - _withStreamTheme( - Semantics( - selected: true, - child: ElevatedButton( - onPressed: () {}, - child: const Text('Submit'), - ), - ), - ), - ); - - debugPrint('--- ElevatedButton + Semantics(selected: true) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(ElevatedButton)).getSemanticsData())); - - await tester.pumpWidget( - _withStreamTheme( - StreamButton( - isSelected: true, - onPressed: () {}, - child: const Text('Submit'), - ), - ), - ); - - debugPrint('--- StreamButton (isSelected: true) ---'); - debugPrint(_fmt(tester.getSemantics(find.byType(StreamButton)).getSemanticsData())); - - handle.dispose(); - }); - }); -}