veilidchat/lib/chat/views/chat_builders/vc_composer_widget.dart
2025-05-17 18:02:17 -04:00

431 lines
16 KiB
Dart

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<VcComposerWidget> createState() => _VcComposerState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty<TextEditingController?>(
'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<EdgeInsetsGeometry?>('padding', padding))
..add(DoubleProperty('gap', gap))
..add(DiagnosticsProperty<InputBorder?>('inputBorder', inputBorder))
..add(DiagnosticsProperty<bool?>('filled', filled))
..add(DiagnosticsProperty<bool>('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<Brightness?>('keyboardAppearance', keyboardAppearance))
..add(DiagnosticsProperty<bool?>('autocorrect', autocorrect))
..add(DiagnosticsProperty<bool>('autofocus', autofocus))
..add(EnumProperty<TextCapitalization>(
'textCapitalization', textCapitalization))
..add(DiagnosticsProperty<TextInputType?>('keyboardType', keyboardType))
..add(EnumProperty<TextInputAction>('textInputAction', textInputAction))
..add(
EnumProperty<ShiftEnterAction>('shiftEnterAction', shiftEnterAction))
..add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode))
..add(IntProperty('maxLength', maxLength))
..add(IntProperty('minLines', minLines))
..add(IntProperty('maxLines', maxLines));
}
}
class _VcComposerState extends State<VcComposerWidget> {
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<OnAttachmentTapCallback?>();
final theme = Theme.of(context);
final scaleTheme = theme.extension<ScaleTheme>()!;
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,
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<ComposerHeightNotifier>().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<OnMessageSendCallback?>()?.call(text);
_textController.clear();
}
}
}