diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 4ea77a42b0..2fe8913f5f 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -245,3 +245,4 @@ xcworkspace yoroi unchunk EUTXO +toggleable \ No newline at end of file diff --git a/catalyst_voices/lib/widgets/common/label_decorator.dart b/catalyst_voices/lib/widgets/common/label_decorator.dart new file mode 100644 index 0000000000..b7c4a63659 --- /dev/null +++ b/catalyst_voices/lib/widgets/common/label_decorator.dart @@ -0,0 +1,55 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class LabelDecorator extends StatelessWidget { + /// An optional widget to display the label text next to the child. + final Widget? label; + + /// An optional widget to display the note text next to the child. + final Widget? note; + + /// The main content of the decorator. + final Widget child; + + const LabelDecorator({ + super.key, + this.label, + this.note, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final label = this.label; + final note = this.note; + final theme = Theme.of(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: child), + if (label != null) + DefaultTextStyle( + style: (theme.textTheme.bodyLarge ?? const TextStyle()) + .copyWith(color: theme.colors.textPrimary), + child: label, + ), + if (note != null) + DefaultTextStyle( + style: (theme.textTheme.bodySmall ?? const TextStyle()) + .copyWith(color: theme.colors.textOnPrimary), + child: note, + ), + ].expandIndexed( + (index, element) { + return [ + if (index == 1) const SizedBox(width: 4), + if (index == 2) const SizedBox(width: 8), + element, + ]; + }, + ).toList(), + ); + } +} diff --git a/catalyst_voices/lib/widgets/toggles/voices_checkbox.dart b/catalyst_voices/lib/widgets/toggles/voices_checkbox.dart new file mode 100644 index 0000000000..9e32449761 --- /dev/null +++ b/catalyst_voices/lib/widgets/toggles/voices_checkbox.dart @@ -0,0 +1,57 @@ +import 'package:catalyst_voices/widgets/common/label_decorator.dart'; +import 'package:flutter/material.dart'; + +/// A checkbox widget with optional label, note, and error state. +/// +/// This widget provides a visual representation of a boolean value and allows +/// users to toggle its state. It can display an optional label and note +/// for context, as well as indicate an error state through visual styling. +class VoicesCheckbox extends StatelessWidget { + /// The current value of the checkbox. + final bool value; + + /// Callback function invoked when the checkbox value changes. + /// + /// The function receives the new value as a parameter. + /// + /// When null widget is disabled. + final ValueChanged? onChanged; + + /// Indicates whether the checkbox is in an error state. + final bool isError; + + /// An optional widget to display the label text. + final Widget? label; + + /// An optional widget to display the note text. + final Widget? note; + + const VoicesCheckbox({ + super.key, + required this.value, + required this.onChanged, + this.isError = false, + this.label, + this.note, + }); + + @override + Widget build(BuildContext context) { + final onChanged = this.onChanged; + + return GestureDetector( + onTap: onChanged != null ? () => onChanged(!value) : null, + behavior: HitTestBehavior.opaque, + child: LabelDecorator( + label: label, + note: note, + child: Checkbox( + value: value, + // forcing null unwrapping because we're not allowing null value + onChanged: onChanged != null ? (value) => onChanged(value!) : null, + isError: isError, + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/widgets/toggles/voices_checkbox_group.dart b/catalyst_voices/lib/widgets/toggles/voices_checkbox_group.dart new file mode 100644 index 0000000000..3f19b4f201 --- /dev/null +++ b/catalyst_voices/lib/widgets/toggles/voices_checkbox_group.dart @@ -0,0 +1,152 @@ +import 'package:catalyst_voices/widgets/toggles/voices_checkbox.dart'; +import 'package:flutter/material.dart'; + +/// Data describing a single checkbox element within a [VoicesCheckboxGroup]. +/// +/// This class holds information for a single checkbox element displayed +/// within a [VoicesCheckboxGroup]. +/// +/// Type Parameters: +/// +/// * T: The type of the value associated with the checkbox element. +final class VoicesCheckboxGroupElement { + /// The value associated with the checkbox element. + final T value; + + /// A widget to display the text label for the checkbox. + final Widget? label; + + /// An optional widget to display additional information below the label. + final Widget? note; + + /// A flag indicating if the checkbox represents an error state. + final bool isError; + + /// Default constructor for [VoicesCheckboxGroupElement]. + /// + /// Should have at least [label] or [note]. + const VoicesCheckboxGroupElement({ + required this.value, + this.label, + this.note, + this.isError = false, + }) : assert( + label != null || note != null, + 'Should have at least label or note', + ); +} + +/// A widget that groups a list of checkboxes in a column with a name +/// and the ability to toggle all elements at once. +/// +/// This widget displays a group of checkboxes with a shared name. It allows +/// users to select individual checkboxes and provides a master checkbox to +/// toggle all elements on/off simultaneously. You can also optionally specify +/// a callback function to be notified when the selection changes. +/// +/// Type Parameters: +/// +/// * T: The type of the value associated with each checkbox element. +class VoicesCheckboxGroup extends StatelessWidget { + /// The name displayed for the checkbox group. + final Widget name; + + /// A list of [VoicesCheckboxGroupElement] objects defining each checkbox. + final List> elements; + + /// A set of currently selected values within the group. + final Set selected; + + /// An optional callback function to be called when the selection changes. + final ValueChanged>? onChanged; + + /// How the checkboxes should be aligned horizontally within the group. + /// + /// Defaults to [CrossAxisAlignment.start]. + final CrossAxisAlignment crossAxisAlignment; + + bool get _isGroupEnabled => onChanged != null; + + bool get _isGroupSelected => + elements.every((element) => selected.contains(element.value)); + + const VoicesCheckboxGroup({ + super.key, + required this.name, + required this.elements, + required this.selected, + this.onChanged, + this.crossAxisAlignment = CrossAxisAlignment.start, + }) : assert(elements.length > 0, 'Elements have to be non empty'); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: crossAxisAlignment, + children: [ + VoicesCheckbox( + value: _isGroupSelected, + label: name, + onChanged: _isGroupEnabled ? _toggleAll : null, + ), + Padding( + padding: const EdgeInsetsDirectional.only(start: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: crossAxisAlignment, + children: elements + .map( + (element) { + return VoicesCheckbox( + value: selected.contains(element.value), + label: element.label, + note: element.note, + onChanged: _isGroupEnabled + ? (value) => _updateElement(element.value, value) + : null, + isError: element.isError, + ); + }, + ) + .expand( + (element) => [ + const SizedBox(height: 16), + element, + ], + ) + .toList(), + ), + ), + ], + ); + } + + void _toggleAll(bool isChecked) { + assert( + onChanged != null, + 'Toggled group status but change callback is null', + ); + + final updatedSelection = + isChecked ? elements.map((e) => e.value).toSet() : {}; + + onChanged!(updatedSelection); + } + + void _updateElement(T value, bool isChecked) { + assert( + onChanged != null, + 'Toggled group status but change callback is null', + ); + + final updatedSelection = {...selected}; + if (isChecked) { + updatedSelection.add(value); + } else { + updatedSelection.remove(value); + } + + onChanged!(updatedSelection); + } +} diff --git a/catalyst_voices/lib/widgets/toggles/voices_radio.dart b/catalyst_voices/lib/widgets/toggles/voices_radio.dart new file mode 100644 index 0000000000..1b259a38d9 --- /dev/null +++ b/catalyst_voices/lib/widgets/toggles/voices_radio.dart @@ -0,0 +1,73 @@ +import 'package:catalyst_voices/widgets/common/label_decorator.dart'; +import 'package:flutter/material.dart'; + +/// A Voices Radio widget that combines a Radio button with optional +/// label and note text elements. +/// +/// Provides a convenient way to create radio buttons with associated +/// information in a visually appealing manner. +class VoicesRadio extends StatelessWidget { + /// The value associated with this radio button. + final T value; + + /// An optional widget to display the label text. + final Widget? label; + + /// An optional widget to display the note text. + final Widget? note; + + /// The current selected value in the radio group. + final T? groupValue; + + /// Whether the radio button value can be changed by widget itself. + final bool toggleable; + + /// A callback function that is called when the radio button value changes. + /// + /// When null widget is disabled. + final ValueChanged? onChanged; + + const VoicesRadio({ + required this.value, + this.label, + this.note, + required this.groupValue, + this.toggleable = false, + required this.onChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onChanged != null ? _changeGroupValue : null, + behavior: HitTestBehavior.opaque, + child: LabelDecorator( + label: label, + note: note, + child: Radio( + value: value, + groupValue: groupValue, + onChanged: onChanged, + toggleable: toggleable, + ), + ), + ); + } + + void _changeGroupValue() { + final onChanged = this.onChanged; + assert( + onChanged != null, + 'Make sure onChange is not null when using _changeGroupValue', + ); + + if (groupValue == value && toggleable) { + onChanged!(null); + } + + if (groupValue != value) { + onChanged!(value); + } + } +} diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index 17ff7b7829..30c4c8cc5e 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -6,3 +6,6 @@ export 'buttons/voices_text_button.dart'; export 'text_field/voices_email_text_field.dart'; export 'text_field/voices_password_text_field.dart'; export 'text_field/voices_text_field.dart'; +export 'toggles/voices_checkbox.dart'; +export 'toggles/voices_checkbox_group.dart'; +export 'toggles/voices_radio.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_brands/lib/src/theme_extensions/voices_color_scheme.dart b/catalyst_voices/packages/catalyst_voices_brands/lib/src/theme_extensions/voices_color_scheme.dart index 2c85d4f9ce..2bb1b698ab 100644 --- a/catalyst_voices/packages/catalyst_voices_brands/lib/src/theme_extensions/voices_color_scheme.dart +++ b/catalyst_voices/packages/catalyst_voices_brands/lib/src/theme_extensions/voices_color_scheme.dart @@ -107,6 +107,58 @@ class VoicesColorScheme extends ThemeExtension { required this.onErrorContainer, }); + @visibleForTesting + const VoicesColorScheme.optional({ + this.textPrimary, + this.textOnPrimary, + this.textOnPrimaryContainer, + this.textDisabled, + this.success, + this.onSuccess, + this.successContainer, + this.onSuccessContainer, + this.warning, + this.onWarning, + this.warningContainer, + this.onWarningContainer, + this.onSurfaceNeutral08, + this.onSurfaceNeutral012, + this.onSurfaceNeutral016, + this.onSurfacePrimaryContainer, + this.onSurfacePrimary08, + this.onSurfacePrimary012, + this.onSurfacePrimary016, + this.onSurfaceNeutralOpaqueLv0, + this.onSurfaceNeutralOpaqueLv1, + this.onSurfaceNeutralOpaqueLv2, + this.onSurfaceSecondary08, + this.onSurfaceSecondary012, + this.onSurfaceSecondary016, + this.onSurfaceError08, + this.onSurfaceError012, + this.onSurfaceError016, + this.iconsForeground, + this.iconsBackground, + this.iconsDisabled, + this.iconsPrimary, + this.iconsSecondary, + this.iconsSuccess, + this.iconsWarning, + this.iconsError, + this.avatarsPrimary, + this.avatarsSecondary, + this.avatarsSuccess, + this.avatarsWarning, + this.avatarsError, + this.elevationsOnSurfaceNeutralLv0, + this.outlineBorder, + this.outlineBorderVariant, + this.primaryContainer, + this.onPrimaryContainer, + this.errorContainer, + this.onErrorContainer, + }); + @override ThemeExtension copyWith({ Color? textPrimary, @@ -317,5 +369,6 @@ class VoicesColorScheme extends ThemeExtension { extension VoicesColorSchemeExtension on ThemeData { VoicesColorScheme get colors => extension()!; + Color get linksPrimary => primaryColor; } diff --git a/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart b/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart index 48e8a2a47a..6c98debc64 100644 --- a/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart +++ b/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart @@ -2,6 +2,7 @@ import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/src/theme_extensions/brand_assets.dart'; import 'package:catalyst_voices_brands/src/theme_extensions/voices_color_scheme.dart'; import 'package:catalyst_voices_brands/src/themes/widgets/buttons_theme.dart'; +import 'package:catalyst_voices_brands/src/themes/widgets/toggles_theme.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -302,5 +303,5 @@ ThemeData _buildThemeData( voicesColorScheme, brandAssets, ], - ).copyWithButtonsTheme(); + ).copyWithButtonsTheme().copyWithTogglesTheme(); } diff --git a/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/widgets/toggles_theme.dart b/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/widgets/toggles_theme.dart new file mode 100644 index 0000000000..640f3315ef --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/widgets/toggles_theme.dart @@ -0,0 +1,111 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +extension TogglesTheme on ThemeData { + /// Applies Toggles themes configuration. + /// + /// Reasoning behind having it as extension is readability mostly and + /// reusability if we're going to have more brands. + ThemeData copyWithTogglesTheme() { + return copyWith( + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.disabled)) { + return colors.iconsDisabled?.withOpacity(0.32); + } + + if (states.contains(WidgetState.selected)) { + return colorScheme.primary; + } + + const foregroundStates = [WidgetState.focused, WidgetState.hovered]; + if (states.any((state) => foregroundStates.contains(state))) { + return colors.iconsForeground; + } + + return colors.outlineBorder; + }, + ), + overlayColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + + if (states.contains(WidgetState.pressed)) { + return states.contains(WidgetState.selected) + ? colors.onSurfaceNeutral012 + : colors.onSurfacePrimary08; + } + + const hoveredFocusedStates = [ + WidgetState.hovered, + WidgetState.focused, + ]; + if (states.any((state) => hoveredFocusedStates.contains(state))) { + return states.contains(WidgetState.selected) + ? colors.onSurfacePrimary08 + : colors.onSurfaceNeutral012; + } + + return null; + }, + ), + ), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.disabled)) { + return colors.onSurfaceNeutral012; + } + + if (states.contains(WidgetState.selected)) { + return states.contains(WidgetState.error) + ? colorScheme.error + : colorScheme.primary; + } + + return null; + }, + ), + checkColor: WidgetStateProperty.resolveWith( + (states) { + return colors.iconsBackground; + }, + ), + side: WidgetStateBorderSide.resolveWith( + (states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide( + color: colors.onSurfaceNeutral012!, + width: 2, + ); + } + + if (states.contains(WidgetState.error)) { + return BorderSide( + color: colorScheme.error, + width: 2, + ); + } + + if (states.contains(WidgetState.selected)) { + return BorderSide( + color: colorScheme.primary, + width: 2, + ); + } + + return BorderSide( + color: colors.outlineBorder!, + width: 2, + ); + }, + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity(horizontal: -4, vertical: -4), + ), + ); + } +} diff --git a/catalyst_voices/pubspec.yaml b/catalyst_voices/pubspec.yaml index 687907faa0..93c8a90d5d 100644 --- a/catalyst_voices/pubspec.yaml +++ b/catalyst_voices/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: path: ./packages/catalyst_voices_shared catalyst_voices_view_models: path: ./packages/catalyst_voices_view_models + collection: ^1.18.0 flutter: sdk: flutter flutter_adaptive_scaffold: ^0.1.11 diff --git a/catalyst_voices/test/widgets/common/affix_decorator_test.dart b/catalyst_voices/test/widgets/common/affix_decorator_test.dart new file mode 100644 index 0000000000..0afcf73e8a --- /dev/null +++ b/catalyst_voices/test/widgets/common/affix_decorator_test.dart @@ -0,0 +1,190 @@ +import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Prefix', () { + testWidgets( + 'is visible when not null ', + (tester) async { + // Given + const closeIcon = Icon(Icons.close); + const affixDecorator = Directionality( + textDirection: TextDirection.ltr, + child: AffixDecorator( + prefix: closeIcon, + child: Text('Label'), + ), + ); + + // When + await tester.pumpWidget(affixDecorator); + + // Then + expect(find.byIcon(Icons.close), findsOneWidget); + }, + ); + + testWidgets( + 'iconTheme is applied to the prefix icon', + (tester) async { + // Given + const closeIcon = Icon(Icons.close); + const iconTheme = IconThemeData(color: Colors.yellow); + const affixDecorator = Directionality( + textDirection: TextDirection.ltr, + child: AffixDecorator( + iconTheme: iconTheme, + prefix: closeIcon, + child: Text('Label'), + ), + ); + + // When + await tester.pumpWidget(affixDecorator); + + // Then + final iconFinder = find.byIcon(Icons.close); + + expect(iconFinder, findsOneWidget); + expect( + (tester.firstWidget(find.byType(IconTheme)) as IconTheme).data.color, + allOf( + isNotNull, + equals(iconTheme.color), + ), + ); + }, + ); + + testWidgets( + 'gap is present and default correctly', + (tester) async { + // Given + const closeIcon = Icon(Icons.close); + const affixDecorator = Directionality( + textDirection: TextDirection.ltr, + child: AffixDecorator( + prefix: closeIcon, + child: Text('Label'), + ), + ); + + // When + await tester.pumpWidget(affixDecorator); + + // Then + final iconFinder = find.byIcon(Icons.close); + final gapFinder = find.byWidgetPredicate( + (widget) => widget is SizedBox && widget.width == 8.0, + ); + + expect(iconFinder, findsOneWidget); + expect(gapFinder, findsOneWidget); + }, + ); + }); + + group('Child', () { + testWidgets( + 'is wrapped in flexible widget', + (tester) async { + // Given + const affixDecorator = Directionality( + textDirection: TextDirection.ltr, + child: AffixDecorator(child: Text('Label')), + ); + + // When + await tester.pumpWidget(affixDecorator); + + // Then + final flexibleFinder = find.byType(Flexible); + + expect(flexibleFinder, findsOneWidget); + }, + ); + }); + + group('Suffix', () { + testWidgets( + 'is visible when not null ', + (tester) async { + // Given + const closeIcon = Icon(Icons.close); + const affixDecorator = Directionality( + textDirection: TextDirection.ltr, + child: AffixDecorator( + suffix: closeIcon, + child: Text('Label'), + ), + ); + + // When + await tester.pumpWidget(affixDecorator); + + // Then + expect(find.byIcon(Icons.close), findsOneWidget); + }, + ); + + testWidgets( + 'iconTheme is applied to the suffix icon', + (tester) async { + // Given + const closeIcon = Icon(Icons.close); + const iconTheme = IconThemeData(color: Colors.yellow); + const affixDecorator = Directionality( + textDirection: TextDirection.ltr, + child: AffixDecorator( + iconTheme: iconTheme, + suffix: closeIcon, + child: Text('Label'), + ), + ); + + // When + await tester.pumpWidget(affixDecorator); + + // Then + final iconFinder = find.byIcon(Icons.close); + + expect(iconFinder, findsOneWidget); + expect( + (tester.firstWidget(find.byType(IconTheme)) as IconTheme).data.color, + allOf( + isNotNull, + equals(iconTheme.color), + ), + ); + }, + ); + + testWidgets( + 'gap is present and default correctly', + (tester) async { + // Given + const closeIcon = Icon(Icons.close); + const affixDecorator = Directionality( + textDirection: TextDirection.ltr, + child: AffixDecorator( + suffix: closeIcon, + child: Text('Label'), + ), + ); + + // When + await tester.pumpWidget(affixDecorator); + + // Then + final iconFinder = find.byIcon(Icons.close); + final gapFinder = find.byWidgetPredicate( + (widget) => widget is SizedBox && widget.width == 8.0, + ); + + expect(iconFinder, findsOneWidget); + expect(gapFinder, findsOneWidget); + }, + ); + }); +} diff --git a/catalyst_voices/test/widgets/common/label_decorator_test.dart b/catalyst_voices/test/widgets/common/label_decorator_test.dart new file mode 100644 index 0000000000..5fe2a5a337 --- /dev/null +++ b/catalyst_voices/test/widgets/common/label_decorator_test.dart @@ -0,0 +1,161 @@ +import 'package:catalyst_voices/widgets/common/label_decorator.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Label', () { + testWidgets( + 'has correct DefaultTextStyle', + (tester) async { + // Given + const bodyLargeStyle = TextStyle( + fontSize: 24, + color: Colors.red, + ); + const colors = VoicesColorScheme.optional(textPrimary: Colors.black); + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + textTheme: const TextTheme( + bodyLarge: bodyLargeStyle, + ), + extensions: const [colors], + ), + child: const LabelDecorator( + label: Text('Label'), + child: Text('Child'), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + expect( + find.byWidgetPredicate( + (widget) { + return widget is DefaultTextStyle && + widget.style.fontSize == bodyLargeStyle.fontSize && + widget.style.color == colors.textPrimary; + }, + ), + findsOneWidget, + ); + }, + ); + }); + + group('Note', () { + testWidgets( + 'has correct DefaultTextStyle', + (tester) async { + // Given + const bodySmallStyle = TextStyle( + fontSize: 12, + color: Colors.red, + ); + const colors = VoicesColorScheme.optional(textPrimary: Colors.black); + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + textTheme: const TextTheme( + bodyLarge: bodySmallStyle, + ), + extensions: const [colors], + ), + child: const LabelDecorator( + label: Text('Label'), + child: Text('Child'), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + expect( + find.byWidgetPredicate( + (widget) { + return widget is DefaultTextStyle && + widget.style.fontSize == bodySmallStyle.fontSize && + widget.style.color == colors.textPrimary; + }, + ), + findsOneWidget, + ); + }, + ); + }); + + group('Spacing', () { + testWidgets( + 'first decorator have 4 spacing between child', + (tester) async { + // Given + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + extensions: const [VoicesColorScheme.optional()], + ), + child: const LabelDecorator( + label: Text('Label'), + child: Text('Child'), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + expect( + find.byWidgetPredicate( + (widget) { + return widget is SizedBox && widget.width == 4.0; + }, + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'second decorator have 8 spacing between previous decorator', + (tester) async { + // Given + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + extensions: const [VoicesColorScheme.optional()], + ), + child: const LabelDecorator( + label: Text('Label'), + note: Text('Note'), + child: Text('Child'), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + expect( + find.byWidgetPredicate( + (widget) { + return widget is SizedBox && widget.width == 8.0; + }, + ), + findsOneWidget, + ); + }, + ); + }); +} diff --git a/catalyst_voices/test/widgets/toggles/voices_checkbox_group_test.dart b/catalyst_voices/test/widgets/toggles/voices_checkbox_group_test.dart new file mode 100644 index 0000000000..a19b1804c8 --- /dev/null +++ b/catalyst_voices/test/widgets/toggles/voices_checkbox_group_test.dart @@ -0,0 +1,210 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group( + 'VoicesCheckboxGroupElement', + () { + test( + 'throws assert error when label and note is null', + () { + expect( + () => VoicesCheckboxGroupElement(value: 1), + throwsA(isA()), + ); + }, + ); + + test( + 'returns normally when label is set', + () { + expect( + () => const VoicesCheckboxGroupElement( + value: 1, + label: Text('Label'), + ), + returnsNormally, + ); + }, + ); + + test( + 'throws assert error when label and note is null', + () { + expect( + () => const VoicesCheckboxGroupElement( + value: 1, + note: Text('Note'), + ), + returnsNormally, + ); + }, + ); + }, + ); + + group( + 'VoicesCheckboxGroup', + () { + testWidgets( + 'name is displayed as a Text', + (tester) async { + // Given + const groupName = 'Select all'; + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + extensions: const [VoicesColorScheme.optional()], + ), + child: Material( + child: VoicesCheckboxGroup( + name: const Text(groupName), + elements: const [ + VoicesCheckboxGroupElement(value: 0, label: Text('Label')), + ], + selected: const {}, + ), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + expect(find.text(groupName), findsOneWidget); + }, + ); + + testWidgets( + 'all elements are mapped into widgets', + (tester) async { + // Given + const elements = [ + VoicesCheckboxGroupElement(value: 0, label: Text('Label')), + VoicesCheckboxGroupElement(value: 1, label: Text('Label')), + VoicesCheckboxGroupElement(value: 2, label: Text('Label')), + VoicesCheckboxGroupElement(value: 3, label: Text('Label')), + ]; + + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + extensions: const [VoicesColorScheme.optional()], + ), + child: Material( + child: VoicesCheckboxGroup( + name: const Text('Select all'), + elements: elements, + selected: const {}, + ), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + expect( + find.byType(VoicesCheckbox), + findsExactly(elements.length + 1), + ); + }, + ); + + testWidgets( + 'every element has gap', + (tester) async { + // Given + const elements = [ + VoicesCheckboxGroupElement(value: 0, label: Text('Label')), + VoicesCheckboxGroupElement(value: 1, label: Text('Label')), + VoicesCheckboxGroupElement(value: 2, label: Text('Label')), + VoicesCheckboxGroupElement(value: 3, label: Text('Label')), + ]; + + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + extensions: const [VoicesColorScheme.optional()], + ), + child: Material( + child: VoicesCheckboxGroup( + name: const Text('Select all'), + elements: elements, + selected: const {}, + ), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + // Then + expect( + find.byWidgetPredicate( + (widget) => widget is SizedBox && widget.height == 16, + ), + findsExactly(elements.length), + ); + }, + ); + + testWidgets( + 'tapping group name should select all elements', + (tester) async { + // Given + const groupName = 'Select all'; + const elements = [ + VoicesCheckboxGroupElement(value: 0, label: Text('Label')), + VoicesCheckboxGroupElement(value: 1, label: Text('Label')), + VoicesCheckboxGroupElement(value: 2, label: Text('Label')), + VoicesCheckboxGroupElement(value: 3, label: Text('Label')), + ]; + + final selectedElements = {}; + final expectedSelections = {0, 1, 2, 3}; + + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + extensions: const [VoicesColorScheme.optional()], + ), + child: Material( + child: VoicesCheckboxGroup( + name: const Text(groupName), + elements: elements, + selected: const {}, + onChanged: (value) { + selectedElements + ..clear() + ..addAll(value); + }, + ), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + await tester.tap(find.text(groupName)); + + // Then + expect( + selectedElements, + expectedSelections, + ); + }, + ); + }, + ); +} diff --git a/catalyst_voices/test/widgets/toggles/voices_checkbox_test.dart b/catalyst_voices/test/widgets/toggles/voices_checkbox_test.dart new file mode 100644 index 0000000000..37284806cf --- /dev/null +++ b/catalyst_voices/test/widgets/toggles/voices_checkbox_test.dart @@ -0,0 +1,48 @@ +import 'package:catalyst_voices/widgets/toggles/voices_checkbox.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group( + 'Interactions', + () { + testWidgets( + 'tapping label triggers change callback', + (tester) async { + // Given + const labelText = 'Label'; + + var isChecked = false; + const expectedIsChecked = true; + + final widget = Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + extensions: const [VoicesColorScheme.optional()], + ), + child: Material( + child: VoicesCheckbox( + value: isChecked, + onChanged: (value) { + isChecked = value; + }, + label: const Text(labelText), + ), + ), + ), + ); + + // When + await tester.pumpWidget(widget); + + await tester.tap(find.text(labelText)); + + // Then + expect(isChecked, expectedIsChecked); + }, + ); + }, + ); +} diff --git a/catalyst_voices/uikit_example/lib/examples/voices_checkbox_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_checkbox_example.dart new file mode 100644 index 0000000000..b261ed443e --- /dev/null +++ b/catalyst_voices/uikit_example/lib/examples/voices_checkbox_example.dart @@ -0,0 +1,109 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:flutter/material.dart'; + +class VoicesCheckboxExample extends StatefulWidget { + static const String route = '/checkbox-example'; + + const VoicesCheckboxExample({super.key}); + + @override + State createState() => _VoicesCheckboxExampleState(); +} + +class _VoicesCheckboxExampleState extends State { + final _checkboxesStates = {}; + final _checkboxGroupSelection = {}; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Voices Checkbox')), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + VoicesCheckbox( + value: _checkboxesStates[0] ?? false, + onChanged: (value) { + setState(() { + _checkboxesStates[0] = value; + }); + }, + ), + const SizedBox(height: 16), + const VoicesCheckbox( + value: false, + onChanged: null, + ), + const SizedBox(height: 16), + VoicesCheckbox( + value: _checkboxesStates[2] ?? false, + onChanged: (value) { + setState(() { + _checkboxesStates[2] = value; + }); + }, + isError: true, + ), + const SizedBox(height: 16), + VoicesCheckbox( + value: _checkboxesStates[3] ?? false, + onChanged: (value) { + setState(() { + _checkboxesStates[3] = value; + }); + }, + label: const Text('Label'), + note: const Text('Note'), + ), + const SizedBox(height: 16), + VoicesCheckbox( + value: _checkboxesStates[4] ?? false, + onChanged: (value) { + setState(() { + _checkboxesStates[4] = value; + }); + }, + isError: true, + label: const Text('Error label'), + ), + const SizedBox(height: 16), + VoicesCheckboxGroup( + name: const Text('Select all'), + elements: const [ + VoicesCheckboxGroupElement( + value: 1, + label: Text('Founded'), + ), + VoicesCheckboxGroupElement( + value: 2, + label: Text('Not founded'), + ), + VoicesCheckboxGroupElement( + value: 3, + label: Text('Not founded'), + ), + VoicesCheckboxGroupElement( + value: 4, + label: Text('In progress'), + ), + VoicesCheckboxGroupElement( + value: 5, + label: Text('Not founded'), + note: Text('Danger'), + isError: true, + ), + ], + selected: _checkboxGroupSelection, + onChanged: (value) { + setState(() { + _checkboxGroupSelection + ..clear() + ..addAll(value); + }); + }, + ), + ], + ), + ); + } +} diff --git a/catalyst_voices/uikit_example/lib/examples/voices_radio_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_radio_example.dart new file mode 100644 index 0000000000..129ba85476 --- /dev/null +++ b/catalyst_voices/uikit_example/lib/examples/voices_radio_example.dart @@ -0,0 +1,93 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:flutter/material.dart'; + +enum _Type { + one, + two, + three, + four, + five; + + ({String? label, String? note}) get labelNote { + return switch (this) { + _Type.one => (label: name, note: 'Public'), + _Type.two => (label: name, note: 'Private'), + _Type.three => (label: name, note: 'Toggle'), + _Type.four => (label: null, note: null), + _Type.five => (label: name, note: null), + }; + } +} + +class VoicesRadioExample extends StatefulWidget { + static const String route = '/radio-example'; + + const VoicesRadioExample({super.key}); + + @override + State createState() => _VoicesRadioExampleState(); +} + +class _VoicesRadioExampleState extends State { + _Type? _current = _Type.values.last; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Voices Radio')), + body: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemBuilder: (context, index) { + final type = _Type.values[index]; + + return _TypeRadio( + type, + key: ObjectKey(type), + groupValue: _current, + toggleable: type == _Type.three, + onChanged: type != _Type.values.last ? _updateGroupSelection : null, + ); + }, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemCount: _Type.values.length, + ), + ); + } + + void _updateGroupSelection(_Type? value) { + setState(() { + _current = value; + }); + } +} + +class _TypeRadio extends StatelessWidget { + const _TypeRadio( + this.type, { + super.key, + this.groupValue, + this.toggleable = false, + this.onChanged, + }); + + final _Type type; + final _Type? groupValue; + final bool toggleable; + final ValueChanged<_Type?>? onChanged; + + @override + Widget build(BuildContext context) { + final labelNote = type.labelNote; + final label = labelNote.label; + final note = labelNote.note; + + return VoicesRadio<_Type>( + value: type, + label: label != null ? Text(label) : null, + note: note != null ? Text(note) : null, + groupValue: groupValue, + toggleable: toggleable, + onChanged: onChanged, + ); + } +} diff --git a/catalyst_voices/uikit_example/lib/examples_list.dart b/catalyst_voices/uikit_example/lib/examples_list.dart index f1af8d43ce..8a4d33c49c 100644 --- a/catalyst_voices/uikit_example/lib/examples_list.dart +++ b/catalyst_voices/uikit_example/lib/examples_list.dart @@ -3,8 +3,10 @@ import 'dart:async'; import 'package:catalyst_voices/widgets/menu/voices_list_tile.dart'; import 'package:flutter/material.dart'; import 'package:uikit_example/examples/voices_buttons_example.dart'; +import 'package:uikit_example/examples/voices_checkbox_example.dart'; import 'package:uikit_example/examples/voices_chip_example.dart'; import 'package:uikit_example/examples/voices_navigation_example.dart'; +import 'package:uikit_example/examples/voices_radio_example.dart'; import 'package:uikit_example/examples/voices_segmented_button_example.dart'; import 'package:uikit_example/examples/voices_snackbar_example.dart'; @@ -36,6 +38,16 @@ class ExamplesListPage extends StatelessWidget { route: VoicesButtonsExample.route, page: VoicesButtonsExample(), ), + ExampleTile( + title: 'Voices Radio', + route: VoicesRadioExample.route, + page: VoicesRadioExample(), + ), + ExampleTile( + title: 'Voices Checkbox', + route: VoicesCheckboxExample.route, + page: VoicesCheckboxExample(), + ), ]; }