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
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
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(alpha: Alpha.strong);
@@ -82,26 +74,18 @@ class _MicButton extends StatelessWidget {
message: tooltip,
child: Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.round),
side: BorderSide(color: borderColor, width: BorderWidth.regular),
),
shape: const CircleBorder(),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.round),
customBorder: const CircleBorder(),
onTap: onTap == null
? null
: () {
HapticFeedback.selectionClick();
onTap!();
},
child: Container(
width: TouchTarget.comfortable,
height: TouchTarget.comfortable,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(AppBorderRadius.round),
boxShadow: ConduitShadows.button,
),
child: SizedBox(
width: TouchTarget.minimum,
height: TouchTarget.minimum,
child: Center(
child: isRecording
? _WaveformBars(intensity: intensity, color: iconColor)
@@ -379,8 +363,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return;
_controller.text = incoming;
_controller.selection =
TextSelection.collapsed(offset: incoming.length);
_controller.selection = TextSelection.collapsed(
offset: incoming.length,
);
try {
ref.read(prefilledInputTextProvider.notifier).state = null;
} catch (_) {}
@@ -398,9 +383,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final webSearchEnabled = ref.watch(webSearchEnabledProvider);
final imageGenEnabled = ref.watch(imageGenerationEnabledProvider);
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
final selectedQuickPills =
ref.watch(appSettingsProvider.select((s) => s.quickPills));
final sendOnEnter = ref.watch(appSettingsProvider.select((s) => s.sendOnEnter));
final selectedQuickPills = ref.watch(
appSettingsProvider.select((s) => s.quickPills),
);
final sendOnEnter = ref.watch(
appSettingsProvider.select((s) => s.sendOnEnter),
);
final toolsAsync = ref.watch(toolsListProvider);
final List<Tool> availableTools = toolsAsync.maybeWhen<List<Tool>>(
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(
// Transparent wrapper so rounded corners are visible against page background
color: Colors.transparent,
@@ -442,25 +451,21 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
Container(
decoration: BoxDecoration(
color: context.conduitTheme.inputBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.xl),
bottom: Radius.circular(0),
),
borderRadius: BorderRadius.circular(AppBorderRadius.bottomSheet),
border: Border(
top: BorderSide(
color: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
left: BorderSide(
color: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
right: BorderSide(
color: context.conduitTheme.dividerColor,
color: outlineColor.withValues(alpha: 0.5),
width: BorderWidth.regular,
),
),
boxShadow: ConduitShadows.input,
boxShadow: [
BoxShadow(
color: glowColor,
blurRadius: 24,
spreadRadius: -16,
offset: const Offset(0, -4),
),
],
),
width: double.infinity,
child: SafeArea(
@@ -488,48 +493,40 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Collapsed/Expanded top row: text input with left/right buttons in collapsed
// Modern header row inspired by the Gemini surface
Padding(
padding: const EdgeInsets.only(
left: Spacing.inputPadding,
right: Spacing.inputPadding,
top: Spacing.inputPadding,
bottom: Spacing.inputPadding,
padding: const EdgeInsets.fromLTRB(
Spacing.sm,
Spacing.sm,
Spacing.sm,
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(
crossAxisAlignment: CrossAxisAlignment.center,
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(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!widget.enabled) return;
if (!_isExpanded) {
_pendingFocusAfterExpand = true;
_setExpanded(true);
} else {
_ensureFocusedIfEnabled();
}
},
child: Semantics(
textField: true,
label: AppLocalizations.of(
@@ -541,24 +538,43 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
child: Shortcuts(
shortcuts: () {
final map = <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): const _SendMessageIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): const _SendMessageIntent(),
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();
map[LogicalKeySet(
LogicalKeyboardKey.enter,
)] =
const _SendMessageIntent();
map[LogicalKeySet(
LogicalKeyboardKey.shift,
LogicalKeyboardKey.enter,
)] =
const _InsertNewlineIntent();
}
return map;
}(),
child: Actions(
actions: <Type, Action<Intent>>{
_SendMessageIntent: CallbackAction<_SendMessageIntent>(
_SendMessageIntent:
CallbackAction<
_SendMessageIntent
>(
onInvoke: (intent) {
_sendMessage();
return null;
},
),
_InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(
_InsertNewlineIntent:
CallbackAction<
_InsertNewlineIntent
>(
onInvoke: (intent) {
_insertNewline();
return null;
@@ -571,18 +587,26 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
enabled: widget.enabled,
autofocus: false,
maxLines: _isExpanded ? null : 1,
keyboardType: TextInputType.multiline,
keyboardType:
TextInputType.multiline,
textCapitalization:
TextCapitalization.sentences,
textInputAction: sendOnEnter
? TextInputAction.send
: TextInputAction.newline,
showCursor: true,
scrollPadding: const EdgeInsets.only(bottom: 80),
keyboardAppearance: Theme.of(context).brightness,
cursorColor:
context.conduitTheme.inputText,
style: AppTypography.chatMessageStyle
scrollPadding:
const EdgeInsets.only(
bottom: 80,
),
keyboardAppearance: Theme.of(
context,
).brightness,
cursorColor: context
.conduitTheme
.inputText,
style: AppTypography
.chatMessageStyle
.copyWith(
color: _isRecording
? context
@@ -603,10 +627,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
context,
)!.messageHintText,
hintStyle: TextStyle(
color: context
.conduitTheme
.inputPlaceholder,
fontSize: AppTypography.bodyLarge,
color: placeholderColor,
fontSize:
AppTypography.bodyLarge,
fontWeight: _isRecording
? FontWeight.w500
: FontWeight.w400,
@@ -614,39 +637,39 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
? FontStyle.italic
: FontStyle.normal,
),
// Ensure the text field background matches its parent container
// and does not use the global InputDecorationTheme fill
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
disabledBorder:
InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
alignLabelWithHint: true,
),
// Send on Enter when enabled; otherwise keep newline behavior
onSubmitted: (_) {
if (sendOnEnter) _sendMessage();
if (sendOnEnter) {
_sendMessage();
}
},
onTap: () {
if (!widget.enabled) return;
if (!_isExpanded) {
_pendingFocusAfterExpand = true;
_pendingFocusAfterExpand =
true;
_setExpanded(true);
// Fallback in case animation is skipped
WidgetsBinding.instance
.addPostFrameCallback((_) {
.addPostFrameCallback((
_,
) {
if (!mounted) return;
if (_pendingFocusAfterExpand) {
_ensureFocusedIfEnabled();
// Focus alone should bring up IME.
}
});
} else {
_ensureFocusedIfEnabled();
// Focus alone should bring up IME.
}
},
),
@@ -654,27 +677,36 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
),
),
),
),
if (!_isExpanded) ...[
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(
_hasText,
isGenerating,
stopGeneration,
),
],
),
],
],
),
),
),
// Expanded bottom row with additional options
if (_isExpanded) ...[
Container(
padding: const EdgeInsets.only(
left: Spacing.inputPadding,
right: Spacing.inputPadding,
bottom: Spacing.inputPadding,
top: Spacing.xs,
bottom: Spacing.sm,
),
child: FadeTransition(
opacity: _expandController,
@@ -696,64 +728,112 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final double total = constraints.maxWidth;
final bool showImage = imageGenAvailable && showImagePillPref;
final double total =
constraints.maxWidth;
final bool showImage =
imageGenAvailable &&
showImagePillPref;
final bool showWeb = showWebPill;
// Tools button is always shown
final double toolsWidth = TouchTarget.comfortable;
final double gapBeforeTools = Spacing.xs;
final double toolsWidth =
TouchTarget.minimum;
final double gapBeforeTools =
Spacing.xs;
final double availableForPills =
math.max(0.0, total - toolsWidth - gapBeforeTools);
final double availableForPills = math
.max(
0.0,
total -
toolsWidth -
gapBeforeTools,
);
// Compose selected pill entries in order
final List<Map<String, dynamic>> entries = [];
final textStyle = AppTypography.labelStyle;
const double horizontalPadding = Spacing.md * 2;
final List<Map<String, dynamic>>
entries = [];
final textStyle =
AppTypography.labelStyle;
const double horizontalPadding =
Spacing.md * 2;
for (final id in selectedQuickPills) {
if (id == 'web' && showWeb) {
final lbl = AppLocalizations.of(context)!.web;
final lbl = AppLocalizations.of(
context,
)!.web;
final tp = TextPainter(
text: TextSpan(text: lbl, style: textStyle),
text: TextSpan(
text: lbl,
style: textStyle,
),
maxLines: 1,
textDirection: Directionality.of(context),
textDirection:
Directionality.of(context),
)..layout();
entries.add({
'id': id,
'label': lbl,
'width': tp.width + horizontalPadding,
'width':
tp.width +
horizontalPadding,
'widgetBuilder': () => _buildPillButton(
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search,
icon: Platform.isIOS
? CupertinoIcons.search
: Icons.search,
label: lbl,
isActive: webSearchEnabled,
onTap: widget.enabled && !_isRecording
onTap:
widget.enabled &&
!_isRecording
? () {
ref.read(webSearchEnabledProvider.notifier).state = !webSearchEnabled;
ref
.read(
webSearchEnabledProvider
.notifier,
)
.state =
!webSearchEnabled;
}
: null,
),
});
} else if (id == 'image' && showImage) {
final lbl = AppLocalizations.of(context)!.imageGen;
} else if (id == 'image' &&
showImage) {
final lbl = AppLocalizations.of(
context,
)!.imageGen;
final tp = TextPainter(
text: TextSpan(text: lbl, style: textStyle),
text: TextSpan(
text: lbl,
style: textStyle,
),
maxLines: 1,
textDirection: Directionality.of(context),
textDirection:
Directionality.of(context),
)..layout();
entries.add({
'id': id,
'label': lbl,
'width': tp.width + horizontalPadding,
'width':
tp.width +
horizontalPadding,
'widgetBuilder': () => _buildPillButton(
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
icon: Platform.isIOS
? CupertinoIcons.photo
: Icons.image,
label: lbl,
isActive: imageGenEnabled,
onTap: widget.enabled && !_isRecording
onTap:
widget.enabled &&
!_isRecording
? () {
ref
.read(imageGenerationEnabledProvider.notifier)
.state = !imageGenEnabled;
.read(
imageGenerationEnabledProvider
.notifier,
)
.state =
!imageGenEnabled;
}
: null,
),
@@ -762,35 +842,68 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// Tool ID from server
Tool? tool;
for (final t in availableTools) {
if (t.id == id) { tool = t; break; }
if (t.id == id) {
tool = t;
break;
}
}
if (tool != null) {
final lbl = tool.name;
final tp = TextPainter(
text: TextSpan(text: lbl, style: textStyle),
text: TextSpan(
text: lbl,
style: textStyle,
),
maxLines: 1,
textDirection: Directionality.of(context),
textDirection:
Directionality.of(
context,
),
)..layout();
final selectedIds = ref.watch(selectedToolIdsProvider);
final isActive = selectedIds.contains(id);
final selectedIds = ref.watch(
selectedToolIdsProvider,
);
final isActive = selectedIds
.contains(id);
entries.add({
'id': id,
'label': lbl,
'width': tp.width + horizontalPadding,
'width':
tp.width +
horizontalPadding,
'widgetBuilder': () => _buildPillButton(
icon: Icons.extension,
icon: Platform.isIOS
? CupertinoIcons.wrench
: Icons.build,
label: lbl,
isActive: isActive,
onTap: widget.enabled && !_isRecording
onTap:
widget.enabled &&
!_isRecording
? () {
final current = List<String>.from(
ref.read(selectedToolIdsProvider));
if (current.contains(id)) {
current.remove(id);
final current =
List<
String
>.from(
ref.read(
selectedToolIdsProvider,
),
);
if (current
.contains(id)) {
current.remove(
id,
);
} else {
current.add(id);
}
ref.read(selectedToolIdsProvider.notifier).state = current;
ref
.read(
selectedToolIdsProvider
.notifier,
)
.state =
current;
}
: null,
),
@@ -805,12 +918,18 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// no quick pills, will just show tools later
} else if (entries.length == 1) {
final e = entries.first;
final pill = e['widgetBuilder']() as Widget;
final pill =
e['widgetBuilder']() as Widget;
final w = (e['width'] as double);
if (w <= availableForPills) {
rowChildren.add(pill);
} else {
rowChildren.add(Flexible(fit: FlexFit.loose, child: pill));
rowChildren.add(
Flexible(
fit: FlexFit.loose,
child: pill,
),
);
}
} else {
// 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 w1 = (e1['width'] as double);
final w2 = (e2['width'] as double);
const double gapBetweenPills = Spacing.xs;
final combined = w1 + gapBetweenPills + w2;
final pill1 = e1['widgetBuilder']() as Widget;
final pill2 = e2['widgetBuilder']() as Widget;
const double gapBetweenPills =
Spacing.xs;
final combined =
w1 + gapBetweenPills + w2;
final pill1 =
e1['widgetBuilder']() as Widget;
final pill2 =
e2['widgetBuilder']() as Widget;
if (combined <= availableForPills) {
rowChildren
..add(pill1)
..add(const SizedBox(width: Spacing.xs))
..add(
const SizedBox(
width: Spacing.xs,
),
)
..add(pill2);
} else if (w1 < availableForPills) {
rowChildren
..add(pill1)
..add(const SizedBox(width: Spacing.xs))
..add(Flexible(fit: FlexFit.loose, child: pill2));
..add(
const SizedBox(
width: Spacing.xs,
),
)
..add(
Flexible(
fit: FlexFit.loose,
child: pill2,
),
);
} else if (w2 < availableForPills) {
rowChildren
..add(Flexible(fit: FlexFit.loose, child: pill1))
..add(const SizedBox(width: Spacing.xs))
..add(
Flexible(
fit: FlexFit.loose,
child: pill1,
),
)
..add(
const SizedBox(
width: Spacing.xs,
),
)
..add(pill2);
} else {
final int f1 = math.max(1, w1.round());
final int f2 = math.max(1, w2.round());
final int f1 = math.max(
1,
w1.round(),
);
final int f2 = math.max(
1,
w2.round(),
);
rowChildren
..add(Flexible(fit: FlexFit.loose, flex: f1, child: pill1))
..add(const SizedBox(width: Spacing.xs))
..add(Flexible(fit: FlexFit.loose, flex: f2, child: pill2));
..add(
Flexible(
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)
rowChildren
..add(const SizedBox(width: Spacing.xs))
..add(_buildRoundButton(
icon: Icons.more_horiz,
onTap: widget.enabled && !_isRecording
rowChildren..add(
_buildIconButton(
icon: Platform.isIOS
? CupertinoIcons.wrench
: Icons.build,
onTap:
widget.enabled &&
!_isRecording
? _showUnifiedToolsModal
: null,
tooltip: AppLocalizations.of(context)!.tools,
isActive: ref.watch(selectedToolIdsProvider).isNotEmpty ||
tooltip: AppLocalizations.of(
context,
)!.tools,
isActive:
ref
.watch(
selectedToolIdsProvider,
)
.isNotEmpty ||
webSearchEnabled ||
imageGenEnabled,
));
),
);
return Row(children: rowChildren);
},
),
),
const SizedBox(width: Spacing.xs),
// Mic + Send cluster pinned to the right
const SizedBox(width: Spacing.sm),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Microphone button: inline voice input toggle with animated intensity ring
Builder(
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,
),
),
],
),
);
},
),
if (voiceAvailable) ...[
_buildVoiceButton(voiceAvailable),
const SizedBox(width: Spacing.xs),
// Primary action button (Send/Stop) when expanded
],
_buildPrimaryButton(
_hasText,
isGenerating,
@@ -975,7 +1090,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
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(
bool hasText,
bool isGenerating,
void Function() stopGeneration,
) {
// Spec: 48px touch target, circular radius, md icon size
const double buttonSize = TouchTarget.comfortable; // 48.0
// Compact 44px touch target, circular radius, md icon size
const double buttonSize = TouchTarget.minimum; // 44.0
const double radius = AppBorderRadius.round; // big to ensure circle
final enabled = !isGenerating && hasText && widget.enabled;
@@ -1147,8 +1338,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
onTap();
},
child: Container(
width: TouchTarget.comfortable,
height: TouchTarget.comfortable,
width: TouchTarget.minimum,
height: TouchTarget.minimum,
decoration: BoxDecoration(
color: isActive
? context.conduitTheme.buttonPrimary
@@ -1227,7 +1418,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final double finalWidth = math.min(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(
width: finalWidth,