diff --git a/apps/design_system_gallery/assets/attachment_image.png b/apps/design_system_gallery/assets/attachment_image.png new file mode 100644 index 0000000..7c129f1 Binary files /dev/null and b/apps/design_system_gallery/assets/attachment_image.png differ diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index dc8ea3e..56a08af 100644 --- a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart +++ b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart @@ -14,6 +14,10 @@ import 'package:design_system_gallery/components/accessories/stream_file_type_ic as _design_system_gallery_components_accessories_stream_file_type_icons; import 'package:design_system_gallery/components/button.dart' as _design_system_gallery_components_button; +import 'package:design_system_gallery/components/message_composer/message_composer.dart' + as _design_system_gallery_components_message_composer_message_composer; +import 'package:design_system_gallery/components/message_composer/message_composer_attachment_media_file.dart' + as _design_system_gallery_components_message_composer_message_composer_attachment_media_file; import 'package:design_system_gallery/components/stream_avatar.dart' as _design_system_gallery_components_stream_avatar; import 'package:design_system_gallery/components/stream_avatar_group.dart' @@ -296,6 +300,45 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Message Composer', + children: [ + _widgetbook.WidgetbookComponent( + name: 'MessageComposerAttachmentMediaFile', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_composer_message_composer_attachment_media_file + .buildMessageComposerAttachmentMediaFilePlayground, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageComposer', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Component Structure', + builder: + _design_system_gallery_components_message_composer_message_composer + .buildStreamMessageComposerStructure, + ), + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_composer_message_composer + .buildStreamMessageComposerPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: + _design_system_gallery_components_message_composer_message_composer + .buildStreamMessageComposerExample, + ), + ], + ), + ], + ), ], ), ]; diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart new file mode 100644 index 0000000..fa71a19 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamMessageComposer, + path: '[Components]/Message Composer', +) +Widget buildStreamMessageComposerPlayground(BuildContext context) { + return const Center( + child: StreamMessageComposer(), + ); +} + +// ============================================================================= +// Component Structure +// ============================================================================= + +@widgetbook.UseCase( + name: 'Component Structure', + type: StreamMessageComposer, + path: '[Components]/Message Composer', +) +Widget buildStreamMessageComposerStructure(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + final componentProps = MessageComposerComponentProps(controller: TextEditingController()); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 500), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Message Composer Structure', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'The composer is built from customizable sub-components:', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + const SizedBox(height: 24), + + // Full Composer + const _ComponentCard( + label: 'StreamMessageComposer', + description: 'Main composer widget', + child: StreamMessageComposer(), + ), + const SizedBox(height: 16), + + // Leading + _ComponentCard( + label: 'StreamMessageComposerLeading', + description: 'Action button(s) before the input', + child: StreamMessageComposerLeading(props: componentProps), + ), + const SizedBox(height: 16), + + // Input + _ComponentCard( + label: 'StreamMessageComposerInput', + description: 'Input area with header, text field, and actions', + child: StreamMessageComposerInput(props: componentProps), + ), + const SizedBox(height: 16), + + // Input Header + _ComponentCard( + label: 'StreamMessageComposerInputHeader', + description: 'Header slots for replies, attachments, etc.', + child: StreamMessageComposerInputHeader(props: componentProps), + ), + const SizedBox(height: 16), + + // Input Trailing + _ComponentCard( + label: 'StreamMessageComposerInputTrailing', + description: 'Send button or other trailing actions', + child: StreamMessageComposerInputTrailing(props: componentProps), + ), + ], + ), + ), + ), + ); +} + +class _ComponentCard extends StatelessWidget { + const _ComponentCard({ + required this.label, + required this.description, + required this.child, + }); + + final String label; + final String description; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 4), + Text( + description, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + ), + Divider( + height: 1, + color: colorScheme.borderSurfaceSubtle, + ), + Padding( + padding: const EdgeInsets.all(12), + child: child, + ), + ], + ), + ); + } +} + +// ============================================================================= +// Real-world Example +// ============================================================================= + +@widgetbook.UseCase( + name: 'Real-world Example', + type: StreamMessageComposer, + path: '[Components]/Message Composer', +) +Widget buildStreamMessageComposerExample(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + final isFloating = context.knobs.boolean( + label: 'Floating', + initialValue: false, + description: 'When true, the composer has no background or border.', + ); + + // Sample messages for scrollable list + const messages = [ + (message: 'Hey! How are you doing today?', isMe: false), + (message: "I'm doing great, thanks for asking!", isMe: true), + (message: 'Did you see the new design updates?', isMe: false), + (message: 'Yes! They look amazing. Great work on the color scheme.', isMe: true), + (message: 'Thanks! We spent a lot of time on the details.', isMe: false), + (message: 'It really shows. The typography is much cleaner now.', isMe: true), + (message: 'Glad you like it! Any feedback?', isMe: false), + (message: 'Maybe we could add more spacing in some areas?', isMe: true), + (message: "Good point, I'll look into that.", isMe: false), + (message: 'Perfect! Let me know if you need any help.', isMe: true), + (message: 'Should be finished by tomorrow.', isMe: false), + (message: 'Great! Thanks for the update.', isMe: true), + (message: "No problem! You're welcome.", isMe: false), + (message: 'I need to go now. See you later!', isMe: false), + (message: 'Bye! Take care.', isMe: true), + (message: 'Thanks! You too!', isMe: false), + (message: 'See you soon!', isMe: true), + (message: 'Bye!', isMe: false), + (message: 'See you soon!', isMe: true), + ]; + + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + StreamAvatar( + size: StreamAvatarSize.sm, + placeholder: (context) => const Text('JD'), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'John Doe', + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + 'Online', + style: textTheme.captionDefault.copyWith( + color: colorScheme.accentSuccess, + ), + ), + ], + ), + ], + ), + ), + body: isFloating + ? Stack( + children: [ + // Scrollable messages area (with bottom padding for composer) + ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 250), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[index]; + return Padding( + padding: EdgeInsets.only( + bottom: index < messages.length - 1 ? 8 : 0, + ), + child: _MessageBubble( + message: msg.message, + isMe: msg.isMe, + ), + ); + }, + ), + // Floating composer at bottom + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: StreamMessageComposer(isFloating: true), + ), + ], + ) + : Column( + children: [ + // Scrollable messages area + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[index]; + return Padding( + padding: EdgeInsets.only( + bottom: index < messages.length - 1 ? 8 : 0, + ), + child: _MessageBubble( + message: msg.message, + isMe: msg.isMe, + ), + ); + }, + ), + ), + // Non-floating composer + const StreamMessageComposer(isFloating: false), + ], + ), + ); +} + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({ + required this.message, + required this.isMe, + }); + + final String message; + final bool isMe; + + @override + Widget build(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isMe ? colorScheme.accentPrimary : colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(16), + border: isMe ? null : Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Text( + message, + style: textTheme.bodyDefault.copyWith( + color: isMe ? colorScheme.textOnAccent : colorScheme.textPrimary, + ), + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart new file mode 100644 index 0000000..42a7873 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: MessageComposerAttachmentMediaFile, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentMediaFilePlayground(BuildContext context) { + return Center( + child: MessageComposerAttachmentMediaFile( + image: const AssetImage('assets/attachment_image.png'), + onRemovePressed: () {}, + ), + ); +} diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index da30d07..214c503 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -4,4 +4,5 @@ export 'components/avatar/stream_avatar_group.dart'; export 'components/avatar/stream_avatar_stack.dart'; export 'components/badge/stream_badge_count.dart'; export 'components/buttons/stream_button.dart' hide DefaultStreamButton; -export 'components/indicator/stream_online_indicator.dart'; +export 'components/badges/stream_online_indicator.dart'; +export 'components/message_composer.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/badges/media_badge.dart b/packages/stream_core_flutter/lib/src/components/badges/media_badge.dart new file mode 100644 index 0000000..3fd52cc --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/badges/media_badge.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; + +import '../../../stream_core_flutter.dart'; + +class MediaBadge extends StatelessWidget { + const MediaBadge({ + super.key, + required this.type, + required this.duration, + }); + + final MediaBadgeType type; + final Duration duration; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: context.streamColorScheme.backgroundInverse, + shape: BoxShape.circle, + ), + padding: EdgeInsets.symmetric( + horizontal: context.streamSpacing.xs, + vertical: context.streamSpacing.xxs, + ), + child: Column( + children: [ + Icon( + switch (type) { + MediaBadgeType.video => context.streamIcons.videoSolid, + MediaBadgeType.audio => context.streamIcons.microphoneSolid, + }, + size: 12, + color: context.streamColorScheme.textPrimary, + ), + + Text(duration.toReadableString()), + ], + ), + ); + } +} + +extension on Duration { + String toReadableString() { + if (inSeconds < 60) { + return '${inSeconds}s'; + } + if (inSeconds < 3600) { + return '${inMinutes}m'; + } + return '${inHours}h'; + } +} + +enum MediaBadgeType { + video, + audio, +} diff --git a/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart b/packages/stream_core_flutter/lib/src/components/badges/stream_online_indicator.dart similarity index 100% rename from packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart rename to packages/stream_core_flutter/lib/src/components/badges/stream_online_indicator.dart diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart index 3e2cd46..8632259 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart @@ -31,6 +31,7 @@ class StreamButton extends StatelessWidget { StreamButtonType type = StreamButtonType.solid, StreamButtonSize size = StreamButtonSize.medium, IconData? icon, + bool isFloating = false, }) : props = StreamButtonProps( label: null, onTap: onTap, @@ -39,6 +40,7 @@ class StreamButton extends StatelessWidget { size: size, iconLeft: icon, iconRight: null, + isFloating: isFloating, ); final StreamButtonProps props; @@ -60,6 +62,7 @@ class StreamButtonProps { required this.size, required this.iconLeft, required this.iconRight, + this.isFloating = false, }); final String? label; @@ -69,6 +72,7 @@ class StreamButtonProps { final StreamButtonSize size; final IconData? iconLeft; final IconData? iconRight; + final bool isFloating; } enum StreamButtonStyle { primary, secondary, destructive } @@ -89,6 +93,7 @@ class DefaultStreamButton extends StatelessWidget { Widget build(BuildContext context) { final spacing = context.streamSpacing; final buttonTheme = context.streamButtonTheme; + final colorScheme = context.streamColorScheme; final defaults = _StreamButtonDefaults(context: context); final themeButtonTypeStyle = switch (props.style) { @@ -114,8 +119,12 @@ class DefaultStreamButton extends StatelessWidget { StreamButtonType.ghost => defaultButtonTypeStyle.ghost, }; + final fallbackBackgroundColor = props.isFloating ? colorScheme.backgroundElevation1 : Colors.transparent; + final backgroundColor = - themeStyle?.backgroundColor ?? defaultStyle?.backgroundColor ?? WidgetStateProperty.all(Colors.transparent); + themeStyle?.backgroundColor ?? + defaultStyle?.backgroundColor ?? + WidgetStateProperty.all(fallbackBackgroundColor); final foregroundColor = themeStyle?.foregroundColor ?? defaultStyle?.foregroundColor; final borderColor = themeStyle?.borderColor ?? defaultStyle?.borderColor; @@ -126,20 +135,22 @@ class DefaultStreamButton extends StatelessWidget { }; const iconSize = 20.0; + final isIconButton = props.label == null; return ElevatedButton( onPressed: props.onTap, style: ButtonStyle( backgroundColor: backgroundColor, foregroundColor: foregroundColor, - minimumSize: WidgetStateProperty.all(Size(minimumSize, minimumSize)), + minimumSize: isIconButton ? null : WidgetStateProperty.all(Size(minimumSize, minimumSize)), + fixedSize: isIconButton ? WidgetStateProperty.all(Size(minimumSize, minimumSize)) : null, + elevation: WidgetStateProperty.all(props.isFloating ? 4 : 0), padding: WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: spacing.md)), side: borderColor == null ? null : WidgetStateProperty.resolveWith( (states) => BorderSide(color: borderColor.resolve(states)), ), - elevation: WidgetStateProperty.all(0), shape: props.label == null ? WidgetStateProperty.all(const CircleBorder()) : WidgetStateProperty.all( diff --git a/packages/stream_core_flutter/lib/src/components/controls/remove_control.dart b/packages/stream_core_flutter/lib/src/components/controls/remove_control.dart new file mode 100644 index 0000000..4f55338 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/controls/remove_control.dart @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; + +import '../../../stream_core_flutter.dart'; + +class RemoveControl extends StatelessWidget { + const RemoveControl({ + super.key, + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Container( + decoration: BoxDecoration( + color: colorScheme.accentBlack, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.borderOnDark, width: 2), + ), + alignment: Alignment.center, + height: 20, + width: 20, + child: Icon( + context.streamIcons.crossSmall, + color: colorScheme.textInverse, + size: 10, + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer.dart b/packages/stream_core_flutter/lib/src/components/message_composer.dart new file mode 100644 index 0000000..072a96d --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer.dart @@ -0,0 +1,4 @@ +export 'message_composer/attachment/message_composer_attachment_media_file.dart'; +export 'message_composer/message_composer.dart'; +export 'message_composer/message_composer_input.dart'; +export 'message_composer/message_composer_input_trailing.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_media_file.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_media_file.dart new file mode 100644 index 0000000..9c4a577 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_media_file.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../stream_core_flutter.dart'; +import '../../controls/remove_control.dart'; + +class MessageComposerAttachmentMediaFile extends StatelessWidget { + const MessageComposerAttachmentMediaFile({super.key, required this.image, required this.onRemovePressed}); + + final ImageProvider image; + final VoidCallback onRemovePressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 80, + width: 80, + child: Stack( + children: [ + Padding( + padding: EdgeInsets.all(context.streamSpacing.xxs), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.lg), + border: Border.all( + color: context.streamColorScheme.borderDefault.withAlpha(25), + ), + image: DecorationImage(image: image, fit: BoxFit.cover), + ), + ), + ), + Align( + alignment: Alignment.topRight, + child: RemoveControl(onPressed: onRemovePressed), + ), + ], + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/message_composer.dart b/packages/stream_core_flutter/lib/src/components/message_composer/message_composer.dart new file mode 100644 index 0000000..bdc0d02 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/message_composer.dart @@ -0,0 +1,109 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../../../stream_core_flutter.dart'; + +class StreamBaseMessageComposer extends StatefulWidget { + const StreamBaseMessageComposer({ + super.key, + required this.controller, + required this.isFloating, + this.placeholder = '', + this.composerLeading, + this.composerTrailing, + this.inputLeading, + this.inputTrailing, + this.inputHeader, + }); + + final TextEditingController? controller; + final bool isFloating; + final String placeholder; + + final Widget? composerLeading; + final Widget? composerTrailing; + final Widget? inputLeading; + final Widget? inputTrailing; + final Widget? inputHeader; + + @override + State createState() => _StreamBaseMessageComposerState(); +} + +class _StreamBaseMessageComposerState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _initController(); + } + + @override + void didUpdateWidget(StreamBaseMessageComposer oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + _disposeController(oldWidget); + _initController(); + } + } + + @override + void dispose() { + _disposeController(widget); + super.dispose(); + } + + void _initController() { + _controller = widget.controller ?? TextEditingController(); + } + + void _disposeController(StreamBaseMessageComposer widget) { + if (widget.controller == null) { + _controller.dispose(); + } + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final bottomPaddingSafeArea = MediaQuery.of(context).padding.bottom; + final minimumBottomPadding = spacing.md; + final bottomPadding = math.max(bottomPaddingSafeArea, minimumBottomPadding); + + return Container( + padding: EdgeInsets.only(top: spacing.md, bottom: bottomPadding), + decoration: widget.isFloating + ? null + : BoxDecoration( + color: context.streamColorScheme.backgroundElevation1, + border: Border( + top: BorderSide(color: context.streamColorScheme.borderDefault), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox(width: spacing.md), + ?widget.composerLeading, + Expanded( + child: StreamMessageComposerInput( + controller: _controller, + placeholder: widget.placeholder, + isFloating: widget.isFloating, + inputLeading: widget.inputLeading, + inputTrailing: widget.inputTrailing, + inputHeader: widget.inputHeader, + ), + ), + ?widget.composerTrailing, + SizedBox(width: spacing.md), + ], + ), + ); + } +} + +class MessageData {} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/message_composer_input.dart b/packages/stream_core_flutter/lib/src/components/message_composer/message_composer_input.dart new file mode 100644 index 0000000..776a047 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/message_composer_input.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../stream_core_flutter.dart'; +import '../../factory/stream_component_factory.dart'; + +class StreamMessageComposerInput extends StatelessWidget { + const StreamMessageComposerInput({ + super.key, + required this.controller, + this.placeholder = '', + this.isFloating = false, + this.inputLeading, + this.inputTrailing, + this.inputHeader, + }); + + final TextEditingController controller; + final String placeholder; + final bool isFloating; + final Widget? inputLeading; + final Widget? inputTrailing; + final Widget? inputHeader; + + @override + Widget build(BuildContext context) { + // TODO: Add message composer theme + + return DecoratedBox( + decoration: BoxDecoration( + color: context.streamColorScheme.backgroundElevation1, + borderRadius: BorderRadius.all(context.streamRadius.xxxl), + border: Border.all( + color: context.streamColorScheme.borderDefault, + ), + boxShadow: isFloating ? context.streamBoxShadow.elevation3 : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ?inputHeader, + Row( + children: [ + ?inputLeading, + Expanded( + child: _MessageComposerInputField( + controller: controller, + placeholder: placeholder, + ), + ), + ?inputTrailing, + ], + ), + ], + ), + ); + } +} + +class _MessageComposerInputField extends StatelessWidget { + _MessageComposerInputField({required this.controller, required this.placeholder}); + + TextEditingController controller; + String placeholder; + + @override + Widget build(BuildContext context) { + // TODO: fully implement the input field + + final composerBorderRadius = context.streamRadius.xxxl; + + final border = OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(composerBorderRadius), + ); + + return TextField( + controller: controller, + decoration: InputDecoration( + border: border, + focusedBorder: border, + enabledBorder: border, + errorBorder: border, + disabledBorder: border, + fillColor: Colors.transparent, + contentPadding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + hintText: placeholder, + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart b/packages/stream_core_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart new file mode 100644 index 0000000..215bcb2 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../stream_core_flutter.dart'; + +class StreamMessageComposerInputTrailing extends StatefulWidget { + const StreamMessageComposerInputTrailing({ + super.key, + required this.controller, + required this.onSendPressed, + required this.onMicrophonePressed, + }); + + final TextEditingController controller; + final VoidCallback onSendPressed; + final VoidCallback? onMicrophonePressed; + + @override + State createState() => _StreamMessageComposerInputTrailingState(); +} + +class _StreamMessageComposerInputTrailingState extends State { + var _hasText = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onInputTextChanged); + _hasText = widget.controller.text.isNotEmpty; + } + + @override + void didUpdateWidget(StreamMessageComposerInputTrailing oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_onInputTextChanged); + widget.controller.addListener(_onInputTextChanged); + } + } + + void _onInputTextChanged() { + final hasText = widget.controller.text.isNotEmpty; + if (_hasText != hasText) { + setState(() => _hasText = hasText); + } + } + + @override + Widget build(BuildContext context) { + // TODO: Implement the trailing component + + if (_hasText || widget.onMicrophonePressed == null) { + return StreamButton.icon( + key: _messageComposerInputTrailingSendKey, + icon: context.streamIcons.paperPlane, + size: StreamButtonSize.small, + onTap: () {}, + ); + } + return StreamButton.icon( + key: _messageComposerInputTrailingMicrophoneKey, + icon: context.streamIcons.microphone, + type: StreamButtonType.ghost, + style: StreamButtonStyle.secondary, + size: StreamButtonSize.small, + onTap: () {}, + ); + } +} + +final _messageComposerInputTrailingSendKey = UniqueKey(); +final _messageComposerInputTrailingMicrophoneKey = UniqueKey(); diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart index 84e7c6a..f63915c 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart @@ -3,10 +3,11 @@ import 'package:flutter/widgets.dart'; import '../components/accessories/stream_file_type_icon.dart' show DefaultStreamFileTypeIcon, StreamFileTypeIconProps; import '../components/buttons/stream_button.dart' show DefaultStreamButton, StreamButtonProps; +import '../components/message_composer.dart'; typedef StreamComponentBuilder = Widget Function(BuildContext context, T props); -class StreamComponentFactory { +class StreamComponentFactory { StreamComponentFactory({ StreamComponentBuilder? buttonFactory, StreamComponentBuilder? fileTypeIconFactory, diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index f9832cb..18bf5f7 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -1,3 +1,5 @@ +export 'factory/stream_component_factory.dart'; + export 'theme/components/stream_avatar_theme.dart'; export 'theme/components/stream_badge_count_theme.dart'; export 'theme/components/stream_online_indicator_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index 7879930..2a0f943 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -52,6 +52,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? accentWarning, Color? accentError, Color? accentNeutral, + Color? accentBlack, // Text Color? textPrimary, Color? textSecondary, @@ -67,6 +68,14 @@ class StreamColorScheme with _$StreamColorScheme { Color? backgroundSurfaceStrong, Color? backgroundOverlay, Color? backgroundDisabled, + Color? backgroundInverse, + + // Background - Elevation + Color? backgroundElevation0, + Color? backgroundElevation1, + Color? backgroundElevation2, + Color? backgroundElevation3, + Color? backgroundElevation4, // Border - Core Color? borderDefault, Color? borderSurface, @@ -104,6 +113,7 @@ class StreamColorScheme with _$StreamColorScheme { accentWarning ??= StreamColors.yellow.shade500; accentError ??= StreamColors.red.shade500; accentNeutral ??= StreamColors.slate.shade500; + accentBlack ??= light_tokens.StreamTokens.accentBlack; // Text textPrimary ??= StreamColors.slate.shade900; @@ -121,6 +131,13 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong ??= StreamColors.slate.shade200; backgroundOverlay ??= StreamColors.black10; backgroundDisabled ??= StreamColors.slate.shade100; + backgroundInverse ??= light_tokens.StreamTokens.badgeBgInverse; // TODO move to backgroundCoreInverse + + backgroundElevation0 ??= light_tokens.StreamTokens.backgroundElevationElevation0; + backgroundElevation1 ??= light_tokens.StreamTokens.backgroundElevationElevation1; + backgroundElevation2 ??= light_tokens.StreamTokens.backgroundElevationElevation2; + backgroundElevation3 ??= light_tokens.StreamTokens.backgroundElevationElevation3; + backgroundElevation4 ??= light_tokens.StreamTokens.backgroundElevationElevation4; // Border - Core borderDefault ??= light_tokens.StreamTokens.borderCoreDefault; @@ -182,6 +199,7 @@ class StreamColorScheme with _$StreamColorScheme { accentWarning: accentWarning, accentError: accentError, accentNeutral: accentNeutral, + accentBlack: accentBlack, textPrimary: textPrimary, textSecondary: textSecondary, textTertiary: textTertiary, @@ -195,6 +213,12 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong: backgroundSurfaceStrong, backgroundOverlay: backgroundOverlay, backgroundDisabled: backgroundDisabled, + backgroundInverse: backgroundInverse, + backgroundElevation0: backgroundElevation0, + backgroundElevation1: backgroundElevation1, + backgroundElevation2: backgroundElevation2, + backgroundElevation3: backgroundElevation3, + backgroundElevation4: backgroundElevation4, borderDefault: borderDefault, borderSurface: borderSurface, borderSurfaceSubtle: borderSurfaceSubtle, @@ -230,6 +254,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? accentWarning, Color? accentError, Color? accentNeutral, + Color? accentBlack, // Text Color? textPrimary, Color? textSecondary, @@ -245,6 +270,13 @@ class StreamColorScheme with _$StreamColorScheme { Color? backgroundSurfaceStrong, Color? backgroundOverlay, Color? backgroundDisabled, + Color? backgroundInverse, + // Background - Elevation + Color? backgroundElevation0, + Color? backgroundElevation1, + Color? backgroundElevation2, + Color? backgroundElevation3, + Color? backgroundElevation4, // Border - Core Color? borderDefault, Color? borderSurface, @@ -282,6 +314,7 @@ class StreamColorScheme with _$StreamColorScheme { accentWarning ??= StreamColors.yellow.shade400; accentError ??= StreamColors.red.shade400; accentNeutral ??= StreamColors.neutral.shade500; + accentBlack ??= dark_tokens.StreamTokens.accentBlack; // Text textPrimary ??= StreamColors.neutral.shade50; @@ -299,6 +332,13 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong ??= StreamColors.neutral.shade700; backgroundOverlay ??= StreamColors.black50; backgroundDisabled ??= StreamColors.neutral.shade900; + backgroundInverse ??= dark_tokens.StreamTokens.badgeBgInverse; // TODO move to backgroundCoreInverse + + backgroundElevation0 ??= dark_tokens.StreamTokens.backgroundElevationElevation0; + backgroundElevation1 ??= dark_tokens.StreamTokens.backgroundElevationElevation1; + backgroundElevation2 ??= dark_tokens.StreamTokens.backgroundElevationElevation2; + backgroundElevation3 ??= dark_tokens.StreamTokens.backgroundElevationElevation3; + backgroundElevation4 ??= dark_tokens.StreamTokens.backgroundElevationElevation4; // Border - Core borderDefault ??= dark_tokens.StreamTokens.borderCoreDefault; @@ -360,6 +400,7 @@ class StreamColorScheme with _$StreamColorScheme { accentWarning: accentWarning, accentError: accentError, accentNeutral: accentNeutral, + accentBlack: accentBlack, textPrimary: textPrimary, textSecondary: textSecondary, textTertiary: textTertiary, @@ -373,6 +414,12 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong: backgroundSurfaceStrong, backgroundOverlay: backgroundOverlay, backgroundDisabled: backgroundDisabled, + backgroundInverse: backgroundInverse, + backgroundElevation0: backgroundElevation0, + backgroundElevation1: backgroundElevation1, + backgroundElevation2: backgroundElevation2, + backgroundElevation3: backgroundElevation3, + backgroundElevation4: backgroundElevation4, borderDefault: borderDefault, borderSurface: borderSurface, borderSurfaceSubtle: borderSurfaceSubtle, @@ -406,6 +453,7 @@ class StreamColorScheme with _$StreamColorScheme { required this.accentWarning, required this.accentError, required this.accentNeutral, + required this.accentBlack, // Text required this.textPrimary, required this.textSecondary, @@ -421,6 +469,13 @@ class StreamColorScheme with _$StreamColorScheme { required this.backgroundSurfaceStrong, required this.backgroundOverlay, required this.backgroundDisabled, + required this.backgroundInverse, + // Background - Elevation + required this.backgroundElevation0, + required this.backgroundElevation1, + required this.backgroundElevation2, + required this.backgroundElevation3, + required this.backgroundElevation4, // Border - Core required this.borderDefault, required this.borderSurface, @@ -472,6 +527,9 @@ class StreamColorScheme with _$StreamColorScheme { /// The neutral accent color. final Color accentNeutral; + /// The black accent color. + final Color accentBlack; + // ---- Text colors ---- /// The primary text color. @@ -515,6 +573,26 @@ class StreamColorScheme with _$StreamColorScheme { /// Disabled background for inputs, buttons, or chips. final Color backgroundDisabled; + /// The inverse background color. + final Color backgroundInverse; + + // ---- Background - Elevation ---- + + /// The elevation 0 background color. + final Color backgroundElevation0; + + /// The elevation 1 background color. + final Color backgroundElevation1; + + /// The elevation 2 background color. + final Color backgroundElevation2; + + /// The elevation 3 background color. + final Color backgroundElevation3; + + /// The elevation 4 background color. + final Color backgroundElevation4; + // ---- Border colors - Core ---- /// Standard surface border diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart index 926c312..b123474 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart @@ -36,6 +36,7 @@ mixin _$StreamColorScheme { accentWarning: Color.lerp(a.accentWarning, b.accentWarning, t)!, accentError: Color.lerp(a.accentError, b.accentError, t)!, accentNeutral: Color.lerp(a.accentNeutral, b.accentNeutral, t)!, + accentBlack: Color.lerp(a.accentBlack, b.accentBlack, t)!, textPrimary: Color.lerp(a.textPrimary, b.textPrimary, t)!, textSecondary: Color.lerp(a.textSecondary, b.textSecondary, t)!, textTertiary: Color.lerp(a.textTertiary, b.textTertiary, t)!, @@ -69,6 +70,36 @@ mixin _$StreamColorScheme { b.backgroundDisabled, t, )!, + backgroundInverse: Color.lerp( + a.backgroundInverse, + b.backgroundInverse, + t, + )!, + backgroundElevation0: Color.lerp( + a.backgroundElevation0, + b.backgroundElevation0, + t, + )!, + backgroundElevation1: Color.lerp( + a.backgroundElevation1, + b.backgroundElevation1, + t, + )!, + backgroundElevation2: Color.lerp( + a.backgroundElevation2, + b.backgroundElevation2, + t, + )!, + backgroundElevation3: Color.lerp( + a.backgroundElevation3, + b.backgroundElevation3, + t, + )!, + backgroundElevation4: Color.lerp( + a.backgroundElevation4, + b.backgroundElevation4, + t, + )!, borderDefault: Color.lerp(a.borderDefault, b.borderDefault, t)!, borderSurface: Color.lerp(a.borderSurface, b.borderSurface, t)!, borderSurfaceSubtle: Color.lerp( @@ -109,6 +140,7 @@ mixin _$StreamColorScheme { Color? accentWarning, Color? accentError, Color? accentNeutral, + Color? accentBlack, Color? textPrimary, Color? textSecondary, Color? textTertiary, @@ -122,6 +154,12 @@ mixin _$StreamColorScheme { Color? backgroundSurfaceStrong, Color? backgroundOverlay, Color? backgroundDisabled, + Color? backgroundInverse, + Color? backgroundElevation0, + Color? backgroundElevation1, + Color? backgroundElevation2, + Color? backgroundElevation3, + Color? backgroundElevation4, Color? borderDefault, Color? borderSurface, Color? borderSurfaceSubtle, @@ -154,6 +192,7 @@ mixin _$StreamColorScheme { accentWarning: accentWarning ?? _this.accentWarning, accentError: accentError ?? _this.accentError, accentNeutral: accentNeutral ?? _this.accentNeutral, + accentBlack: accentBlack ?? _this.accentBlack, textPrimary: textPrimary ?? _this.textPrimary, textSecondary: textSecondary ?? _this.textSecondary, textTertiary: textTertiary ?? _this.textTertiary, @@ -169,6 +208,12 @@ mixin _$StreamColorScheme { backgroundSurfaceStrong ?? _this.backgroundSurfaceStrong, backgroundOverlay: backgroundOverlay ?? _this.backgroundOverlay, backgroundDisabled: backgroundDisabled ?? _this.backgroundDisabled, + backgroundInverse: backgroundInverse ?? _this.backgroundInverse, + backgroundElevation0: backgroundElevation0 ?? _this.backgroundElevation0, + backgroundElevation1: backgroundElevation1 ?? _this.backgroundElevation1, + backgroundElevation2: backgroundElevation2 ?? _this.backgroundElevation2, + backgroundElevation3: backgroundElevation3 ?? _this.backgroundElevation3, + backgroundElevation4: backgroundElevation4 ?? _this.backgroundElevation4, borderDefault: borderDefault ?? _this.borderDefault, borderSurface: borderSurface ?? _this.borderSurface, borderSurfaceSubtle: borderSurfaceSubtle ?? _this.borderSurfaceSubtle, @@ -212,6 +257,7 @@ mixin _$StreamColorScheme { accentWarning: other.accentWarning, accentError: other.accentError, accentNeutral: other.accentNeutral, + accentBlack: other.accentBlack, textPrimary: other.textPrimary, textSecondary: other.textSecondary, textTertiary: other.textTertiary, @@ -225,6 +271,12 @@ mixin _$StreamColorScheme { backgroundSurfaceStrong: other.backgroundSurfaceStrong, backgroundOverlay: other.backgroundOverlay, backgroundDisabled: other.backgroundDisabled, + backgroundInverse: other.backgroundInverse, + backgroundElevation0: other.backgroundElevation0, + backgroundElevation1: other.backgroundElevation1, + backgroundElevation2: other.backgroundElevation2, + backgroundElevation3: other.backgroundElevation3, + backgroundElevation4: other.backgroundElevation4, borderDefault: other.borderDefault, borderSurface: other.borderSurface, borderSurfaceSubtle: other.borderSurfaceSubtle, @@ -269,6 +321,7 @@ mixin _$StreamColorScheme { _other.accentWarning == _this.accentWarning && _other.accentError == _this.accentError && _other.accentNeutral == _this.accentNeutral && + _other.accentBlack == _this.accentBlack && _other.textPrimary == _this.textPrimary && _other.textSecondary == _this.textSecondary && _other.textTertiary == _this.textTertiary && @@ -282,6 +335,12 @@ mixin _$StreamColorScheme { _other.backgroundSurfaceStrong == _this.backgroundSurfaceStrong && _other.backgroundOverlay == _this.backgroundOverlay && _other.backgroundDisabled == _this.backgroundDisabled && + _other.backgroundInverse == _this.backgroundInverse && + _other.backgroundElevation0 == _this.backgroundElevation0 && + _other.backgroundElevation1 == _this.backgroundElevation1 && + _other.backgroundElevation2 == _this.backgroundElevation2 && + _other.backgroundElevation3 == _this.backgroundElevation3 && + _other.backgroundElevation4 == _this.backgroundElevation4 && _other.borderDefault == _this.borderDefault && _other.borderSurface == _this.borderSurface && _other.borderSurfaceSubtle == _this.borderSurfaceSubtle && @@ -318,6 +377,7 @@ mixin _$StreamColorScheme { _this.accentWarning, _this.accentError, _this.accentNeutral, + _this.accentBlack, _this.textPrimary, _this.textSecondary, _this.textTertiary, @@ -331,6 +391,12 @@ mixin _$StreamColorScheme { _this.backgroundSurfaceStrong, _this.backgroundOverlay, _this.backgroundDisabled, + _this.backgroundInverse, + _this.backgroundElevation0, + _this.backgroundElevation1, + _this.backgroundElevation2, + _this.backgroundElevation3, + _this.backgroundElevation4, _this.borderDefault, _this.borderSurface, _this.borderSurfaceSubtle,