diff --git a/example/lib/pages/components/comment.dart b/example/lib/pages/components/comment.dart new file mode 100644 index 0000000..5fd2a66 --- /dev/null +++ b/example/lib/pages/components/comment.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:zds_flutter/zds_flutter.dart'; + +class CommentDemo extends StatefulWidget { + const CommentDemo({super.key}); + + @override + State createState() => _CommentDemoState(); +} + +class _CommentDemoState extends State { + @override + Widget build(BuildContext context) { + return Container( + color: Zeta.of(context).colors.surfaceDefault, + child: Column( + children: [ + ZdsComment( + avatar: ZetaAvatar.initials( + initials: 'JP', + size: ZetaAvatarSize.xxxs, + ), + author: 'John Doe', + downloadCallback: () {}, + comment: 'This is a comment', + onReply: () {}, + replySemanticLabel: 'Reply to comment', + onDelete: () {}, + deleteSemanticLabel: 'Delete', + timeStamp: '09:30 AM', + attachment: ZdsChatAttachment( + type: ZdsChatAttachmentType.docNetwork, + name: 'Blueprints.xls', + size: '1234kb', + extension: 'xls', + ), + ), + ZdsComment( + avatar: ZetaAvatar.initials( + initials: 'JP', + size: ZetaAvatarSize.xxxs, + backgroundColor: Zeta.of(context).colors.surfaceAvatarPurple, + ), + onDelete: () {}, + deleteSemanticLabel: 'Delete', + isReply: true, + author: 'John Doe', + comment: 'This is a comment', + timeStamp: '09:30 AM', + ), + ZdsComment( + avatar: ZetaAvatar.initials( + initials: 'JP', + size: ZetaAvatarSize.xxxs, + ), + author: 'John Doe', + comment: 'This is a comment', + onReply: () {}, + replySemanticLabel: 'Reply to comment', + onDelete: () {}, + deleteSemanticLabel: 'Delete', + timeStamp: '09:30 AM', + ), + ], + ), + ); + } +} diff --git a/example/lib/routes.dart b/example/lib/routes.dart index 6042262..010f29e 100644 --- a/example/lib/routes.dart +++ b/example/lib/routes.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:zds_flutter_example/pages/components/chat.dart'; +import 'package:zds_flutter_example/pages/components/comment.dart'; import 'home.dart'; import 'pages/assets/animations.dart'; @@ -92,6 +94,14 @@ final kRoutes = { title: 'Card Actions', child: CardActionsDemo(), ), + const DemoRoute( + title: 'Chat', + child: ChatDemo(), + ), + const DemoRoute( + title: 'Comments', + child: CommentDemo(), + ), const DemoRoute( title: 'Interactive Viewer', wrapper: false, diff --git a/lib/src/components/molecules.dart b/lib/src/components/molecules.dart index 57576f0..76a45c4 100644 --- a/lib/src/components/molecules.dart +++ b/lib/src/components/molecules.dart @@ -5,6 +5,7 @@ export 'molecules/bottom_sheet.dart'; export 'molecules/card_actions.dart'; export 'molecules/card_header.dart'; export 'molecules/check_button.dart'; +export 'molecules/comment.dart'; export 'molecules/date_range_picker.dart'; export 'molecules/date_time_picker.dart'; export 'molecules/dropdown.dart'; diff --git a/lib/src/components/molecules/comment.dart b/lib/src/components/molecules/comment.dart new file mode 100644 index 0000000..c4c62d7 --- /dev/null +++ b/lib/src/components/molecules/comment.dart @@ -0,0 +1,278 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../../zds_flutter.dart'; + +/// Displays a comment with an optional attachment and delete and reply swipeable actions. +class ZdsComment extends StatelessWidget { + /// Constructs a [ZdsComment] widget. + const ZdsComment({ + required this.comment, + required this.author, + this.isReply = false, + this.avatar, + this.timeStamp, + this.onDelete, + this.onReply, + super.key, + this.attachment, + this.downloadCallback, + this.deleteSemanticLabel, + this.replySemanticLabel, + this.attachmentThumbnail, + }) : assert( + onReply != null && replySemanticLabel != null || onReply == null && replySemanticLabel == null, + 'replySemanticLabel must be not null if onReply is defined', + ), + assert( + onDelete != null && deleteSemanticLabel != null || onDelete == null && deleteSemanticLabel == null, + 'deleteSemanticLabel must be not null if onDelete is defined', + ); + + /// The comment text. + final String comment; + + /// The avatar widget to display. + /// Should be a [ZetaAvatar] + final Widget? avatar; + + /// The timestamp of the comment. + final String? timeStamp; + + /// The author of the comment. + final String author; + + /// Whether the comment is a reply. + /// If this is true, the reply action will automatically be hidden. + final bool isReply; + + /// The callback to be called when the delete action is tapped. + /// If this is null, the delete action will be hidden. + /// If this is not null, [deleteSemanticLabel] must also be not null. + final VoidCallback? onDelete; + + /// The semantic label for the delete action. + final String? deleteSemanticLabel; + + /// The callback to be called when the reply action is tapped. + /// If this is null, the reply action will be hidden. + /// If this is not null, [replySemanticLabel] must also be not null. + final VoidCallback? onReply; + + /// The semantic label for the reply action. + final String? replySemanticLabel; + + /// The attachment to display. + final ZdsChatAttachment? attachment; + + /// The callback to be called when the attachment is tapped. + final VoidCallback? downloadCallback; + + /// The custom thumbnail to display for the attachment. + final Widget? attachmentThumbnail; + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + final spacing = Zeta.of(context).spacing; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isReply) + Padding( + padding: EdgeInsets.only( + left: spacing.large, + right: spacing.minimum, + top: spacing.minimum, + ), + child: const ZetaIcon( + ZetaIcons.reply, + size: 24, + applyTextScaling: true, + ), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return ZdsSlidableListTile( + width: constraints.maxWidth, + elevation: 0, + actions: [ + if (!isReply && onReply != null && replySemanticLabel != null) + ZdsSlidableAction( + icon: ZetaIcons.reply, + semanticLabel: replySemanticLabel, + foregroundColor: colors.primary, + backgroundColor: colors.surfacePrimarySubtle, + onPressed: (_) => onReply!(), + ), + if (onDelete != null && deleteSemanticLabel != null) + ZdsSlidableAction( + icon: ZetaIcons.delete, + semanticLabel: deleteSemanticLabel, + onPressed: (_) {}, + backgroundColor: colors.surfaceNegativeSubtle, + foregroundColor: colors.error, + ), + ], + child: Container( + decoration: BoxDecoration( + color: colors.surfaceDefault, + border: Border( + bottom: BorderSide( + color: colors.borderSubtle, + ), + ), + ), + padding: EdgeInsets.symmetric( + vertical: spacing.large, + horizontal: spacing.medium, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.minimum), + child: Row( + children: [ + if (avatar != null) + Padding( + padding: EdgeInsets.only(right: spacing.small), + child: avatar, + ), + Text( + author, + style: ZetaTextStyles.labelLarge.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + if (timeStamp != null) + Padding( + padding: EdgeInsets.only(left: spacing.small), + child: Text( + timeStamp!, + style: ZetaTextStyles.bodyXSmall.copyWith(color: colors.textSubtle), + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.only( + top: spacing.small, + left: spacing.minimum, + right: spacing.minimum, + ), + child: Text( + comment, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + if (attachment != null) + Padding( + padding: EdgeInsets.only(top: spacing.medium), + child: _AttachmentRow( + attachment: attachment!, + downloadCallback: downloadCallback, + customThumbnail: attachmentThumbnail, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('comment', comment)) + ..add(StringProperty('timeStamp', timeStamp)) + ..add(StringProperty('author', author)) + ..add(DiagnosticsProperty('isReply', isReply)) + ..add(ObjectFlagProperty.has('onDelete', onDelete)) + ..add(ObjectFlagProperty.has('onReply', onReply)) + ..add(DiagnosticsProperty('attachment', attachment)) + ..add(ObjectFlagProperty.has('downloadCallback', downloadCallback)) + ..add(StringProperty('deleteSemanticLabel', deleteSemanticLabel)) + ..add(StringProperty('replySemanticLabel', replySemanticLabel)); + } +} + +class _AttachmentRow extends StatelessWidget { + const _AttachmentRow({ + required this.attachment, + this.customThumbnail, + this.downloadCallback, + }); + + final ZdsChatAttachment attachment; + final VoidCallback? downloadCallback; + final Widget? customThumbnail; + + @override + Widget build(BuildContext context) { + final spacing = Zeta.of(context).spacing; + final colors = Zeta.of(context).colors; + final radius = Zeta.of(context).radius; + + return Material( + child: InkWell( + borderRadius: radius.minimal, + onTap: downloadCallback, + child: Padding( + padding: EdgeInsets.all(spacing.minimum), + child: Row( + children: [ + if (customThumbnail != null) + SizedBox( + width: 40, + height: 40, + child: customThumbnail, + ) + else + ZetaIcon( + extensionIcon('.${attachment.fileType}'), + color: iconColor('.${attachment.fileType}'), + size: 40, + ), + SizedBox(width: spacing.small), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + attachment.name, + style: ZetaTextStyles.bodySmall, + ), + if (attachment.size != null) + Text( + attachment.size!, + style: ZetaTextStyles.bodySmall.copyWith(color: colors.textSubtle), + ), + ], + ), + ], + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('attachment', attachment)) + ..add(ObjectFlagProperty.has('downloadCallback', downloadCallback)); + } +} diff --git a/lib/src/components/molecules/slidable_list_tile.dart b/lib/src/components/molecules/slidable_list_tile.dart index 649c399..224f533 100644 --- a/lib/src/components/molecules/slidable_list_tile.dart +++ b/lib/src/components/molecules/slidable_list_tile.dart @@ -48,6 +48,7 @@ class ZdsSlidableListTile extends StatelessWidget { this.slideButtonWidth = 100, this.minHeight = 80, this.onTap, + this.elevation = 1, this.slideEnabled = true, this.semanticDescription, this.excludeSemantics = false, @@ -56,6 +57,9 @@ class ZdsSlidableListTile extends StatelessWidget { /// The tile's main content. Usually a [Row] final Widget child; + /// The elevation of the tile. Defaults to 1. + final double elevation; + /// The length of the tile. On vertical displays this usually is `MediaQuery.of(context).size.width`. /// Must exceed or be equal to [slideButtonWidth] * [actions].length. final double width; @@ -95,7 +99,7 @@ class ZdsSlidableListTile extends StatelessWidget { final Map semanticActions = {}; for (final ZdsSlidableAction action in [...?actions, ...?leadingActions]) { - semanticActions[CustomSemanticsAction(label: action.label)] = () { + semanticActions[CustomSemanticsAction(label: action.semanticLabel ?? action.label!)] = () { action.onPressed!(context); }; } @@ -123,6 +127,7 @@ class ZdsSlidableListTile extends StatelessWidget { ) : null, child: Card( + elevation: elevation, shape: const ContinuousRectangleBorder(), color: backgroundColor ?? Theme.of(context).colorScheme.surface, margin: EdgeInsets.zero, @@ -152,7 +157,8 @@ class ZdsSlidableListTile extends StatelessWidget { ..add(DiagnosticsProperty('slideEnabled', slideEnabled)) ..add(DoubleProperty('minHeight', minHeight)) ..add(StringProperty('semanticDescription', semanticDescription)) - ..add(DiagnosticsProperty('excludeSemantics', excludeSemantics)); + ..add(DiagnosticsProperty('excludeSemantics', excludeSemantics)) + ..add(DoubleProperty('elevation', elevation)); } } @@ -218,10 +224,10 @@ class _ActionBuilderState extends State<_ActionBuilder> { /// Defines an action that will be shown when sliding on a ZdsSlidableListTile. class ZdsSlidableAction { /// Defines an action that will be shown when sliding on a ZdsSlidableListTile. - /// [label] must not be empty. /// [backgroundColor], [foregroundColor], and [autoclose] must not be null ZdsSlidableAction({ - required this.label, + this.label, + this.semanticLabel, this.onPressed, this.icon, this.backgroundColor, @@ -229,13 +235,21 @@ class ZdsSlidableAction { this.autoclose = true, this.padding = EdgeInsets.zero, this.textOverflow, - }) : assert(label.isNotEmpty, 'Label must have content as it acts as the semantic button description'); + }) : assert( + label != null || semanticLabel != null, + 'Slideable actions must define either a label or semantic label to meet accessability standards.', + ); /// Function called on press of the widget. final void Function(BuildContext)? onPressed; - /// The text that will be shown above the icon. It can't be empty. - final String label; + /// The text that will be shown above the icon. + /// If this is not set, [semanticLabel] must be set. + final String? label; + + /// The semantic label for the action. + /// If this is not set, [label] must be set. + final String? semanticLabel; /// An optional icon that will be shown below the label. final IconData? icon; diff --git a/lib/src/components/organisms/chat/message_body/chat_utils.dart b/lib/src/components/organisms/chat/message_body/chat_utils.dart index d613012..4c82204 100644 --- a/lib/src/components/organisms/chat/message_body/chat_utils.dart +++ b/lib/src/components/organisms/chat/message_body/chat_utils.dart @@ -451,6 +451,7 @@ class ZdsChatAttachment { this.content, this.url, this.localPath, + this.size, this.id, }); @@ -462,6 +463,9 @@ class ZdsChatAttachment { /// If not provided, [name] will be parsed for extensions. final String? extension; + /// The size of the file + final String? size; + /// Content of attachment encoded in base64. final String? content;