refactor: enhance chat input UI with compact composer and improved styling

- Introduced a compact composer mode for the chat input, optimizing space when no quick pills are available.
- Refactored the input shell decoration and layout to improve visual consistency and responsiveness.
- Enhanced the text field and button styling, ensuring better integration with the current theme and improved user experience.
- Streamlined the build method by encapsulating the composer text field logic into a separate method for better readability and maintainability.
This commit is contained in:
cogwheel0
2025-10-02 12:12:29 +05:30
parent a85655d639
commit 2538181f8a

View File

@@ -723,14 +723,21 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} }
} }
Widget shell = AnimatedContainer( final bool showCompactComposer = quickPills.isEmpty;
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic, final BorderRadius shellRadius = BorderRadius.circular(
decoration: BoxDecoration( showCompactComposer ? AppBorderRadius.round : _composerRadius,
color: composerBackground, );
borderRadius: BorderRadius.circular(_composerRadius),
border: Border.all(color: outlineColor, width: BorderWidth.thin), final BoxDecoration shellDecoration = BoxDecoration(
boxShadow: [ color: showCompactComposer ? Colors.transparent : composerBackground,
borderRadius: shellRadius,
border: showCompactComposer
? null
: Border.all(color: outlineColor, width: BorderWidth.thin),
boxShadow: showCompactComposer
? const <BoxShadow>[]
: <BoxShadow>[
BoxShadow( BoxShadow(
color: shellShadowColor, color: shellShadowColor,
blurRadius: 12 + (isActive ? 4 : 0), blurRadius: 12 + (isActive ? 4 : 0),
@@ -738,25 +745,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
offset: const Offset(0, -2), offset: const Offset(0, -2),
), ),
], ],
), );
width: double.infinity,
child: SafeArea( final List<Widget> composerChildren = <Widget>[
top: false,
bottom: true,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
),
child: AnimatedSize(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: RepaintBoundary(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_showPromptOverlay) if (_showPromptOverlay)
Padding( Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
@@ -767,6 +758,80 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
child: _buildPromptOverlay(context), child: _buildPromptOverlay(context),
), ),
if (showCompactComposer)
Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.screenPadding,
Spacing.xs,
Spacing.screenPadding,
Spacing.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildOverflowButton(
tooltip: AppLocalizations.of(context)!.more,
webSearchActive: webSearchEnabled,
imageGenerationActive: imageGenEnabled,
toolsActive: selectedToolIds.isNotEmpty,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
constraints: const BoxConstraints(
minHeight: TouchTarget.input,
),
decoration: BoxDecoration(
color: brightness == Brightness.dark
? composerSurface.withValues(alpha: 0.9)
: context.conduitTheme.surfaceContainer,
borderRadius: BorderRadius.circular(AppBorderRadius.round),
border: Border.all(
color: outlineColor.withValues(
alpha: brightness == Brightness.dark ? 0.32 : 0.2,
),
width: BorderWidth.micro,
),
boxShadow: <BoxShadow>[
BoxShadow(
color: shellShadowColor.withValues(
alpha: brightness == Brightness.dark ? 0.4 : 0.22,
),
blurRadius: 24,
spreadRadius: -6,
offset: const Offset(0, 12),
),
],
),
child: Align(
alignment: Alignment.centerLeft,
child: _buildComposerTextField(
brightness: brightness,
sendOnEnter: sendOnEnter,
placeholderBase: placeholderBase,
placeholderFocused: placeholderFocused,
contentPadding: const EdgeInsets.symmetric(
vertical: Spacing.xs,
),
isActive: isActive,
),
),
),
),
const SizedBox(width: Spacing.sm),
_buildPrimaryButton(
_hasText,
isGenerating,
stopGeneration,
voiceAvailable,
),
],
),
)
else ...[
Padding( Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
Spacing.sm, Spacing.sm,
@@ -789,209 +854,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: GestureDetector( child: _buildComposerTextField(
behavior: HitTestBehavior.opaque, brightness: brightness,
onTap: () { sendOnEnter: sendOnEnter,
if (!widget.enabled) return; placeholderBase: placeholderBase,
_ensureFocusedIfEnabled(); placeholderFocused: placeholderFocused,
}, contentPadding: const EdgeInsets.symmetric(
child: Semantics(
textField: true,
label: AppLocalizations.of(
context,
)!.messageInputLabel,
hint: AppLocalizations.of(
context,
)!.messageInputHint,
child: Shortcuts(
shortcuts: () {
final map = <LogicalKeySet, Intent>{
LogicalKeySet(
LogicalKeyboardKey.meta,
LogicalKeyboardKey.enter,
): const _SendMessageIntent(),
LogicalKeySet(
LogicalKeyboardKey.control,
LogicalKeyboardKey.enter,
): const _SendMessageIntent(),
};
if (sendOnEnter) {
map[LogicalKeySet(
LogicalKeyboardKey.enter,
)] =
const _SendMessageIntent();
map[LogicalKeySet(
LogicalKeyboardKey.shift,
LogicalKeyboardKey.enter,
)] =
const _InsertNewlineIntent();
}
if (_showPromptOverlay) {
map[LogicalKeySet(
LogicalKeyboardKey.arrowDown,
)] =
const _SelectNextPromptIntent();
map[LogicalKeySet(
LogicalKeyboardKey.arrowUp,
)] =
const _SelectPreviousPromptIntent();
map[LogicalKeySet(
LogicalKeyboardKey.escape,
)] =
const _DismissPromptIntent();
}
return map;
}(),
child: Actions(
actions: <Type, Action<Intent>>{
_SendMessageIntent:
CallbackAction<_SendMessageIntent>(
onInvoke: (intent) {
if (_showPromptOverlay) {
_confirmPromptSelection();
return null;
}
_sendMessage();
return null;
},
),
_InsertNewlineIntent:
CallbackAction<
_InsertNewlineIntent
>(
onInvoke: (intent) {
_insertNewline();
return null;
},
),
_SelectNextPromptIntent:
CallbackAction<
_SelectNextPromptIntent
>(
onInvoke: (intent) {
_movePromptSelection(1);
return null;
},
),
_SelectPreviousPromptIntent:
CallbackAction<
_SelectPreviousPromptIntent
>(
onInvoke: (intent) {
_movePromptSelection(-1);
return null;
},
),
_DismissPromptIntent:
CallbackAction<
_DismissPromptIntent
>(
onInvoke: (intent) {
_hidePromptOverlay();
return null;
},
),
},
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0.0,
end: isActive ? 1.0 : 0.0,
),
duration: const Duration(
milliseconds: 180,
),
curve: Curves.easeOutCubic,
builder: (context, factor, child) {
final Color animatedPlaceholder =
Color.lerp(
placeholderBase,
placeholderFocused,
factor,
)!;
final Color animatedTextColor =
Color.lerp(
context.conduitTheme.inputText
.withValues(alpha: 0.88),
context.conduitTheme.inputText,
factor,
)!;
final FontWeight recordingWeight =
_isRecording
? FontWeight.w500
: FontWeight.w400;
final TextStyle baseChatStyle =
AppTypography.chatMessageStyle;
return TextField(
controller: _controller,
focusNode: _focusNode,
enabled: widget.enabled,
autofocus: false,
minLines: 1,
maxLines: null,
keyboardType:
TextInputType.multiline,
textCapitalization:
TextCapitalization.sentences,
textInputAction: sendOnEnter
? TextInputAction.send
: TextInputAction.newline,
autofillHints: const <String>[],
showCursor: true,
scrollPadding:
const EdgeInsets.only(
bottom: 80,
),
keyboardAppearance: brightness,
cursorColor: animatedTextColor,
style: baseChatStyle.copyWith(
color: animatedTextColor,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
fontWeight: recordingWeight,
),
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
)!.messageHintText,
hintStyle: baseChatStyle.copyWith(
color: animatedPlaceholder,
fontWeight: recordingWeight,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
),
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(
horizontal: Spacing.sm, horizontal: Spacing.sm,
vertical: Spacing.xs, vertical: Spacing.xs,
), ),
isDense: true, isActive: isActive,
alignLabelWithHint: true,
),
onSubmitted: (_) {
if (sendOnEnter) {
_sendMessage();
}
},
onTap: () {
if (!widget.enabled) return;
_ensureFocusedIfEnabled();
},
);
},
),
),
),
),
), ),
), ),
], ],
@@ -1015,18 +887,13 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
Expanded( Expanded(
child: quickPills.isEmpty child: ClipRect(
? const SizedBox.shrink()
: ClipRect(
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: _withHorizontalSpacing( children: _withHorizontalSpacing(quickPills, Spacing.xxs),
quickPills,
Spacing.xxs,
),
), ),
), ),
), ),
@@ -1047,6 +914,30 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
), ),
], ],
];
Widget shell = AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
decoration: shellDecoration,
width: double.infinity,
child: SafeArea(
top: false,
bottom: true,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
),
child: AnimatedSize(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: RepaintBoundary(
child: Column(
mainAxisSize: MainAxisSize.min,
children: composerChildren,
), ),
), ),
), ),
@@ -1055,9 +946,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
); );
if (brightness == Brightness.dark) { if (brightness == Brightness.dark && !showCompactComposer) {
shell = ClipRRect( shell = ClipRRect(
borderRadius: BorderRadius.circular(_composerRadius), borderRadius: shellRadius,
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: shell, child: shell,
@@ -1088,6 +979,173 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
return result; return result;
} }
Widget _buildComposerTextField({
required Brightness brightness,
required bool sendOnEnter,
required Color placeholderBase,
required Color placeholderFocused,
required EdgeInsetsGeometry contentPadding,
required bool isActive,
}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!widget.enabled) return;
_ensureFocusedIfEnabled();
},
child: Semantics(
textField: true,
label: AppLocalizations.of(context)!.messageInputLabel,
hint: AppLocalizations.of(context)!.messageInputHint,
child: Shortcuts(
shortcuts: () {
final map = <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter):
const _SendMessageIntent(),
LogicalKeySet(
LogicalKeyboardKey.control,
LogicalKeyboardKey.enter,
): const _SendMessageIntent(),
};
if (sendOnEnter) {
map[LogicalKeySet(LogicalKeyboardKey.enter)] =
const _SendMessageIntent();
map[LogicalKeySet(
LogicalKeyboardKey.shift,
LogicalKeyboardKey.enter,
)] =
const _InsertNewlineIntent();
}
if (_showPromptOverlay) {
map[LogicalKeySet(LogicalKeyboardKey.arrowDown)] =
const _SelectNextPromptIntent();
map[LogicalKeySet(LogicalKeyboardKey.arrowUp)] =
const _SelectPreviousPromptIntent();
map[LogicalKeySet(LogicalKeyboardKey.escape)] =
const _DismissPromptIntent();
}
return map;
}(),
child: Actions(
actions: <Type, Action<Intent>>{
_SendMessageIntent: CallbackAction<_SendMessageIntent>(
onInvoke: (intent) {
if (_showPromptOverlay) {
_confirmPromptSelection();
return null;
}
_sendMessage();
return null;
},
),
_InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(
onInvoke: (intent) {
_insertNewline();
return null;
},
),
_SelectNextPromptIntent: CallbackAction<_SelectNextPromptIntent>(
onInvoke: (intent) {
_movePromptSelection(1);
return null;
},
),
_SelectPreviousPromptIntent:
CallbackAction<_SelectPreviousPromptIntent>(
onInvoke: (intent) {
_movePromptSelection(-1);
return null;
},
),
_DismissPromptIntent: CallbackAction<_DismissPromptIntent>(
onInvoke: (intent) {
_hidePromptOverlay();
return null;
},
),
},
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: isActive ? 1.0 : 0.0),
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
builder: (context, factor, child) {
final Color animatedPlaceholder = Color.lerp(
placeholderBase,
placeholderFocused,
factor,
)!;
final Color animatedTextColor = Color.lerp(
context.conduitTheme.inputText.withValues(alpha: 0.88),
context.conduitTheme.inputText,
factor,
)!;
final FontWeight recordingWeight = _isRecording
? FontWeight.w500
: FontWeight.w400;
final TextStyle baseChatStyle = AppTypography.chatMessageStyle;
return TextField(
controller: _controller,
focusNode: _focusNode,
enabled: widget.enabled,
autofocus: false,
minLines: 1,
maxLines: null,
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
textInputAction: sendOnEnter
? TextInputAction.send
: TextInputAction.newline,
autofillHints: const <String>[],
showCursor: true,
scrollPadding: const EdgeInsets.only(bottom: 80),
keyboardAppearance: brightness,
cursorColor: animatedTextColor,
style: baseChatStyle.copyWith(
color: animatedTextColor,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
fontWeight: recordingWeight,
),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.messageHintText,
hintStyle: baseChatStyle.copyWith(
color: animatedPlaceholder,
fontWeight: recordingWeight,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
),
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
contentPadding: contentPadding,
isDense: true,
alignLabelWithHint: true,
),
onSubmitted: (_) {
if (sendOnEnter) {
_sendMessage();
}
},
onTap: () {
if (!widget.enabled) return;
_ensureFocusedIfEnabled();
},
);
},
),
),
),
),
);
}
Widget _buildOverflowButton({ Widget _buildOverflowButton({
required String tooltip, required String tooltip,
required bool webSearchActive, required bool webSearchActive,