Skip to content

Commit

Permalink
feat: tooltips (#707)
Browse files Browse the repository at this point in the history
* feat: plain tooltip + example

* feat: VoicesRichTooltip

* chore: remove console prints

* feat: light mode shadows

* chore: widgets docs

* fix: example texts makes more sens now

* chore: Tooltips tests

* chore: cleanup docs
  • Loading branch information
damian-molinski authored Aug 22, 2024
1 parent 9ed3380 commit 1bd7a72
Show file tree
Hide file tree
Showing 8 changed files with 568 additions and 5 deletions.
55 changes: 55 additions & 0 deletions catalyst_voices/lib/widgets/tooltips/voices_plain_tooltip.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
171 changes: 171 additions & 0 deletions catalyst_voices/lib/widgets/tooltips/voices_rich_tooltip.dart
Original file line number Diff line number Diff line change
@@ -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<VoicesRichTooltipActionData> 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<VoicesRichTooltipActionData> actions;

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: actions
.map<Widget>(
(action) {
return VoicesTextButton(
onTap: () {
Tooltip.dismissAllToolTips();
action.onTap();
},
child: Text(action.name),
);
},
)
.separatedBy(const SizedBox(width: 8))
.toList(),
),
);
}
}
2 changes: 2 additions & 0 deletions catalyst_voices/lib/widgets/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
13 changes: 8 additions & 5 deletions catalyst_voices/test/helpers/pump_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ import 'package:flutter_test/flutter_test.dart';
extension PumpApp on WidgetTester {
Future<void> pumpApp(
Widget widget, {
ThemeData? theme,
VoicesColorScheme voicesColors = const VoicesColorScheme.optional(),
}) {
final effectiveTheme = (theme ?? ThemeData()).copyWith(
extensions: [
voicesColors,
],
);

return pumpWidget(
MaterialApp(
localizationsDelegates: const [
Expand All @@ -17,11 +24,7 @@ extension PumpApp on WidgetTester {
],
supportedLocales: VoicesLocalizations.supportedLocales,
localeListResolutionCallback: basicLocaleListResolution,
theme: ThemeData(
extensions: [
voicesColors,
],
),
theme: effectiveTheme,
home: widget,
),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading

0 comments on commit 1bd7a72

Please sign in to comment.