mirror of
https://gitlab.com/veilid/veilidchat.git
synced 2025-05-30 19:44:57 -04:00
431 lines
16 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|