feat: conditional show of mic/send
This commit is contained in:
@@ -52,89 +52,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<ModernChatInput> createState() => _ModernChatInputState();
|
ConsumerState<ModernChatInput> createState() => _ModernChatInputState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MicButton extends StatelessWidget {
|
// (Removed legacy _MicButton; inline mic logic now lives in primary button)
|
||||||
final bool isRecording;
|
|
||||||
final int intensity; // 0..10
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
final String tooltip;
|
|
||||||
|
|
||||||
const _MicButton({
|
|
||||||
required this.isRecording,
|
|
||||||
required this.intensity,
|
|
||||||
required this.onTap,
|
|
||||||
required this.tooltip,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final Color iconColor = isRecording
|
|
||||||
? context.conduitTheme.buttonPrimaryText
|
|
||||||
: context.conduitTheme.textPrimary.withValues(alpha: Alpha.strong);
|
|
||||||
final double normalized = (intensity.clamp(0, 10)) / 10.0;
|
|
||||||
|
|
||||||
return Tooltip(
|
|
||||||
message: tooltip,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
child: InkWell(
|
|
||||||
customBorder: const CircleBorder(),
|
|
||||||
onTap: onTap == null
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
onTap!();
|
|
||||||
},
|
|
||||||
child: SizedBox(
|
|
||||||
width: TouchTarget.minimum,
|
|
||||||
height: TouchTarget.minimum,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 180),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
width: TouchTarget.minimum * 0.74,
|
|
||||||
height: TouchTarget.minimum * 0.74,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: isRecording
|
|
||||||
? context.conduitTheme.buttonPrimary.withValues(
|
|
||||||
alpha: 0.12 + (0.14 * normalized),
|
|
||||||
)
|
|
||||||
: Colors.transparent,
|
|
||||||
boxShadow: isRecording
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: context.conduitTheme.buttonPrimary
|
|
||||||
.withValues(
|
|
||||||
alpha: 0.22 + (0.18 * normalized),
|
|
||||||
),
|
|
||||||
blurRadius: 14 + (8 * normalized),
|
|
||||||
spreadRadius: 2 + (normalized * 2),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: const [],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AnimatedScale(
|
|
||||||
scale: isRecording ? 1.05 + (normalized * 0.05) : 1.0,
|
|
||||||
duration: const Duration(milliseconds: 180),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
child: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic,
|
|
||||||
size: IconSize.medium,
|
|
||||||
color: iconColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
@@ -147,9 +65,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
bool _hasText = false; // track locally without rebuilding on each keystroke
|
bool _hasText = false; // track locally without rebuilding on each keystroke
|
||||||
StreamSubscription<String>? _voiceStreamSubscription;
|
StreamSubscription<String>? _voiceStreamSubscription;
|
||||||
late VoiceInputService _voiceService;
|
late VoiceInputService _voiceService;
|
||||||
StreamSubscription<int>? _intensitySub;
|
StreamSubscription<int>?
|
||||||
|
_intensitySub; // removed usage; will be cleaned fully
|
||||||
StreamSubscription<String>? _textSub;
|
StreamSubscription<String>? _textSub;
|
||||||
int _intensity = 0; // 0..10 from service
|
|
||||||
String _baseTextAtStart = '';
|
String _baseTextAtStart = '';
|
||||||
bool _isDeactivated = false;
|
bool _isDeactivated = false;
|
||||||
int _lastHandledFocusTick = 0;
|
int _lastHandledFocusTick = 0;
|
||||||
@@ -650,10 +568,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
Spacing.inputPadding,
|
Spacing.inputPadding - Spacing.xs,
|
||||||
|
0,
|
||||||
|
Spacing.lg + Spacing.xs,
|
||||||
0,
|
0,
|
||||||
Spacing.inputPadding,
|
|
||||||
Spacing.sm,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -685,14 +603,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (voiceAvailable) ...[
|
|
||||||
_buildVoiceButton(voiceAvailable),
|
|
||||||
const SizedBox(width: Spacing.xs),
|
|
||||||
],
|
|
||||||
_buildPrimaryButton(
|
_buildPrimaryButton(
|
||||||
_hasText,
|
_hasText,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
|
voiceAvailable,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -725,54 +640,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildVoiceButton(bool voiceAvailable) {
|
// (Removed legacy _buildVoiceButton; mic functionality moved to primary button)
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> _withHorizontalSpacing(List<Widget> children, double gap) {
|
List<Widget> _withHorizontalSpacing(List<Widget> children, double gap) {
|
||||||
if (children.length <= 1) {
|
if (children.length <= 1) {
|
||||||
@@ -850,6 +718,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
bool hasText,
|
bool hasText,
|
||||||
bool isGenerating,
|
bool isGenerating,
|
||||||
void Function() stopGeneration,
|
void Function() stopGeneration,
|
||||||
|
bool voiceAvailable,
|
||||||
) {
|
) {
|
||||||
// Compact 44px touch target, circular radius, md icon size
|
// Compact 44px touch target, circular radius, md icon size
|
||||||
const double buttonSize = TouchTarget.minimum; // 44.0
|
const double buttonSize = TouchTarget.minimum; // 44.0
|
||||||
@@ -912,52 +781,117 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default SEND variant
|
// If there's text, render SEND variant; otherwise render MIC variant
|
||||||
|
if (hasText) {
|
||||||
|
return Tooltip(
|
||||||
|
message: enabled
|
||||||
|
? AppLocalizations.of(context)!.sendMessage
|
||||||
|
: AppLocalizations.of(context)!.send,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: enabled ? Alpha.primary : Alpha.disabled,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !enabled,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
side: BorderSide(
|
||||||
|
color: enabled
|
||||||
|
? context.conduitTheme.buttonPrimary
|
||||||
|
: context.conduitTheme.cardBorder.withValues(
|
||||||
|
alpha: Alpha.medium,
|
||||||
|
),
|
||||||
|
width: BorderWidth.regular,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
onTap: enabled
|
||||||
|
? () {
|
||||||
|
PlatformUtils.lightHaptic();
|
||||||
|
_sendMessage();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Container(
|
||||||
|
width: buttonSize,
|
||||||
|
height: buttonSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: enabled
|
||||||
|
? context.conduitTheme.buttonPrimary
|
||||||
|
: context.conduitTheme.cardBackground,
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
boxShadow: ConduitShadows.button,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.arrow_up
|
||||||
|
: Icons.arrow_upward,
|
||||||
|
size: IconSize.medium,
|
||||||
|
color: enabled
|
||||||
|
? context.conduitTheme.buttonPrimaryText
|
||||||
|
: context.conduitTheme.textPrimary.withValues(
|
||||||
|
alpha: Alpha.disabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIC variant when no text is present
|
||||||
|
final bool enabledMic = widget.enabled && voiceAvailable;
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: enabled
|
message: AppLocalizations.of(context)!.voiceInput,
|
||||||
? AppLocalizations.of(context)!.sendMessage
|
|
||||||
: AppLocalizations.of(context)!.send,
|
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: enabled ? Alpha.primary : Alpha.disabled,
|
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !enabled,
|
ignoring: !enabledMic,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: enabled
|
color: _isRecording
|
||||||
? context.conduitTheme.cardBorder
|
? context.conduitTheme.buttonPrimary
|
||||||
: context.conduitTheme.cardBorder.withValues(
|
: context.conduitTheme.cardBorder.withValues(
|
||||||
alpha: Alpha.medium,
|
alpha: enabledMic ? Alpha.strong : Alpha.medium,
|
||||||
),
|
),
|
||||||
width: BorderWidth.regular,
|
width: BorderWidth.regular,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
onTap: enabled
|
onTap: enabledMic
|
||||||
? () {
|
? () {
|
||||||
PlatformUtils.lightHaptic();
|
HapticFeedback.selectionClick();
|
||||||
_sendMessage();
|
_toggleVoice();
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: buttonSize,
|
width: buttonSize,
|
||||||
height: buttonSize,
|
height: buttonSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.conduitTheme.cardBackground,
|
color: _isRecording
|
||||||
|
? context.conduitTheme.buttonPrimary.withValues(
|
||||||
|
alpha: Alpha.buttonPressed,
|
||||||
|
)
|
||||||
|
: context.conduitTheme.cardBackground,
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
boxShadow: ConduitShadows.button,
|
boxShadow: ConduitShadows.button,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Platform.isIOS ? CupertinoIcons.arrow_up : Icons.arrow_upward,
|
Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic,
|
||||||
size: IconSize.medium,
|
size: IconSize.medium,
|
||||||
color: enabled
|
color: _isRecording
|
||||||
? context.conduitTheme.textPrimary
|
? context.conduitTheme.buttonPrimary
|
||||||
: context.conduitTheme.textPrimary.withValues(
|
: (enabledMic
|
||||||
alpha: Alpha.disabled,
|
? context.conduitTheme.textPrimary
|
||||||
),
|
: context.conduitTheme.textPrimary.withValues(
|
||||||
|
alpha: Alpha.disabled,
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1781,10 +1715,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
_baseTextAtStart = _controller.text;
|
_baseTextAtStart = _controller.text;
|
||||||
});
|
});
|
||||||
_intensitySub?.cancel();
|
_intensitySub?.cancel();
|
||||||
_intensitySub = _voiceService.intensityStream.listen((value) {
|
// intensity stream no longer used for UI; stop listening
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _intensity = value);
|
|
||||||
});
|
|
||||||
_textSub?.cancel();
|
_textSub?.cancel();
|
||||||
_textSub = stream.listen(
|
_textSub = stream.listen(
|
||||||
(text) async {
|
(text) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user