Files
iiEsaywebUIapp/lib/features/chat/widgets/modern_chat_input.dart

2415 lines
81 KiB
Dart
Raw Normal View History

2025-08-10 01:20:45 +05:30
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
2025-08-21 23:56:47 +05:30
import 'package:flutter/services.dart';
2025-08-10 01:20:45 +05:30
import '../../../shared/theme/theme_extensions.dart';
// app_theme not required here; using theme extension tokens
2025-08-22 01:24:04 +05:30
import '../../../shared/widgets/sheet_handle.dart';
2025-08-10 01:20:45 +05:30
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:io' show Platform;
2025-08-10 01:20:45 +05:30
import 'dart:async';
2025-09-19 11:58:22 +05:30
import 'dart:ui';
2025-09-19 21:12:15 +05:30
import 'dart:math' as math;
2025-08-10 01:20:45 +05:30
import '../providers/chat_providers.dart';
2025-08-19 20:26:19 +05:30
import '../../tools/providers/tools_providers.dart';
2025-09-20 23:22:57 +05:30
import '../../prompts/providers/prompts_providers.dart';
2025-09-07 14:40:20 +05:30
import '../../../core/models/tool.dart';
2025-09-20 23:22:57 +05:30
import '../../../core/models/prompt.dart';
2025-08-24 14:35:17 +05:30
import '../../../core/providers/app_providers.dart';
2025-09-07 14:40:20 +05:30
import '../../../core/services/settings_service.dart';
2025-08-25 10:35:48 +05:30
import '../../chat/services/voice_input_service.dart';
2025-08-10 01:20:45 +05:30
import '../../../shared/utils/platform_utils.dart';
import 'package:conduit/l10n/app_localizations.dart';
2025-09-19 21:12:15 +05:30
import '../../../shared/widgets/modal_safe_area.dart';
2025-08-10 01:20:45 +05:30
class _SendMessageIntent extends Intent {
const _SendMessageIntent();
}
class _InsertNewlineIntent extends Intent {
const _InsertNewlineIntent();
}
2025-09-20 23:22:57 +05:30
class _SelectNextPromptIntent extends Intent {
const _SelectNextPromptIntent();
}
class _SelectPreviousPromptIntent extends Intent {
const _SelectPreviousPromptIntent();
}
class _DismissPromptIntent extends Intent {
const _DismissPromptIntent();
}
class _PromptCommandMatch {
const _PromptCommandMatch({
required this.command,
required this.start,
required this.end,
});
final String command;
final int start;
final int end;
}
2025-08-10 01:20:45 +05:30
class ModernChatInput extends ConsumerStatefulWidget {
final Function(String) onSendMessage;
final bool enabled;
final Function()? onVoiceInput;
final Function()? onVoiceCall;
2025-08-10 01:20:45 +05:30
final Function()? onFileAttachment;
final Function()? onImageAttachment;
final Function()? onCameraCapture;
const ModernChatInput({
super.key,
required this.onSendMessage,
this.enabled = true,
this.onVoiceInput,
this.onVoiceCall,
2025-08-10 01:20:45 +05:30
this.onFileAttachment,
this.onImageAttachment,
this.onCameraCapture,
});
@override
ConsumerState<ModernChatInput> createState() => _ModernChatInputState();
}
2025-09-20 19:54:00 +05:30
// (Removed legacy _MicButton; inline mic logic now lives in primary button)
2025-08-10 01:20:45 +05:30
class _ModernChatInputState extends ConsumerState<ModernChatInput>
with TickerProviderStateMixin {
2025-09-19 11:58:22 +05:30
static const double _composerRadius = AppBorderRadius.card;
2025-08-10 01:20:45 +05:30
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _pendingFocus = false;
2025-08-25 10:35:48 +05:30
bool _isRecording = false;
2025-08-10 01:20:45 +05:30
// final String _voiceInputText = '';
bool _hasText = false; // track locally without rebuilding on each keystroke
StreamSubscription<String>? _voiceStreamSubscription;
2025-08-25 10:35:48 +05:30
late VoiceInputService _voiceService;
2025-09-20 19:54:00 +05:30
StreamSubscription<int>?
_intensitySub; // removed usage; will be cleaned fully
2025-08-25 10:35:48 +05:30
StreamSubscription<String>? _textSub;
String _baseTextAtStart = '';
2025-08-28 18:54:06 +05:30
bool _isDeactivated = false;
2025-08-28 18:59:59 +05:30
int _lastHandledFocusTick = 0;
2025-09-20 23:22:57 +05:30
bool _showPromptOverlay = false;
String _currentPromptCommand = '';
TextRange? _currentPromptRange;
int _promptSelectionIndex = 0;
2025-08-10 01:20:45 +05:30
@override
void initState() {
super.initState();
2025-08-25 10:35:48 +05:30
_voiceService = ref.read(voiceInputServiceProvider);
2025-08-10 01:20:45 +05:30
2025-09-18 15:01:21 +05:30
// Apply any prefilled text on first frame (focus handled via inputFocusTrigger)
WidgetsBinding.instance.addPostFrameCallback((_) {
2025-08-28 18:54:06 +05:30
if (!mounted || _isDeactivated) return;
final text = ref.read(prefilledInputTextProvider);
if (text != null && text.isNotEmpty) {
_controller.text = text;
_controller.selection = TextSelection.collapsed(offset: text.length);
// Clear after applying so it doesn't re-apply on rebuilds
2025-09-21 22:31:44 +05:30
ref.read(prefilledInputTextProvider.notifier).clear();
}
});
2025-08-28 18:54:06 +05:30
// Removed ref.listen here; it must be used from build in this Riverpod version
2025-09-20 23:22:57 +05:30
// Listen for text and selection changes in the composer
_controller.addListener(_handleComposerChanged);
2025-08-10 01:20:45 +05:30
2025-09-18 15:01:21 +05:30
// Publish focus changes to listeners
2025-08-10 01:20:45 +05:30
_focusNode.addListener(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
2025-08-28 18:54:06 +05:30
if (!mounted || _isDeactivated) return;
2025-08-10 01:20:45 +05:30
final hasFocus = _focusNode.hasFocus;
2025-09-08 01:15:31 +05:30
// Publish composer focus state
try {
2025-09-21 22:31:44 +05:30
ref.read(composerHasFocusProvider.notifier).set(hasFocus);
2025-09-08 01:15:31 +05:30
} catch (_) {}
2025-08-10 01:20:45 +05:30
});
});
// Do not auto-focus on mount; only focus on explicit user intent
2025-08-10 01:20:45 +05:30
}
@override
void dispose() {
// Note: Avoid using ref in dispose as per Riverpod best practices
// The focus state will be naturally cleared when the widget is disposed
2025-09-20 23:22:57 +05:30
_controller.removeListener(_handleComposerChanged);
2025-08-10 01:20:45 +05:30
_controller.dispose();
_focusNode.dispose();
_pendingFocus = false;
2025-08-10 01:20:45 +05:30
_voiceStreamSubscription?.cancel();
2025-08-25 10:35:48 +05:30
_intensitySub?.cancel();
_textSub?.cancel();
_voiceService.stopListening();
2025-08-10 01:20:45 +05:30
super.dispose();
}
void _ensureFocusedIfEnabled() {
// Respect global suppression flag to avoid re-opening keyboard
final autofocusEnabled = ref.read(composerAutofocusEnabledProvider);
if (!widget.enabled ||
_focusNode.hasFocus ||
_pendingFocus ||
!autofocusEnabled) {
return;
2025-08-10 01:20:45 +05:30
}
_pendingFocus = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_pendingFocus = false;
if (widget.enabled && !_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
2025-08-10 01:20:45 +05:30
}
2025-08-28 18:54:06 +05:30
@override
void deactivate() {
_isDeactivated = true;
super.deactivate();
}
@override
void activate() {
super.activate();
_isDeactivated = false;
}
2025-08-10 01:20:45 +05:30
@override
void didUpdateWidget(covariant ModernChatInput oldWidget) {
super.didUpdateWidget(oldWidget);
// Avoid auto-focusing when becoming enabled; wait for user intent
2025-08-10 01:20:45 +05:30
if (!widget.enabled && oldWidget.enabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
2025-08-28 18:54:06 +05:30
if (!mounted || _isDeactivated) return;
2025-08-10 01:20:45 +05:30
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
});
}
}
void _sendMessage() {
final text = _controller.text.trim();
if (text.isEmpty || !widget.enabled) return;
PlatformUtils.lightHaptic();
widget.onSendMessage(text);
_controller.clear();
// Keep focus and keyboard open; do not collapse automatically
2025-08-10 01:20:45 +05:30
}
void _insertNewline() {
final text = _controller.text;
TextSelection sel = _controller.selection;
final int start = sel.isValid ? sel.start : text.length;
final int end = sel.isValid ? sel.end : text.length;
final String before = text.substring(0, start);
final String after = text.substring(end);
final String updated = '$before\n$after';
_controller.value = TextEditingValue(
text: updated,
selection: TextSelection.collapsed(offset: before.length + 1),
composing: TextRange.empty,
);
// Ensure field stays focused
_ensureFocusedIfEnabled();
}
2025-09-20 23:22:57 +05:30
static final RegExp _promptCommandBoundary = RegExp(r'\s');
void _handleComposerChanged() {
if (!mounted || _isDeactivated) return;
final String text = _controller.text;
final TextSelection selection = _controller.selection;
final bool hasText = text.trim().isNotEmpty;
final _PromptCommandMatch? match = _resolvePromptCommand(
text,
selection,
widget.enabled,
);
final bool shouldShow = match != null;
final bool wasShowing = _showPromptOverlay;
final String previousCommand = _currentPromptCommand;
bool needsUpdate = hasText != _hasText || shouldShow != _showPromptOverlay;
if (!needsUpdate) {
if (match != null) {
final TextRange? range = _currentPromptRange;
needsUpdate =
previousCommand != match.command ||
range == null ||
range.start != match.start ||
range.end != match.end;
} else {
needsUpdate =
_currentPromptCommand.isNotEmpty || _currentPromptRange != null;
}
}
if (!needsUpdate) return;
setState(() {
_hasText = hasText;
if (match != null) {
if (previousCommand != match.command) {
_promptSelectionIndex = 0;
}
_currentPromptCommand = match.command;
_currentPromptRange = TextRange(start: match.start, end: match.end);
_showPromptOverlay = true;
} else {
_currentPromptCommand = '';
_currentPromptRange = null;
_promptSelectionIndex = 0;
_showPromptOverlay = false;
}
});
if (!wasShowing && shouldShow) {
// Trigger prompt fetch lazily when overlay first appears
ref.read(promptsListProvider.future);
}
}
_PromptCommandMatch? _resolvePromptCommand(
String text,
TextSelection selection,
bool enabled,
) {
if (!enabled) return null;
if (!selection.isValid || !selection.isCollapsed) return null;
final int cursor = selection.start;
if (cursor < 0 || cursor > text.length) return null;
if (cursor == 0) return null;
int start = cursor;
while (start > 0) {
final String previous = text.substring(start - 1, start);
if (_promptCommandBoundary.hasMatch(previous)) {
break;
}
start--;
}
final String candidate = text.substring(start, cursor);
if (candidate.isEmpty || !candidate.startsWith('/')) {
return null;
}
return _PromptCommandMatch(command: candidate, start: start, end: cursor);
}
List<Prompt> _filterPrompts(List<Prompt> prompts) {
if (prompts.isEmpty) return const <Prompt>[];
final String query = _currentPromptCommand.toLowerCase();
final List<Prompt> filtered =
prompts
.where(
(prompt) =>
prompt.command.toLowerCase().contains(query.trim()) &&
prompt.content.isNotEmpty,
)
.toList()
..sort((a, b) {
final int titleCompare = a.title.toLowerCase().compareTo(
b.title.toLowerCase(),
);
if (titleCompare != 0) return titleCompare;
return a.command.toLowerCase().compareTo(b.command.toLowerCase());
});
return filtered;
}
void _movePromptSelection(int delta) {
final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider);
final List<Prompt>? prompts = promptsAsync.value;
if (prompts == null || prompts.isEmpty) return;
final List<Prompt> filtered = _filterPrompts(prompts);
if (filtered.isEmpty) return;
int newIndex = _promptSelectionIndex + delta;
if (newIndex < 0) {
newIndex = 0;
} else if (newIndex >= filtered.length) {
newIndex = filtered.length - 1;
}
if (newIndex == _promptSelectionIndex) return;
setState(() {
_promptSelectionIndex = newIndex;
});
}
void _confirmPromptSelection() {
final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider);
final List<Prompt>? prompts = promptsAsync.value;
if (prompts == null || prompts.isEmpty) return;
final List<Prompt> filtered = _filterPrompts(prompts);
if (filtered.isEmpty) return;
int index = _promptSelectionIndex;
if (index < 0) {
index = 0;
} else if (index >= filtered.length) {
index = filtered.length - 1;
}
_applyPrompt(filtered[index]);
}
void _applyPrompt(Prompt prompt) {
final TextRange? range = _currentPromptRange;
if (range == null) return;
final String text = _controller.text;
final String before = text.substring(0, range.start);
final String after = text.substring(range.end);
final String content = prompt.content;
final int caret = before.length + content.length;
_controller.value = TextEditingValue(
text: '$before$content$after',
selection: TextSelection.collapsed(offset: caret),
composing: TextRange.empty,
);
_ensureFocusedIfEnabled();
setState(() {
_showPromptOverlay = false;
_currentPromptCommand = '';
_currentPromptRange = null;
_promptSelectionIndex = 0;
});
}
void _hidePromptOverlay() {
if (!_showPromptOverlay) return;
setState(() {
_showPromptOverlay = false;
_currentPromptCommand = '';
_currentPromptRange = null;
_promptSelectionIndex = 0;
});
}
Widget _buildPromptOverlay(BuildContext context) {
final Brightness brightness = Theme.of(context).brightness;
final overlayColor = context.conduitTheme.cardBackground;
final borderColor = context.conduitTheme.cardBorder.withValues(
alpha: brightness == Brightness.dark ? 0.6 : 0.4,
);
final AsyncValue<List<Prompt>> promptsAsync = ref.watch(
promptsListProvider,
);
return Container(
decoration: BoxDecoration(
color: overlayColor,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(color: borderColor, width: BorderWidth.thin),
boxShadow: [
BoxShadow(
color: context.conduitTheme.cardShadow.withValues(
alpha: brightness == Brightness.dark ? 0.28 : 0.16,
),
blurRadius: 22,
offset: const Offset(0, 8),
spreadRadius: -4,
),
],
),
child: promptsAsync.when(
data: (prompts) {
final List<Prompt> filtered = _filterPrompts(prompts);
if (filtered.isEmpty) {
return _buildPromptOverlayPlaceholder(
context,
Icon(
Icons.inbox_outlined,
size: IconSize.medium,
color: context.conduitTheme.textSecondary.withValues(
alpha: Alpha.medium,
),
),
AppLocalizations.of(context)!.noResults,
);
}
int activeIndex = _promptSelectionIndex;
if (activeIndex < 0) {
activeIndex = 0;
} else if (activeIndex >= filtered.length) {
activeIndex = filtered.length - 1;
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: Spacing.xs),
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemCount: filtered.length,
separatorBuilder: (context, index) =>
const SizedBox(height: Spacing.xxs),
itemBuilder: (context, index) {
final prompt = filtered[index];
final bool isSelected = index == activeIndex;
final Color highlight = isSelected
? context.conduitTheme.navigationSelectedBackground
.withValues(alpha: 0.4)
: Colors.transparent;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.card),
onTap: () => _applyPrompt(prompt),
child: Container(
decoration: BoxDecoration(
color: highlight,
borderRadius: BorderRadius.circular(
AppBorderRadius.card,
),
),
padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.xs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
prompt.command,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary,
),
),
if (prompt.title.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: Spacing.xxs),
child: Text(
prompt.title,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
),
],
),
),
),
);
},
),
);
},
loading: () => _buildPromptOverlayPlaceholder(
context,
SizedBox(
width: IconSize.large,
height: IconSize.large,
child: CircularProgressIndicator(
strokeWidth: BorderWidth.regular,
valueColor: AlwaysStoppedAnimation<Color>(
context.conduitTheme.loadingIndicator,
),
),
),
null,
),
error: (error, stackTrace) => _buildPromptOverlayPlaceholder(
context,
Icon(
Icons.error_outline,
size: IconSize.medium,
color: context.conduitTheme.error,
),
null,
),
),
);
}
Widget _buildPromptOverlayPlaceholder(
BuildContext context,
Widget leading,
String? message,
) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.md,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
leading,
if (message != null) ...[
const SizedBox(width: Spacing.sm),
Flexible(
child: Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
),
],
],
),
);
}
2025-08-10 01:20:45 +05:30
@override
Widget build(BuildContext context) {
2025-08-28 18:54:06 +05:30
ref.listen<String?>(prefilledInputTextProvider, (previous, next) {
final incoming = next?.trim();
if (incoming == null || incoming.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return;
_controller.text = incoming;
_controller.selection = TextSelection.collapsed(
offset: incoming.length,
);
2025-08-28 18:54:06 +05:30
try {
2025-09-21 22:31:44 +05:30
ref.read(prefilledInputTextProvider.notifier).clear();
2025-08-28 18:54:06 +05:30
} catch (_) {}
});
});
2025-08-10 01:20:45 +05:30
final messages = ref.watch(chatMessagesProvider);
final isGenerating =
messages.isNotEmpty &&
messages.last.role == 'assistant' &&
messages.last.isStreaming;
final stopGeneration = ref.read(stopGenerationProvider);
2025-08-24 14:35:17 +05:30
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),
);
2025-09-07 14:40:20 +05:30
final toolsAsync = ref.watch(toolsListProvider);
final List<Tool> availableTools = toolsAsync.maybeWhen<List<Tool>>(
data: (t) => t,
orElse: () => const <Tool>[],
);
final bool showWebPill = selectedQuickPills.contains('web');
final bool showImagePillPref = selectedQuickPills.contains('image');
2025-08-25 10:35:48 +05:30
final voiceAvailableAsync = ref.watch(voiceInputAvailableProvider);
final bool voiceAvailable = voiceAvailableAsync.maybeWhen(
data: (v) => v,
orElse: () => false,
);
2025-09-19 11:58:22 +05:30
final selectedToolIds = ref.watch(selectedToolIdsProvider);
2025-08-24 14:35:17 +05:30
2025-08-28 14:45:46 +05:30
final focusTick = ref.watch(inputFocusTriggerProvider);
final autofocusEnabled = ref.watch(composerAutofocusEnabledProvider);
if (autofocusEnabled && focusTick != _lastHandledFocusTick) {
2025-08-28 18:59:59 +05:30
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return;
_ensureFocusedIfEnabled();
2025-08-28 18:59:59 +05:30
_lastHandledFocusTick = focusTick;
});
}
2025-08-28 14:45:46 +05:30
final Brightness brightness = Theme.of(context).brightness;
2025-09-19 11:58:22 +05:30
final bool isActive = _focusNode.hasFocus || _hasText;
final Color composerSurface = context.conduitTheme.inputBackground;
2025-09-20 18:09:22 +05:30
final Color composerBackground = brightness == Brightness.dark
? composerSurface.withValues(alpha: 0.78)
: context.conduitTheme.surfaceContainerHighest;
final Color placeholderBase = context.conduitTheme.inputText.withValues(
alpha: 0.64,
);
2025-09-19 11:58:22 +05:30
final Color placeholderFocused = context.conduitTheme.inputText.withValues(
alpha: 0.64,
);
final Color outlineColor = Color.lerp(
context.conduitTheme.inputBorder,
context.conduitTheme.inputBorderFocused,
isActive ? 1.0 : 0.0,
)!.withValues(alpha: brightness == Brightness.dark ? 0.55 : 0.45);
final Color shellShadowColor = context.conduitTheme.cardShadow.withValues(
alpha: brightness == Brightness.dark
? 0.22 + (isActive ? 0.08 : 0.0)
: 0.12 + (isActive ? 0.06 : 0.0),
);
final List<Widget> quickPills = <Widget>[];
for (final id in selectedQuickPills) {
if (id == 'web' && showWebPill) {
final String label = AppLocalizations.of(context)!.web;
final IconData icon = Platform.isIOS
? CupertinoIcons.search
: Icons.search;
void handleTap() {
final notifier = ref.read(webSearchEnabledProvider.notifier);
2025-09-21 22:31:44 +05:30
notifier.set(!webSearchEnabled);
2025-09-19 11:58:22 +05:30
}
quickPills.add(
_buildPillButton(
icon: icon,
label: label,
isActive: webSearchEnabled,
onTap: widget.enabled && !_isRecording ? handleTap : null,
),
);
} else if (id == 'image' && showImagePillPref && imageGenAvailable) {
final String label = AppLocalizations.of(context)!.imageGen;
final IconData icon = Platform.isIOS
? CupertinoIcons.photo
: Icons.image;
void handleTap() {
final notifier = ref.read(imageGenerationEnabledProvider.notifier);
2025-09-21 22:31:44 +05:30
notifier.set(!imageGenEnabled);
2025-09-19 11:58:22 +05:30
}
quickPills.add(
_buildPillButton(
icon: icon,
label: label,
isActive: imageGenEnabled,
onTap: widget.enabled && !_isRecording ? handleTap : null,
),
);
} else {
Tool? tool;
for (final t in availableTools) {
if (t.id == id) {
tool = t;
break;
}
}
if (tool != null) {
final bool isSelected = selectedToolIds.contains(id);
final String label = tool.name;
final IconData icon = Platform.isIOS
? CupertinoIcons.wrench
: Icons.build;
void handleTap() {
final current = List<String>.from(selectedToolIds);
if (current.contains(id)) {
current.remove(id);
} else {
current.add(id);
}
2025-09-21 22:31:44 +05:30
ref.read(selectedToolIdsProvider.notifier).set(current);
2025-09-19 11:58:22 +05:30
}
quickPills.add(
_buildPillButton(
icon: icon,
label: label,
isActive: isSelected,
onTap: widget.enabled && !_isRecording ? handleTap : null,
2025-08-10 01:20:45 +05:30
),
2025-09-19 11:58:22 +05:30
);
}
}
}
final bool showCompactComposer = quickPills.isEmpty;
final BorderRadius shellRadius = BorderRadius.circular(
showCompactComposer ? AppBorderRadius.round : _composerRadius,
);
final BoxDecoration shellDecoration = BoxDecoration(
color: showCompactComposer ? Colors.transparent : composerBackground,
borderRadius: shellRadius,
border: showCompactComposer
? null
: Border.all(color: outlineColor, width: BorderWidth.thin),
boxShadow: showCompactComposer
? const <BoxShadow>[]
: <BoxShadow>[
BoxShadow(
color: shellShadowColor,
blurRadius: 12 + (isActive ? 4 : 0),
spreadRadius: -2,
offset: const Offset(0, -2),
),
],
);
final List<Widget> composerChildren = <Widget>[
if (_showPromptOverlay)
Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.sm,
0,
Spacing.sm,
Spacing.xs,
),
child: _buildPromptOverlay(context),
),
if (showCompactComposer)
Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.screenPadding,
Spacing.xs,
Spacing.screenPadding,
Spacing.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildOverflowButton(
tooltip: AppLocalizations.of(context)!.more,
webSearchActive: webSearchEnabled,
imageGenerationActive: imageGenEnabled,
toolsActive: selectedToolIds.isNotEmpty,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.25,
),
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: composerSurface.withValues(
alpha: brightness == Brightness.dark ? 0.9 : 0.2,
),
borderRadius: BorderRadius.circular(_composerRadius),
border: Border.all(
color: outlineColor.withValues(
alpha: brightness == Brightness.dark ? 0.32 : 0.2,
),
width: BorderWidth.micro,
),
),
child: Row(
children: [
Expanded(
child: _buildComposerTextField(
brightness: brightness,
sendOnEnter: sendOnEnter,
placeholderBase: placeholderBase,
placeholderFocused: placeholderFocused,
contentPadding: const EdgeInsets.symmetric(
vertical: Spacing.xs,
),
isActive: isActive,
),
),
if (!_hasText && voiceAvailable && !isGenerating)
_buildInlineMicIcon(voiceAvailable),
],
),
),
),
),
const SizedBox(width: Spacing.sm),
_buildPrimaryButton(
_hasText,
isGenerating,
stopGeneration,
voiceAvailable,
),
],
),
)
else ...[
Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.sm,
Spacing.xs,
Spacing.sm,
Spacing.xs,
),
child: Container(
padding: const EdgeInsets.fromLTRB(
Spacing.sm,
Spacing.xs,
Spacing.sm,
Spacing.xs,
),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(_composerRadius),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _buildComposerTextField(
brightness: brightness,
sendOnEnter: sendOnEnter,
placeholderBase: placeholderBase,
placeholderFocused: placeholderFocused,
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.xs,
),
isActive: isActive,
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.inputPadding,
0,
Spacing.inputPadding,
0,
),
child: Row(
children: [
_buildOverflowButton(
tooltip: AppLocalizations.of(context)!.more,
webSearchActive: webSearchEnabled,
imageGenerationActive: imageGenEnabled,
toolsActive: selectedToolIds.isNotEmpty,
),
const SizedBox(width: Spacing.xs),
Expanded(
child: ClipRect(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: _withHorizontalSpacing(quickPills, Spacing.xxs),
),
),
),
),
const SizedBox(width: Spacing.sm),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!_hasText && voiceAvailable && !isGenerating) ...[
_buildMicButton(voiceAvailable),
const SizedBox(width: Spacing.sm),
],
_buildPrimaryButton(
_hasText,
isGenerating,
stopGeneration,
voiceAvailable,
),
],
),
],
),
),
],
];
2025-09-19 11:58:22 +05:30
Widget shell = AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
decoration: shellDecoration,
2025-09-19 11:58:22 +05:30
width: double.infinity,
child: SafeArea(
top: false,
bottom: true,
2025-09-19 11:58:22 +05:30
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,
2025-08-10 01:20:45 +05:30
),
),
),
),
2025-09-19 11:58:22 +05:30
),
),
);
if (brightness == Brightness.dark && !showCompactComposer) {
2025-09-19 11:58:22 +05:30
shell = ClipRRect(
borderRadius: shellRadius,
2025-09-19 11:58:22 +05:30
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: shell,
),
);
}
return Container(
color: Colors.transparent,
2025-09-20 18:09:22 +05:30
padding: EdgeInsets.zero,
2025-09-19 11:58:22 +05:30
child: Column(mainAxisSize: MainAxisSize.min, children: [shell]),
2025-08-10 01:20:45 +05:30
);
}
2025-09-20 19:54:00 +05:30
// (Removed legacy _buildVoiceButton; mic functionality moved to primary button)
2025-09-19 11:58:22 +05:30
List<Widget> _withHorizontalSpacing(List<Widget> children, double gap) {
if (children.length <= 1) {
return List<Widget>.from(children);
}
final result = <Widget>[];
for (var i = 0; i < children.length; i++) {
result.add(children[i]);
if (i != children.length - 1) {
result.add(SizedBox(width: gap));
}
}
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;
// Explicit user intent to focus: re-enable autofocus and focus
try {
ref.read(composerAutofocusEnabledProvider.notifier).set(true);
} catch (_) {}
_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: Builder(
builder: (context) {
final double factor = isActive ? 1.0 : 0.0;
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();
},
);
},
),
),
),
),
);
}
2025-09-20 18:09:22 +05:30
Widget _buildOverflowButton({
required String tooltip,
required bool webSearchActive,
required bool imageGenerationActive,
required bool toolsActive,
}) {
final bool enabled = widget.enabled && !_isRecording;
IconData icon;
Color? activeColor;
if (webSearchActive) {
icon = Platform.isIOS ? CupertinoIcons.search : Icons.search;
activeColor = context.conduitTheme.buttonPrimary;
} else if (imageGenerationActive) {
icon = Platform.isIOS ? CupertinoIcons.photo : Icons.image;
activeColor = context.conduitTheme.buttonPrimary;
} else if (toolsActive) {
icon = Platform.isIOS ? CupertinoIcons.wrench : Icons.build;
activeColor = context.conduitTheme.buttonPrimary;
} else {
icon = Platform.isIOS ? CupertinoIcons.add : Icons.add;
activeColor = null;
}
const double iconSize = IconSize.large;
const double buttonSize = TouchTarget.minimum;
final bool isActive = activeColor != null;
2025-09-20 18:09:22 +05:30
final Color iconColor = !enabled
? context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled)
: (activeColor ??
context.conduitTheme.textPrimary.withValues(alpha: Alpha.strong));
final Brightness brightness = Theme.of(context).brightness;
final Color baseBackground = context.conduitTheme.inputBackground
.withValues(alpha: brightness == Brightness.dark ? 0.9 : 0.2);
final Color backgroundColor = !enabled
? baseBackground.withValues(alpha: Alpha.disabled)
: isActive
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.16)
: baseBackground;
final Color borderColor = isActive
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.6)
: context.conduitTheme.cardBorder.withValues(alpha: 0.45);
2025-09-20 18:09:22 +05:30
return Tooltip(
message: tooltip,
child: Opacity(
opacity: enabled ? 1.0 : Alpha.disabled,
2025-10-02 00:54:35 +05:30
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.round),
2025-10-02 00:54:35 +05:30
onTap: enabled
? () {
HapticFeedback.selectionClick();
_showOverflowSheet();
}
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
width: buttonSize,
height: buttonSize,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppBorderRadius.round),
border: Border.all(color: borderColor, width: BorderWidth.thin),
),
child: Center(
child: Icon(icon, size: iconSize, color: iconColor),
),
2025-10-02 00:54:35 +05:30
),
),
2025-09-20 18:09:22 +05:30
),
),
);
}
Widget _buildMicButton(bool voiceAvailable) {
final bool enabledMic = widget.enabled && voiceAvailable;
return Tooltip(
message: AppLocalizations.of(context)!.voiceInput,
child: Opacity(
opacity: enabledMic ? Alpha.primary : Alpha.disabled,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.circular),
onTap: enabledMic
? () {
HapticFeedback.selectionClick();
_toggleVoice();
}
: null,
child: SizedBox(
width: TouchTarget.minimum,
height: TouchTarget.minimum,
child: Icon(
Platform.isIOS ? CupertinoIcons.mic : Icons.mic,
size: IconSize.large,
color: _isRecording
? context.conduitTheme.buttonPrimary
: (enabledMic
? context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
)
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
)),
),
),
),
),
),
);
}
Widget _buildInlineMicIcon(bool voiceAvailable) {
final bool enabledMic = widget.enabled && voiceAvailable;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.circular),
onTap: enabledMic
? () {
HapticFeedback.selectionClick();
_toggleVoice();
}
: null,
child: Padding(
padding: const EdgeInsets.all(Spacing.xs),
child: Icon(
Platform.isIOS ? CupertinoIcons.mic : Icons.mic,
size: IconSize.medium,
color: _isRecording
? context.conduitTheme.buttonPrimary
: context.conduitTheme.textSecondary.withValues(
alpha: enabledMic ? Alpha.strong : Alpha.disabled,
),
),
),
),
);
}
2025-08-10 01:20:45 +05:30
Widget _buildPrimaryButton(
bool hasText,
bool isGenerating,
void Function() stopGeneration,
2025-09-20 19:54:00 +05:30
bool voiceAvailable,
2025-08-10 01:20:45 +05:30
) {
// Compact 44px touch target, circular radius, md icon size
const double buttonSize = TouchTarget.minimum; // 44.0
2025-08-10 01:20:45 +05:30
const double radius = AppBorderRadius.round; // big to ensure circle
final enabled = !isGenerating && hasText && widget.enabled;
// Generating -> STOP variant
if (isGenerating) {
return Tooltip(
message: AppLocalizations.of(context)!.stopGenerating,
2025-08-21 23:56:47 +05:30
child: Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
2025-08-24 14:35:17 +05:30
side: BorderSide(
color: context.conduitTheme.error,
width: BorderWidth.regular,
),
2025-08-21 23:56:47 +05:30
),
child: InkWell(
borderRadius: BorderRadius.circular(radius),
overlayColor: WidgetStateProperty.resolveWith<Color>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return context.conduitTheme.error.withValues(
alpha: Alpha.buttonPressed,
);
}
if (states.contains(WidgetState.hovered)) {
return context.conduitTheme.error.withValues(
alpha: Alpha.hover,
);
}
return Colors.transparent;
}),
2025-08-21 23:56:47 +05:30
onTap: () {
HapticFeedback.lightImpact();
stopGeneration();
},
child: Container(
width: buttonSize,
height: buttonSize,
decoration: BoxDecoration(
color: context.conduitTheme.error.withValues(
alpha: Alpha.buttonPressed,
),
borderRadius: BorderRadius.circular(radius),
boxShadow: ConduitShadows.button(context),
2025-08-10 01:20:45 +05:30
),
child: Center(
child: Icon(
Platform.isIOS ? CupertinoIcons.stop_fill : Icons.stop,
size: IconSize.large,
color: context.conduitTheme.buttonPrimaryText,
),
2025-08-21 23:56:47 +05:30
),
2025-08-10 01:20:45 +05:30
),
),
),
);
}
// If there's text, render SEND variant; otherwise render VOICE CALL variant
2025-09-20 19:54:00 +05:30
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,
child: InkWell(
borderRadius: BorderRadius.circular(radius),
onTap: enabled
? () {
PlatformUtils.lightHaptic();
_sendMessage();
}
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
2025-09-20 19:54:00 +05:30
width: buttonSize,
height: buttonSize,
decoration: BoxDecoration(
color: enabled
? context.conduitTheme.buttonPrimary
: context.conduitTheme.surfaceContainerHighest,
2025-09-20 19:54:00 +05:30
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: enabled
? context.conduitTheme.buttonPrimary.withValues(
alpha: 0.8,
)
: context.conduitTheme.cardBorder.withValues(
alpha: 0.45,
),
width: BorderWidth.thin,
),
boxShadow: enabled
? <BoxShadow>[
BoxShadow(
color: context.conduitTheme.cardShadow.withValues(
alpha:
Theme.of(context).brightness ==
Brightness.dark
? 0.36
: 0.18,
),
blurRadius: 18,
spreadRadius: -6,
offset: const Offset(0, 8),
),
]
: const [],
2025-09-20 19:54:00 +05:30
),
child: Center(
child: Icon(
Platform.isIOS
? CupertinoIcons.arrow_up
: Icons.arrow_upward,
size: IconSize.large,
color: enabled
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
),
),
2025-09-20 19:54:00 +05:30
),
),
),
),
),
),
);
}
// VOICE CALL variant when no text is present
final bool enabledVoiceCall = widget.enabled && widget.onVoiceCall != null;
2025-08-10 01:20:45 +05:30
return Tooltip(
message: 'Voice Call',
2025-08-21 23:56:47 +05:30
child: Opacity(
opacity: enabledVoiceCall ? Alpha.primary : Alpha.disabled,
child: IgnorePointer(
ignoring: !enabledVoiceCall,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(radius),
onTap: enabledVoiceCall
? () {
PlatformUtils.lightHaptic();
widget.onVoiceCall!();
}
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
width: buttonSize,
height: buttonSize,
decoration: BoxDecoration(
color: enabledVoiceCall
? context.conduitTheme.buttonPrimary
: context.conduitTheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: enabledVoiceCall
? context.conduitTheme.buttonPrimary.withValues(
alpha: 0.8,
)
: context.conduitTheme.cardBorder.withValues(
alpha: 0.45,
),
width: BorderWidth.thin,
),
boxShadow: enabledVoiceCall
? <BoxShadow>[
BoxShadow(
color: context.conduitTheme.cardShadow.withValues(
alpha:
Theme.of(context).brightness ==
Brightness.dark
? 0.36
: 0.18,
),
blurRadius: 18,
spreadRadius: -6,
offset: const Offset(0, 8),
),
]
: const [],
),
child: Center(
child: Icon(
Platform.isIOS ? CupertinoIcons.waveform : Icons.graphic_eq,
size: IconSize.large,
color: enabledVoiceCall
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
),
),
),
2025-10-02 00:54:35 +05:30
),
),
2025-08-10 01:20:45 +05:30
),
),
),
);
}
2025-08-24 14:35:17 +05:30
Widget _buildPillButton({
required IconData icon,
required String label,
required bool isActive,
VoidCallback? onTap,
}) {
2025-09-19 11:58:22 +05:30
final bool enabled = onTap != null;
final Brightness brightness = Theme.of(context).brightness;
final Color baseBackground = context.conduitTheme.cardBackground;
final Color background = isActive
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.16)
: baseBackground.withValues(
alpha: brightness == Brightness.dark ? 0.18 : 0.12,
);
final Color outline = isActive
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.8)
: context.conduitTheme.cardBorder.withValues(alpha: 0.6);
final Color contentColor = isActive
? context.conduitTheme.buttonPrimary
: context.conduitTheme.textPrimary.withValues(
alpha: enabled ? Alpha.strong : Alpha.disabled,
);
2025-08-24 14:35:17 +05:30
return Material(
color: Colors.transparent,
child: InkWell(
2025-09-19 11:58:22 +05:30
borderRadius: BorderRadius.circular(AppBorderRadius.input),
2025-08-24 14:35:17 +05:30
onTap: onTap == null
? null
: () {
HapticFeedback.selectionClick();
onTap();
},
2025-09-19 11:58:22 +05:30
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.sm,
vertical: Spacing.xs,
),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppBorderRadius.input),
border: Border.all(color: outline, width: BorderWidth.thin),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: IconSize.medium, color: contentColor),
const SizedBox(width: Spacing.xs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.labelStyle.copyWith(color: contentColor),
2025-09-07 13:52:09 +05:30
),
2025-09-19 11:58:22 +05:30
],
),
2025-08-10 01:20:45 +05:30
),
),
2025-08-24 14:35:17 +05:30
);
2025-08-10 01:20:45 +05:30
}
2025-09-19 11:58:22 +05:30
void _showOverflowSheet() {
2025-08-21 23:56:47 +05:30
HapticFeedback.selectionClick();
final prevCanRequest = _focusNode.canRequestFocus;
2025-09-08 01:15:31 +05:30
final wasFocused = _focusNode.hasFocus;
_focusNode.canRequestFocus = false;
2025-09-08 01:15:31 +05:30
try {
FocusScope.of(context).unfocus();
SystemChannels.textInput.invokeMethod('TextInput.hide');
} catch (_) {}
2025-09-19 11:58:22 +05:30
2025-08-10 01:20:45 +05:30
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
2025-09-19 21:12:15 +05:30
isScrollControlled: true,
2025-09-19 11:58:22 +05:30
builder: (modalContext) => Consumer(
builder: (innerContext, modalRef, _) {
final l10n = AppLocalizations.of(innerContext)!;
final theme = innerContext.conduitTheme;
final attachments = <Widget>[
_buildOverflowAction(
icon: Platform.isIOS ? CupertinoIcons.doc : Icons.attach_file,
label: l10n.file,
onTap: widget.onFileAttachment == null
? null
: () {
2025-08-24 14:35:17 +05:30
HapticFeedback.lightImpact();
2025-09-19 11:58:22 +05:30
widget.onFileAttachment!.call();
2025-08-24 14:35:17 +05:30
},
2025-09-19 11:58:22 +05:30
),
_buildOverflowAction(
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
label: l10n.photo,
onTap: widget.onImageAttachment == null
? null
: () {
2025-08-24 14:35:17 +05:30
HapticFeedback.lightImpact();
2025-09-19 11:58:22 +05:30
widget.onImageAttachment!.call();
2025-08-24 14:35:17 +05:30
},
2025-09-19 11:58:22 +05:30
),
_buildOverflowAction(
icon: Platform.isIOS ? CupertinoIcons.camera : Icons.camera_alt,
label: l10n.camera,
onTap: widget.onCameraCapture == null
? null
: () {
2025-08-24 14:35:17 +05:30
HapticFeedback.lightImpact();
2025-09-19 11:58:22 +05:30
widget.onCameraCapture!.call();
2025-08-24 14:35:17 +05:30
},
2025-09-19 11:58:22 +05:30
),
];
final featureTiles = <Widget>[];
final webSearchAvailable = modalRef.watch(webSearchAvailableProvider);
final webSearchEnabled = modalRef.watch(webSearchEnabledProvider);
if (webSearchAvailable) {
featureTiles.add(
_buildFeatureToggleTile(
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search,
title: l10n.webSearch,
subtitle: l10n.webSearchDescription,
value: webSearchEnabled,
onChanged: (next) {
2025-09-21 22:31:44 +05:30
modalRef.read(webSearchEnabledProvider.notifier).set(next);
2025-09-19 11:58:22 +05:30
},
),
);
}
final imageGenAvailable = modalRef.watch(
imageGenerationAvailableProvider,
);
final imageGenEnabled = modalRef.watch(
imageGenerationEnabledProvider,
);
if (imageGenAvailable) {
featureTiles.add(
_buildFeatureToggleTile(
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
title: l10n.imageGeneration,
subtitle: l10n.imageGenerationDescription,
value: imageGenEnabled,
onChanged: (next) {
2025-09-21 22:31:44 +05:30
modalRef
.read(imageGenerationEnabledProvider.notifier)
.set(next);
2025-09-19 11:58:22 +05:30
},
),
);
}
final selectedToolIds = modalRef.watch(selectedToolIdsProvider);
final toolsAsync = modalRef.watch(toolsListProvider);
final Widget toolsSection = toolsAsync.when(
data: (tools) {
if (tools.isEmpty) {
return _buildInfoCard('No tools available');
}
final tiles = tools.map((tool) {
final isSelected = selectedToolIds.contains(tool.id);
return _buildToolTile(
tool: tool,
selected: isSelected,
onToggle: () {
final current = List<String>.from(
modalRef.read(selectedToolIdsProvider),
);
if (isSelected) {
current.remove(tool.id);
} else {
current.add(tool.id);
}
2025-09-21 22:31:44 +05:30
modalRef
.read(selectedToolIdsProvider.notifier)
.set(current);
2025-09-19 11:58:22 +05:30
},
);
}).toList();
return Column(children: _withVerticalSpacing(tiles, Spacing.xxs));
},
loading: () => Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: BorderWidth.thin),
),
),
error: (error, stack) => _buildInfoCard('Failed to load tools'),
);
final bodyChildren = <Widget>[
const SheetHandle(),
2025-09-20 19:23:37 +05:30
const SizedBox(height: Spacing.sm),
2025-09-19 11:58:22 +05:30
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < attachments.length; i++) ...[
2025-09-20 19:23:37 +05:30
if (i != 0) const SizedBox(width: Spacing.sm),
2025-09-19 11:58:22 +05:30
Expanded(child: attachments[i]),
],
],
2025-08-24 14:35:17 +05:30
),
2025-08-10 01:20:45 +05:30
],
),
2025-09-19 11:58:22 +05:30
];
if (featureTiles.isNotEmpty) {
bodyChildren
2025-09-20 19:23:37 +05:30
..add(const SizedBox(height: Spacing.sm))
..addAll(_withVerticalSpacing(featureTiles, Spacing.xxs));
2025-09-19 11:58:22 +05:30
}
bodyChildren
2025-09-20 19:23:37 +05:30
..add(const SizedBox(height: Spacing.sm))
2025-09-19 11:58:22 +05:30
..add(_buildSectionLabel(l10n.tools))
..add(toolsSection);
2025-09-19 21:12:15 +05:30
// Measure content height and cap the sheet's max size to avoid extra blank space
final GlobalKey sheetContentKey = GlobalKey();
double? measuredContentHeight;
return StatefulBuilder(
builder: (context, setModalState) {
// Schedule a post-frame measurement of the content height
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = sheetContentKey.currentContext;
if (ctx != null) {
final renderObject = ctx.findRenderObject();
if (renderObject is RenderBox) {
final double h = renderObject.size.height;
if (h > 0 && h != measuredContentHeight) {
measuredContentHeight = h;
setModalState(() {});
}
}
}
});
final media = MediaQuery.of(modalContext);
final double availableHeight =
media.size.height - media.padding.top;
double computedMax = 0.9;
if (measuredContentHeight != null && availableHeight > 0) {
computedMax = (measuredContentHeight! / availableHeight).clamp(
0.1,
0.9,
);
}
2025-09-20 19:23:37 +05:30
final double computedMin = math.min(0.2, computedMax);
final double computedInitial = math.min(0.34, computedMax);
2025-09-19 21:12:15 +05:30
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(modalContext).maybePop(),
child: const SizedBox.shrink(),
),
),
DraggableScrollableSheet(
expand: false,
initialChildSize: computedInitial,
minChildSize: computedMin,
maxChildSize: computedMax,
snap: true,
snapSizes: [computedMax],
builder: (sheetContext, scrollController) {
return Container(
decoration: BoxDecoration(
color: theme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.bottomSheet),
),
border: Border.all(
color: theme.dividerColor,
width: BorderWidth.thin,
),
boxShadow: ConduitShadows.modal(context),
2025-09-19 21:12:15 +05:30
),
child: ModalSheetSafeArea(
2025-09-20 19:23:37 +05:30
padding: const EdgeInsets.fromLTRB(
Spacing.md,
Spacing.xs,
Spacing.md,
Spacing.md,
),
2025-09-19 21:12:15 +05:30
child: SingleChildScrollView(
controller: scrollController,
padding: EdgeInsets.zero,
child: Column(
key: sheetContentKey,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: bodyChildren,
),
),
),
);
},
),
],
);
},
2025-09-19 11:58:22 +05:30
);
},
2025-08-10 01:20:45 +05:30
),
).whenComplete(() {
if (mounted) {
_focusNode.canRequestFocus = prevCanRequest;
2025-09-08 01:15:31 +05:30
if (wasFocused && widget.enabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_ensureFocusedIfEnabled();
});
}
}
});
2025-08-10 01:20:45 +05:30
}
2025-09-19 11:58:22 +05:30
List<Widget> _withVerticalSpacing(List<Widget> children, double gap) {
if (children.length <= 1) {
return List<Widget>.from(children);
}
final spaced = <Widget>[];
for (var i = 0; i < children.length; i++) {
spaced.add(children[i]);
if (i != children.length - 1) {
spaced.add(SizedBox(height: gap));
}
2025-09-19 11:58:22 +05:30
}
return spaced;
}
Widget _buildSectionLabel(String text) {
return Padding(
2025-09-20 19:23:37 +05:30
padding: const EdgeInsets.only(bottom: Spacing.xxs),
2025-09-19 11:58:22 +05:30
child: Text(
text,
style: AppTypography.labelStyle.copyWith(
color: context.conduitTheme.textSecondary.withValues(
alpha: Alpha.strong,
),
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildFeatureToggleTile({
required IconData icon,
required String title,
String? subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
final theme = context.conduitTheme;
final brightness = Theme.of(context).brightness;
final description = subtitle?.trim() ?? '';
final Color background = value
? theme.buttonPrimary.withValues(
alpha: brightness == Brightness.dark ? 0.28 : 0.16,
)
: theme.surfaceContainer.withValues(
alpha: brightness == Brightness.dark ? 0.32 : 0.12,
);
final Color borderColor = value
? theme.buttonPrimary.withValues(alpha: 0.7)
: theme.cardBorder.withValues(alpha: 0.55);
return Semantics(
button: true,
toggled: value,
label: title,
hint: description.isEmpty ? null : description,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
onTap: () {
HapticFeedback.selectionClick();
onChanged(!value);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(vertical: Spacing.xxs),
2025-09-20 19:23:37 +05:30
padding: const EdgeInsets.all(Spacing.sm),
2025-09-19 11:58:22 +05:30
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppBorderRadius.input),
border: Border.all(color: borderColor, width: BorderWidth.thin),
boxShadow: value ? ConduitShadows.low(context) : const [],
2025-09-19 11:58:22 +05:30
),
child: Row(
2025-09-19 21:12:15 +05:30
crossAxisAlignment: CrossAxisAlignment.center,
2025-09-19 11:58:22 +05:30
children: [
_buildToolGlyph(icon: icon, selected: value, theme: theme),
2025-09-20 19:23:37 +05:30
const SizedBox(width: Spacing.xs),
2025-09-19 11:58:22 +05:30
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2025-09-19 21:12:15 +05:30
Text(
title,
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textPrimary,
fontWeight: value ? FontWeight.w600 : FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
2025-09-19 11:58:22 +05:30
),
if (description.isNotEmpty) ...[
const SizedBox(height: Spacing.xs),
Text(
description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
2025-09-19 21:12:15 +05:30
style: AppTypography.captionStyle.copyWith(
2025-09-19 11:58:22 +05:30
color: theme.textSecondary.withValues(
alpha: Alpha.strong,
),
),
),
],
],
),
),
2025-09-20 19:23:37 +05:30
const SizedBox(width: Spacing.xs),
2025-09-19 21:12:15 +05:30
_buildTogglePill(isOn: value, theme: theme),
2025-09-19 11:58:22 +05:30
],
),
),
),
),
);
}
Widget _buildToolTile({
required Tool tool,
required bool selected,
required VoidCallback onToggle,
}) {
final theme = context.conduitTheme;
final brightness = Theme.of(context).brightness;
final description = _toolDescriptionFor(tool);
final Color background = selected
? theme.buttonPrimary.withValues(
alpha: brightness == Brightness.dark ? 0.28 : 0.16,
)
: theme.surfaceContainer.withValues(
alpha: brightness == Brightness.dark ? 0.32 : 0.12,
);
final Color borderColor = selected
? theme.buttonPrimary.withValues(alpha: 0.7)
: theme.cardBorder.withValues(alpha: 0.55);
return Semantics(
button: true,
toggled: selected,
label: tool.name,
hint: description.isEmpty ? null : description,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.input),
onTap: () {
HapticFeedback.selectionClick();
onToggle();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(vertical: Spacing.xxs),
2025-09-20 19:23:37 +05:30
padding: const EdgeInsets.all(Spacing.sm),
2025-09-19 11:58:22 +05:30
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppBorderRadius.input),
border: Border.all(color: borderColor, width: BorderWidth.thin),
boxShadow: selected ? ConduitShadows.low(context) : const [],
2025-09-19 11:58:22 +05:30
),
child: Row(
2025-09-19 21:12:15 +05:30
crossAxisAlignment: CrossAxisAlignment.center,
2025-09-19 11:58:22 +05:30
children: [
_buildToolGlyph(
icon: _toolIconFor(tool),
selected: selected,
theme: theme,
),
2025-09-20 19:23:37 +05:30
const SizedBox(width: Spacing.xs),
2025-09-19 11:58:22 +05:30
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2025-09-19 21:12:15 +05:30
Text(
tool.name,
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textPrimary,
fontWeight: selected
? FontWeight.w600
: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
2025-09-19 11:58:22 +05:30
),
if (description.isNotEmpty) ...[
const SizedBox(height: Spacing.xs),
Text(
description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
2025-09-19 21:12:15 +05:30
style: AppTypography.captionStyle.copyWith(
2025-09-19 11:58:22 +05:30
color: theme.textSecondary.withValues(
alpha: Alpha.strong,
),
),
),
],
],
),
),
2025-09-20 19:23:37 +05:30
const SizedBox(width: Spacing.xs),
2025-09-19 21:12:15 +05:30
_buildTogglePill(isOn: selected, theme: theme),
2025-09-19 11:58:22 +05:30
],
),
),
),
),
);
}
Widget _buildToolGlyph({
required IconData icon,
required bool selected,
required ConduitThemeExtension theme,
}) {
final Color accentStart = theme.buttonPrimary.withValues(
alpha: selected ? Alpha.active : Alpha.hover,
);
final Color accentEnd = theme.buttonPrimary.withValues(
alpha: selected ? Alpha.highlight : Alpha.focus,
);
final Color iconColor = selected
? theme.buttonPrimaryText
: theme.iconPrimary.withValues(alpha: Alpha.strong);
return Container(
2025-09-20 19:23:37 +05:30
width: 36,
height: 36,
2025-09-19 11:58:22 +05:30
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [accentStart, accentEnd],
),
),
child: Icon(icon, color: iconColor, size: IconSize.modal),
);
}
String _toolDescriptionFor(Tool tool) {
final metaDescription = _extractMetaDescription(tool.meta);
if (metaDescription != null && metaDescription.isNotEmpty) {
return metaDescription;
}
final custom = tool.description?.trim();
if (custom != null && custom.isNotEmpty) {
return custom;
}
final name = tool.name.toLowerCase();
if (name.contains('search') || name.contains('browse')) {
return 'Search the web for fresh context to improve answers.';
}
if (name.contains('image') ||
name.contains('vision') ||
name.contains('media')) {
return 'Understand or generate imagery alongside your conversation.';
}
if (name.contains('code') ||
name.contains('python') ||
name.contains('notebook')) {
return 'Execute code snippets and return computed results inline.';
}
if (name.contains('calc') || name.contains('math')) {
return 'Perform precise math and calculations on demand.';
}
if (name.contains('file') || name.contains('document')) {
return 'Access and summarize your uploaded files during chat.';
}
if (name.contains('api') || name.contains('request')) {
return 'Trigger API requests and bring external data into the chat.';
}
return 'Enhance responses with specialized capabilities from this tool.';
}
String? _extractMetaDescription(Map<String, dynamic>? meta) {
if (meta == null || meta.isEmpty) return null;
final value = meta['description'];
if (value is String) {
final trimmed = value.trim();
if (trimmed.isNotEmpty) return trimmed;
}
return null;
}
Widget _buildTogglePill({
required bool isOn,
required ConduitThemeExtension theme,
}) {
final Color trackColor = isOn
? theme.buttonPrimary.withValues(alpha: 0.9)
: theme.cardBorder.withValues(alpha: 0.5);
final Color thumbColor = isOn
? theme.buttonPrimaryText
: theme.surfaceBackground.withValues(alpha: 0.9);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: 42,
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.round),
color: trackColor,
),
alignment: isOn ? Alignment.centerRight : Alignment.centerLeft,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: thumbColor,
boxShadow: [
BoxShadow(
color: theme.buttonPrimary.withValues(alpha: 0.25),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
),
);
}
IconData _toolIconFor(Tool tool) {
final name = tool.name.toLowerCase();
if (name.contains('image') || name.contains('vision')) {
return Platform.isIOS ? CupertinoIcons.photo : Icons.image;
}
if (name.contains('code') || name.contains('python')) {
return Platform.isIOS
? CupertinoIcons.chevron_left_slash_chevron_right
: Icons.code;
}
if (name.contains('calculator') || name.contains('math')) {
return Icons.calculate;
}
if (name.contains('file') || name.contains('document')) {
return Platform.isIOS ? CupertinoIcons.doc : Icons.description;
}
if (name.contains('api') || name.contains('request')) {
return Icons.cloud;
}
if (name.contains('search')) {
return Platform.isIOS ? CupertinoIcons.search : Icons.search;
}
return Platform.isIOS ? CupertinoIcons.square_grid_2x2 : Icons.extension;
}
Widget _buildInfoCard(String message) {
final theme = context.conduitTheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color: theme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.input),
border: Border.all(
color: theme.cardBorder.withValues(alpha: 0.6),
width: BorderWidth.thin,
),
),
child: Text(
message,
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textSecondary,
),
),
);
}
Widget _buildOverflowAction({
required IconData icon,
required String label,
VoidCallback? onTap,
}) {
final theme = context.conduitTheme;
final brightness = Theme.of(context).brightness;
final VoidCallback? callback = onTap;
final bool enabled = callback != null;
final Color iconColor = enabled ? theme.buttonPrimary : theme.iconDisabled;
final Color textColor = enabled
? theme.textPrimary
: theme.textPrimary.withValues(alpha: Alpha.disabled);
final Color background = theme.surfaceContainer.withValues(
alpha: brightness == Brightness.dark ? 0.45 : 0.92,
);
final Color borderColor = theme.cardBorder.withValues(
alpha: enabled ? 0.5 : 0.25,
);
final Color accent = theme.buttonPrimary.withValues(
alpha: enabled ? Alpha.selected : Alpha.hover,
);
return Opacity(
opacity: enabled ? 1.0 : Alpha.disabled,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.card),
onTap: callback == null
? null
: () {
Navigator.of(context).pop();
Future.microtask(callback);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(
2025-09-20 19:23:37 +05:30
horizontal: Spacing.xs,
vertical: Spacing.sm,
2025-09-19 11:58:22 +05:30
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(color: borderColor, width: BorderWidth.thin),
color: background,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
2025-09-20 19:23:37 +05:30
width: 36,
height: 36,
2025-09-19 11:58:22 +05:30
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
accent,
theme.buttonPrimary.withValues(
alpha: enabled ? Alpha.highlight : Alpha.hover,
),
],
),
),
child: Icon(icon, color: iconColor, size: IconSize.modal),
),
2025-09-20 19:23:37 +05:30
const SizedBox(height: Spacing.xs),
2025-09-19 11:58:22 +05:30
Text(
label,
textAlign: TextAlign.center,
2025-09-20 19:23:37 +05:30
style: AppTypography.captionStyle.copyWith(
2025-09-19 11:58:22 +05:30
fontWeight: FontWeight.w600,
color: textColor,
),
),
],
),
),
),
),
);
2025-08-20 16:08:44 +05:30
}
2025-08-25 10:35:48 +05:30
// --- Inline Voice Input ---
Future<void> _toggleVoice() async {
if (_isRecording) {
await _stopVoice();
} else {
await _startVoice();
}
}
Future<void> _startVoice() async {
if (!widget.enabled) return;
try {
final ok = await _voiceService.initialize();
2025-09-16 18:15:44 +05:30
if (!mounted) return;
2025-08-25 10:35:48 +05:30
if (!ok) {
_showVoiceUnavailable(
AppLocalizations.of(context)?.errorMessage ??
'Voice input unavailable',
);
return;
}
2025-08-28 19:48:35 +05:30
// Centralized permission + start
final stream = await _voiceService.beginListening();
2025-09-16 18:15:44 +05:30
if (!mounted) return;
2025-08-25 10:35:48 +05:30
setState(() {
_isRecording = true;
_baseTextAtStart = _controller.text;
});
_intensitySub?.cancel();
2025-09-20 19:54:00 +05:30
// intensity stream no longer used for UI; stop listening
2025-08-25 10:35:48 +05:30
_textSub?.cancel();
_textSub = stream.listen(
(text) async {
2025-09-02 00:04:21 +05:30
final updated = _baseTextAtStart.isEmpty
? text
: '${_baseTextAtStart.trimRight()} $text';
_controller.value = TextEditingValue(
text: updated,
selection: TextSelection.collapsed(offset: updated.length),
);
2025-08-25 10:35:48 +05:30
},
onDone: () {
if (!mounted) return;
setState(() => _isRecording = false);
_intensitySub?.cancel();
_intensitySub = null;
},
onError: (_) {
if (!mounted) return;
setState(() => _isRecording = false);
_intensitySub?.cancel();
_intensitySub = null;
},
);
_ensureFocusedIfEnabled();
} catch (_) {
_showVoiceUnavailable(
AppLocalizations.of(context)?.errorMessage ??
'Failed to start voice input',
);
if (!mounted) return;
setState(() => _isRecording = false);
}
}
Future<void> _stopVoice() async {
_intensitySub?.cancel();
_intensitySub = null;
await _voiceService.stopListening();
if (!mounted) return;
setState(() => _isRecording = false);
HapticFeedback.selectionClick();
}
// Server transcription removed; only on-device STT updates the input text
2025-08-25 10:35:48 +05:30
void _showVoiceUnavailable(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
2025-08-10 01:20:45 +05:30
}