2025-08-10 01:20:45 +05:30
|
|
|
import 'package:flutter/material.dart';
|
2025-08-16 20:27:44 +05:30
|
|
|
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';
|
2025-08-28 10:29:58 +05:30
|
|
|
// 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';
|
|
|
|
|
|
2025-08-25 20:56:33 +05:30
|
|
|
import 'dart:io' show Platform;
|
2025-08-10 01:20:45 +05:30
|
|
|
import 'dart:async';
|
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-12-02 21:10:59 +05:30
|
|
|
import '../services/clipboard_attachment_service.dart';
|
|
|
|
|
import '../services/file_attachment_service.dart';
|
2025-11-26 22:19:19 +05:30
|
|
|
import '../providers/context_attachments_provider.dart';
|
|
|
|
|
import '../providers/knowledge_cache_provider.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-12-05 22:19:31 +05:30
|
|
|
import '../../../core/models/toggle_filter.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-11-26 22:19:19 +05:30
|
|
|
import '../../../core/models/knowledge_base.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
import '../../../shared/utils/platform_utils.dart';
|
2025-08-23 20:09:43 +05:30
|
|
|
import 'package:conduit/l10n/app_localizations.dart';
|
2025-09-19 21:12:15 +05:30
|
|
|
import '../../../shared/widgets/modal_safe_area.dart';
|
2025-12-07 10:47:57 +05:30
|
|
|
import '../../../core/utils/prompt_variable_parser.dart';
|
|
|
|
|
import '../../prompts/widgets/prompt_variable_dialog.dart';
|
|
|
|
|
import '../../auth/providers/unified_auth_providers.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-09-08 01:05:48 +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;
|
2025-10-08 19:09:57 +05:30
|
|
|
final Function()? onVoiceCall;
|
2025-08-10 01:20:45 +05:30
|
|
|
final Function()? onFileAttachment;
|
|
|
|
|
final Function()? onImageAttachment;
|
|
|
|
|
final Function()? onCameraCapture;
|
2025-11-26 22:19:19 +05:30
|
|
|
final Function()? onWebAttachment;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-12-02 21:10:59 +05:30
|
|
|
/// Callback invoked when images or files are pasted from clipboard.
|
|
|
|
|
final Future<void> Function(List<LocalAttachment>)? onPastedAttachments;
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
const ModernChatInput({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.onSendMessage,
|
|
|
|
|
this.enabled = true,
|
|
|
|
|
this.onVoiceInput,
|
2025-10-08 19:09:57 +05:30
|
|
|
this.onVoiceCall,
|
2025-08-10 01:20:45 +05:30
|
|
|
this.onFileAttachment,
|
|
|
|
|
this.onImageAttachment,
|
|
|
|
|
this.onCameraCapture,
|
2025-11-26 22:19:19 +05:30
|
|
|
this.onWebAttachment,
|
2025-12-02 21:10:59 +05:30
|
|
|
this.onPastedAttachments,
|
2025-08-10 01:20:45 +05:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@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-25 21:53:41 +05:30
|
|
|
|
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();
|
2025-10-02 22:38:28 +05:30
|
|
|
bool _pendingFocus = false;
|
2025-08-25 10:35:48 +05:30
|
|
|
bool _isRecording = false;
|
2025-08-10 01:20:45 +05:30
|
|
|
bool _hasText = false; // track locally without rebuilding on each keystroke
|
2025-12-14 18:52:12 +05:30
|
|
|
bool _isMultiline = false; // track multiline for dynamic border radius
|
2025-08-10 01:20:45 +05:30
|
|
|
StreamSubscription<String>? _voiceStreamSubscription;
|
2025-08-25 10:35:48 +05:30
|
|
|
late VoiceInputService _voiceService;
|
|
|
|
|
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
|
|
|
|
2025-12-02 21:10:59 +05:30
|
|
|
/// Service for handling clipboard paste operations.
|
|
|
|
|
final ClipboardAttachmentService _clipboardService =
|
|
|
|
|
ClipboardAttachmentService();
|
|
|
|
|
|
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)
|
2025-08-28 12:59:48 +05:30
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
2025-08-28 18:54:06 +05:30
|
|
|
if (!mounted || _isDeactivated) return;
|
2025-08-28 12:59:48 +05:30
|
|
|
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 12:59:48 +05:30
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-08 01:05:48 +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() {
|
2025-09-30 14:27:50 +05:30
|
|
|
// 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();
|
2025-10-02 22:38:28 +05:30
|
|
|
_pendingFocus = false;
|
2025-08-10 01:20:45 +05:30
|
|
|
_voiceStreamSubscription?.cancel();
|
2025-08-25 10:35:48 +05:30
|
|
|
_textSub?.cancel();
|
|
|
|
|
_voiceService.stopListening();
|
2025-08-10 01:20:45 +05:30
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _ensureFocusedIfEnabled() {
|
2025-10-10 15:22:54 +05:30
|
|
|
// Respect global suppression flag to avoid re-opening keyboard
|
|
|
|
|
final autofocusEnabled = ref.read(composerAutofocusEnabledProvider);
|
|
|
|
|
if (!widget.enabled ||
|
|
|
|
|
_focusNode.hasFocus ||
|
|
|
|
|
_pendingFocus ||
|
|
|
|
|
!autofocusEnabled) {
|
2025-10-02 22:38:28 +05:30
|
|
|
return;
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
2025-10-02 22:38:28 +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);
|
2025-09-08 01:05:48 +05:30
|
|
|
// 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();
|
2025-10-24 00:39:43 +05:30
|
|
|
|
|
|
|
|
// Dismiss keyboard after sending to recover screen space
|
|
|
|
|
_focusNode.unfocus();
|
|
|
|
|
try {
|
|
|
|
|
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// Silently handle if keyboard dismissal fails
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
2025-12-02 21:10:59 +05:30
|
|
|
/// Handles content insertion from keyboard/clipboard (images, files).
|
|
|
|
|
///
|
|
|
|
|
/// This is called when the user pastes rich content into the text field
|
|
|
|
|
/// on iOS and Android.
|
|
|
|
|
Future<void> _handleContentInserted(KeyboardInsertedContent content) async {
|
|
|
|
|
if (!widget.enabled) return;
|
|
|
|
|
|
|
|
|
|
// Check if we have a callback to handle pasted attachments
|
|
|
|
|
final onPasted = widget.onPastedAttachments;
|
|
|
|
|
if (onPasted == null) return;
|
|
|
|
|
|
|
|
|
|
final mimeType = content.mimeType;
|
|
|
|
|
final data = content.data;
|
|
|
|
|
|
|
|
|
|
// Only process image content
|
|
|
|
|
if (!_clipboardService.isSupportedImageType(mimeType)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if we have actual data
|
|
|
|
|
if (data == null || data.isEmpty) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
PlatformUtils.lightHaptic();
|
|
|
|
|
|
|
|
|
|
// Create attachment from pasted image data
|
|
|
|
|
String? suggestedName;
|
|
|
|
|
final uriString = content.uri;
|
|
|
|
|
if (uriString.isNotEmpty) {
|
|
|
|
|
try {
|
|
|
|
|
final uri = Uri.parse(uriString);
|
|
|
|
|
if (uri.pathSegments.isNotEmpty) {
|
|
|
|
|
suggestedName = uri.pathSegments.last;
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// Ignore URI parsing errors
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
final attachment = await _clipboardService.createAttachmentFromImageData(
|
|
|
|
|
imageData: data,
|
|
|
|
|
mimeType: mimeType,
|
|
|
|
|
suggestedFileName: suggestedName,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (attachment != null) {
|
|
|
|
|
await onPasted([attachment]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-08 12:47:12 +05:30
|
|
|
/// Handles pasting images/files from clipboard with pre-loaded image data.
|
|
|
|
|
///
|
|
|
|
|
/// This avoids a second clipboard read by using data already fetched when
|
|
|
|
|
/// building the context menu.
|
|
|
|
|
Future<void> _handleClipboardPasteWithData(Uint8List imageData) async {
|
|
|
|
|
if (!widget.enabled) return;
|
|
|
|
|
|
|
|
|
|
final onPasted = widget.onPastedAttachments;
|
|
|
|
|
if (onPasted == null) return;
|
|
|
|
|
|
|
|
|
|
PlatformUtils.lightHaptic();
|
|
|
|
|
|
|
|
|
|
final attachment = await _clipboardService.createAttachmentFromImageData(
|
|
|
|
|
imageData: imageData,
|
|
|
|
|
mimeType: 'image/png',
|
|
|
|
|
);
|
|
|
|
|
if (attachment != null) {
|
|
|
|
|
await onPasted([attachment]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds a custom context menu with standard options plus "Paste Image".
|
|
|
|
|
///
|
|
|
|
|
/// The standard paste only works for text. This adds a "Paste Image"
|
|
|
|
|
/// option that uses the pasteboard package to read images from clipboard
|
|
|
|
|
/// on both iOS and Android. The option only appears when there's actually
|
|
|
|
|
/// an image in the clipboard.
|
|
|
|
|
Widget _buildContextMenu(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
EditableTextState editableTextState,
|
|
|
|
|
) {
|
|
|
|
|
final List<ContextMenuButtonItem> buttonItems = List.from(
|
|
|
|
|
editableTextState.contextMenuButtonItems,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Only add "Paste Image" if we have a callback for pasted attachments
|
|
|
|
|
if (widget.onPastedAttachments == null) {
|
|
|
|
|
return AdaptiveTextSelectionToolbar.buttonItems(
|
|
|
|
|
anchors: editableTextState.contextMenuAnchors,
|
|
|
|
|
buttonItems: buttonItems,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check clipboard for images - the data is captured in the closure to
|
|
|
|
|
// avoid double-read and stale cache issues
|
|
|
|
|
return FutureBuilder<Uint8List?>(
|
|
|
|
|
future: _clipboardService.getClipboardImage(),
|
|
|
|
|
builder: (context, snapshot) {
|
|
|
|
|
final imageData = snapshot.data;
|
|
|
|
|
final hasImage = imageData != null && imageData.isNotEmpty;
|
|
|
|
|
|
|
|
|
|
if (hasImage) {
|
|
|
|
|
// Find the index of the standard Paste button to insert after it
|
|
|
|
|
final pasteIndex = buttonItems.indexWhere(
|
|
|
|
|
(item) => item.type == ContextMenuButtonType.paste,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Capture imageData in closure to avoid re-reading clipboard
|
|
|
|
|
final pasteImageItem = ContextMenuButtonItem(
|
|
|
|
|
label: AppLocalizations.of(context)?.pasteImage ?? 'Paste Image',
|
|
|
|
|
onPressed: () {
|
|
|
|
|
// Close the context menu first
|
|
|
|
|
ContextMenuController.removeAny();
|
|
|
|
|
// Use the captured imageData directly
|
|
|
|
|
_handleClipboardPasteWithData(imageData);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Insert after Paste if found, otherwise add at the end
|
|
|
|
|
if (pasteIndex >= 0) {
|
|
|
|
|
buttonItems.insert(pasteIndex + 1, pasteImageItem);
|
|
|
|
|
} else {
|
|
|
|
|
buttonItems.add(pasteImageItem);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return AdaptiveTextSelectionToolbar.buttonItems(
|
|
|
|
|
anchors: editableTextState.contextMenuAnchors,
|
|
|
|
|
buttonItems: buttonItems,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 01:05:48 +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;
|
2025-12-14 18:52:12 +05:30
|
|
|
// Consider multiline if text contains newlines or exceeds ~50 chars
|
|
|
|
|
final bool isMultiline = text.contains('\n') || text.length > 50;
|
2025-09-20 23:22:57 +05:30
|
|
|
final _PromptCommandMatch? match = _resolvePromptCommand(
|
|
|
|
|
text,
|
|
|
|
|
selection,
|
|
|
|
|
widget.enabled,
|
|
|
|
|
);
|
|
|
|
|
final bool shouldShow = match != null;
|
|
|
|
|
final bool wasShowing = _showPromptOverlay;
|
|
|
|
|
final String previousCommand = _currentPromptCommand;
|
|
|
|
|
|
2025-12-14 18:52:12 +05:30
|
|
|
bool needsUpdate =
|
|
|
|
|
hasText != _hasText ||
|
|
|
|
|
isMultiline != _isMultiline ||
|
|
|
|
|
shouldShow != _showPromptOverlay;
|
2025-09-20 23:22:57 +05:30
|
|
|
|
|
|
|
|
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;
|
2025-12-14 18:52:12 +05:30
|
|
|
_isMultiline = isMultiline;
|
2025-09-20 23:22:57 +05:30
|
|
|
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
|
2025-11-26 22:19:19 +05:30
|
|
|
if (_currentPromptCommand.startsWith('/')) {
|
|
|
|
|
ref.read(promptsListProvider.future);
|
|
|
|
|
}
|
2025-09-20 23:22:57 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_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);
|
2025-11-26 22:19:19 +05:30
|
|
|
if (candidate.isEmpty ||
|
|
|
|
|
!(candidate.startsWith('/') || candidate.startsWith('#'))) {
|
2025-09-20 23:22:57 +05:30
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return _PromptCommandMatch(command: candidate, start: start, end: cursor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<Prompt> _filterPrompts(List<Prompt> prompts) {
|
|
|
|
|
if (prompts.isEmpty) return const <Prompt>[];
|
2025-11-26 22:19:19 +05:30
|
|
|
final String query = _currentPromptCommand.toLowerCase().trim();
|
|
|
|
|
// Strip leading '/' prefix so we can match prompt commands (e.g., "help")
|
2025-12-02 21:10:59 +05:30
|
|
|
final String searchQuery = query.startsWith('/')
|
|
|
|
|
? query.substring(1)
|
|
|
|
|
: query;
|
2025-11-26 22:19:19 +05:30
|
|
|
|
2025-09-20 23:22:57 +05:30
|
|
|
final List<Prompt> filtered =
|
|
|
|
|
prompts
|
|
|
|
|
.where(
|
|
|
|
|
(prompt) =>
|
2025-11-26 22:19:19 +05:30
|
|
|
prompt.command.toLowerCase().contains(searchQuery) &&
|
2025-09-20 23:22:57 +05:30
|
|
|
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) {
|
2025-11-26 22:19:19 +05:30
|
|
|
if (_currentPromptCommand.startsWith('#')) {
|
|
|
|
|
// Only a single option in knowledge overlay; nothing to move.
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 23:22:57 +05:30
|
|
|
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() {
|
2025-11-26 22:19:19 +05:30
|
|
|
if (_currentPromptCommand.startsWith('#')) {
|
|
|
|
|
_openKnowledgePicker();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 23:22:57 +05:30
|
|
|
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;
|
|
|
|
|
|
2025-12-07 10:47:57 +05:30
|
|
|
// Check if the prompt has variables that need processing
|
|
|
|
|
const parser = PromptVariableParser();
|
|
|
|
|
if (parser.hasVariables(prompt.content)) {
|
|
|
|
|
_processPromptWithVariables(prompt, range);
|
|
|
|
|
} else {
|
|
|
|
|
_insertPromptContent(prompt.content, range);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _processPromptWithVariables(
|
|
|
|
|
Prompt prompt,
|
|
|
|
|
TextRange range,
|
|
|
|
|
) async {
|
|
|
|
|
// Hide overlay first
|
|
|
|
|
setState(() {
|
|
|
|
|
_showPromptOverlay = false;
|
|
|
|
|
_currentPromptCommand = '';
|
|
|
|
|
_currentPromptRange = null;
|
|
|
|
|
_promptSelectionIndex = 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Get user info for system variables
|
|
|
|
|
final authUser = ref.read(currentUserProvider2);
|
|
|
|
|
final userAsync = ref.read(currentUserProvider);
|
|
|
|
|
final user = userAsync.maybeWhen(
|
|
|
|
|
data: (value) => value ?? authUser,
|
|
|
|
|
orElse: () => authUser,
|
|
|
|
|
);
|
|
|
|
|
final locale = Localizations.localeOf(context);
|
|
|
|
|
|
|
|
|
|
// Create the processor with system variable context
|
|
|
|
|
const parser = PromptVariableParser();
|
|
|
|
|
final systemResolver = SystemVariableResolver(
|
|
|
|
|
userName: user?.name ?? user?.email,
|
|
|
|
|
userLanguage: locale.languageCode,
|
|
|
|
|
// userLocation requires permission - left empty for now
|
|
|
|
|
);
|
|
|
|
|
final processor = PromptProcessor(
|
|
|
|
|
parser: parser,
|
|
|
|
|
systemResolver: systemResolver,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Process system variables first
|
|
|
|
|
final processed = await processor.process(prompt.content);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
|
|
String finalContent = processed.content;
|
|
|
|
|
|
|
|
|
|
// If there are user input variables, show the dialog
|
|
|
|
|
if (processed.needsUserInput) {
|
|
|
|
|
final values = await PromptVariableDialog.show(
|
|
|
|
|
context,
|
|
|
|
|
variables: processed.userInputVariables,
|
|
|
|
|
promptTitle: prompt.title,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (values == null || !mounted) {
|
|
|
|
|
// User cancelled - restore focus
|
|
|
|
|
_ensureFocusedIfEnabled();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply user-provided values
|
|
|
|
|
finalContent = processor.applyUserValues(finalContent, values);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Insert the fully processed content
|
|
|
|
|
_insertPromptContent(finalContent, range);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _insertPromptContent(String content, TextRange range) {
|
2025-09-20 23:22:57 +05:30
|
|
|
final String text = _controller.text;
|
|
|
|
|
final String before = text.substring(0, range.start);
|
|
|
|
|
final String after = text.substring(range.end);
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 22:19:19 +05:30
|
|
|
Future<void> _openKnowledgePicker() async {
|
|
|
|
|
_hidePromptOverlay();
|
|
|
|
|
|
|
|
|
|
// Ensure bases are loaded in the centralized cache
|
|
|
|
|
final cacheNotifier = ref.read(knowledgeCacheProvider.notifier);
|
|
|
|
|
await cacheNotifier.ensureBases();
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
|
|
// Track selected base ID outside the builder so it persists across rebuilds
|
|
|
|
|
String? selectedBaseId;
|
|
|
|
|
|
|
|
|
|
await showModalBottomSheet(
|
|
|
|
|
context: context,
|
|
|
|
|
backgroundColor: Colors.transparent,
|
|
|
|
|
isScrollControlled: true,
|
|
|
|
|
builder: (modalContext) {
|
|
|
|
|
return ModalSheetSafeArea(
|
|
|
|
|
// Use StatefulBuilder to manage selectedBaseId locally so that
|
|
|
|
|
// selecting a knowledge base triggers a proper rebuild.
|
|
|
|
|
child: StatefulBuilder(
|
|
|
|
|
builder: (statefulContext, setModalState) {
|
|
|
|
|
return Consumer(
|
|
|
|
|
builder: (innerContext, innerRef, _) {
|
|
|
|
|
final cacheState = innerRef.watch(knowledgeCacheProvider);
|
|
|
|
|
final bases = cacheState.bases;
|
|
|
|
|
final itemsMap = cacheState.items;
|
|
|
|
|
final items = selectedBaseId != null
|
|
|
|
|
? itemsMap[selectedBaseId] ?? const <KnowledgeBaseItem>[]
|
|
|
|
|
: const <KnowledgeBaseItem>[];
|
2025-12-02 21:10:59 +05:30
|
|
|
final loading =
|
|
|
|
|
cacheState.isLoading ||
|
2025-11-26 22:19:19 +05:30
|
|
|
(selectedBaseId != null &&
|
|
|
|
|
!itemsMap.containsKey(selectedBaseId));
|
|
|
|
|
|
|
|
|
|
Future<void> loadItems(KnowledgeBase base) async {
|
|
|
|
|
setModalState(() {
|
|
|
|
|
selectedBaseId = base.id;
|
|
|
|
|
});
|
|
|
|
|
await innerRef
|
|
|
|
|
.read(knowledgeCacheProvider.notifier)
|
|
|
|
|
.fetchItemsForBase(base.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: innerContext.conduitTheme.surfaceBackground,
|
|
|
|
|
borderRadius: const BorderRadius.vertical(
|
|
|
|
|
top: Radius.circular(AppBorderRadius.modal),
|
|
|
|
|
),
|
|
|
|
|
boxShadow: ConduitShadows.modal(innerContext),
|
|
|
|
|
),
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
height: MediaQuery.of(innerContext).size.height * 0.6,
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
flex: 1,
|
|
|
|
|
child: ListView.builder(
|
|
|
|
|
itemCount: bases.length,
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final base = bases[index];
|
|
|
|
|
final isSelected = selectedBaseId == base.id;
|
|
|
|
|
return ListTile(
|
|
|
|
|
dense: true,
|
|
|
|
|
selected: isSelected,
|
|
|
|
|
title: Text(base.name),
|
|
|
|
|
onTap: () => loadItems(base),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const VerticalDivider(width: 1),
|
|
|
|
|
Expanded(
|
|
|
|
|
flex: 2,
|
|
|
|
|
child: loading
|
|
|
|
|
? const Center(
|
|
|
|
|
child: CircularProgressIndicator(),
|
|
|
|
|
)
|
|
|
|
|
: ListView.builder(
|
|
|
|
|
itemCount: items.length,
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final item = items[index];
|
|
|
|
|
final KnowledgeBase? selectedBase =
|
|
|
|
|
bases.isEmpty
|
2025-12-02 21:10:59 +05:30
|
|
|
? null
|
|
|
|
|
: bases.firstWhere(
|
|
|
|
|
(b) => b.id == selectedBaseId,
|
|
|
|
|
orElse: () => bases.first,
|
|
|
|
|
);
|
2025-11-26 22:19:19 +05:30
|
|
|
return ListTile(
|
|
|
|
|
title: Text(
|
|
|
|
|
item.title ??
|
2025-12-02 21:10:59 +05:30
|
|
|
item.metadata['name']
|
|
|
|
|
?.toString() ??
|
2025-11-26 22:19:19 +05:30
|
|
|
'Document',
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
|
|
|
|
subtitle: Text(
|
|
|
|
|
item.metadata['source']?.toString() ??
|
|
|
|
|
item.content,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
|
|
|
|
onTap: () {
|
|
|
|
|
innerRef
|
|
|
|
|
.read(
|
|
|
|
|
contextAttachmentsProvider
|
|
|
|
|
.notifier,
|
|
|
|
|
)
|
|
|
|
|
.addKnowledge(
|
2025-12-02 21:10:59 +05:30
|
|
|
displayName:
|
|
|
|
|
item.title ??
|
2025-11-26 22:19:19 +05:30
|
|
|
item.metadata['name']
|
|
|
|
|
?.toString() ??
|
|
|
|
|
'Document',
|
|
|
|
|
fileId: item.id,
|
|
|
|
|
collectionName:
|
|
|
|
|
selectedBase?.name ??
|
2025-12-02 21:10:59 +05:30
|
|
|
'Unknown',
|
2025-11-26 22:19:19 +05:30
|
|
|
url: item.metadata['source']
|
|
|
|
|
?.toString(),
|
|
|
|
|
);
|
|
|
|
|
if (modalContext.mounted) {
|
|
|
|
|
Navigator.of(modalContext).pop();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 23:22:57 +05:30
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-26 22:19:19 +05:30
|
|
|
if (_currentPromptCommand.startsWith('#')) {
|
|
|
|
|
return _buildKnowledgeOverlay(context, overlayColor, borderColor);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 23:22:57 +05:30
|
|
|
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-11-26 22:19:19 +05:30
|
|
|
Widget _buildKnowledgeOverlay(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
Color overlayColor,
|
|
|
|
|
Color borderColor,
|
|
|
|
|
) {
|
|
|
|
|
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: Theme.of(context).brightness == Brightness.dark
|
|
|
|
|
? 0.28
|
|
|
|
|
: 0.16,
|
|
|
|
|
),
|
|
|
|
|
blurRadius: 22,
|
|
|
|
|
offset: const Offset(0, 8),
|
|
|
|
|
spreadRadius: -4,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: ListTile(
|
|
|
|
|
title: const Text('Browse knowledge base'),
|
|
|
|
|
subtitle: const Text('Press Enter to pick a document'),
|
|
|
|
|
leading: const Icon(Icons.folder_outlined),
|
|
|
|
|
onTap: _openKnowledgePicker,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
2025-09-16 15:48:09 +05:30
|
|
|
_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);
|
2025-09-16 15:48:09 +05:30
|
|
|
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-12-05 22:19:31 +05:30
|
|
|
final selectedFilterIds = ref.watch(selectedFilterIdsProvider);
|
|
|
|
|
|
|
|
|
|
// Get filters from the selected model for quick pills
|
|
|
|
|
final selectedModel = ref.watch(selectedModelProvider);
|
|
|
|
|
final availableFilters = selectedModel?.filters ?? const [];
|
2025-08-24 14:35:17 +05:30
|
|
|
|
2025-08-28 14:45:46 +05:30
|
|
|
final focusTick = ref.watch(inputFocusTriggerProvider);
|
2025-10-10 15:22:54 +05:30
|
|
|
final autofocusEnabled = ref.watch(composerAutofocusEnabledProvider);
|
|
|
|
|
if (autofocusEnabled && focusTick != _lastHandledFocusTick) {
|
2025-08-28 18:59:59 +05:30
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
if (!mounted || _isDeactivated) return;
|
2025-09-08 01:05:48 +05:30
|
|
|
_ensureFocusedIfEnabled();
|
2025-08-28 18:59:59 +05:30
|
|
|
_lastHandledFocusTick = focusTick;
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-08-28 14:45:46 +05:30
|
|
|
|
2025-09-16 15:48:09 +05:30
|
|
|
final Brightness brightness = Theme.of(context).brightness;
|
2025-09-19 11:58:22 +05:30
|
|
|
final bool isActive = _focusNode.hasFocus || _hasText;
|
2025-12-11 11:35:56 +05:30
|
|
|
// Use high-contrast background for floating input
|
2025-09-20 18:09:22 +05:30
|
|
|
final Color composerBackground = brightness == Brightness.dark
|
2025-12-11 11:35:56 +05:30
|
|
|
? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)!
|
|
|
|
|
: Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!;
|
2025-10-19 15:08:15 +05:30
|
|
|
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,
|
2025-12-11 11:35:56 +05:30
|
|
|
)!.withValues(alpha: brightness == Brightness.dark ? 0.65 : 0.55);
|
2025-09-19 11:58:22 +05:30
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
);
|
2025-12-05 22:19:31 +05:30
|
|
|
} else if (id.startsWith('filter:')) {
|
|
|
|
|
// Handle filter quick pills
|
|
|
|
|
final filterId = id.substring(7); // Remove 'filter:' prefix
|
|
|
|
|
ToggleFilter? filter;
|
|
|
|
|
for (final f in availableFilters) {
|
|
|
|
|
if (f.id == filterId) {
|
|
|
|
|
filter = f;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (filter != null) {
|
|
|
|
|
final bool isSelected = selectedFilterIds.contains(filterId);
|
|
|
|
|
final String label = filter.name;
|
|
|
|
|
final IconData icon = Platform.isIOS
|
|
|
|
|
? CupertinoIcons.sparkles
|
|
|
|
|
: Icons.auto_awesome;
|
|
|
|
|
|
|
|
|
|
void handleTap() {
|
|
|
|
|
ref.read(selectedFilterIdsProvider.notifier).toggle(filterId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
quickPills.add(
|
|
|
|
|
_buildPillButton(
|
|
|
|
|
icon: icon,
|
|
|
|
|
label: label,
|
|
|
|
|
isActive: isSelected,
|
|
|
|
|
onTap: widget.enabled && !_isRecording ? handleTap : null,
|
|
|
|
|
iconUrl: filter.icon,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-09-19 11:58:22 +05:30
|
|
|
} else {
|
2025-12-05 22:19:31 +05:30
|
|
|
// Handle tool quick pills
|
2025-09-19 11:58:22 +05:30
|
|
|
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
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 12:12:29 +05:30
|
|
|
final bool showCompactComposer = quickPills.isEmpty;
|
|
|
|
|
|
2025-12-14 18:52:12 +05:30
|
|
|
// Use a reduced border radius when content is multiline to prevent text
|
|
|
|
|
// from overflowing outside the rounded corners (fixes #272)
|
|
|
|
|
final double compactRadius = _isMultiline
|
|
|
|
|
? AppBorderRadius.xl
|
|
|
|
|
: AppBorderRadius.round;
|
2025-10-02 12:12:29 +05:30
|
|
|
final BorderRadius shellRadius = BorderRadius.circular(
|
2025-12-14 18:52:12 +05:30
|
|
|
showCompactComposer ? compactRadius : _composerRadius,
|
2025-10-02 12:12:29 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final BoxDecoration shellDecoration = BoxDecoration(
|
2025-12-11 11:35:56 +05:30
|
|
|
color: composerBackground,
|
2025-10-02 12:12:29 +05:30
|
|
|
borderRadius: shellRadius,
|
2025-12-11 11:35:56 +05:30
|
|
|
border: Border.all(color: outlineColor, width: BorderWidth.thin),
|
|
|
|
|
boxShadow: <BoxShadow>[
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: shellShadowColor,
|
|
|
|
|
blurRadius: 12 + (isActive ? 4 : 0),
|
|
|
|
|
spreadRadius: -2,
|
|
|
|
|
offset: const Offset(0, -2),
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-10-02 12:12:29 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final List<Widget> composerChildren = <Widget>[
|
|
|
|
|
if (_showPromptOverlay)
|
|
|
|
|
Padding(
|
2025-11-27 20:14:30 +05:30
|
|
|
key: const ValueKey('prompt-overlay'),
|
2025-10-02 12:12:29 +05:30
|
|
|
padding: const EdgeInsets.fromLTRB(
|
|
|
|
|
Spacing.sm,
|
|
|
|
|
0,
|
|
|
|
|
Spacing.sm,
|
|
|
|
|
Spacing.xs,
|
|
|
|
|
),
|
|
|
|
|
child: _buildPromptOverlay(context),
|
|
|
|
|
),
|
2025-12-11 11:35:56 +05:30
|
|
|
if (!showCompactComposer) ...[
|
2025-10-02 12:12:29 +05:30
|
|
|
Padding(
|
2025-11-27 20:14:30 +05:30
|
|
|
key: const ValueKey('composer-expanded-input'),
|
2025-10-02 12:12:29 +05:30
|
|
|
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(
|
2025-11-27 20:14:30 +05:30
|
|
|
key: const ValueKey('composer-expanded-buttons'),
|
2025-10-02 12:12:29 +05:30
|
|
|
padding: const EdgeInsets.fromLTRB(
|
|
|
|
|
Spacing.inputPadding,
|
|
|
|
|
0,
|
|
|
|
|
Spacing.inputPadding,
|
2025-12-11 16:57:53 +05:30
|
|
|
Spacing.sm,
|
2025-10-02 12:12:29 +05:30
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
_buildOverflowButton(
|
|
|
|
|
tooltip: AppLocalizations.of(context)!.more,
|
|
|
|
|
webSearchActive: webSearchEnabled,
|
|
|
|
|
imageGenerationActive: imageGenEnabled,
|
|
|
|
|
toolsActive: selectedToolIds.isNotEmpty,
|
2025-12-05 22:19:31 +05:30
|
|
|
filtersActive: selectedFilterIds.isNotEmpty,
|
2025-10-02 12:12:29 +05:30
|
|
|
),
|
|
|
|
|
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: [
|
2025-10-08 19:09:57 +05:30
|
|
|
if (!_hasText && voiceAvailable && !isGenerating) ...[
|
|
|
|
|
_buildMicButton(voiceAvailable),
|
|
|
|
|
const SizedBox(width: Spacing.sm),
|
|
|
|
|
],
|
2025-10-02 12:12:29 +05:30
|
|
|
_buildPrimaryButton(
|
|
|
|
|
_hasText,
|
|
|
|
|
isGenerating,
|
|
|
|
|
stopGeneration,
|
|
|
|
|
voiceAvailable,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
2025-12-11 11:35:56 +05:30
|
|
|
// For compact mode, render text field shell with floating buttons on sides
|
|
|
|
|
if (showCompactComposer) {
|
|
|
|
|
// Build the text field shell
|
2025-12-20 18:25:55 +05:30
|
|
|
Widget textFieldShell = Container(
|
2025-12-11 11:35:56 +05:30
|
|
|
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
|
|
|
|
|
constraints: const BoxConstraints(minHeight: TouchTarget.input),
|
|
|
|
|
decoration: shellDecoration,
|
2025-09-19 11:58:22 +05:30
|
|
|
child: ConstrainedBox(
|
|
|
|
|
constraints: BoxConstraints(
|
2025-12-11 11:35:56 +05:30
|
|
|
maxHeight: MediaQuery.of(context).size.height * 0.25,
|
2025-09-19 11:58:22 +05:30
|
|
|
),
|
2025-12-11 11:35:56 +05:30
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: _buildComposerTextField(
|
|
|
|
|
brightness: brightness,
|
|
|
|
|
sendOnEnter: sendOnEnter,
|
|
|
|
|
placeholderBase: placeholderBase,
|
|
|
|
|
placeholderFocused: placeholderFocused,
|
|
|
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
|
|
|
vertical: Spacing.xs,
|
|
|
|
|
),
|
|
|
|
|
isActive: isActive,
|
2025-08-10 01:20:45 +05:30
|
|
|
),
|
|
|
|
|
),
|
2025-12-11 11:35:56 +05:30
|
|
|
if (!_hasText && voiceAvailable && !isGenerating)
|
|
|
|
|
_buildInlineMicIcon(voiceAvailable),
|
|
|
|
|
],
|
2025-08-10 01:20:45 +05:30
|
|
|
),
|
2025-09-19 11:58:22 +05:30
|
|
|
),
|
2025-12-11 11:35:56 +05:30
|
|
|
);
|
2025-09-19 11:58:22 +05:30
|
|
|
|
2025-12-11 11:35:56 +05:30
|
|
|
final bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: EdgeInsets.fromLTRB(
|
|
|
|
|
Spacing.screenPadding,
|
|
|
|
|
0,
|
|
|
|
|
Spacing.screenPadding,
|
|
|
|
|
bottomPadding + Spacing.md,
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
|
|
children: [
|
|
|
|
|
_buildOverflowButton(
|
|
|
|
|
tooltip: AppLocalizations.of(context)!.more,
|
|
|
|
|
webSearchActive: webSearchEnabled,
|
|
|
|
|
imageGenerationActive: imageGenEnabled,
|
|
|
|
|
toolsActive: selectedToolIds.isNotEmpty,
|
|
|
|
|
filtersActive: selectedFilterIds.isNotEmpty,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: Spacing.sm),
|
|
|
|
|
Expanded(child: textFieldShell),
|
|
|
|
|
const SizedBox(width: Spacing.sm),
|
|
|
|
|
_buildPrimaryButton(
|
|
|
|
|
_hasText,
|
|
|
|
|
isGenerating,
|
|
|
|
|
stopGeneration,
|
|
|
|
|
voiceAvailable,
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-09-19 11:58:22 +05:30
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 11:35:56 +05:30
|
|
|
// For expanded mode with quick pills, use the full shell
|
2025-12-20 18:25:55 +05:30
|
|
|
Widget shell = Container(
|
2025-12-11 11:35:56 +05:30
|
|
|
decoration: shellDecoration,
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Wrap with padding for floating effect, accounting for safe area
|
|
|
|
|
final bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: EdgeInsets.fromLTRB(
|
2025-12-11 16:57:53 +05:30
|
|
|
Spacing.screenPadding,
|
2025-12-11 11:35:56 +05:30
|
|
|
0,
|
2025-12-11 16:57:53 +05:30
|
|
|
Spacing.screenPadding,
|
2025-12-11 11:35:56 +05:30
|
|
|
bottomPadding + Spacing.md,
|
|
|
|
|
),
|
|
|
|
|
child: 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-16 15:48:09 +05:30
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 12:12:29 +05:30
|
|
|
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,
|
2025-12-05 18:23:00 +05:30
|
|
|
// Exclude from semantics so screen readers interact directly with the
|
|
|
|
|
// TextField, which provides its own accessibility via hintText.
|
|
|
|
|
excludeFromSemantics: true,
|
2025-10-02 12:12:29 +05:30
|
|
|
onTap: () {
|
|
|
|
|
if (!widget.enabled) return;
|
2025-10-10 15:22:54 +05:30
|
|
|
// Explicit user intent to focus: re-enable autofocus and focus
|
|
|
|
|
try {
|
|
|
|
|
ref.read(composerAutofocusEnabledProvider.notifier).set(true);
|
|
|
|
|
} catch (_) {}
|
2025-10-02 12:12:29 +05:30
|
|
|
_ensureFocusedIfEnabled();
|
|
|
|
|
},
|
2025-12-05 18:23:00 +05:30
|
|
|
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,
|
2025-12-03 15:04:53 +05:30
|
|
|
LogicalKeyboardKey.enter,
|
2025-12-05 18:23:00 +05:30
|
|
|
)] =
|
|
|
|
|
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>(
|
2025-12-03 15:04:53 +05:30
|
|
|
onInvoke: (intent) {
|
2025-12-05 18:23:00 +05:30
|
|
|
_movePromptSelection(-1);
|
2025-12-03 15:04:53 +05:30
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
),
|
2025-12-05 18:23:00 +05:30
|
|
|
_DismissPromptIntent: CallbackAction<_DismissPromptIntent>(
|
|
|
|
|
onInvoke: (intent) {
|
|
|
|
|
_hidePromptOverlay();
|
|
|
|
|
return null;
|
2025-10-02 12:12:29 +05:30
|
|
|
},
|
2025-12-05 18:23:00 +05:30
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
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;
|
|
|
|
|
|
2025-12-16 16:47:05 +05:30
|
|
|
// Rely on TextField's built-in accessibility via hintText.
|
|
|
|
|
// Wrapping with Semantics creates duplicate accessibility nodes
|
|
|
|
|
// which confuses screen readers and causes keyboard issues with
|
|
|
|
|
// alternative input methods (e.g., Braille keyboards).
|
|
|
|
|
// The hintText "Ask Conduit" provides sufficient context for
|
|
|
|
|
// screen readers to identify this as a message input field.
|
|
|
|
|
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,
|
2025-12-05 18:23:00 +05:30
|
|
|
fontWeight: recordingWeight,
|
2025-12-16 19:59:28 +05:30
|
|
|
fontStyle: _isRecording
|
|
|
|
|
? FontStyle.italic
|
|
|
|
|
: FontStyle.normal,
|
2025-12-05 18:23:00 +05:30
|
|
|
),
|
2025-12-16 16:47:05 +05:30
|
|
|
filled: false,
|
|
|
|
|
border: InputBorder.none,
|
|
|
|
|
enabledBorder: InputBorder.none,
|
|
|
|
|
focusedBorder: InputBorder.none,
|
|
|
|
|
errorBorder: InputBorder.none,
|
|
|
|
|
disabledBorder: InputBorder.none,
|
|
|
|
|
contentPadding: contentPadding,
|
|
|
|
|
isDense: true,
|
|
|
|
|
alignLabelWithHint: true,
|
|
|
|
|
),
|
|
|
|
|
// Enable pasting images and files from clipboard
|
|
|
|
|
contentInsertionConfiguration: ContentInsertionConfiguration(
|
|
|
|
|
allowedMimeTypes: ClipboardAttachmentService
|
|
|
|
|
.supportedImageMimeTypes
|
|
|
|
|
.toList(),
|
|
|
|
|
onContentInserted: _handleContentInserted,
|
2025-12-05 18:23:00 +05:30
|
|
|
),
|
2025-12-16 16:47:05 +05:30
|
|
|
// Custom context menu with "Paste Image" option for iOS
|
|
|
|
|
contextMenuBuilder: (context, editableTextState) {
|
|
|
|
|
return _buildContextMenu(context, editableTextState);
|
|
|
|
|
},
|
|
|
|
|
onSubmitted: (_) {
|
|
|
|
|
if (sendOnEnter) {
|
|
|
|
|
_sendMessage();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onTap: () {
|
|
|
|
|
if (!widget.enabled) return;
|
|
|
|
|
_ensureFocusedIfEnabled();
|
|
|
|
|
},
|
2025-12-05 18:23:00 +05:30
|
|
|
);
|
|
|
|
|
},
|
2025-10-02 12:12:29 +05:30
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 18:09:22 +05:30
|
|
|
Widget _buildOverflowButton({
|
|
|
|
|
required String tooltip,
|
|
|
|
|
required bool webSearchActive,
|
|
|
|
|
required bool imageGenerationActive,
|
|
|
|
|
required bool toolsActive,
|
2025-12-05 22:19:31 +05:30
|
|
|
required bool filtersActive,
|
2025-09-20 18:09:22 +05:30
|
|
|
}) {
|
|
|
|
|
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;
|
2025-12-05 22:19:31 +05:30
|
|
|
} else if (filtersActive) {
|
|
|
|
|
icon = Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome;
|
|
|
|
|
activeColor = context.conduitTheme.buttonPrimary;
|
2025-09-20 18:09:22 +05:30
|
|
|
} else {
|
|
|
|
|
icon = Platform.isIOS ? CupertinoIcons.add : Icons.add;
|
|
|
|
|
activeColor = null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 00:30:14 +05:30
|
|
|
const double iconSize = IconSize.large;
|
2025-10-02 12:22:07 +05:30
|
|
|
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));
|
|
|
|
|
|
2025-12-11 11:35:56 +05:30
|
|
|
// Use high-contrast background for floating button
|
2025-10-18 15:35:07 +05:30
|
|
|
final Brightness brightness = Theme.of(context).brightness;
|
2025-12-11 11:35:56 +05:30
|
|
|
final Color baseBackground = brightness == Brightness.dark
|
|
|
|
|
? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)!
|
|
|
|
|
: Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!;
|
2025-10-02 12:22:07 +05:30
|
|
|
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(
|
2025-10-02 12:22:07 +05:30
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
2025-10-02 00:54:35 +05:30
|
|
|
onTap: enabled
|
|
|
|
|
? () {
|
|
|
|
|
HapticFeedback.selectionClick();
|
|
|
|
|
_showOverflowSheet();
|
|
|
|
|
}
|
|
|
|
|
: null,
|
2025-10-02 12:22:07 +05:30
|
|
|
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
|
|
|
),
|
|
|
|
|
),
|
2025-09-16 15:48:09 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 19:09:57 +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,
|
|
|
|
|
)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-18 15:35:07 +05:30
|
|
|
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
|
|
|
) {
|
2025-09-16 15:48:09 +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(
|
2025-08-23 20:09:43 +05:30
|
|
|
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),
|
2025-10-19 23:07:36 +05:30
|
|
|
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),
|
2025-10-03 00:12:25 +05:30
|
|
|
boxShadow: ConduitShadows.button(context),
|
2025-08-10 01:20:45 +05:30
|
|
|
),
|
2025-10-02 12:34:12 +05:30
|
|
|
child: Center(
|
|
|
|
|
child: Icon(
|
|
|
|
|
Platform.isIOS ? CupertinoIcons.stop_fill : Icons.stop,
|
|
|
|
|
size: IconSize.large,
|
2025-10-19 23:07:36 +05:30
|
|
|
color: context.conduitTheme.buttonPrimaryText,
|
2025-10-02 12:34:12 +05:30
|
|
|
),
|
2025-08-21 23:56:47 +05:30
|
|
|
),
|
2025-08-10 01:20:45 +05:30
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 19:09:57 +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,
|
2025-10-02 12:22:07 +05:30
|
|
|
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
|
2025-10-02 12:22:07 +05:30
|
|
|
: context.conduitTheme.surfaceContainerHighest,
|
2025-09-20 19:54:00 +05:30
|
|
|
borderRadius: BorderRadius.circular(radius),
|
2025-10-02 12:22:07 +05:30
|
|
|
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
|
|
|
),
|
2025-10-02 12:22:07 +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
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 19:09:57 +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(
|
2025-10-08 19:09:57 +05:30
|
|
|
message: 'Voice Call',
|
2025-08-21 23:56:47 +05:30
|
|
|
child: Opacity(
|
2025-10-08 19:09:57 +05:30
|
|
|
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(
|
2025-10-09 01:49:56 +05:30
|
|
|
Platform.isIOS ? CupertinoIcons.waveform : Icons.graphic_eq,
|
2025-10-08 19:09:57 +05:30
|
|
|
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-12-05 22:19:31 +05:30
|
|
|
String? iconUrl,
|
2025-08-24 14:35:17 +05:30
|
|
|
}) {
|
2025-09-19 11:58:22 +05:30
|
|
|
final bool enabled = onTap != null;
|
|
|
|
|
final Brightness brightness = Theme.of(context).brightness;
|
2025-10-30 22:44:08 +05:30
|
|
|
final theme = context.conduitTheme;
|
2025-10-31 23:20:04 +05:30
|
|
|
|
2025-10-30 22:44:08 +05:30
|
|
|
// Enhanced color scheme for active state
|
|
|
|
|
final Color activeBackground = isActive
|
2025-10-31 23:20:04 +05:30
|
|
|
? theme.buttonPrimary.withValues(
|
|
|
|
|
alpha: brightness == Brightness.dark ? 0.22 : 0.14,
|
|
|
|
|
)
|
2025-10-30 22:44:08 +05:30
|
|
|
: Colors.transparent;
|
2025-10-31 23:20:04 +05:30
|
|
|
|
2025-10-30 22:44:08 +05:30
|
|
|
final Color inactiveBackground = brightness == Brightness.dark
|
|
|
|
|
? theme.cardBackground.withValues(alpha: 0.25)
|
|
|
|
|
: theme.cardBackground.withValues(alpha: 0.08);
|
2025-10-31 23:20:04 +05:30
|
|
|
|
2025-10-30 22:44:08 +05:30
|
|
|
final Color background = isActive ? activeBackground : inactiveBackground;
|
2025-10-31 23:20:04 +05:30
|
|
|
|
2025-10-30 22:44:08 +05:30
|
|
|
// Enhanced border styling
|
|
|
|
|
final Color activeBorder = theme.buttonPrimary.withValues(
|
|
|
|
|
alpha: brightness == Brightness.dark ? 0.85 : 0.75,
|
|
|
|
|
);
|
|
|
|
|
final Color inactiveBorder = theme.cardBorder.withValues(
|
|
|
|
|
alpha: brightness == Brightness.dark ? 0.4 : 0.25,
|
|
|
|
|
);
|
|
|
|
|
final Color borderColor = isActive ? activeBorder : inactiveBorder;
|
2025-10-31 23:20:04 +05:30
|
|
|
|
2025-10-30 22:44:08 +05:30
|
|
|
// Enhanced content colors
|
|
|
|
|
final Color activeTextColor = theme.buttonPrimary;
|
|
|
|
|
final Color inactiveTextColor = theme.textPrimary.withValues(
|
2025-10-31 23:20:04 +05:30
|
|
|
alpha: enabled
|
|
|
|
|
? (brightness == Brightness.dark ? 0.85 : 0.75)
|
|
|
|
|
: Alpha.disabled,
|
2025-10-30 22:44:08 +05:30
|
|
|
);
|
|
|
|
|
final Color textColor = isActive ? activeTextColor : inactiveTextColor;
|
2025-10-31 23:20:04 +05:30
|
|
|
|
|
|
|
|
final Color iconColor = isActive ? activeTextColor : inactiveTextColor;
|
2025-10-30 22:44:08 +05:30
|
|
|
|
|
|
|
|
return AnimatedContainer(
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
curve: Curves.easeOutCubic,
|
|
|
|
|
child: Material(
|
|
|
|
|
color: Colors.transparent,
|
|
|
|
|
child: InkWell(
|
|
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
|
|
|
|
onTap: onTap == null
|
|
|
|
|
? null
|
|
|
|
|
: () {
|
|
|
|
|
HapticFeedback.mediumImpact();
|
|
|
|
|
onTap();
|
|
|
|
|
},
|
|
|
|
|
child: AnimatedContainer(
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
curve: Curves.easeOutCubic,
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: Spacing.md,
|
|
|
|
|
vertical: Spacing.sm - 2,
|
|
|
|
|
),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: background,
|
|
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
|
|
|
|
border: Border.all(
|
|
|
|
|
color: borderColor,
|
|
|
|
|
width: isActive ? BorderWidth.medium : BorderWidth.thin,
|
2025-09-07 13:52:09 +05:30
|
|
|
),
|
2025-10-30 22:44:08 +05:30
|
|
|
boxShadow: isActive
|
|
|
|
|
? [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: theme.buttonPrimary.withValues(
|
|
|
|
|
alpha: brightness == Brightness.dark ? 0.25 : 0.15,
|
|
|
|
|
),
|
|
|
|
|
blurRadius: 8,
|
|
|
|
|
spreadRadius: 0,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
: [],
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
AnimatedContainer(
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
curve: Curves.easeOutCubic,
|
2025-12-05 22:19:31 +05:30
|
|
|
child: iconUrl != null && iconUrl.isNotEmpty
|
|
|
|
|
? SizedBox(
|
|
|
|
|
width: IconSize.small + 1,
|
|
|
|
|
height: IconSize.small + 1,
|
|
|
|
|
child: Image.network(
|
|
|
|
|
iconUrl,
|
|
|
|
|
width: IconSize.small + 1,
|
|
|
|
|
height: IconSize.small + 1,
|
|
|
|
|
color: iconUrl.endsWith('.svg') ? iconColor : null,
|
|
|
|
|
colorBlendMode: BlendMode.srcIn,
|
2025-12-05 22:24:36 +05:30
|
|
|
errorBuilder: (_, _, _) => Icon(
|
2025-12-05 22:19:31 +05:30
|
|
|
icon,
|
|
|
|
|
size: IconSize.small + 1,
|
|
|
|
|
color: iconColor,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: Icon(icon, size: IconSize.small + 1, color: iconColor),
|
2025-10-30 22:44:08 +05:30
|
|
|
),
|
|
|
|
|
const SizedBox(width: Spacing.xs + 1),
|
|
|
|
|
AnimatedDefaultTextStyle(
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
curve: Curves.easeOutCubic,
|
|
|
|
|
style: AppTypography.labelStyle.copyWith(
|
|
|
|
|
color: textColor,
|
|
|
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
letterSpacing: -0.1,
|
|
|
|
|
),
|
|
|
|
|
child: Text(
|
|
|
|
|
label,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
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();
|
2025-09-08 01:05:48 +05:30
|
|
|
final prevCanRequest = _focusNode.canRequestFocus;
|
2025-09-08 01:15:31 +05:30
|
|
|
final wasFocused = _focusNode.hasFocus;
|
2025-09-08 01:05:48 +05:30
|
|
|
_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
|
|
|
),
|
2025-11-26 22:19:19 +05:30
|
|
|
_buildOverflowAction(
|
|
|
|
|
icon: Icons.public,
|
|
|
|
|
label: 'Attach webpage',
|
|
|
|
|
onTap: widget.onWebAttachment == null
|
|
|
|
|
? null
|
|
|
|
|
: () {
|
|
|
|
|
HapticFeedback.lightImpact();
|
|
|
|
|
widget.onWebAttachment!.call();
|
|
|
|
|
},
|
|
|
|
|
),
|
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-12-05 22:19:31 +05:30
|
|
|
// Add filters section (like tools section)
|
|
|
|
|
final modalSelectedModel = modalRef.watch(selectedModelProvider);
|
|
|
|
|
final modalToggleFilters =
|
|
|
|
|
modalSelectedModel?.filters ?? const <ToggleFilter>[];
|
|
|
|
|
|
|
|
|
|
if (modalToggleFilters.isNotEmpty) {
|
|
|
|
|
final modalSelectedFilterIds = modalRef.watch(
|
|
|
|
|
selectedFilterIdsProvider,
|
|
|
|
|
);
|
|
|
|
|
final filterTiles = modalToggleFilters.map((filter) {
|
|
|
|
|
final isSelected = modalSelectedFilterIds.contains(filter.id);
|
|
|
|
|
return _buildFilterTile(
|
|
|
|
|
filter: filter,
|
|
|
|
|
selected: isSelected,
|
|
|
|
|
onToggle: () {
|
|
|
|
|
modalRef
|
|
|
|
|
.read(selectedFilterIdsProvider.notifier)
|
|
|
|
|
.toggle(filter.id);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}).toList();
|
|
|
|
|
|
|
|
|
|
bodyChildren
|
|
|
|
|
..add(const SizedBox(height: Spacing.sm))
|
|
|
|
|
..add(_buildSectionLabel(l10n.filters))
|
|
|
|
|
..add(
|
|
|
|
|
Column(
|
|
|
|
|
children: _withVerticalSpacing(filterTiles, Spacing.xxs),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
),
|
2025-10-03 00:12:25 +05:30
|
|
|
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
|
|
|
),
|
2025-09-08 01:05:48 +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-09-08 01:05:48 +05:30
|
|
|
}
|
|
|
|
|
});
|
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-08 01:05:48 +05:30
|
|
|
}
|
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,
|
2025-12-05 22:19:31 +05:30
|
|
|
String? iconUrl,
|
2025-09-19 11:58:22 +05:30
|
|
|
}) {
|
|
|
|
|
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),
|
2025-10-03 00:12:25 +05:30
|
|
|
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: [
|
2025-12-05 22:19:31 +05:30
|
|
|
iconUrl != null && iconUrl.isNotEmpty
|
|
|
|
|
? _buildFilterGlyph(
|
|
|
|
|
iconUrl: iconUrl,
|
|
|
|
|
selected: value,
|
|
|
|
|
theme: theme,
|
|
|
|
|
)
|
|
|
|
|
: _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),
|
2025-10-03 00:12:25 +05:30
|
|
|
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
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 22:19:31 +05:30
|
|
|
Widget _buildFilterTile({
|
|
|
|
|
required ToggleFilter filter,
|
|
|
|
|
required bool selected,
|
|
|
|
|
required VoidCallback onToggle,
|
|
|
|
|
}) {
|
|
|
|
|
final theme = context.conduitTheme;
|
|
|
|
|
final brightness = Theme.of(context).brightness;
|
|
|
|
|
final description = filter.description ?? '';
|
|
|
|
|
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: filter.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),
|
|
|
|
|
padding: const EdgeInsets.all(Spacing.sm),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: background,
|
|
|
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.input),
|
|
|
|
|
border: Border.all(color: borderColor, width: BorderWidth.thin),
|
|
|
|
|
boxShadow: selected ? ConduitShadows.low(context) : const [],
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
_buildFilterGlyph(
|
|
|
|
|
iconUrl: filter.icon,
|
|
|
|
|
selected: selected,
|
|
|
|
|
theme: theme,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: Spacing.xs),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
filter.name,
|
|
|
|
|
style: AppTypography.bodySmallStyle.copyWith(
|
|
|
|
|
color: theme.textPrimary,
|
|
|
|
|
fontWeight: selected
|
|
|
|
|
? FontWeight.w600
|
|
|
|
|
: FontWeight.w500,
|
|
|
|
|
),
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
|
|
|
|
if (description.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: Spacing.xs),
|
|
|
|
|
Text(
|
|
|
|
|
description,
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: AppTypography.captionStyle.copyWith(
|
|
|
|
|
color: theme.textSecondary.withValues(
|
|
|
|
|
alpha: Alpha.strong,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: Spacing.xs),
|
|
|
|
|
_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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 22:19:31 +05:30
|
|
|
/// Builds the circular glyph/avatar for a filter tile.
|
|
|
|
|
Widget _buildFilterGlyph({
|
|
|
|
|
String? iconUrl,
|
|
|
|
|
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(
|
|
|
|
|
width: 36,
|
|
|
|
|
height: 36,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
gradient: LinearGradient(
|
|
|
|
|
begin: Alignment.topLeft,
|
|
|
|
|
end: Alignment.bottomRight,
|
|
|
|
|
colors: [accentStart, accentEnd],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: iconUrl != null && iconUrl.isNotEmpty
|
|
|
|
|
? ClipOval(
|
|
|
|
|
child: Image.network(
|
|
|
|
|
iconUrl,
|
|
|
|
|
width: 36,
|
|
|
|
|
height: 36,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
color: iconUrl.endsWith('.svg') ? iconColor : null,
|
|
|
|
|
colorBlendMode: BlendMode.srcIn,
|
2025-12-05 22:24:36 +05:30
|
|
|
errorBuilder: (_, _, _) => Icon(
|
2025-12-05 22:19:31 +05:30
|
|
|
Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome,
|
|
|
|
|
color: iconColor,
|
|
|
|
|
size: IconSize.modal,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: Icon(
|
|
|
|
|
Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome,
|
|
|
|
|
color: iconColor,
|
|
|
|
|
size: IconSize.modal,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-19 11:58:22 +05:30
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
_textSub?.cancel();
|
|
|
|
|
_textSub = stream.listen(
|
|
|
|
|
(text) async {
|
2025-09-02 00:04:21 +05:30
|
|
|
final updated = _baseTextAtStart.isEmpty
|
|
|
|
|
? text
|
|
|
|
|
: '${_baseTextAtStart.trimRight()} $text';
|
2025-08-25 20:56:33 +05:30
|
|
|
_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);
|
|
|
|
|
},
|
|
|
|
|
onError: (_) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() => _isRecording = false);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
_ensureFocusedIfEnabled();
|
|
|
|
|
} catch (_) {
|
|
|
|
|
_showVoiceUnavailable(
|
|
|
|
|
AppLocalizations.of(context)?.errorMessage ??
|
|
|
|
|
'Failed to start voice input',
|
|
|
|
|
);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() => _isRecording = false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _stopVoice() async {
|
|
|
|
|
await _voiceService.stopListening();
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() => _isRecording = false);
|
|
|
|
|
HapticFeedback.selectionClick();
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-02 19:02:37 +05:30
|
|
|
// When on-device STT is unavailable we rely on server transcription.
|
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
|
|
|
}
|