From 823370e161dd753abd22060d8dd862752dc8a34f Mon Sep 17 00:00:00 2001 From: Daniel Eshkeri Date: Mon, 28 Oct 2024 16:58:34 +0000 Subject: [PATCH] feat(UX-1231): created avatar rail (#196) feat: created avatar rail test: created tests for avatar rail feat: added label to avatar test: fixed parent folder for stepper chore(automated): Lint commit and format fix: added MainAxisSize.min to avatar column to regulate height fix: widgetbook max lines avatar rail --- example/lib/home.dart | 2 + .../pages/components/avatar_rail_example.dart | 96 ++++++ example/widgetbook/main.dart | 9 +- .../components/avatar_rail_widgetbook.dart | 174 ++++++++++ .../pages/components/avatar_widgetbook.dart | 3 + .../components/avatar_rail/avatar_rail.dart | 146 +++++++++ lib/src/components/avatars/avatar.dart | 188 ++++++++--- lib/src/components/components.dart | 1 + test/TESTING_README.md | 2 +- .../components/avatar/avatar_rail_test.dart | 297 ++++++++++++++++++ .../golden/zeta_avatar_rail_default.png | Bin 0 -> 5696 bytes .../golden/stepper_horizontal_complete.png | Bin .../golden/stepper_horizontal_incomplete.png | Bin .../stepper_horizontal_step_disabled.png | Bin .../golden/stepper_vertical_complete.png | Bin .../golden/stepper_vertical_incomplete.png | Bin .../golden/stepper_vertical_step_disabled.png | Bin test/src/components/stepper/stepper_test.dart | 4 +- 18 files changed, 869 insertions(+), 53 deletions(-) create mode 100644 example/lib/pages/components/avatar_rail_example.dart create mode 100644 example/widgetbook/pages/components/avatar_rail_widgetbook.dart create mode 100644 lib/src/components/avatar_rail/avatar_rail.dart create mode 100644 test/src/components/avatar/avatar_rail_test.dart create mode 100644 test/src/components/avatar/golden/zeta_avatar_rail_default.png rename test/src/components/{ENTER_PARENT_FOLDER (e.g. button) => stepper}/golden/stepper_horizontal_complete.png (100%) rename test/src/components/{ENTER_PARENT_FOLDER (e.g. button) => stepper}/golden/stepper_horizontal_incomplete.png (100%) rename test/src/components/{ENTER_PARENT_FOLDER (e.g. button) => stepper}/golden/stepper_horizontal_step_disabled.png (100%) rename test/src/components/{ENTER_PARENT_FOLDER (e.g. button) => stepper}/golden/stepper_vertical_complete.png (100%) rename test/src/components/{ENTER_PARENT_FOLDER (e.g. button) => stepper}/golden/stepper_vertical_incomplete.png (100%) rename test/src/components/{ENTER_PARENT_FOLDER (e.g. button) => stepper}/golden/stepper_vertical_step_disabled.png (100%) diff --git a/example/lib/home.dart b/example/lib/home.dart index 20e08aeb..db398437 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:zeta_example/pages/components/accordion_example.dart'; +import 'package:zeta_example/pages/components/avatar_rail_example.dart'; import 'package:zeta_example/pages/components/avatar_example.dart'; import 'package:zeta_example/pages/components/badges_example.dart'; import 'package:zeta_example/pages/components/banner_example.dart'; @@ -61,6 +62,7 @@ class Component { final List components = [ Component(AccordionExample.name, (context) => const AccordionExample()), Component(TopAppBarExample.name, (context) => const TopAppBarExample()), + Component(AvatarRailExample.name, (context) => const AvatarRailExample()), Component(AvatarExample.name, (context) => const AvatarExample()), Component(BannerExample.name, (context) => const BannerExample()), Component(BadgesExample.name, (context) => const BadgesExample()), diff --git a/example/lib/pages/components/avatar_rail_example.dart b/example/lib/pages/components/avatar_rail_example.dart new file mode 100644 index 00000000..3199a6bb --- /dev/null +++ b/example/lib/pages/components/avatar_rail_example.dart @@ -0,0 +1,96 @@ +// import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class AvatarRailExample extends StatefulWidget { + static const String name = 'AvatarRail'; + + const AvatarRailExample({super.key}); + + @override + State createState() => _AvatarRailExampleState(); +} + +class _AvatarRailExampleState extends State { + int? selected; + @override + Widget build(BuildContext context) { + final avatarList = [ + ZetaAvatar.initials( + initials: 'AZ', + label: 'Archie', + ), + ZetaAvatar.initials( + initials: 'BY', + label: 'Beth', + ), + ZetaAvatar.initials( + initials: 'CX', + label: 'Clara', + ), + ZetaAvatar.initials( + initials: 'DW', + label: 'Dan', + ), + ZetaAvatar.initials( + initials: 'EV', + label: 'Emily', + ), + ZetaAvatar.initials( + initials: 'FU', + label: 'Frank', + ), + ZetaAvatar.initials( + initials: 'GT', + label: 'George', + ), + ZetaAvatar.initials( + initials: 'HS', + label: 'Harith', + ), + ZetaAvatar.initials( + initials: 'IR', + label: 'Irene', + ), + ZetaAvatar.initials( + initials: 'KQ', + label: 'Katie', + ), + ]; + return ExampleScaffold( + name: AvatarRailExample.name, + child: SingleChildScrollView( + child: Column( + children: [ + for (final size in ZetaAvatarSize.values) + Row( + children: [ + Text(size.toString()), + SizedBox( + width: 500, + child: ZetaAvatarRail( + gap: 10, + size: size, + labelMaxLines: 3, + onTap: (key) => { + setState(() { + selected = int.parse(key.toString().replaceAll(RegExp(r'[^0-9]'), '')); + }) + }, + avatars: avatarList, + ), + ), + if (selected != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: avatarList[selected!].copyWith(size: size), + ), + ].gap(50), + ) + ], + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 96d48344..6a5202c6 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -11,6 +11,7 @@ import 'pages/components/notification_list_item_widgetbook.dart'; import 'pages/components/slider_widgetbook.dart'; import 'pages/components/text_input_widgetbook.dart'; import 'pages/components/top_app_bar_widgetbook.dart'; +import 'pages/components/avatar_rail_widgetbook.dart'; import 'pages/components/avatar_widgetbook.dart'; import 'pages/components/badges_widgetbook.dart'; import 'pages/components/banner_widgetbook.dart'; @@ -121,6 +122,13 @@ class _HotReloadState extends State { name: 'Components', isInitiallyExpanded: false, children: [ + WidgetbookComponent( + name: 'Avatar', + useCases: [ + WidgetbookUseCase(name: 'Avatar', builder: (context) => avatarUseCase(context)), + WidgetbookUseCase(name: 'Avatar Rail', builder: (context) => avatarRailUseCase(context)), + ], + ), WidgetbookComponent( name: 'Top App Bar', useCases: [ @@ -181,7 +189,6 @@ class _HotReloadState extends State { ], ), WidgetbookUseCase(name: 'Accordion', builder: (context) => accordionUseCase(context)), - WidgetbookUseCase(name: 'Avatar', builder: (context) => avatarUseCase(context)), WidgetbookUseCase(name: 'Banners', builder: (context) => bannerUseCase(context)), WidgetbookUseCase(name: 'Bottom Sheet', builder: (context) => bottomSheetContentUseCase(context)), WidgetbookUseCase(name: 'BreadCrumbs', builder: (context) => breadCrumbsUseCase(context)), diff --git a/example/widgetbook/pages/components/avatar_rail_widgetbook.dart b/example/widgetbook/pages/components/avatar_rail_widgetbook.dart new file mode 100644 index 00000000..1b738085 --- /dev/null +++ b/example/widgetbook/pages/components/avatar_rail_widgetbook.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../utils/scaffold.dart'; + +Widget avatarRailUseCase(BuildContext context) { + final Widget image = Image.asset('assets/Omer.jpg', fit: BoxFit.cover); + final colors = Zeta.of(context).colors; + + return WidgetbookScaffold( + builder: (context, _) => ZetaAvatarRail( + labelMaxLines: context.knobs.int.slider(label: 'Label Max Lines', min: 1, max: 3, initialValue: 1), + avatars: [ + ZetaAvatar( + image: context.knobs.boolean(label: 'Image') ? image : null, + size: context.knobs.list( + label: 'Size', + options: ZetaAvatarSize.values, + labelBuilder: (value) => value.name.split('.').last.toUpperCase(), + initialOption: ZetaAvatarSize.m, + ), + upperBadge: context.knobs.boolean(label: 'Status Badge', initialValue: false) + ? ZetaAvatarBadge.icon( + icon: ZetaIcons.close, + color: context.knobs.colorOrNull(label: "Upper Badge Color", initialValue: colors.green) ?? + colors.iconDefault, + ) + : null, + borderColor: context.knobs.colorOrNull(label: 'Outline', initialValue: colors.green), + lowerBadge: context.knobs.boolean(label: 'Notification Badge', initialValue: false) + ? ZetaAvatarBadge.notification( + value: context.knobs.intOrNull.input(label: "Value", initialValue: 1), + ) + : null, + initials: context.knobs.stringOrNull(label: 'Initials', initialValue: 'AZ'), + backgroundColor: context.knobs.colorOrNull(label: 'Background color', initialValue: colors.purple.shade80), + onTap: () => print('Avatar tapped'), + label: context.knobs.stringOrNull(label: 'Label', initialValue: 'ABC'), + ), + ZetaAvatar( + image: context.knobs.boolean(label: 'Image') ? image : null, + size: context.knobs.list( + label: 'Size', + options: ZetaAvatarSize.values, + labelBuilder: (value) => value.name.split('.').last.toUpperCase(), + initialOption: ZetaAvatarSize.m, + ), + upperBadge: context.knobs.boolean(label: 'Status Badge', initialValue: false) + ? ZetaAvatarBadge.icon( + icon: ZetaIcons.close, + color: context.knobs.colorOrNull(label: "Upper Badge Color", initialValue: colors.green) ?? + colors.iconDefault, + ) + : null, + borderColor: context.knobs.colorOrNull(label: 'Outline', initialValue: colors.green), + lowerBadge: context.knobs.boolean(label: 'Notification Badge', initialValue: false) + ? ZetaAvatarBadge.notification( + value: context.knobs.intOrNull.input(label: "Value", initialValue: 1), + ) + : null, + initials: context.knobs.stringOrNull(label: 'Initials', initialValue: 'AZ'), + backgroundColor: context.knobs.colorOrNull(label: 'Background color', initialValue: colors.purple.shade80), + onTap: () => print('Avatar tapped'), + label: context.knobs.stringOrNull(label: 'Label', initialValue: 'ABC'), + ), + ZetaAvatar( + image: context.knobs.boolean(label: 'Image') ? image : null, + size: context.knobs.list( + label: 'Size', + options: ZetaAvatarSize.values, + labelBuilder: (value) => value.name.split('.').last.toUpperCase(), + initialOption: ZetaAvatarSize.m, + ), + upperBadge: context.knobs.boolean(label: 'Status Badge', initialValue: false) + ? ZetaAvatarBadge.icon( + icon: ZetaIcons.close, + color: context.knobs.colorOrNull(label: "Upper Badge Color", initialValue: colors.green) ?? + colors.iconDefault, + ) + : null, + borderColor: context.knobs.colorOrNull(label: 'Outline', initialValue: colors.green), + lowerBadge: context.knobs.boolean(label: 'Notification Badge', initialValue: false) + ? ZetaAvatarBadge.notification( + value: context.knobs.intOrNull.input(label: "Value", initialValue: 1), + ) + : null, + initials: context.knobs.stringOrNull(label: 'Initials', initialValue: 'AZ'), + backgroundColor: context.knobs.colorOrNull(label: 'Background color', initialValue: colors.purple.shade80), + onTap: () => print('Avatar tapped'), + label: context.knobs.stringOrNull(label: 'Label', initialValue: 'ABC'), + ), + ZetaAvatar( + image: context.knobs.boolean(label: 'Image') ? image : null, + size: context.knobs.list( + label: 'Size', + options: ZetaAvatarSize.values, + labelBuilder: (value) => value.name.split('.').last.toUpperCase(), + initialOption: ZetaAvatarSize.m, + ), + upperBadge: context.knobs.boolean(label: 'Status Badge', initialValue: false) + ? ZetaAvatarBadge.icon( + icon: ZetaIcons.close, + color: context.knobs.colorOrNull(label: "Upper Badge Color", initialValue: colors.green) ?? + colors.iconDefault, + ) + : null, + borderColor: context.knobs.colorOrNull(label: 'Outline', initialValue: colors.green), + lowerBadge: context.knobs.boolean(label: 'Notification Badge', initialValue: false) + ? ZetaAvatarBadge.notification( + value: context.knobs.intOrNull.input(label: "Value", initialValue: 1), + ) + : null, + initials: context.knobs.stringOrNull(label: 'Initials', initialValue: 'AZ'), + backgroundColor: context.knobs.colorOrNull(label: 'Background color', initialValue: colors.purple.shade80), + onTap: () => print('Avatar tapped'), + label: context.knobs.stringOrNull(label: 'Label', initialValue: 'ABC'), + ), + ZetaAvatar( + image: context.knobs.boolean(label: 'Image') ? image : null, + size: context.knobs.list( + label: 'Size', + options: ZetaAvatarSize.values, + labelBuilder: (value) => value.name.split('.').last.toUpperCase(), + initialOption: ZetaAvatarSize.m, + ), + upperBadge: context.knobs.boolean(label: 'Status Badge', initialValue: false) + ? ZetaAvatarBadge.icon( + icon: ZetaIcons.close, + color: context.knobs.colorOrNull(label: "Upper Badge Color", initialValue: colors.green) ?? + colors.iconDefault, + ) + : null, + borderColor: context.knobs.colorOrNull(label: 'Outline', initialValue: colors.green), + lowerBadge: context.knobs.boolean(label: 'Notification Badge', initialValue: false) + ? ZetaAvatarBadge.notification( + value: context.knobs.intOrNull.input(label: "Value", initialValue: 1), + ) + : null, + initials: context.knobs.stringOrNull(label: 'Initials', initialValue: 'AZ'), + backgroundColor: context.knobs.colorOrNull(label: 'Background color', initialValue: colors.purple.shade80), + onTap: () => print('Avatar tapped'), + label: context.knobs.stringOrNull(label: 'Label', initialValue: 'ABC'), + ), + ZetaAvatar( + image: context.knobs.boolean(label: 'Image') ? image : null, + size: context.knobs.list( + label: 'Size', + options: ZetaAvatarSize.values, + labelBuilder: (value) => value.name.split('.').last.toUpperCase(), + initialOption: ZetaAvatarSize.m, + ), + upperBadge: context.knobs.boolean(label: 'Status Badge', initialValue: false) + ? ZetaAvatarBadge.icon( + icon: ZetaIcons.close, + color: context.knobs.colorOrNull(label: "Upper Badge Color", initialValue: colors.green) ?? + colors.iconDefault, + ) + : null, + borderColor: context.knobs.colorOrNull(label: 'Outline', initialValue: colors.green), + lowerBadge: context.knobs.boolean(label: 'Notification Badge', initialValue: false) + ? ZetaAvatarBadge.notification( + value: context.knobs.intOrNull.input(label: "Value", initialValue: 1), + ) + : null, + initials: context.knobs.stringOrNull(label: 'Initials', initialValue: 'AZ'), + backgroundColor: context.knobs.colorOrNull(label: 'Background color', initialValue: colors.purple.shade80), + onTap: () => print('Avatar tapped'), + label: context.knobs.stringOrNull(label: 'Label', initialValue: 'ABC'), + ), + ], + ), + ); +} diff --git a/example/widgetbook/pages/components/avatar_widgetbook.dart b/example/widgetbook/pages/components/avatar_widgetbook.dart index 13d1a6cd..8751ee0d 100644 --- a/example/widgetbook/pages/components/avatar_widgetbook.dart +++ b/example/widgetbook/pages/components/avatar_widgetbook.dart @@ -32,6 +32,9 @@ Widget avatarUseCase(BuildContext context) { : null, initials: context.knobs.stringOrNull(label: 'Initials', initialValue: 'AZ'), backgroundColor: context.knobs.colorOrNull(label: 'Background color', initialValue: colors.purple.shade80), + onTap: () => print('Avatar tapped'), + label: context.knobs.stringOrNull(label: 'Label', initialValue: 'ABC'), + labelMaxLines: context.knobs.int.slider(label: 'Label Max Lines', min: 1, max: 3, initialValue: 1), ), ); } diff --git a/lib/src/components/avatar_rail/avatar_rail.dart b/lib/src/components/avatar_rail/avatar_rail.dart new file mode 100644 index 00000000..c57feac4 --- /dev/null +++ b/lib/src/components/avatar_rail/avatar_rail.dart @@ -0,0 +1,146 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../utils/utils.dart'; +import '../avatars/avatar.dart'; + +/// A stateless widget that represents an avatar rail in the Zeta application. +/// +/// The `ZetaAvatarRail` widget is used to display a horizontal rail of avatars, +/// typically used for navigation or selection purposes within the application. +/// +/// This widget does not maintain any state and relies on its parent and children widgets to +/// provide the necessary data and handle interactions. +/// +/// Example usage: +/// +/// ```dart +/// ZetaAvatarRail( +/// avatars: [ +/// ZetaAvatar.initials( +/// key: Key('avatar1'), +/// initials: 'AZ', +/// onTap: () => print('Avatar tapped'), +/// label: 'Archie', +/// ), +/// ZetaAvatar.initials( +/// key: Key('avatar2'), +/// initials: 'BY', +/// onTap: () => print('Avatar tapped'), +/// label: 'Beth', +/// ), +/// ZetaAvatar.initials( +/// key: Key('avatar3'), +/// initials: 'CX', +/// onTap: () => print('Avatar tapped'), +/// label: 'Carla', +/// ), +/// ] +/// ) +/// ``` +/// +/// See also: +/// +/// * [StatelessWidget], which is the superclass of this widget. +/// * [ZetaAvatar], which is used within this rail to represent individual avatars. +/// {@category Components} +/// +/// Figma: https://www.figma.com/file/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?type=design&node-id=20816-388 +/// +/// Widgetbook: https://design.zebra.com/flutter/widgetbook/index.html#/?path=components/avatar/avatar-rail +class ZetaAvatarRail extends StatelessWidget { + /// + const ZetaAvatarRail({ + super.key, + this.size, + required this.avatars, + this.labelTextStyle, + this.labelMaxLines = 1, + this.onTap, + this.gap, + }); + + /// A list of `ZetaAvatar` objects representing the avatars to be displayed. + final List avatars; + + /// The size of the [ZetaAvatar]s + final ZetaAvatarSize? size; + + /// The text style to be applied to the label of the [ZetaAvatar]s. + final TextStyle? labelTextStyle; + + /// The maximum number of lines to be displayed in the label of the [ZetaAvatar]s. + final int labelMaxLines; + + /// A callback function to be executed when an [ZetaAvatar] is tapped. + /// The function receives the key of the tapped [ZetaAvatar] as a parameter. + /// If no key is provided, the index of the [ZetaAvatar] in the list is used. + final void Function(Key)? onTap; + + /// The gap between the avatars. + /// Defaults to 'Zeta.of(context).spacing.small) + final double? gap; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + hitTestBehavior: HitTestBehavior.translucent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final avatar in avatars) + avatar.copyWith( + size: size, + labelTextStyle: labelTextStyle, + labelMaxLines: labelMaxLines, + onTap: () => onTap?.call(key ?? Key(avatars.indexOf(avatar).toString())), + key: key ?? Key(avatars.indexOf(avatar).toString()), + ), + ].gap(gap ?? Zeta.of(context).spacing.small), + ), + ), + ], + ); + } + + /// Returns pixel size for [ZetaAvatarSize] + static double pixelSize(BuildContext context, ZetaAvatarSize size) { + switch (size) { + case ZetaAvatarSize.xxxl: + return Zeta.of(context).spacing.minimum * 50; // TODO(UX-1202): ZetaSpacingBase + // return ZetaSpacingBase.x50; + case ZetaAvatarSize.xxl: + return Zeta.of(context).spacing.minimum * 30; // TODO(UX-1202): ZetaSpacingBase + // return ZetaSpacingBase.x30; + case ZetaAvatarSize.xl: + return Zeta.of(context).spacing.xl_10; + case ZetaAvatarSize.l: + return Zeta.of(context).spacing.xl_9; + case ZetaAvatarSize.m: + return Zeta.of(context).spacing.xl_8; + case ZetaAvatarSize.s: + return Zeta.of(context).spacing.xl_6; + case ZetaAvatarSize.xs: + return Zeta.of(context).spacing.xl_5; + case ZetaAvatarSize.xxs: + return Zeta.of(context).spacing.xl_4; + case ZetaAvatarSize.xxxs: + return Zeta.of(context).spacing.xl_2; + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('labelTextStyle', labelTextStyle)) + ..add(EnumProperty('size', size)) + ..add(IntProperty('labelMaxLines', labelMaxLines)) + ..add(ObjectFlagProperty.has('onTap', onTap)) + ..add(DoubleProperty('gap', gap)); + } +} diff --git a/lib/src/components/avatars/avatar.dart b/lib/src/components/avatars/avatar.dart index b72e1988..39a1b7eb 100644 --- a/lib/src/components/avatars/avatar.dart +++ b/lib/src/components/avatars/avatar.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -38,7 +39,7 @@ enum ZetaAvatarSize { /// /// Figma: https://www.figma.com/file/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?type=design&node-id=20816-388 /// -/// Widgetbook: https://zeta-ds.web.app/flutter/widgetbook/index.html#/?path=components/avatar +/// Widgetbook: https://zeta-ds.web.app/flutter/widgetbook/index.html#/?path=components/avatar/avatar class ZetaAvatar extends ZetaStatelessWidget { /// Constructor for [ZetaAvatar] const ZetaAvatar({ @@ -54,6 +55,10 @@ class ZetaAvatar extends ZetaStatelessWidget { this.semanticUpperBadgeLabel = 'upperBadge', this.semanticLowerBadgeLabel = 'lowerBadge', this.initialTextStyle, + this.label, + this.labelTextStyle, + this.labelMaxLines = 1, + this.onTap, }); /// Constructor for [ZetaAvatar] with image. @@ -67,6 +72,10 @@ class ZetaAvatar extends ZetaStatelessWidget { this.semanticLabel = 'avatar', this.semanticUpperBadgeLabel = 'upperBadge', this.semanticLowerBadgeLabel = 'lowerBadge', + this.label, + this.labelTextStyle, + this.labelMaxLines = 1, + this.onTap, }) : backgroundColor = null, initials = null, initialTextStyle = null; @@ -84,6 +93,10 @@ class ZetaAvatar extends ZetaStatelessWidget { this.semanticUpperBadgeLabel = 'upperBadge', this.semanticLowerBadgeLabel = 'lowerBadge', this.initialTextStyle, + this.label, + this.labelTextStyle, + this.labelMaxLines = 1, + this.onTap, }) : image = null; /// Constructor for [ZetaAvatar] with initials from a full name. @@ -99,6 +112,10 @@ class ZetaAvatar extends ZetaStatelessWidget { this.semanticUpperBadgeLabel = 'upperBadge', this.semanticLowerBadgeLabel = 'lowerBadge', this.initialTextStyle, + this.label, + this.labelTextStyle, + this.labelMaxLines = 1, + this.onTap, }) : image = null, initials = name.initials; @@ -155,6 +172,18 @@ class ZetaAvatar extends ZetaStatelessWidget { /// ``` final TextStyle? initialTextStyle; + /// Label to display below the avatar. + final String? label; + + /// Text style for label. + final TextStyle? labelTextStyle; + + /// Maximum number of lines for label. + final int labelMaxLines; + + /// Callback when avatar is tapped. + final VoidCallback? onTap; + /// Return copy of avatar with certain changed fields ZetaAvatar copyWith({ ZetaAvatarSize? size, @@ -164,6 +193,11 @@ class ZetaAvatar extends ZetaStatelessWidget { Color? borderColor, ZetaAvatarBadge? lowerBadge, ZetaAvatarBadge? upperBadge, + String? label, + TextStyle? labelTextStyle, + int? labelMaxLines, + VoidCallback? onTap, + Key? key, }) { return ZetaAvatar( size: size ?? this.size, @@ -173,6 +207,11 @@ class ZetaAvatar extends ZetaStatelessWidget { borderColor: borderColor ?? this.borderColor, lowerBadge: lowerBadge ?? this.lowerBadge, upperBadge: upperBadge ?? this.upperBadge, + label: label ?? this.label, + labelTextStyle: labelTextStyle ?? this.labelTextStyle, + labelMaxLines: labelMaxLines ?? this.labelMaxLines, + onTap: onTap ?? this.onTap, + key: key ?? this.key, ); } @@ -212,58 +251,85 @@ class ZetaAvatar extends ZetaStatelessWidget { child: Semantics( value: semanticLabel, child: SelectionContainer.disabled( - child: Stack( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Container( - width: pSize, - height: pSize, - decoration: BoxDecoration( - border: borderColor != null ? Border.all(color: borderColor!, width: 0) : null, - borderRadius: Zeta.of(context).radius.full, - color: backgroundColor ?? (_showPlaceholder ? zetaColors.surfacePrimary : zetaColors.cool.shade20), - ), - child: borderColor != null - ? Container( - width: pSize, - height: pSize, - decoration: BoxDecoration( - color: backgroundColor ?? zetaColors.surfaceHover, - border: Border.all(color: borderColor!, width: borderSize(context)), - borderRadius: Zeta.of(context).radius.full, - ), - child: ClipRRect( - borderRadius: Zeta.of(context).radius.full, - child: innerContent, - ), - ) - : DecoratedBox( - decoration: BoxDecoration( - borderRadius: Zeta.of(context).radius.full, - color: backgroundColor ?? zetaColors.surfaceHover, - ), - child: ClipRRect( - borderRadius: Zeta.of(context).radius.full, - child: innerContent, - ), + Stack( + children: [ + GestureDetector( + onTap: onTap, + child: Container( + width: pSize, + height: pSize, + decoration: BoxDecoration( + border: borderColor != null ? Border.all(color: borderColor!, width: 0) : null, + borderRadius: Zeta.of(context).radius.full, + color: + backgroundColor ?? (_showPlaceholder ? zetaColors.surfacePrimary : zetaColors.cool.shade20), ), - ), - if (upperBadge != null) - Positioned( - right: Zeta.of(context).spacing.none, - child: Semantics( - value: semanticLowerBadgeLabel, - child: upperBadge!.copyWith( - size: size, + child: borderColor != null + ? Container( + width: pSize, + height: pSize, + decoration: BoxDecoration( + color: backgroundColor ?? zetaColors.surfaceHover, + border: Border.all(color: borderColor!, width: borderSize(context)), + borderRadius: Zeta.of(context).radius.full, + ), + child: ClipRRect( + borderRadius: Zeta.of(context).radius.full, + child: innerContent, + ), + ) + : DecoratedBox( + decoration: BoxDecoration( + borderRadius: Zeta.of(context).radius.full, + color: backgroundColor ?? zetaColors.surfaceHover, + ), + child: ClipRRect( + borderRadius: Zeta.of(context).radius.full, + child: innerContent, + ), + ), ), ), + if (upperBadge != null) + Positioned( + right: Zeta.of(context).spacing.none, + child: Semantics( + value: semanticLowerBadgeLabel, + child: upperBadge!.copyWith( + size: size, + ), + ), + ), + if (lowerBadge != null) + Positioned( + right: Zeta.of(context).spacing.none, + bottom: Zeta.of(context).spacing.none, + child: Semantics( + value: semanticLowerBadgeLabel, + child: lowerBadge!.copyWith(size: size), + ), + ), + ], + ), + if (label != null) + SizedBox( + height: Zeta.of(context).spacing.minimum, ), - if (lowerBadge != null) - Positioned( - right: Zeta.of(context).spacing.none, - bottom: Zeta.of(context).spacing.none, - child: Semantics( - value: semanticLowerBadgeLabel, - child: lowerBadge!.copyWith(size: size), + if (label != null) + SizedBox( + width: pSize, + child: Text( + label!, + style: labelTextStyle ?? + size.labelStyle(context).copyWith( + color: zetaColors.textSubtle, + ), + maxLines: labelMaxLines, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, ), ), ], @@ -286,7 +352,11 @@ class ZetaAvatar extends ZetaStatelessWidget { ..add(StringProperty('semanticUpperBadgeValue', semanticUpperBadgeLabel)) ..add(StringProperty('semanticValue', semanticLabel)) ..add(StringProperty('semanticLowerBadgeValue', semanticLowerBadgeLabel)) - ..add(DiagnosticsProperty('initialTextStyle', initialTextStyle)); + ..add(DiagnosticsProperty('initialTextStyle', initialTextStyle)) + ..add(DiagnosticsProperty('labelTextStyle', labelTextStyle)) + ..add(StringProperty('label', label)) + ..add(IntProperty('labelMaxLines', labelMaxLines)) + ..add(ObjectFlagProperty.has('onTap', onTap)); } /// Returns pixel size for [ZetaAvatarSize] @@ -347,6 +417,26 @@ extension on ZetaAvatarSize { double fontSize(BuildContext context) { return ZetaAvatar.fontSize(context, this); } + + TextStyle labelStyle(BuildContext context) { + switch (this) { + case ZetaAvatarSize.xxxl: + return ZetaTextStyles.displaySmall; + case ZetaAvatarSize.xxl: + case ZetaAvatarSize.xl: + return ZetaTextStyles.bodyLarge; + case ZetaAvatarSize.l: + return ZetaTextStyles.bodyMedium; + case ZetaAvatarSize.m: + return ZetaTextStyles.bodySmall; + case ZetaAvatarSize.s: + case ZetaAvatarSize.xs: + case ZetaAvatarSize.xxs: + return ZetaTextStyles.bodyXSmall; + case ZetaAvatarSize.xxxs: + return ZetaTextStyles.bodyXSmall; + } + } } /// Enum of types for [ZetaAvatarBadge] diff --git a/lib/src/components/components.dart b/lib/src/components/components.dart index b1e5eb25..63bae3a4 100644 --- a/lib/src/components/components.dart +++ b/lib/src/components/components.dart @@ -1,4 +1,5 @@ export 'accordion/accordion.dart'; +export 'avatar_rail/avatar_rail.dart'; export 'avatars/avatar.dart'; export 'badges/indicator.dart'; export 'badges/label.dart'; diff --git a/test/TESTING_README.md b/test/TESTING_README.md index 0bcb169e..6d31f3b5 100644 --- a/test/TESTING_README.md +++ b/test/TESTING_README.md @@ -77,7 +77,7 @@ void main() { group('Interaction Tests', () {}); group('Golden Tests', () { - goldenTest(goldenFile, widget, widgetType, 'PNG_FILE_NAME'); + goldenTest(goldenFile, widget, 'PNG_FILE_NAME'); }); group('Performance Tests', () {}); diff --git a/test/src/components/avatar/avatar_rail_test.dart b/test/src/components/avatar/avatar_rail_test.dart new file mode 100644 index 00000000..3906ba51 --- /dev/null +++ b/test/src/components/avatar/avatar_rail_test.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../../test_utils/test_app.dart'; +import '../../../test_utils/tolerant_comparator.dart'; +import '../../../test_utils/utils.dart'; + +void main() { + final avatarList = [ + const ZetaAvatar.initials( + initials: 'AZ', + label: 'Archie', + ), + const ZetaAvatar.initials( + initials: 'BY', + label: 'Beth', + ), + const ZetaAvatar.initials( + initials: 'CX', + label: 'Clara', + ), + const ZetaAvatar.initials( + initials: 'DW', + label: 'Dan', + ), + const ZetaAvatar.initials( + initials: 'EV', + label: 'Emily', + ), + const ZetaAvatar.initials( + initials: 'FU', + label: 'Frank', + ), + const ZetaAvatar.initials( + initials: 'GT', + label: 'George', + ), + const ZetaAvatar.initials( + initials: 'HS', + label: 'Harith', + ), + const ZetaAvatar.initials( + initials: 'IR', + label: 'Irene', + ), + const ZetaAvatar.initials( + initials: 'KQ', + label: 'Katie', + ), + ]; + + const String parentFolder = 'avatar'; + + const goldenFile = GoldenFiles(component: parentFolder); + setUpAll(() { + goldenFileComparator = TolerantComparator(goldenFile.uri); + }); + + group('Accessibility Tests', () { + testWidgets('meets labeled tap target guideline', (WidgetTester tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaAvatarRail( + avatars: avatarList, + size: ZetaAvatarSize.m, + onTap: (key) {}, + ), + ), + ); + + await expectLater( + tester, + meetsGuideline(labeledTapTargetGuideline), + ); + }); + }); + + group('Content Tests', () { + final debugFillProperties = { + 'labelTextStyle': 'null', + 'size': 'm', + 'labelMaxLines': '2', + 'onTap': 'has onTap', + 'gap': '10.0', + }; + debugFillPropertiesTest( + ZetaAvatarRail( + avatars: avatarList, + size: ZetaAvatarSize.m, + gap: 10, + labelMaxLines: 2, + onTap: (key) {}, + ), + debugFillProperties, + ); + + testWidgets('renders the correct number of avatars', (WidgetTester tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaAvatarRail( + avatars: avatarList, + size: ZetaAvatarSize.m, + onTap: (key) {}, + ), + ), + ); + + final railFinder = find.byType(ZetaAvatarRail); + final avatarFinder = find.byType(ZetaAvatar); + + expect(railFinder, findsOneWidget); + expect(avatarFinder, findsNWidgets(avatarList.length)); + }); + }); + + group('Dimensions Tests', () { + for (final size in ZetaAvatarSize.values) { + testWidgets('width is correct - avatar size: $size', (WidgetTester tester) async { + const screenWidth = 800.0; + const gap = 10.0; + await tester.pumpWidget( + TestApp( + screenSize: const Size(screenWidth, 600), + home: ZetaAvatarRail( + avatars: avatarList, + size: size, + gap: gap, + onTap: (key) {}, + ), + ), + ); + + final railFinder = find.byType(ZetaAvatarRail); + final avatarFinder = find.byType(ZetaAvatar); + + expect(railFinder, findsOneWidget); + expect(avatarFinder, findsNWidgets(avatarList.length)); + + final railSize = tester.getSize(railFinder); + final avatarSize = tester.getSize(avatarFinder.first); + + if (railSize.width < screenWidth) { + expect(railSize.width, equals(avatarSize.width * avatarList.length + gap * (avatarList.length - 1))); + } else { + expect(railSize.width, equals(screenWidth)); + } + }); + + testWidgets('height is correct - avatar size: $size', (WidgetTester tester) async { + const gap = 10.0; + await tester.pumpWidget( + TestApp( + home: ZetaAvatarRail( + avatars: avatarList, + size: size, + gap: gap, + onTap: (key) {}, + ), + ), + ); + + final railFinder = find.byType(ZetaAvatarRail); + final avatarFinder = find.byType(ZetaAvatar); + + final railSize = tester.getSize(railFinder); + final avatarSize = tester.getSize(avatarFinder.first); + + expect(railSize.height, equals(avatarSize.height)); + }); + } + }); + + group('Styling Tests', () { + testWidgets('applies the correct text style to labels', (WidgetTester tester) async { + const textStyle = TextStyle(color: Colors.red, fontSize: 16); + await tester.pumpWidget( + TestApp( + home: ZetaAvatarRail( + avatars: avatarList, + size: ZetaAvatarSize.m, + labelTextStyle: textStyle, + onTap: (key) {}, + ), + ), + ); + + final labelFinder = find.text('Archie'); + final labelWidget = tester.widget(labelFinder); + + expect(labelWidget.style, equals(textStyle)); + }); + }); + + group('Interaction Tests', () { + final shortList = [ + const ZetaAvatar.initials( + key: Key('avatar1'), + initials: 'AZ', + label: 'Archie', + ), + const ZetaAvatar.initials( + key: Key('avatar2'), + initials: 'BY', + label: 'Beth', + ), + const ZetaAvatar.initials( + key: Key('avatar3'), + initials: 'CX', + label: 'Clara', + ), + ]; + testWidgets('onTap is called when an avatar is tapped', (WidgetTester tester) async { + var tapped = false; + + await tester.pumpWidget( + TestApp( + home: ZetaAvatarRail( + gap: 10, + avatars: shortList, + size: ZetaAvatarSize.m, + onTap: (key) { + tapped = true; + }, + ), + ), + ); + + final avatarFinder = find.byType(ZetaAvatar); + + // Use hitTestable to ensure the widget can receive pointer events + final hitTestableAvatarFinder = avatarFinder.hitTestable(); + + expect(tapped, equals(false)); + + await tester.tap(hitTestableAvatarFinder.first); + await tester.pumpAndSettle(); // Ensure the tap event is processed + + expect(tapped, equals(true)); + }); + + testWidgets('swipe functionality works', (WidgetTester tester) async { + await tester.pumpWidget( + TestApp( + home: SizedBox( + height: 70, + width: 300, + child: ZetaAvatarRail( + avatars: avatarList, + size: ZetaAvatarSize.m, + onTap: (key) {}, + ), + ), + ), + ); + + final railFinder = find.byType(ZetaAvatarRail); + expect(railFinder, findsOneWidget); + + await tester.drag(railFinder, const Offset(-300, 0)); + await tester.pumpAndSettle(); + + // Check if the rail has been scrolled + final firstAvatarFinder = find.text('Archie'); + expect(tester.getTopRight(firstAvatarFinder).dx, lessThan(0.0)); + }); + }); + + group('Golden Tests', () { + goldenTest(goldenFile, ZetaAvatarRail(avatars: avatarList), 'zeta_avatar_rail_default'); + }); + + group('Performance Tests', () { + testWidgets( + 'renders within 5 frames', + (WidgetTester tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaAvatarRail( + avatars: avatarList, + size: ZetaAvatarSize.m, + onTap: (key) {}, + ), + ), + ); + + final railFinder = find.byType(ZetaAvatarRail); + expect(railFinder, findsOneWidget); + + await tester.pumpAndSettle(); + final frameCount = tester.platformDispatcher.frameData.frameNumber; + expect(frameCount, lessThan(5)); + }, + skip: true, + ); // Skip this test as it is not stable + }); +} diff --git a/test/src/components/avatar/golden/zeta_avatar_rail_default.png b/test/src/components/avatar/golden/zeta_avatar_rail_default.png new file mode 100644 index 0000000000000000000000000000000000000000..1932c51cc3a33612255d4b8abdb033caf9ac0cac GIT binary patch literal 5696 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 literal 0 HcmV?d00001 diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_complete.png b/test/src/components/stepper/golden/stepper_horizontal_complete.png similarity index 100% rename from test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_complete.png rename to test/src/components/stepper/golden/stepper_horizontal_complete.png diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_incomplete.png b/test/src/components/stepper/golden/stepper_horizontal_incomplete.png similarity index 100% rename from test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_incomplete.png rename to test/src/components/stepper/golden/stepper_horizontal_incomplete.png diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_step_disabled.png b/test/src/components/stepper/golden/stepper_horizontal_step_disabled.png similarity index 100% rename from test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_horizontal_step_disabled.png rename to test/src/components/stepper/golden/stepper_horizontal_step_disabled.png diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_complete.png b/test/src/components/stepper/golden/stepper_vertical_complete.png similarity index 100% rename from test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_complete.png rename to test/src/components/stepper/golden/stepper_vertical_complete.png diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_incomplete.png b/test/src/components/stepper/golden/stepper_vertical_incomplete.png similarity index 100% rename from test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_incomplete.png rename to test/src/components/stepper/golden/stepper_vertical_incomplete.png diff --git a/test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_step_disabled.png b/test/src/components/stepper/golden/stepper_vertical_step_disabled.png similarity index 100% rename from test/src/components/ENTER_PARENT_FOLDER (e.g. button)/golden/stepper_vertical_step_disabled.png rename to test/src/components/stepper/golden/stepper_vertical_step_disabled.png diff --git a/test/src/components/stepper/stepper_test.dart b/test/src/components/stepper/stepper_test.dart index f8341f0c..8dd9a0bb 100644 --- a/test/src/components/stepper/stepper_test.dart +++ b/test/src/components/stepper/stepper_test.dart @@ -8,7 +8,7 @@ import '../../../test_utils/tolerant_comparator.dart'; import '../../../test_utils/utils.dart'; void main() { - const String parentFolder = 'ENTER_PARENT_FOLDER (e.g. button)'; + const String parentFolder = 'stepper'; const goldenFile = GoldenFiles(component: parentFolder); setUpAll(() { @@ -16,7 +16,7 @@ void main() { }); group('ZetaStepper Accessibility Tests', () { - testWidgets('Horizontal stepper meets accessibility requirements', (WidgetTester tester) async { + testWidgets('Horizontal stepper meets accessibility requirements', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget( TestApp(