import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; // Typedefs need to come out // ignore: implementation_imports import 'package:flutter_chat_ui/src/utils/typedefs.dart'; import 'package:provider/provider.dart'; import '../../../theme/theme.dart'; import '../../chat.dart'; enum ShiftEnterAction { newline, send } /// The message composer widget positioned at the bottom of the chat screen. /// /// Includes a text input field, an optional attachment button, /// and a send button. class VcComposerWidget extends StatefulWidget { /// Creates a message composer widget. const VcComposerWidget({ super.key, this.textEditingController, this.left = 0, this.right = 0, this.top, this.bottom = 0, this.sigmaX = 20, this.sigmaY = 20, this.padding = const EdgeInsets.all(8), this.attachmentIcon = const Icon(Icons.attachment), this.sendIcon = const Icon(Icons.send), this.gap = 8, this.inputBorder, this.filled, this.topWidget, this.handleSafeArea = true, this.backgroundColor, this.attachmentIconColor, this.sendIconColor, this.hintColor, this.textColor, this.inputFillColor, this.hintText = 'Type a message', this.keyboardAppearance, this.autocorrect, this.autofocus = false, this.textCapitalization = TextCapitalization.sentences, this.keyboardType, this.textInputAction = TextInputAction.newline, this.shiftEnterAction = ShiftEnterAction.send, this.focusNode, this.maxLength, this.minLines = 1, this.maxLines = 3, }); /// Optional controller for the text input field. final TextEditingController? textEditingController; /// Optional left position. final double? left; /// Optional right position. final double? right; /// Optional top position. final double? top; /// Optional bottom position. final double? bottom; /// Optional X blur value for the background (if using glassmorphism). final double? sigmaX; /// Optional Y blur value for the background (if using glassmorphism). final double? sigmaY; /// Padding around the composer content. final EdgeInsetsGeometry? padding; /// Icon for the attachment button. Defaults to [Icons.attachment]. final Widget? attachmentIcon; /// Icon for the send button. Defaults to [Icons.send]. final Widget? sendIcon; /// Horizontal gap between elements (attachment icon, text field, send icon). final double? gap; /// Border style for the text input field. final InputBorder? inputBorder; /// Whether the text input field should be filled. final bool? filled; /// Optional widget to display above the main composer row. final Widget? topWidget; /// Whether to adjust padding for the bottom safe area. final bool handleSafeArea; /// Background color of the composer container. final Color? backgroundColor; /// Color of the attachment icon. final Color? attachmentIconColor; /// Color of the send icon. final Color? sendIconColor; /// Color of the hint text in the input field. final Color? hintColor; /// Color of the text entered in the input field. final Color? textColor; /// Fill color for the text input field when [filled] is true. final Color? inputFillColor; /// Placeholder text for the input field. final String? hintText; /// Appearance of the keyboard. final Brightness? keyboardAppearance; /// Whether to enable autocorrect for the input field. final bool? autocorrect; /// Whether the input field should autofocus. final bool autofocus; /// Capitalization behavior for the input field. final TextCapitalization textCapitalization; /// Type of keyboard to display. final TextInputType? keyboardType; /// Action button type for the keyboard (e.g., newline, send). final TextInputAction textInputAction; /// Action when shift-enter is pressed (e.g., newline, send). final ShiftEnterAction shiftEnterAction; /// Focus node for the text input field. final FocusNode? focusNode; /// Maximum character length for the input field. final int? maxLength; /// Minimum number of lines for the input field. final int? minLines; /// Maximum number of lines the input field can expand to. final int? maxLines; @override State createState() => _VcComposerState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties ..add(DiagnosticsProperty( 'textEditingController', textEditingController)) ..add(DoubleProperty('left', left)) ..add(DoubleProperty('right', right)) ..add(DoubleProperty('top', top)) ..add(DoubleProperty('bottom', bottom)) ..add(DoubleProperty('sigmaX', sigmaX)) ..add(DoubleProperty('sigmaY', sigmaY)) ..add(DiagnosticsProperty('padding', padding)) ..add(DoubleProperty('gap', gap)) ..add(DiagnosticsProperty('inputBorder', inputBorder)) ..add(DiagnosticsProperty('filled', filled)) ..add(DiagnosticsProperty('handleSafeArea', handleSafeArea)) ..add(ColorProperty('backgroundColor', backgroundColor)) ..add(ColorProperty('attachmentIconColor', attachmentIconColor)) ..add(ColorProperty('sendIconColor', sendIconColor)) ..add(ColorProperty('hintColor', hintColor)) ..add(ColorProperty('textColor', textColor)) ..add(ColorProperty('inputFillColor', inputFillColor)) ..add(StringProperty('hintText', hintText)) ..add(EnumProperty('keyboardAppearance', keyboardAppearance)) ..add(DiagnosticsProperty('autocorrect', autocorrect)) ..add(DiagnosticsProperty('autofocus', autofocus)) ..add(EnumProperty( 'textCapitalization', textCapitalization)) ..add(DiagnosticsProperty('keyboardType', keyboardType)) ..add(EnumProperty('textInputAction', textInputAction)) ..add( EnumProperty('shiftEnterAction', shiftEnterAction)) ..add(DiagnosticsProperty('focusNode', focusNode)) ..add(IntProperty('maxLength', maxLength)) ..add(IntProperty('minLines', minLines)) ..add(IntProperty('maxLines', maxLines)); } } class _VcComposerState extends State { final _key = GlobalKey(); late final TextEditingController _textController; late final FocusNode _focusNode; late String _suffixText; @override void initState() { super.initState(); _textController = widget.textEditingController ?? TextEditingController(); _focusNode = widget.focusNode ?? FocusNode(); _focusNode.onKeyEvent = _handleKeyEvent; _updateSuffixText(); WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); } void _updateSuffixText() { final utf8Length = utf8.encode(_textController.text).length; _suffixText = '$utf8Length/${widget.maxLength}'; } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { // Check for Shift+Enter if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter && HardwareKeyboard.instance.isShiftPressed) { if (widget.shiftEnterAction == ShiftEnterAction.send) { _handleSubmitted(_textController.text); return KeyEventResult.handled; } else if (widget.shiftEnterAction == ShiftEnterAction.newline) { final val = _textController.value; final insertOffset = val.selection.extent.offset; final messageWithNewLine = '${_textController.text.substring(0, insertOffset)}\n' '${_textController.text.substring(insertOffset)}'; _textController.value = TextEditingValue( text: messageWithNewLine, selection: TextSelection.fromPosition( TextPosition(offset: insertOffset + 1), ), ); return KeyEventResult.handled; } } return KeyEventResult.ignored; } @override void didUpdateWidget(covariant VcComposerWidget oldWidget) { super.didUpdateWidget(oldWidget); WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); } @override void dispose() { // Only try to dispose text controller if it's not provided, let // user handle disposing it how they want. if (widget.textEditingController == null) { _textController.dispose(); } if (widget.focusNode == null) { _focusNode.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final bottomSafeArea = widget.handleSafeArea ? MediaQuery.of(context).padding.bottom : 0.0; final onAttachmentTap = context.read(); final theme = Theme.of(context); final scaleTheme = theme.extension()!; final config = scaleTheme.config; final scheme = scaleTheme.scheme; final scale = scaleTheme.scheme.scale(ScaleKind.primary); final textTheme = theme.textTheme; final scaleChatTheme = scaleTheme.chatTheme(); final chatTheme = scaleChatTheme.chatTheme; final suffixTextStyle = textTheme.bodySmall!.copyWith(color: scale.subtleText); return Positioned( left: widget.left, right: widget.right, top: widget.top, bottom: widget.bottom, child: ClipRect( child: DecoratedBox( key: _key, decoration: BoxDecoration( border: config.preferBorders ? Border(top: BorderSide(color: scale.border, width: 2)) : null, color: config.preferBorders ? scale.elementBackground : scale.border), child: Column( children: [ if (widget.topWidget != null) widget.topWidget!, Padding( padding: widget.handleSafeArea ? (widget.padding?.add( EdgeInsets.only(bottom: bottomSafeArea), ) ?? EdgeInsets.only(bottom: bottomSafeArea)) : (widget.padding ?? EdgeInsets.zero), child: Row( children: [ if (widget.attachmentIcon != null && onAttachmentTap != null) IconButton( icon: widget.attachmentIcon!, color: widget.attachmentIconColor ?? chatTheme.colors.onSurface.withValues(alpha: 0.5), onPressed: onAttachmentTap, ) else const SizedBox.shrink(), SizedBox(width: widget.gap), Expanded( child: TextField( controller: _textController, decoration: InputDecoration( filled: widget.filled ?? !config.preferBorders, fillColor: widget.inputFillColor ?? scheme.primaryScale.subtleBackground, isDense: true, contentPadding: const EdgeInsets.fromLTRB(8, 8, 8, 8), disabledBorder: OutlineInputBorder( borderSide: config.preferBorders ? BorderSide( color: scheme.grayScale.border, width: 2) : BorderSide.none, borderRadius: BorderRadius.all(Radius.circular( 8 * config.borderRadiusScale))), enabledBorder: OutlineInputBorder( borderSide: config.preferBorders ? BorderSide( color: scheme.primaryScale.border, width: 2) : BorderSide.none, borderRadius: BorderRadius.all(Radius.circular( 8 * config.borderRadiusScale))), focusedBorder: OutlineInputBorder( borderSide: config.preferBorders ? BorderSide( color: scheme.primaryScale.border, width: 2) : BorderSide.none, borderRadius: BorderRadius.all(Radius.circular( 8 * config.borderRadiusScale))), hintText: widget.hintText, hintMaxLines: 1, hintStyle: chatTheme.typography.bodyMedium.copyWith( color: widget.hintColor ?? chatTheme.colors.onSurface .withValues(alpha: 0.5), ), border: widget.inputBorder, hoverColor: Colors.transparent, suffix: Text(_suffixText, style: suffixTextStyle)), onSubmitted: _handleSubmitted, onChanged: (value) { setState(_updateSuffixText); }, textInputAction: widget.textInputAction, keyboardAppearance: widget.keyboardAppearance, autocorrect: widget.autocorrect ?? true, autofocus: widget.autofocus, textCapitalization: widget.textCapitalization, keyboardType: widget.keyboardType, focusNode: _focusNode, //maxLength: widget.maxLength, minLines: widget.minLines, maxLines: widget.maxLines, maxLengthEnforcement: MaxLengthEnforcement.none, inputFormatters: [ Utf8LengthLimitingTextInputFormatter( maxLength: widget.maxLength), ], ), ), SizedBox(width: widget.gap), if ((widget.sendIcon ?? scaleChatTheme.sendButtonIcon) != null) IconButton( icon: (widget.sendIcon ?? scaleChatTheme.sendButtonIcon)!, color: widget.sendIconColor, onPressed: () => _handleSubmitted(_textController.text), ) else const SizedBox.shrink(), ], ), ), ], ), ), ), ); } void _measure() { if (!mounted) { return; } final renderBox = _key.currentContext?.findRenderObject() as RenderBox?; if (renderBox != null) { final height = renderBox.size.height; final bottomSafeArea = MediaQuery.of(context).padding.bottom; context.read().setHeight( // only set real height of the composer, ignoring safe area widget.handleSafeArea ? height - bottomSafeArea : height, ); } } void _handleSubmitted(String text) { if (text.isNotEmpty) { context.read()?.call(text); _textController.clear(); } } }