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..e826c76 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,39 @@ 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: 'StreamBaseMessageComposer', + useCases: [ + _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..e74dccc --- /dev/null +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart @@ -0,0 +1,210 @@ +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: StreamBaseMessageComposer, + path: '[Components]/Message Composer', +) +Widget buildStreamMessageComposerPlayground(BuildContext context) { + final textEditingController = TextEditingController(); + + return Center( + child: StreamBaseMessageComposer( + controller: textEditingController, + isFloating: false, + inputTrailing: StreamMessageComposerInputTrailing( + controller: textEditingController, + onSendPressed: () {}, + onMicrophonePressed: () {}, + ), + ), + ); +} + +// ============================================================================= +// Real-world Example +// ============================================================================= + +@widgetbook.UseCase( + name: 'Real-world Example', + type: StreamBaseMessageComposer, + 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', + 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), + ]; + + final textEditingController = TextEditingController(); + + 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 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: StreamBaseMessageComposer( + controller: textEditingController, + isFloating: true, + inputTrailing: StreamMessageComposerInputTrailing( + controller: textEditingController, + onSendPressed: () {}, + onMicrophonePressed: () {}, + ), + ), + ), + ], + ) + : 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 + StreamBaseMessageComposer( + controller: textEditingController, + isFloating: false, + inputTrailing: StreamMessageComposerInputTrailing( + controller: textEditingController, + onSendPressed: () {}, + onMicrophonePressed: () {}, + ), + ), + ], + ), + ); +} + +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.borderSubtle), + ), + 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..5698d92 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.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 574737c..e13aac4 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -3,7 +3,8 @@ export 'components/avatar/stream_avatar.dart' hide DefaultStreamAvatar; export 'components/avatar/stream_avatar_group.dart' hide DefaultStreamAvatarGroup; export 'components/avatar/stream_avatar_stack.dart' hide DefaultStreamAvatarStack; export 'components/badge/stream_badge_count.dart' hide DefaultStreamBadgeCount; +export 'components/badge/stream_online_indicator.dart' hide DefaultStreamOnlineIndicator; export 'components/buttons/stream_button.dart' hide DefaultStreamButton; -export 'components/indicator/stream_online_indicator.dart' hide DefaultStreamOnlineIndicator; +export 'components/message_composer.dart'; export 'factory/stream_component_factory.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/badge/media_badge.dart b/packages/stream_core_flutter/lib/src/components/badge/media_badge.dart new file mode 100644 index 0000000..3fd52cc --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/badge/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/badge/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/badge/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 e30291c..f86393f 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 @@ -32,6 +32,7 @@ class StreamButton extends StatelessWidget { StreamButtonType type = StreamButtonType.solid, StreamButtonSize size = StreamButtonSize.medium, IconData? icon, + bool isFloating = false, }) : props = .new( label: null, onTap: onTap, @@ -40,6 +41,7 @@ class StreamButton extends StatelessWidget { size: size, iconLeft: icon, iconRight: null, + isFloating: isFloating, ); final StreamButtonProps props; @@ -61,6 +63,7 @@ class StreamButtonProps { required this.size, required this.iconLeft, required this.iconRight, + this.isFloating = false, }); final String? label; @@ -70,6 +73,7 @@ class StreamButtonProps { final StreamButtonSize size; final IconData? iconLeft; final IconData? iconRight; + final bool isFloating; } enum StreamButtonStyle { primary, secondary, destructive } @@ -87,6 +91,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) { @@ -112,8 +117,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; @@ -124,6 +133,7 @@ class DefaultStreamButton extends StatelessWidget { }; const iconSize = 20.0; + final isIconButton = props.label == null; return ElevatedButton( onPressed: props.onTap, @@ -131,37 +141,44 @@ class DefaultStreamButton extends StatelessWidget { backgroundColor: backgroundColor, foregroundColor: foregroundColor, minimumSize: WidgetStateProperty.all(Size(minimumSize, minimumSize)), - padding: WidgetStateProperty.all(EdgeInsets.symmetric(horizontal: spacing.md)), + maximumSize: isIconButton ? WidgetStateProperty.all(Size(minimumSize, minimumSize)) : null, + tapTargetSize: MaterialTapTargetSize.padded, + elevation: WidgetStateProperty.all(props.isFloating ? 4 : 0), + padding: WidgetStateProperty.all( + isIconButton ? EdgeInsets.zero : 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( - RoundedRectangleBorder(borderRadius: BorderRadius.all(context.streamRadius.max)), + RoundedRectangleBorder( + borderRadius: BorderRadius.all(context.streamRadius.max), + ), ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - spacing: spacing.xs, - children: [ - if (props.iconLeft case final iconLeft?) Icon(iconLeft, size: iconSize), - if (props.label case final label?) Text(label), - if (props.iconRight case final iconRight?) Icon(iconRight, size: iconSize), - ], - ), + child: isIconButton + ? Icon(props.iconLeft, size: iconSize) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + spacing: spacing.xs, + children: [ + if (props.iconLeft case final iconLeft?) Icon(iconLeft, size: iconSize), + if (props.label case final label?) Text(label), + if (props.iconRight case final iconRight?) Icon(iconRight, size: iconSize), + ], + ), ); } } class _StreamButtonDefaults { - _StreamButtonDefaults({ - required this.context, - }) : _colorScheme = context.streamColorScheme; + _StreamButtonDefaults({required this.context}) : _colorScheme = context.streamColorScheme; final BuildContext context; final StreamColorScheme _colorScheme; 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..f60d578 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/message_composer.dart @@ -0,0 +1,112 @@ +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.focusNode, + this.composerLeading, + this.composerTrailing, + this.inputLeading, + this.inputTrailing, + this.inputHeader, + }); + + final TextEditingController? controller; + final bool isFloating; + final String placeholder; + final FocusNode? focusNode; + + 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, + focusNode: widget.focusNode, + ), + ), + ?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..dcb56ba --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/message_composer_input.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import '../../../stream_core_flutter.dart'; + +class StreamMessageComposerInput extends StatelessWidget { + const StreamMessageComposerInput({ + super.key, + required this.controller, + this.placeholder = '', + this.isFloating = false, + this.inputLeading, + this.inputTrailing, + this.inputHeader, + this.focusNode, + }); + + final TextEditingController controller; + final String placeholder; + final bool isFloating; + final Widget? inputLeading; + final Widget? inputTrailing; + final Widget? inputHeader; + final FocusNode? focusNode; + + @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, + focusNode: focusNode, + ), + ), + ?inputTrailing, + ], + ), + ], + ), + ); + } +} + +class _MessageComposerInputField extends StatelessWidget { + const _MessageComposerInputField({ + required this.controller, + required this.placeholder, + this.focusNode, + }); + + final TextEditingController controller; + final String placeholder; + final FocusNode? focusNode; + + @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, + focusNode: focusNode, + 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..c8caed3 --- /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: widget.onSendPressed, + ); + } + return StreamButton.icon( + key: _messageComposerInputTrailingMicrophoneKey, + icon: context.streamIcons.microphone, + type: StreamButtonType.ghost, + style: StreamButtonStyle.secondary, + size: StreamButtonSize.small, + onTap: widget.onMicrophonePressed, + ); + } +} + +final _messageComposerInputTrailingSendKey = UniqueKey(); +final _messageComposerInputTrailingMicrophoneKey = UniqueKey(); diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 7580b39..d360881 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_button_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 ad3664b..afe899d 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? borderSubtle, @@ -103,6 +112,7 @@ class StreamColorScheme with _$StreamColorScheme { accentWarning ??= light_tokens.StreamTokens.accentWarning; accentError ??= light_tokens.StreamTokens.accentError; accentNeutral ??= light_tokens.StreamTokens.accentNeutral; + accentBlack ??= light_tokens.StreamTokens.accentBlack; // Text textPrimary ??= light_tokens.StreamTokens.textPrimary; @@ -114,12 +124,20 @@ class StreamColorScheme with _$StreamColorScheme { textOnAccent ??= light_tokens.StreamTokens.textOnAccent; // Background + backgroundApp ??= light_tokens.StreamTokens.backgroundCoreApp; backgroundSurface ??= light_tokens.StreamTokens.backgroundCoreSurface; backgroundSurfaceSubtle ??= light_tokens.StreamTokens.backgroundCoreSurfaceSubtle; backgroundSurfaceStrong ??= light_tokens.StreamTokens.backgroundCoreSurfaceStrong; backgroundOverlay ??= light_tokens.StreamTokens.backgroundCoreOverlay; backgroundDisabled ??= light_tokens.StreamTokens.backgroundCoreDisabled; + backgroundInverse ??= light_tokens.StreamTokens.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; @@ -180,6 +198,7 @@ class StreamColorScheme with _$StreamColorScheme { accentWarning: accentWarning, accentError: accentError, accentNeutral: accentNeutral, + accentBlack: accentBlack, textPrimary: textPrimary, textSecondary: textSecondary, textTertiary: textTertiary, @@ -193,6 +212,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, borderOnDark: borderOnDark, borderOnAccent: borderOnAccent, @@ -227,6 +252,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? accentWarning, Color? accentError, Color? accentNeutral, + Color? accentBlack, // Text Color? textPrimary, Color? textSecondary, @@ -242,6 +268,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? borderSubtle, @@ -278,6 +311,7 @@ class StreamColorScheme with _$StreamColorScheme { accentWarning ??= dark_tokens.StreamTokens.accentWarning; accentError ??= dark_tokens.StreamTokens.accentError; accentNeutral ??= dark_tokens.StreamTokens.accentNeutral; + accentBlack ??= dark_tokens.StreamTokens.accentBlack; // Text textPrimary ??= dark_tokens.StreamTokens.textPrimary; @@ -295,6 +329,13 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong ??= dark_tokens.StreamTokens.backgroundCoreSurfaceStrong; backgroundOverlay ??= dark_tokens.StreamTokens.backgroundCoreOverlay; backgroundDisabled ??= dark_tokens.StreamTokens.backgroundCoreDisabled; + backgroundInverse ??= dark_tokens.StreamTokens.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; @@ -355,6 +396,7 @@ class StreamColorScheme with _$StreamColorScheme { accentWarning: accentWarning, accentError: accentError, accentNeutral: accentNeutral, + accentBlack: accentBlack, textPrimary: textPrimary, textSecondary: textSecondary, textTertiary: textTertiary, @@ -368,6 +410,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, borderStrong: borderStrong, borderOpacity10: borderOpacity10, @@ -400,6 +448,7 @@ class StreamColorScheme with _$StreamColorScheme { required this.accentWarning, required this.accentError, required this.accentNeutral, + required this.accentBlack, // Text required this.textPrimary, required this.textSecondary, @@ -415,6 +464,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.borderSubtle, @@ -465,6 +521,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. @@ -508,6 +567,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 93496e9..7ef8ea8 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)!, borderSubtle: Color.lerp(a.borderSubtle, b.borderSubtle, t)!, borderStrong: Color.lerp(a.borderStrong, b.borderStrong, t)!, @@ -100,6 +131,7 @@ mixin _$StreamColorScheme { Color? accentWarning, Color? accentError, Color? accentNeutral, + Color? accentBlack, Color? textPrimary, Color? textSecondary, Color? textTertiary, @@ -113,6 +145,12 @@ mixin _$StreamColorScheme { Color? backgroundSurfaceStrong, Color? backgroundOverlay, Color? backgroundDisabled, + Color? backgroundInverse, + Color? backgroundElevation0, + Color? backgroundElevation1, + Color? backgroundElevation2, + Color? backgroundElevation3, + Color? backgroundElevation4, Color? borderDefault, Color? borderSubtle, Color? borderStrong, @@ -144,6 +182,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, @@ -159,6 +198,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, borderSubtle: borderSubtle ?? _this.borderSubtle, borderStrong: borderStrong ?? _this.borderStrong, @@ -201,6 +246,7 @@ mixin _$StreamColorScheme { accentWarning: other.accentWarning, accentError: other.accentError, accentNeutral: other.accentNeutral, + accentBlack: other.accentBlack, textPrimary: other.textPrimary, textSecondary: other.textSecondary, textTertiary: other.textTertiary, @@ -214,6 +260,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, borderSubtle: other.borderSubtle, borderStrong: other.borderStrong, @@ -257,6 +309,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 && @@ -270,6 +323,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.borderSubtle == _this.borderSubtle && _other.borderStrong == _this.borderStrong && @@ -305,6 +364,7 @@ mixin _$StreamColorScheme { _this.accentWarning, _this.accentError, _this.accentNeutral, + _this.accentBlack, _this.textPrimary, _this.textSecondary, _this.textTertiary, @@ -318,6 +378,12 @@ mixin _$StreamColorScheme { _this.backgroundSurfaceStrong, _this.backgroundOverlay, _this.backgroundDisabled, + _this.backgroundInverse, + _this.backgroundElevation0, + _this.backgroundElevation1, + _this.backgroundElevation2, + _this.backgroundElevation3, + _this.backgroundElevation4, _this.borderDefault, _this.borderSubtle, _this.borderStrong, diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only.png index 4db5f2c..6b68791 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only.png differ