refactor: chat input bar to make it more compact

This commit is contained in:
cogwheel0
2025-09-16 15:48:09 +05:30
parent 3457fd6478
commit b8195871ff

View File

@@ -66,14 +66,6 @@ class _MicButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color borderColor = isRecording
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBorder;
final Color bgColor = isRecording
? context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.buttonHover,
)
: context.conduitTheme.cardBackground;
final Color iconColor = isRecording final Color iconColor = isRecording
? context.conduitTheme.buttonPrimaryText ? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(alpha: Alpha.strong); : context.conduitTheme.textPrimary.withValues(alpha: Alpha.strong);
@@ -82,26 +74,18 @@ class _MicButton extends StatelessWidget {
message: tooltip, message: tooltip,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
shape: RoundedRectangleBorder( shape: const CircleBorder(),
borderRadius: BorderRadius.circular(AppBorderRadius.round),
side: BorderSide(color: borderColor, width: BorderWidth.regular),
),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.round), customBorder: const CircleBorder(),
onTap: onTap == null onTap: onTap == null
? null ? null
: () { : () {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
onTap!(); onTap!();
}, },
child: Container( child: SizedBox(
width: TouchTarget.comfortable, width: TouchTarget.minimum,
height: TouchTarget.comfortable, height: TouchTarget.minimum,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(AppBorderRadius.round),
boxShadow: ConduitShadows.button,
),
child: Center( child: Center(
child: isRecording child: isRecording
? _WaveformBars(intensity: intensity, color: iconColor) ? _WaveformBars(intensity: intensity, color: iconColor)
@@ -379,8 +363,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
_controller.text = incoming; _controller.text = incoming;
_controller.selection = _controller.selection = TextSelection.collapsed(
TextSelection.collapsed(offset: incoming.length); offset: incoming.length,
);
try { try {
ref.read(prefilledInputTextProvider.notifier).state = null; ref.read(prefilledInputTextProvider.notifier).state = null;
} catch (_) {} } catch (_) {}
@@ -398,9 +383,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final webSearchEnabled = ref.watch(webSearchEnabledProvider); final webSearchEnabled = ref.watch(webSearchEnabledProvider);
final imageGenEnabled = ref.watch(imageGenerationEnabledProvider); final imageGenEnabled = ref.watch(imageGenerationEnabledProvider);
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider); final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
final selectedQuickPills = final selectedQuickPills = ref.watch(
ref.watch(appSettingsProvider.select((s) => s.quickPills)); appSettingsProvider.select((s) => s.quickPills),
final sendOnEnter = ref.watch(appSettingsProvider.select((s) => s.sendOnEnter)); );
final sendOnEnter = ref.watch(
appSettingsProvider.select((s) => s.sendOnEnter),
);
final toolsAsync = ref.watch(toolsListProvider); final toolsAsync = ref.watch(toolsListProvider);
final List<Tool> availableTools = toolsAsync.maybeWhen<List<Tool>>( final List<Tool> availableTools = toolsAsync.maybeWhen<List<Tool>>(
data: (t) => t, data: (t) => t,
@@ -426,6 +414,27 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}); });
} }
final bool showPlaceholder =
!_hasText && !_focusNode.hasFocus && !_isRecording;
final Brightness brightness = Theme.of(context).brightness;
final Color outlineColor = (_focusNode.hasFocus || _hasText)
? context.conduitTheme.inputBorderFocused.withValues(alpha: 0.6)
: context.conduitTheme.inputBorder.withValues(alpha: 0.7);
final Color glowColor = context.conduitTheme.inputBackground.withValues(
alpha: brightness == Brightness.dark ? 0.2 : 0.12,
);
final Color composerSurface = context.conduitTheme.inputBackground;
final Color placeholderColor = context.conduitTheme.inputPlaceholder;
final Color badgeBackground = showPlaceholder
? placeholderColor.withValues(alpha: 0.12)
: composerSurface.withValues(alpha: 0.3);
final Color badgeBorder = showPlaceholder
? Colors.transparent
: outlineColor.withValues(alpha: 0.35);
final Color badgeIconColor = showPlaceholder
? placeholderColor
: context.conduitTheme.textPrimary.withValues(alpha: 0.75);
return Container( return Container(
// Transparent wrapper so rounded corners are visible against page background // Transparent wrapper so rounded corners are visible against page background
color: Colors.transparent, color: Colors.transparent,
@@ -442,25 +451,21 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.inputBackground, color: context.conduitTheme.inputBackground,
borderRadius: const BorderRadius.vertical( borderRadius: BorderRadius.circular(AppBorderRadius.bottomSheet),
top: Radius.circular(AppBorderRadius.xl),
bottom: Radius.circular(0),
),
border: Border( border: Border(
top: BorderSide( top: BorderSide(
color: context.conduitTheme.dividerColor, color: outlineColor.withValues(alpha: 0.5),
width: BorderWidth.regular,
),
left: BorderSide(
color: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
right: BorderSide(
color: context.conduitTheme.dividerColor,
width: BorderWidth.regular, width: BorderWidth.regular,
), ),
), ),
boxShadow: ConduitShadows.input, boxShadow: [
BoxShadow(
color: glowColor,
blurRadius: 24,
spreadRadius: -16,
offset: const Offset(0, -4),
),
],
), ),
width: double.infinity, width: double.infinity,
child: SafeArea( child: SafeArea(
@@ -488,48 +493,40 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Collapsed/Expanded top row: text input with left/right buttons in collapsed // Modern header row inspired by the Gemini surface
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.fromLTRB(
left: Spacing.inputPadding, Spacing.sm,
right: Spacing.inputPadding, Spacing.sm,
top: Spacing.inputPadding, Spacing.sm,
bottom: Spacing.inputPadding, Spacing.sm,
),
child: Container(
decoration: BoxDecoration(
color: composerSurface,
borderRadius: BorderRadius.circular(
AppBorderRadius.large,
),
),
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
), ),
child: GestureDetector(
// Defer taps to the TextField so it can gain focus immediately.
// This prevents the first tap from being consumed by the wrapper,
// which previously opened the keyboard without focusing the field.
behavior: HitTestBehavior.deferToChild,
onTap: () {
if (!_isExpanded && widget.enabled) {
_pendingFocusAfterExpand = true;
_setExpanded(true);
// Defer focus until AnimatedSize finishes changing layout
// to avoid IME/client race conditions.
}
},
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
if (!_isExpanded) ...[
_buildRoundButton(
icon: Icons.add,
onTap: widget.enabled
? _showAttachmentOptions
: null,
tooltip: AppLocalizations.of(
context,
)!.addAttachment,
showBackground: false,
iconSize: IconSize.large + 2.0,
),
const SizedBox(width: Spacing.xs),
] else ...[
SizedBox(width: Spacing.xs),
],
// Text input expands to fill
Expanded( Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!widget.enabled) return;
if (!_isExpanded) {
_pendingFocusAfterExpand = true;
_setExpanded(true);
} else {
_ensureFocusedIfEnabled();
}
},
child: Semantics( child: Semantics(
textField: true, textField: true,
label: AppLocalizations.of( label: AppLocalizations.of(
@@ -541,24 +538,43 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
child: Shortcuts( child: Shortcuts(
shortcuts: () { shortcuts: () {
final map = <LogicalKeySet, Intent>{ final map = <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): const _SendMessageIntent(), LogicalKeySet(
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): const _SendMessageIntent(), LogicalKeyboardKey.meta,
LogicalKeyboardKey.enter,
): const _SendMessageIntent(),
LogicalKeySet(
LogicalKeyboardKey.control,
LogicalKeyboardKey.enter,
): const _SendMessageIntent(),
}; };
if (sendOnEnter) { if (sendOnEnter) {
map[LogicalKeySet(LogicalKeyboardKey.enter)] = const _SendMessageIntent(); map[LogicalKeySet(
map[LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.enter)] = const _InsertNewlineIntent(); LogicalKeyboardKey.enter,
)] =
const _SendMessageIntent();
map[LogicalKeySet(
LogicalKeyboardKey.shift,
LogicalKeyboardKey.enter,
)] =
const _InsertNewlineIntent();
} }
return map; return map;
}(), }(),
child: Actions( child: Actions(
actions: <Type, Action<Intent>>{ actions: <Type, Action<Intent>>{
_SendMessageIntent: CallbackAction<_SendMessageIntent>( _SendMessageIntent:
CallbackAction<
_SendMessageIntent
>(
onInvoke: (intent) { onInvoke: (intent) {
_sendMessage(); _sendMessage();
return null; return null;
}, },
), ),
_InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>( _InsertNewlineIntent:
CallbackAction<
_InsertNewlineIntent
>(
onInvoke: (intent) { onInvoke: (intent) {
_insertNewline(); _insertNewline();
return null; return null;
@@ -571,18 +587,26 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
enabled: widget.enabled, enabled: widget.enabled,
autofocus: false, autofocus: false,
maxLines: _isExpanded ? null : 1, maxLines: _isExpanded ? null : 1,
keyboardType: TextInputType.multiline, keyboardType:
TextInputType.multiline,
textCapitalization: textCapitalization:
TextCapitalization.sentences, TextCapitalization.sentences,
textInputAction: sendOnEnter textInputAction: sendOnEnter
? TextInputAction.send ? TextInputAction.send
: TextInputAction.newline, : TextInputAction.newline,
showCursor: true, showCursor: true,
scrollPadding: const EdgeInsets.only(bottom: 80), scrollPadding:
keyboardAppearance: Theme.of(context).brightness, const EdgeInsets.only(
cursorColor: bottom: 80,
context.conduitTheme.inputText, ),
style: AppTypography.chatMessageStyle keyboardAppearance: Theme.of(
context,
).brightness,
cursorColor: context
.conduitTheme
.inputText,
style: AppTypography
.chatMessageStyle
.copyWith( .copyWith(
color: _isRecording color: _isRecording
? context ? context
@@ -603,10 +627,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
context, context,
)!.messageHintText, )!.messageHintText,
hintStyle: TextStyle( hintStyle: TextStyle(
color: context color: placeholderColor,
.conduitTheme fontSize:
.inputPlaceholder, AppTypography.bodyLarge,
fontSize: AppTypography.bodyLarge,
fontWeight: _isRecording fontWeight: _isRecording
? FontWeight.w500 ? FontWeight.w500
: FontWeight.w400, : FontWeight.w400,
@@ -614,39 +637,39 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
? FontStyle.italic ? FontStyle.italic
: FontStyle.normal, : FontStyle.normal,
), ),
// Ensure the text field background matches its parent container
// and does not use the global InputDecorationTheme fill
filled: false, filled: false,
border: InputBorder.none, border: InputBorder.none,
enabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none, focusedBorder: InputBorder.none,
errorBorder: InputBorder.none, errorBorder: InputBorder.none,
disabledBorder: InputBorder.none, disabledBorder:
InputBorder.none,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
isDense: true, isDense: true,
alignLabelWithHint: true, alignLabelWithHint: true,
), ),
// Send on Enter when enabled; otherwise keep newline behavior
onSubmitted: (_) { onSubmitted: (_) {
if (sendOnEnter) _sendMessage(); if (sendOnEnter) {
_sendMessage();
}
}, },
onTap: () { onTap: () {
if (!widget.enabled) return; if (!widget.enabled) return;
if (!_isExpanded) { if (!_isExpanded) {
_pendingFocusAfterExpand = true; _pendingFocusAfterExpand =
true;
_setExpanded(true); _setExpanded(true);
// Fallback in case animation is skipped
WidgetsBinding.instance WidgetsBinding.instance
.addPostFrameCallback((_) { .addPostFrameCallback((
_,
) {
if (!mounted) return; if (!mounted) return;
if (_pendingFocusAfterExpand) { if (_pendingFocusAfterExpand) {
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
// Focus alone should bring up IME.
} }
}); });
} else { } else {
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
// Focus alone should bring up IME.
} }
}, },
), ),
@@ -654,27 +677,36 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
), ),
), ),
),
if (!_isExpanded) ...[ if (!_isExpanded) ...[
const SizedBox(width: Spacing.sm), const SizedBox(width: Spacing.sm),
// Primary action button (Send/Stop) when collapsed Row(
mainAxisSize: MainAxisSize.min,
children: [
if (voiceAvailable) ...[
_buildVoiceButton(voiceAvailable),
const SizedBox(width: Spacing.xs),
],
_buildPrimaryButton( _buildPrimaryButton(
_hasText, _hasText,
isGenerating, isGenerating,
stopGeneration, stopGeneration,
), ),
], ],
),
],
], ],
), ),
), ),
), ),
// Expanded bottom row with additional options // Expanded bottom row with additional options
if (_isExpanded) ...[ if (_isExpanded) ...[
Container( Container(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: Spacing.inputPadding, left: Spacing.inputPadding,
right: Spacing.inputPadding, right: Spacing.inputPadding,
bottom: Spacing.inputPadding, top: Spacing.xs,
bottom: Spacing.sm,
), ),
child: FadeTransition( child: FadeTransition(
opacity: _expandController, opacity: _expandController,
@@ -696,64 +728,112 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final double total = constraints.maxWidth; final double total =
final bool showImage = imageGenAvailable && showImagePillPref; constraints.maxWidth;
final bool showImage =
imageGenAvailable &&
showImagePillPref;
final bool showWeb = showWebPill; final bool showWeb = showWebPill;
// Tools button is always shown // Tools button is always shown
final double toolsWidth = TouchTarget.comfortable; final double toolsWidth =
final double gapBeforeTools = Spacing.xs; TouchTarget.minimum;
final double gapBeforeTools =
Spacing.xs;
final double availableForPills = final double availableForPills = math
math.max(0.0, total - toolsWidth - gapBeforeTools); .max(
0.0,
total -
toolsWidth -
gapBeforeTools,
);
// Compose selected pill entries in order // Compose selected pill entries in order
final List<Map<String, dynamic>> entries = []; final List<Map<String, dynamic>>
final textStyle = AppTypography.labelStyle; entries = [];
const double horizontalPadding = Spacing.md * 2; final textStyle =
AppTypography.labelStyle;
const double horizontalPadding =
Spacing.md * 2;
for (final id in selectedQuickPills) { for (final id in selectedQuickPills) {
if (id == 'web' && showWeb) { if (id == 'web' && showWeb) {
final lbl = AppLocalizations.of(context)!.web; final lbl = AppLocalizations.of(
context,
)!.web;
final tp = TextPainter( final tp = TextPainter(
text: TextSpan(text: lbl, style: textStyle), text: TextSpan(
text: lbl,
style: textStyle,
),
maxLines: 1, maxLines: 1,
textDirection: Directionality.of(context), textDirection:
Directionality.of(context),
)..layout(); )..layout();
entries.add({ entries.add({
'id': id, 'id': id,
'label': lbl, 'label': lbl,
'width': tp.width + horizontalPadding, 'width':
tp.width +
horizontalPadding,
'widgetBuilder': () => _buildPillButton( 'widgetBuilder': () => _buildPillButton(
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search, icon: Platform.isIOS
? CupertinoIcons.search
: Icons.search,
label: lbl, label: lbl,
isActive: webSearchEnabled, isActive: webSearchEnabled,
onTap: widget.enabled && !_isRecording onTap:
widget.enabled &&
!_isRecording
? () { ? () {
ref.read(webSearchEnabledProvider.notifier).state = !webSearchEnabled; ref
.read(
webSearchEnabledProvider
.notifier,
)
.state =
!webSearchEnabled;
} }
: null, : null,
), ),
}); });
} else if (id == 'image' && showImage) { } else if (id == 'image' &&
final lbl = AppLocalizations.of(context)!.imageGen; showImage) {
final lbl = AppLocalizations.of(
context,
)!.imageGen;
final tp = TextPainter( final tp = TextPainter(
text: TextSpan(text: lbl, style: textStyle), text: TextSpan(
text: lbl,
style: textStyle,
),
maxLines: 1, maxLines: 1,
textDirection: Directionality.of(context), textDirection:
Directionality.of(context),
)..layout(); )..layout();
entries.add({ entries.add({
'id': id, 'id': id,
'label': lbl, 'label': lbl,
'width': tp.width + horizontalPadding, 'width':
tp.width +
horizontalPadding,
'widgetBuilder': () => _buildPillButton( 'widgetBuilder': () => _buildPillButton(
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, icon: Platform.isIOS
? CupertinoIcons.photo
: Icons.image,
label: lbl, label: lbl,
isActive: imageGenEnabled, isActive: imageGenEnabled,
onTap: widget.enabled && !_isRecording onTap:
widget.enabled &&
!_isRecording
? () { ? () {
ref ref
.read(imageGenerationEnabledProvider.notifier) .read(
.state = !imageGenEnabled; imageGenerationEnabledProvider
.notifier,
)
.state =
!imageGenEnabled;
} }
: null, : null,
), ),
@@ -762,35 +842,68 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// Tool ID from server // Tool ID from server
Tool? tool; Tool? tool;
for (final t in availableTools) { for (final t in availableTools) {
if (t.id == id) { tool = t; break; } if (t.id == id) {
tool = t;
break;
}
} }
if (tool != null) { if (tool != null) {
final lbl = tool.name; final lbl = tool.name;
final tp = TextPainter( final tp = TextPainter(
text: TextSpan(text: lbl, style: textStyle), text: TextSpan(
text: lbl,
style: textStyle,
),
maxLines: 1, maxLines: 1,
textDirection: Directionality.of(context), textDirection:
Directionality.of(
context,
),
)..layout(); )..layout();
final selectedIds = ref.watch(selectedToolIdsProvider); final selectedIds = ref.watch(
final isActive = selectedIds.contains(id); selectedToolIdsProvider,
);
final isActive = selectedIds
.contains(id);
entries.add({ entries.add({
'id': id, 'id': id,
'label': lbl, 'label': lbl,
'width': tp.width + horizontalPadding, 'width':
tp.width +
horizontalPadding,
'widgetBuilder': () => _buildPillButton( 'widgetBuilder': () => _buildPillButton(
icon: Icons.extension, icon: Platform.isIOS
? CupertinoIcons.wrench
: Icons.build,
label: lbl, label: lbl,
isActive: isActive, isActive: isActive,
onTap: widget.enabled && !_isRecording onTap:
widget.enabled &&
!_isRecording
? () { ? () {
final current = List<String>.from( final current =
ref.read(selectedToolIdsProvider)); List<
if (current.contains(id)) { String
current.remove(id); >.from(
ref.read(
selectedToolIdsProvider,
),
);
if (current
.contains(id)) {
current.remove(
id,
);
} else { } else {
current.add(id); current.add(id);
} }
ref.read(selectedToolIdsProvider.notifier).state = current; ref
.read(
selectedToolIdsProvider
.notifier,
)
.state =
current;
} }
: null, : null,
), ),
@@ -805,12 +918,18 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// no quick pills, will just show tools later // no quick pills, will just show tools later
} else if (entries.length == 1) { } else if (entries.length == 1) {
final e = entries.first; final e = entries.first;
final pill = e['widgetBuilder']() as Widget; final pill =
e['widgetBuilder']() as Widget;
final w = (e['width'] as double); final w = (e['width'] as double);
if (w <= availableForPills) { if (w <= availableForPills) {
rowChildren.add(pill); rowChildren.add(pill);
} else { } else {
rowChildren.add(Flexible(fit: FlexFit.loose, child: pill)); rowChildren.add(
Flexible(
fit: FlexFit.loose,
child: pill,
),
);
} }
} else { } else {
// up to 2 based on settings enforcement; if more, take first 2 // up to 2 based on settings enforcement; if more, take first 2
@@ -818,126 +937,122 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final e2 = entries[1]; final e2 = entries[1];
final w1 = (e1['width'] as double); final w1 = (e1['width'] as double);
final w2 = (e2['width'] as double); final w2 = (e2['width'] as double);
const double gapBetweenPills = Spacing.xs; const double gapBetweenPills =
final combined = w1 + gapBetweenPills + w2; Spacing.xs;
final pill1 = e1['widgetBuilder']() as Widget; final combined =
final pill2 = e2['widgetBuilder']() as Widget; w1 + gapBetweenPills + w2;
final pill1 =
e1['widgetBuilder']() as Widget;
final pill2 =
e2['widgetBuilder']() as Widget;
if (combined <= availableForPills) { if (combined <= availableForPills) {
rowChildren rowChildren
..add(pill1) ..add(pill1)
..add(const SizedBox(width: Spacing.xs)) ..add(
const SizedBox(
width: Spacing.xs,
),
)
..add(pill2); ..add(pill2);
} else if (w1 < availableForPills) { } else if (w1 < availableForPills) {
rowChildren rowChildren
..add(pill1) ..add(pill1)
..add(const SizedBox(width: Spacing.xs)) ..add(
..add(Flexible(fit: FlexFit.loose, child: pill2)); const SizedBox(
width: Spacing.xs,
),
)
..add(
Flexible(
fit: FlexFit.loose,
child: pill2,
),
);
} else if (w2 < availableForPills) { } else if (w2 < availableForPills) {
rowChildren rowChildren
..add(Flexible(fit: FlexFit.loose, child: pill1)) ..add(
..add(const SizedBox(width: Spacing.xs)) Flexible(
fit: FlexFit.loose,
child: pill1,
),
)
..add(
const SizedBox(
width: Spacing.xs,
),
)
..add(pill2); ..add(pill2);
} else { } else {
final int f1 = math.max(1, w1.round()); final int f1 = math.max(
final int f2 = math.max(1, w2.round()); 1,
w1.round(),
);
final int f2 = math.max(
1,
w2.round(),
);
rowChildren rowChildren
..add(Flexible(fit: FlexFit.loose, flex: f1, child: pill1)) ..add(
..add(const SizedBox(width: Spacing.xs)) Flexible(
..add(Flexible(fit: FlexFit.loose, flex: f2, child: pill2)); fit: FlexFit.loose,
flex: f1,
child: pill1,
),
)
..add(
const SizedBox(
width: Spacing.xs,
),
)
..add(
Flexible(
fit: FlexFit.loose,
flex: f2,
child: pill2,
),
);
} }
} }
// Append tools button at the end (always visible) // Append tools button at the end (always visible)
rowChildren
..add(const SizedBox(width: Spacing.xs)) rowChildren..add(
..add(_buildRoundButton( _buildIconButton(
icon: Icons.more_horiz, icon: Platform.isIOS
onTap: widget.enabled && !_isRecording ? CupertinoIcons.wrench
: Icons.build,
onTap:
widget.enabled &&
!_isRecording
? _showUnifiedToolsModal ? _showUnifiedToolsModal
: null, : null,
tooltip: AppLocalizations.of(context)!.tools, tooltip: AppLocalizations.of(
isActive: ref.watch(selectedToolIdsProvider).isNotEmpty || context,
)!.tools,
isActive:
ref
.watch(
selectedToolIdsProvider,
)
.isNotEmpty ||
webSearchEnabled || webSearchEnabled ||
imageGenEnabled, imageGenEnabled,
)); ),
);
return Row(children: rowChildren); return Row(children: rowChildren);
}, },
), ),
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.sm),
// Mic + Send cluster pinned to the right
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Microphone button: inline voice input toggle with animated intensity ring if (voiceAvailable) ...[
Builder( _buildVoiceButton(voiceAvailable),
builder: (context) {
const double buttonSize =
TouchTarget.comfortable;
final double t = _isRecording
? (_intensity.clamp(0, 10) /
10.0)
: 0.0;
final double ringMaxExtra = 16.0;
final double ringSize =
buttonSize + (ringMaxExtra * t);
final double ringOpacity =
0.15 + (0.35 * t);
return SizedBox(
width: buttonSize,
height: buttonSize,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedContainer(
duration: const Duration(
milliseconds: 120,
),
width: ringSize,
height: ringSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context
.conduitTheme
.buttonPrimary
.withValues(
alpha: ringOpacity,
),
),
),
Transform.scale(
scale: _isRecording
? 1.0 +
(_intensity.clamp(
0,
10,
) /
200)
: 1.0,
child: _MicButton(
isRecording: _isRecording,
intensity: _intensity,
onTap:
(widget.enabled &&
voiceAvailable)
? _toggleVoice
: null,
tooltip:
AppLocalizations.of(
context,
)!.voiceInput,
),
),
],
),
);
},
),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
// Primary action button (Send/Stop) when expanded ],
_buildPrimaryButton( _buildPrimaryButton(
_hasText, _hasText,
isGenerating, isGenerating,
@@ -975,7 +1090,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
tooltip: 'Test On-Device STT', tooltip: 'Test On-Device STT',
), ),
], ],
// removed duplicate send button; now only in right cluster
], ],
), ),
), ),
@@ -994,13 +1108,90 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
); );
} }
Widget _buildVoiceButton(bool voiceAvailable) {
if (!voiceAvailable) {
return const SizedBox.shrink();
}
return Builder(
builder: (context) {
const double buttonSize = TouchTarget.minimum;
final double t = _isRecording ? (_intensity.clamp(0, 10) / 10.0) : 0.0;
final double ringMaxExtra = 16.0;
final double ringSize = buttonSize + (ringMaxExtra * t);
final double ringOpacity = _isRecording ? 0.15 + (0.35 * t) : 0.0;
return SizedBox(
width: buttonSize,
height: buttonSize,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: ringSize,
height: ringSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.conduitTheme.buttonPrimary.withValues(
alpha: ringOpacity,
),
),
),
Transform.scale(
scale: _isRecording
? 1.0 + (_intensity.clamp(0, 10) / 200)
: 1.0,
child: _MicButton(
isRecording: _isRecording,
intensity: _intensity,
onTap: (widget.enabled && voiceAvailable)
? _toggleVoice
: null,
tooltip: AppLocalizations.of(context)!.voiceInput,
),
),
],
),
);
},
);
}
Widget _buildIconButton({
required IconData icon,
required VoidCallback? onTap,
required String tooltip,
bool isActive = false,
}) {
final Color iconColor = widget.enabled
? (isActive
? context.conduitTheme.buttonPrimary
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
))
: context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled);
return Tooltip(
message: tooltip,
child: IconButton(
onPressed: onTap,
padding: const EdgeInsets.all(Spacing.xs),
constraints: const BoxConstraints(
minWidth: TouchTarget.minimum,
minHeight: TouchTarget.minimum,
),
splashRadius: TouchTarget.minimum / 2,
icon: Icon(icon, color: iconColor, size: IconSize.medium),
),
);
}
Widget _buildPrimaryButton( Widget _buildPrimaryButton(
bool hasText, bool hasText,
bool isGenerating, bool isGenerating,
void Function() stopGeneration, void Function() stopGeneration,
) { ) {
// Spec: 48px touch target, circular radius, md icon size // Compact 44px touch target, circular radius, md icon size
const double buttonSize = TouchTarget.comfortable; // 48.0 const double buttonSize = TouchTarget.minimum; // 44.0
const double radius = AppBorderRadius.round; // big to ensure circle const double radius = AppBorderRadius.round; // big to ensure circle
final enabled = !isGenerating && hasText && widget.enabled; final enabled = !isGenerating && hasText && widget.enabled;
@@ -1147,8 +1338,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
onTap(); onTap();
}, },
child: Container( child: Container(
width: TouchTarget.comfortable, width: TouchTarget.minimum,
height: TouchTarget.comfortable, height: TouchTarget.minimum,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive color: isActive
? context.conduitTheme.buttonPrimary ? context.conduitTheme.buttonPrimary
@@ -1227,7 +1418,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final double finalWidth = math.min(naturalWidth, maxAllowed); final double finalWidth = math.min(naturalWidth, maxAllowed);
final bool needsClamp = naturalWidth > maxAllowed; final bool needsClamp = naturalWidth > maxAllowed;
final double innerTextWidth = math.max(0.0, finalWidth - horizontalPadding); final double innerTextWidth = math.max(
0.0,
finalWidth - horizontalPadding,
);
return Container( return Container(
width: finalWidth, width: finalWidth,