From c2dd6308d57ebda6f715bef1225ad326ab4ea64b Mon Sep 17 00:00:00 2001 From: sd-athlon <163880004+sd-athlon@users.noreply.github.com> Date: Tue, 30 Apr 2024 14:59:08 +0300 Subject: [PATCH] feat: Chat Item (#37) * feat(main): Chat Item * [automated commit] lint format and import sort * remove hard coded avatar * [automated commit] lint format and import sort --------- Co-authored-by: github-actions --- example/lib/home.dart | 2 + .../pages/components/chat_item_example.dart | 61 +++ example/widgetbook/main.dart | 5 + .../components/chat_item_widgetbook.dart | 49 +++ lib/src/components/chat_item/chat_item.dart | 368 ++++++++++++++++++ lib/zeta_flutter.dart | 1 + pubspec.yaml | 1 + 7 files changed, 487 insertions(+) create mode 100644 example/lib/pages/components/chat_item_example.dart create mode 100644 example/widgetbook/pages/components/chat_item_widgetbook.dart create mode 100644 lib/src/components/chat_item/chat_item.dart diff --git a/example/lib/home.dart b/example/lib/home.dart index dd349a64..a3e4f41e 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -8,6 +8,7 @@ import 'package:zeta_example/pages/components/banner_example.dart'; import 'package:zeta_example/pages/components/bottom_sheet_example.dart'; import 'package:zeta_example/pages/components/breadcrumbs_example.dart'; import 'package:zeta_example/pages/components/button_example.dart'; +import 'package:zeta_example/pages/components/chat_item_example.dart'; import 'package:zeta_example/pages/components/checkbox_example.dart'; import 'package:zeta_example/pages/components/chip_example.dart'; import 'package:zeta_example/pages/components/contact_item_example.dart'; @@ -57,6 +58,7 @@ final List components = [ Component(BottomSheetExample.name, (context) => const BottomSheetExample()), Component(BreadCrumbsExample.name, (context) => const BreadCrumbsExample()), Component(ButtonExample.name, (context) => const ButtonExample()), + Component(ChatItemExample.name, (context) => const ChatItemExample()), Component(CheckBoxExample.name, (context) => const CheckBoxExample()), Component(ChipExample.name, (context) => const ChipExample()), Component(ContactItemExample.name, (context) => const ContactItemExample()), diff --git a/example/lib/pages/components/chat_item_example.dart b/example/lib/pages/components/chat_item_example.dart new file mode 100644 index 00000000..e124e765 --- /dev/null +++ b/example/lib/pages/components/chat_item_example.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class ChatItemExample extends StatefulWidget { + static const String name = 'ChatItem'; + + const ChatItemExample({Key? key}) : super(key: key); + + @override + State createState() => _ChatItemExampleState(); +} + +class _ChatItemExampleState extends State { + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: 'Chat Item', + child: SingleChildScrollView( + child: Column( + children: [ + ZetaChatItem( + time: DateTime.now(), + enabledWarningIcon: true, + enabledNotificationIcon: true, + leading: const ZetaAvatar( + size: ZetaAvatarSize.l, + ), + count: 100, + onTap: () {}, + onDeleteTap: () {}, + onCallTap: () {}, + onMenuMoreTap: () {}, + onPttTap: () {}, + title: Text("Chat name ID"), + subtitle: Text( + "Dummy text to represent the first lines of most recent message dsadas dsa dsa ds dssd sd sdsd s ds"), + ), + Padding( + padding: const EdgeInsets.only(top: ZetaSpacing.b), + child: ZetaChatItem( + highlighted: true, + count: 99, + time: DateTime.now(), + onTap: () {}, + starred: true, + leading: const ZetaAvatar( + size: ZetaAvatarSize.l, + ), + title: Text("Chat name ID"), + subtitle: Text( + "Dummy text to represent the first lines of most recent message", + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 38719607..3b53bea0 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -11,6 +11,7 @@ import 'pages/components/banner_widgetbook.dart'; import 'pages/components/bottom_sheet_widgetbook.dart'; import 'pages/components/breadcrumbs_widgetbook.dart'; import 'pages/components/button_widgetbook.dart'; +import 'pages/components/chat_item_widgetbook.dart'; import 'pages/components/checkbox_widgetbook.dart'; import 'pages/components/chip_widgetbook.dart'; import 'pages/components/contact_item_widgetbook.dart'; @@ -83,6 +84,10 @@ class HotReload extends StatelessWidget { ], ), WidgetbookUseCase(name: 'Avatar', builder: (context) => avatarUseCase(context)), + WidgetbookUseCase( + name: 'Chat Item', + builder: (context) => chatItemWidgetBook(context), + ), WidgetbookUseCase(name: 'Checkbox', builder: (context) => checkboxUseCase(context)), WidgetbookUseCase(name: 'Contact Item', builder: (context) => contactItemUseCase(context)), WidgetbookComponent( diff --git a/example/widgetbook/pages/components/chat_item_widgetbook.dart b/example/widgetbook/pages/components/chat_item_widgetbook.dart new file mode 100644 index 00000000..7c55a1cb --- /dev/null +++ b/example/widgetbook/pages/components/chat_item_widgetbook.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget chatItemWidgetBook(BuildContext context) { + final title = context.knobs.string(label: 'Title', initialValue: 'Chat name ID'); + + final subtitle = context.knobs.string( + label: 'Subtitle', + initialValue: 'Dummy text to represent the first lines of most recent message', + ); + + final count = context.knobs.int.input(label: 'Count', initialValue: 3); + + final enabledWarningIcon = context.knobs.boolean(label: 'Warning Icon', initialValue: false); + final enabledNotificationIcon = context.knobs.boolean(label: 'Notification Icon', initialValue: false); + final starred = context.knobs.boolean(label: 'Starred', initialValue: false); + + final enabledOnTap = context.knobs.boolean(label: 'Enabled Tap', initialValue: true); + final enabledOnDelete = context.knobs.boolean(label: 'Delete', initialValue: true); + + final enabledOnMenuMore = context.knobs.boolean(label: 'Menu More', initialValue: true); + + final enabledOnCall = context.knobs.boolean(label: 'Call', initialValue: true); + + final enabledOnPtt = context.knobs.boolean(label: 'Ptt', initialValue: true); + + return WidgetbookTestWidget( + widget: ZetaChatItem( + time: DateTime.now(), + enabledWarningIcon: enabledWarningIcon, + enabledNotificationIcon: enabledNotificationIcon, + count: count, + onTap: enabledOnTap ? () {} : null, + onDeleteTap: enabledOnDelete ? () {} : null, + onCallTap: enabledOnCall ? () {} : null, + onMenuMoreTap: enabledOnMenuMore ? () {} : null, + onPttTap: enabledOnPtt ? () {} : null, + starred: starred, + leading: const ZetaAvatar( + size: ZetaAvatarSize.l, + ), + title: Text(title), + subtitle: Text(subtitle), + ), + ); +} diff --git a/lib/src/components/chat_item/chat_item.dart b/lib/src/components/chat_item/chat_item.dart new file mode 100644 index 00000000..0732cad9 --- /dev/null +++ b/lib/src/components/chat_item/chat_item.dart @@ -0,0 +1,368 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:intl/intl.dart'; +import '../../../zeta_flutter.dart'; + +/// Chat item widget that can be dragged to reveal contextual actions. +class ZetaChatItem extends StatelessWidget { + /// Creates a [ZetaChatItem] + const ZetaChatItem({ + super.key, + this.highlighted = false, + this.time, + this.timeFormat, + required this.title, + required this.subtitle, + required this.leading, + this.enabledWarningIcon = false, + this.enabledNotificationIcon = false, + this.additionalIcons = const [], + this.count, + this.onTap, + this.starred = false, + this.onMenuMoreTap, + this.onCallTap, + this.onDeleteTap, + this.onPttTap, + }); + + /// Whether to apply different background color. + final bool highlighted; + + /// Normally the person name. + final Widget title; + + /// Normally the begining of the chat message. + final Widget subtitle; + + /// Normally [ZetaAvatar]. + final Widget leading; + + /// The time when the message is sent. It applies default date format - [timeFormat]. + final DateTime? time; + + /// The dafault date format. + final DateFormat? timeFormat; + + /// Whether to show warning icon. + final bool enabledWarningIcon; + + /// Whether to show notification icon. + final bool enabledNotificationIcon; + + /// Optional icons to be displayed on the top right corder next to warning and notification icons. + final List additionalIcons; + + /// Count displayed on the top right corder. + final int? count; + + /// Callback to call when tap on the list tile. + final VoidCallback? onTap; + + /// Whether the chat list is starred. + final bool starred; + + /// Callback for slidable action - menu more. + final VoidCallback? onMenuMoreTap; + + /// Callback for slidable action - call. + final VoidCallback? onCallTap; + + /// Callback for slidable action - delete. + final VoidCallback? onDeleteTap; + + /// Callback for slidable action - ptt. + final VoidCallback? onPttTap; + + DateFormat get _dateFormat => timeFormat ?? DateFormat('hh:mm a'); + String? get _count => count != null && count! > 99 ? '99+' : count?.toString(); + + double _getSlidableExtend({ + required int slidableActionsCount, + required double maxWidth, + }) { + if (slidableActionsCount == 0) return 0.5; + + final actionsExtend = slidableActionsCount * ZetaSpacing.x20; + final extend = actionsExtend / maxWidth; + + return extend > 1 ? 1 : extend; + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + final slidableActions = [ + if (onMenuMoreTap != null) + _ZetaSlidableAction( + onPressed: onMenuMoreTap, + backgroundColor: colors.purple.shade10, + foregroundColor: colors.purple.shade60, + icon: ZetaIcons.more_vertical_round, + ), + if (onCallTap != null) + _ZetaSlidableAction( + onPressed: onCallTap, + backgroundColor: colors.green.shade10, + foregroundColor: colors.positive, + icon: Icons.call, + ), + if (onPttTap != null) + _ZetaSlidableAction( + onPressed: onPttTap, + backgroundColor: colors.blue.shade10, + foregroundColor: colors.primary, + icon: ZetaIcons.ptt_round, + ), + if (onDeleteTap != null) + _ZetaSlidableAction( + onPressed: onDeleteTap, + backgroundColor: colors.red.shade10, + foregroundColor: colors.negative, + icon: ZetaIcons.delete_round, + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + return Slidable( + enabled: slidableActions.isNotEmpty, + endActionPane: ActionPane( + extentRatio: _getSlidableExtend( + slidableActionsCount: slidableActions.length, + maxWidth: constraints.maxWidth, + ), + motion: const ScrollMotion(), + children: slidableActions, + ), + child: ColoredBox( + color: highlighted ? colors.blue.shade10 : colors.surfacePrimary, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: ZetaSpacing.s, + vertical: ZetaSpacing.xs, + ), + child: Row( + children: [ + leading, + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: ZetaSpacing.s), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (highlighted) + Container( + margin: const EdgeInsets.only( + right: ZetaSpacing.xxs, + ), + height: ZetaSpacing.x2, + width: ZetaSpacing.x2, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + ), + ), + Flexible( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: DefaultTextStyle( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: (highlighted ? ZetaTextStyles.labelLarge : ZetaTextStyles.bodyMedium) + .copyWith( + color: colors.textDefault, + ), + child: title, + ), + ), + Row( + children: [ + if (time != null) + Text( + _dateFormat.format(time!), + style: ZetaTextStyles.bodyXSmall, + ), + IconTheme( + data: const IconThemeData( + size: ZetaSpacing.x4, + ), + child: Row( + children: [ + ...additionalIcons, + if (enabledWarningIcon) + Padding( + padding: const EdgeInsets.only( + left: ZetaSpacing.xxs, + ), + child: Icon( + ZetaIcons.info_round, + color: colors.cool.shade70, + ), + ), + if (enabledWarningIcon) + Padding( + padding: const EdgeInsets.only( + left: ZetaSpacing.xxs, + ), + child: Icon( + Icons.circle_notifications, + color: colors.negative, + ), + ), + if (_count != null) + Container( + margin: const EdgeInsets.only( + left: ZetaSpacing.xxs, + ), + padding: const EdgeInsets.symmetric( + horizontal: ZetaSpacing.x2, + ), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: ZetaRadius.full, + ), + child: Text( + _count!, + style: ZetaTextStyles.labelSmall.copyWith( + color: colors.textInverse, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: DefaultTextStyle( + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: ZetaTextStyles.bodySmall.copyWith( + color: colors.textSubtle, + ), + child: subtitle, + ), + ), + Padding( + padding: const EdgeInsets.only( + left: ZetaSpacing.xxs, + ), + child: Icon( + starred ? ZetaIcons.star_sharp : ZetaIcons.star_outline_sharp, + color: starred ? colors.yellow.shade60 : null, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('highlighted', highlighted)) + ..add(DiagnosticsProperty('time', time)) + ..add(DiagnosticsProperty('timeFormat', timeFormat)) + ..add(DiagnosticsProperty('enabledWarningIcon', enabledWarningIcon)) + ..add( + DiagnosticsProperty( + 'enabledNotificationIcon', + enabledNotificationIcon, + ), + ) + ..add(IntProperty('count', count)) + ..add(ObjectFlagProperty.has('onTap', onTap)) + ..add(DiagnosticsProperty('starred', starred)) + ..add( + ObjectFlagProperty.has('onMenuMoreTap', onMenuMoreTap), + ) + ..add(ObjectFlagProperty.has('onCallTap', onCallTap)) + ..add(ObjectFlagProperty.has('onDeleteTap', onDeleteTap)) + ..add(ObjectFlagProperty.has('onPttTap', onPttTap)); + } +} + +class _ZetaSlidableAction extends StatelessWidget { + const _ZetaSlidableAction({ + required this.onPressed, + required this.icon, + required this.foregroundColor, + required this.backgroundColor, + }); + + final VoidCallback? onPressed; + final IconData icon; + final Color foregroundColor; + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + return Expanded( + child: SizedBox.expand( + child: Padding( + padding: const EdgeInsets.only(left: ZetaSpacing.xxs), + child: IconButton( + onPressed: () => onPressed, + style: IconButton.styleFrom( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + shape: const RoundedRectangleBorder( + borderRadius: ZetaRadius.minimal, + ), + side: BorderSide.none, + ), + icon: Icon( + icon, + color: foregroundColor, + size: ZetaSpacing.x8, + ), + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ObjectFlagProperty.has('onPressed', onPressed)) + ..add(DiagnosticsProperty('icon', icon)) + ..add(ColorProperty('foregroundColor', foregroundColor)) + ..add(ColorProperty('backgroundColor', backgroundColor)) + ..add(DiagnosticsProperty('icon', icon)); + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 48a3c902..70b9e30c 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -21,6 +21,7 @@ export 'src/components/buttons/button_group.dart'; export 'src/components/buttons/button_style.dart'; export 'src/components/buttons/fab.dart'; export 'src/components/buttons/icon_button.dart'; +export 'src/components/chat_item/chat_item.dart'; export 'src/components/checkbox/checkbox.dart'; export 'src/components/chips/chip.dart'; export 'src/components/contact_item/contact_item.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 52c07e53..e0ed7648 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ environment: dependencies: flutter: sdk: flutter + flutter_slidable: ^3.1.0 intl: ^0.19.0 mask_text_input_formatter: ^2.9.0