diff --git a/catalyst_voices/lib/widgets/tooltips/voices_plain_tooltip.dart b/catalyst_voices/lib/widgets/tooltips/voices_plain_tooltip.dart new file mode 100644 index 00000000000..3ea8724555a --- /dev/null +++ b/catalyst_voices/lib/widgets/tooltips/voices_plain_tooltip.dart @@ -0,0 +1,55 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +/// A simple tooltip widget with a plain text message and a child widget. +/// +/// **Notes:** +/// - The tooltip's colors might need to be adjusted based on the final design. +/// - The tooltip's text is constrained to a maximum width of 200 pixels. +/// +/// **Usage:** +/// ```dart +/// VoicesPlainTooltip( +/// message: "This is a tooltip message.", +/// child: Icon(Icons.info), +/// ) +/// ``` +class VoicesPlainTooltip extends StatelessWidget { + /// The text message to display in the tooltip. + final String message; + + /// The widget that triggers tooltip visibility. + final Widget child; + + const VoicesPlainTooltip({ + super.key, + required this.message, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Tooltip( + richMessage: WidgetSpan( + child: ConstrainedBox( + key: const ValueKey('VoicesPlainTooltipContentKey'), + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colors.iconsBackground, + ), + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colors.iconsForeground, + borderRadius: BorderRadius.circular(4), + ), + child: child, + ); + } +} diff --git a/catalyst_voices/lib/widgets/tooltips/voices_rich_tooltip.dart b/catalyst_voices/lib/widgets/tooltips/voices_rich_tooltip.dart new file mode 100644 index 00000000000..3af6d65c558 --- /dev/null +++ b/catalyst_voices/lib/widgets/tooltips/voices_rich_tooltip.dart @@ -0,0 +1,171 @@ +import 'package:catalyst_voices/widgets/buttons/voices_text_button.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +final class VoicesRichTooltipActionData { + final String name; + final VoidCallback onTap; + + VoicesRichTooltipActionData({ + required this.name, + required this.onTap, + }); +} + +/// A tooltip widget with a rich text message (title and message) and +/// optional actions displayed at the bottom. +/// +/// **Notes:** +/// - The tooltip's maximum width is constrained to 312 pixels. +/// - The tooltip's background color and shadow are theme-dependent. +/// - If no actions are provided, the tooltip can be dismissed by tapping +/// anywhere on it. Otherwise, tapping will only trigger the action button +/// taps which will dismiss all tooltips see [Tooltip.dismissAllToolTips]. +/// +/// **Example Usage:** +/// ```dart +/// final actions = [ +/// VoicesRichTooltipActionData( +/// name: "Edit", +/// onTap: () => print("Edit tapped"), +/// ), +/// VoicesRichTooltipActionData( +/// name: "Delete", +/// onTap: () => print("Delete tapped"), +/// ), +/// ]; +/// +/// VoicesRichTooltip( +/// title: "Tooltip Title", +/// message: "This is a tooltip with a descriptive message.", +/// actions: actions, +/// child: Icon(Icons.info), +/// ) +/// ``` +class VoicesRichTooltip extends StatelessWidget { + /// The main title displayed at the top of the tooltip. + final String title; + + /// The descriptive message displayed below the title. + final String message; + + /// (Optional) A list of action buttons displayed at the + /// bottom of the tooltip. Each action has a `name` and an `onTap` callback. + final List actions; + + /// The widget that triggers tooltip visibility. + final Widget child; + + const VoicesRichTooltip({ + super.key, + required this.title, + required this.message, + this.actions = const [], + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isLightTheme = theme.brightness == Brightness.light; + + return Tooltip( + richMessage: WidgetSpan( + child: ConstrainedBox( + key: const ValueKey('VoicesRichTooltipContentKey'), + constraints: const BoxConstraints(maxWidth: 312), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Content(title, message), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 8), + _ActionsRow(actions), + ], + const SizedBox(height: 8), + ], + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colors.onSurfaceNeutralOpaqueLv2, + borderRadius: BorderRadius.circular(12), + boxShadow: isLightTheme ? kElevationToShadow[2] : null, + ), + enableTapToDismiss: actions.isEmpty, + child: child, + ); + } +} + +class _Content extends StatelessWidget { + final String title; + final String message; + + const _Content( + this.title, + this.message, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16) + .add(const EdgeInsets.only(top: 8)), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colors.textPrimary, + ), + textAlign: TextAlign.start, + ), + const SizedBox(height: 4), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colors.textPrimary, + ), + textAlign: TextAlign.start, + ), + ], + ), + ); + } +} + +class _ActionsRow extends StatelessWidget { + const _ActionsRow(this.actions); + + final List actions; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: actions + .map( + (action) { + return VoicesTextButton( + onTap: () { + Tooltip.dismissAllToolTips(); + action.onTap(); + }, + child: Text(action.name), + ); + }, + ) + .separatedBy(const SizedBox(width: 8)) + .toList(), + ), + ); + } +} diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index 354de826c42..16573a8e657 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -23,3 +23,5 @@ export 'toggles/voices_checkbox.dart'; export 'toggles/voices_checkbox_group.dart'; export 'toggles/voices_radio.dart'; export 'toggles/voices_switch.dart'; +export 'tooltips/voices_plain_tooltip.dart'; +export 'tooltips/voices_rich_tooltip.dart'; diff --git a/catalyst_voices/test/helpers/pump_app.dart b/catalyst_voices/test/helpers/pump_app.dart index 4b5bc5f1809..0e8ad8de6fe 100644 --- a/catalyst_voices/test/helpers/pump_app.dart +++ b/catalyst_voices/test/helpers/pump_app.dart @@ -7,8 +7,15 @@ import 'package:flutter_test/flutter_test.dart'; extension PumpApp on WidgetTester { Future pumpApp( Widget widget, { + ThemeData? theme, VoicesColorScheme voicesColors = const VoicesColorScheme.optional(), }) { + final effectiveTheme = (theme ?? ThemeData()).copyWith( + extensions: [ + voicesColors, + ], + ); + return pumpWidget( MaterialApp( localizationsDelegates: const [ @@ -17,11 +24,7 @@ extension PumpApp on WidgetTester { ], supportedLocales: VoicesLocalizations.supportedLocales, localeListResolutionCallback: basicLocaleListResolution, - theme: ThemeData( - extensions: [ - voicesColors, - ], - ), + theme: effectiveTheme, home: widget, ), ); diff --git a/catalyst_voices/test/widgets/tooltips/voices_plain_tooltip_test.dart b/catalyst_voices/test/widgets/tooltips/voices_plain_tooltip_test.dart new file mode 100644 index 00000000000..c39c1dc61ca --- /dev/null +++ b/catalyst_voices/test/widgets/tooltips/voices_plain_tooltip_test.dart @@ -0,0 +1,82 @@ +import 'package:catalyst_voices/widgets/tooltips/voices_plain_tooltip.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('VoicesPlainTooltip', () { + testWidgets('displays the correct message', (tester) async { + // Given + const message = 'This is a tooltip message.'; + const child = Icon(Icons.info); + + const widget = VoicesPlainTooltip( + message: message, + child: child, + ); + + // When + await tester.pumpApp(widget); + await tester.pumpAndSettle(); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(Icon))); + await tester.pumpAndSettle(); + + // Then + expect(find.text(message), findsOneWidget); + }); + + testWidgets('is not displayed without hover', (tester) async { + // Given + const message = 'This is a tooltip message.'; + const child = Icon(Icons.info); + + const widget = VoicesPlainTooltip( + message: message, + child: child, + ); + + // When + await tester.pumpApp(widget); + await tester.pumpAndSettle(); + + // Then + expect(find.text(message), findsNothing); + }); + + testWidgets('constrains the text width', (tester) async { + // Given + const message = 'This is a very long tooltip message.'; + const child = Icon(Icons.info); + + const widget = VoicesPlainTooltip( + message: message, + child: child, + ); + + // When + await tester.pumpApp(widget); + await tester.pumpAndSettle(); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(Icon))); + await tester.pumpAndSettle(); + + // Then + final size = tester.getSize( + find.byKey(const ValueKey('VoicesPlainTooltipContentKey')), + ); + + expect(size.width, 200.0); + }); + }); +} diff --git a/catalyst_voices/test/widgets/tooltips/voices_rich_tooltip_test.dart b/catalyst_voices/test/widgets/tooltips/voices_rich_tooltip_test.dart new file mode 100644 index 00000000000..ed1931152fb --- /dev/null +++ b/catalyst_voices/test/widgets/tooltips/voices_rich_tooltip_test.dart @@ -0,0 +1,146 @@ +import 'package:catalyst_voices/widgets/buttons/voices_text_button.dart'; +import 'package:catalyst_voices/widgets/tooltips/voices_rich_tooltip.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('VoicesRichTooltip', () { + testWidgets('renders title and message', (tester) async { + // Given + const title = 'Tooltip Title'; + const message = 'This is a tooltip message.'; + + const widget = VoicesRichTooltip( + title: title, + message: message, + child: Icon(Icons.info), + ); + + // When + await tester.pumpApp(widget); + await tester.pumpAndSettle(); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(Icon))); + await tester.pumpAndSettle(); + + // Then + // Find the Text widgets for title and message + final titleFinder = find.text(title); + final messageFinder = find.text(message); + + // Expect them to be present in the widget tree + expect(tester.widget(titleFinder), isNotNull); + expect(tester.widget(messageFinder), isNotNull); + }); + }); + + testWidgets('renders actions', (tester) async { + // Given + const title = 'Tooltip Title'; + const message = 'This is a tooltip message.'; + final actions = [ + VoicesRichTooltipActionData( + name: 'Edit', + onTap: () => debugPrint('Edit tapped'), + ), + VoicesRichTooltipActionData( + name: 'Delete', + onTap: () => debugPrint('Delete tapped'), + ), + ]; + + final widget = VoicesRichTooltip( + title: title, + message: message, + actions: actions, + child: const Icon(Icons.info), + ); + + // When + await tester.pumpApp(widget); + await tester.pumpAndSettle(); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(Icon))); + await tester.pumpAndSettle(); + + // Then + final actionButtons = find + .byWidgetPredicate((widget) => widget is VoicesTextButton) + .evaluate(); + + expect(actionButtons.length, actions.length); + + // Optionally, verify the text content of each button + for (var i = 0; i < actions.length; i++) { + expect(find.text(actions[i].name), findsOne); + } + }); + + testWidgets('dismisses on tap without actions', (tester) async { + // Given + const title = 'Tooltip Title'; + const message = 'This is a tooltip message.'; + + final tapped = ValueNotifier(false); + + final widget = Padding( + padding: const EdgeInsets.all(8), + child: VoicesRichTooltip( + title: title, + message: message, + child: TextButton( + onPressed: () => tapped.value = true, + child: const Text('Trigger'), + ), + ), + ); + + // When + await tester.pumpApp(widget); + await tester.pumpAndSettle(); + + // Tap the trigger button + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(TextButton))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Trigger')); + await tester.pump(); + + expect( + find.byKey(const ValueKey('VoicesRichTooltipContentKey')), + findsOne, + ); + + // Simulate a tap on the tooltip itself + await tester.tapAt(tester.getCenter(find.byType(Tooltip))); + await tester.pump(); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + // Then + // Expect the Tooltip to be dismissed (no longer rendered) + expect( + find.byKey(const ValueKey('VoicesRichTooltipContentKey')), + findsNothing, + ); + + // Expect the trigger button to be tapped + expect(tapped.value, true); + }); +} diff --git a/catalyst_voices/uikit_example/lib/examples/voices_tooltips_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_tooltips_example.dart new file mode 100644 index 00000000000..6103636c413 --- /dev/null +++ b/catalyst_voices/uikit_example/lib/examples/voices_tooltips_example.dart @@ -0,0 +1,98 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class VoicesTooltipsExample extends StatelessWidget { + static const String route = '/tooltips-example'; + + const VoicesTooltipsExample({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Tooltips')), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), + children: [ + Center( + child: VoicesPlainTooltip( + message: 'Supporting text', + child: Container( + color: Colors.blue, + padding: const EdgeInsets.all(8), + child: const Text( + 'Plain Tooltip trigger', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + Center( + child: VoicesPlainTooltip( + message: 'This is very long supporting text. ' + 'This is very long supporting text. ' + 'This is very long supporting text. ' + 'This is very long supporting text.', + child: Container( + color: Colors.blue, + padding: const EdgeInsets.all(8), + child: const Text( + 'Big Plain Tooltip trigger', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + Center( + child: VoicesRichTooltip( + title: 'Title', + message: 'This is supporting text. This is supporting text.', + child: Container( + color: Colors.blue, + padding: const EdgeInsets.all(8), + child: const Text( + 'Rich Tooltip trigger', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + Center( + child: VoicesRichTooltip( + title: 'Title', + message: 'This is supporting text. This is supporting text.', + actions: [ + VoicesRichTooltipActionData( + name: 'Action', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tooltip action clicked!')), + ); + }, + ), + VoicesRichTooltipActionData( + name: 'Action', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tooltip action clicked!')), + ); + }, + ), + ], + child: Container( + color: Colors.blue, + padding: const EdgeInsets.all(8), + child: const Text( + 'Rich Tooltip [Actions] trigger', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + ].separatedBy(const SizedBox(height: 16)).toList(), + ), + ); + } +} diff --git a/catalyst_voices/uikit_example/lib/examples_list.dart b/catalyst_voices/uikit_example/lib/examples_list.dart index 4bda4b4d363..80861fccfff 100644 --- a/catalyst_voices/uikit_example/lib/examples_list.dart +++ b/catalyst_voices/uikit_example/lib/examples_list.dart @@ -17,6 +17,7 @@ import 'package:uikit_example/examples/voices_separators_example.dart'; import 'package:uikit_example/examples/voices_snackbar_example.dart'; import 'package:uikit_example/examples/voices_switch_example.dart'; import 'package:uikit_example/examples/voices_text_field_example.dart'; +import 'package:uikit_example/examples/voices_tooltips_example.dart'; class ExamplesListPage extends StatelessWidget { static List get examples { @@ -96,6 +97,11 @@ class ExamplesListPage extends StatelessWidget { route: VoicesSeedPhraseExample.route, page: VoicesSeedPhraseExample(), ), + ExampleTile( + title: 'Voices Tooltips', + route: VoicesTooltipsExample.route, + page: VoicesTooltipsExample(), + ), ]; }