refactor: tools design

This commit is contained in:
cogwheel0
2025-08-24 14:35:17 +05:30
parent 3783ca26b4
commit 25201cbcfc
5 changed files with 1803 additions and 812 deletions

View File

@@ -110,46 +110,49 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
textDirection: TextDirection.ltr,
child: Scaffold(
backgroundColor: context.conduitTheme.surfaceBackground,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: context.conduitTheme.error,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.errorMessage,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: context.conduitTheme.textPrimary,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: context.conduitTheme.error,
),
),
const SizedBox(height: 8),
Text(
enhancedErrorService.getUserMessage(_error!),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.conduitTheme.textSecondary,
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)?.errorMessage ??
'An unexpected error occurred',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: context.conduitTheme.textPrimary,
),
),
),
if (widget.allowRetry) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.retry),
const SizedBox(height: 8),
Text(
enhancedErrorService.getUserMessage(_error!),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
if (widget.allowRetry) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: Text(
AppLocalizations.of(context)?.retry ?? 'Retry',
),
),
],
],
],
),
),
),
),
),
);
);
}
// Wrap child in error handler

View File

@@ -16,6 +16,7 @@ import '../widgets/modern_chat_input.dart';
import '../widgets/user_message_bubble.dart';
import '../widgets/assistant_message_widget.dart' as assistant;
import '../widgets/file_attachment_widget.dart';
import '../widgets/voice_input_sheet.dart';
import '../services/voice_input_service.dart';
import '../services/file_attachment_service.dart';
import '../../tools/providers/tools_providers.dart';
@@ -170,10 +171,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}
Future<void> _checkAndLoadDemoConversation() async {
if (!mounted) return;
final isReviewerMode = ref.read(reviewerModeProvider);
if (!isReviewerMode) return;
// Check if there's already an active conversation
if (!mounted) return;
final activeConversation = ref.read(activeConversationProvider);
if (activeConversation != null) {
DebugLogger.log(
@@ -183,10 +186,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}
// Force refresh conversations provider to ensure we get the demo conversations
if (!mounted) return;
ref.invalidate(conversationsProvider);
// Try to load demo conversation
for (int i = 0; i < 10; i++) {
if (!mounted) return;
final conversationsAsync = ref.read(conversationsProvider);
if (conversationsAsync.hasValue && conversationsAsync.value!.isNotEmpty) {
@@ -196,6 +201,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
orElse: () => conversationsAsync.value!.first,
);
if (!mounted) return;
ref.read(activeConversationProvider.notifier).state = welcomeConv;
debugPrint('Auto-loaded demo conversation');
return;
@@ -204,6 +210,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// If conversations are still loading, wait a bit and retry
if (conversationsAsync.isLoading || i == 0) {
await Future.delayed(const Duration(milliseconds: 200));
if (!mounted) return;
continue;
}
@@ -223,11 +230,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Initialize chat page components
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
// First, ensure a model is selected
await _checkAndAutoSelectModel();
if (!mounted) return;
// Then check for demo conversation in reviewer mode
await _checkAndLoadDemoConversation();
if (!mounted) return;
// Finally, show onboarding if needed
await _checkAndShowOnboarding();
@@ -311,7 +321,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => _VoiceInputSheet(
builder: (context) => VoiceInputSheet(
onTextReceived: (text) {
if (text.isNotEmpty) {
final selectedModel = ref.read(selectedModelProvider);
@@ -976,7 +986,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
drawerEnableOpenDragGesture: true,
drawerEdgeDragWidth: 32,
drawer: Drawer(
width: (MediaQuery.of(context).size.width * 0.88).clamp(280.0, 420.0),
width: (MediaQuery.of(context).size.width * 0.88).clamp(
280.0,
420.0,
),
backgroundColor: context.conduitTheme.surfaceBackground,
child: const SafeArea(child: ChatsDrawer()),
),
@@ -1822,7 +1835,10 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
_autoSendFinal = settings.voiceAutoSendFinal;
if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) {
_voiceService.setLocale(settings.voiceLocaleId);
_languageTag = settings.voiceLocaleId!.split(RegExp('[-_]')).first.toLowerCase();
_languageTag = settings.voiceLocaleId!
.split(RegExp('[-_]'))
.first
.toLowerCase();
}
}
@@ -2022,12 +2038,14 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
children: [
const SheetHandle(),
const SizedBox(height: Spacing.md),
Text('Select Language',
style: TextStyle(
fontSize: AppTypography.headlineSmall,
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
)),
Text(
'Select Language',
style: TextStyle(
fontSize: AppTypography.headlineSmall,
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Spacing.sm),
Flexible(
child: ListView.separated(
@@ -2039,18 +2057,26 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
),
itemBuilder: (ctx, i) {
final l = locales[i];
final isSelected = l.localeId == _voiceService.selectedLocaleId;
final isSelected =
l.localeId == _voiceService.selectedLocaleId;
return ListTile(
title: Text(
l.name,
style: TextStyle(color: context.conduitTheme.textPrimary),
style: TextStyle(
color: context.conduitTheme.textPrimary,
),
),
subtitle: Text(
l.localeId,
style: TextStyle(color: context.conduitTheme.textSecondary),
style: TextStyle(
color: context.conduitTheme.textSecondary,
),
),
trailing: isSelected
? Icon(Icons.check, color: context.conduitTheme.buttonPrimary)
? Icon(
Icons.check,
color: context.conduitTheme.buttonPrimary,
)
: null,
onTap: () => Navigator.pop(ctx, l.localeId),
);
@@ -2118,410 +2144,482 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
child: Padding(
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
child: Column(
children: [
// Handle bar
const SheetHandle(),
children: [
// Handle bar
const SheetHandle(),
// Header: Title + timer + language chip
// Header: Title + timer + language chip
Padding(
padding: const EdgeInsets.only(top: Spacing.md, bottom: Spacing.md),
padding: const EdgeInsets.only(
top: Spacing.md,
bottom: Spacing.md,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_isTranscribing
? 'Transcribing…'
: _isListening
? (_voiceService.hasLocalStt ? 'Listening…' : 'Recording…')
_isTranscribing
? 'Transcribing…'
: _isListening
? (_voiceService.hasLocalStt
? 'Listening…'
: 'Recording…')
: 'Voice',
style: TextStyle(
fontSize: AppTypography.headlineMedium,
fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary,
),
),
Row(
children: [
// Language chip
GestureDetector(
onTap: _voiceService.hasLocalStt ? _pickLanguage : null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.xs,
vertical: 4,
),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
AppBorderRadius.badge,
),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.thin,
),
),
child: Row(
children: [
Text(
_languageTag.toUpperCase(),
style: TextStyle(
fontSize: AppTypography.labelSmall,
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
style: TextStyle(
fontSize: AppTypography.headlineMedium,
fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary,
),
),
Row(
children: [
// Language chip
GestureDetector(
onTap: _voiceService.hasLocalStt
? _pickLanguage
: null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.xs,
vertical: 4,
),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
AppBorderRadius.badge,
),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.thin,
),
),
if (_voiceService.hasLocalStt) ...[
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 16,
color: context.conduitTheme.iconSecondary,
),
],
],
child: Row(
children: [
Text(
_languageTag.toUpperCase(),
style: TextStyle(
fontSize: AppTypography.labelSmall,
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
if (_voiceService.hasLocalStt) ...[
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 16,
color: context.conduitTheme.iconSecondary,
),
],
],
),
),
),
),
),
const SizedBox(width: Spacing.sm),
// Timer
AnimatedOpacity(
opacity: _isListening ? 1 : 0.6,
duration: AnimationDuration.fast,
child: Text(
_formatSeconds(_elapsedSeconds),
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
const SizedBox(width: Spacing.sm),
// Timer
AnimatedOpacity(
opacity: _isListening ? 1 : 0.6,
duration: AnimationDuration.fast,
child: Text(
_formatSeconds(_elapsedSeconds),
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
),
),
const SizedBox(width: Spacing.sm),
// Close sheet
ConduitIconButton(
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
tooltip: 'Close',
isCompact: true,
onPressed: () => Navigator.of(context).pop(),
const SizedBox(width: Spacing.sm),
// Close sheet
ConduitIconButton(
icon: Platform.isIOS
? CupertinoIcons.xmark
: Icons.close,
tooltip: 'Close',
isCompact: true,
onPressed: () => Navigator.of(context).pop(),
),
],
),
],
),
],
),
),
),
// Toggles row: Hold to talk, Auto-send
Padding(
padding: const EdgeInsets.only(bottom: Spacing.sm),
child: Row(
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemedSwitch(
value: _holdToTalk,
onChanged: (v) async {
setState(() => _holdToTalk = v);
await ref.read(appSettingsProvider.notifier).setVoiceHoldToTalk(v);
},
),
const SizedBox(width: Spacing.xs),
Text(
'Hold to talk',
style: TextStyle(color: context.conduitTheme.textSecondary),
),
],
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildThemedSwitch(
value: _autoSendFinal,
onChanged: (v) async {
setState(() => _autoSendFinal = v);
await ref.read(appSettingsProvider.notifier).setVoiceAutoSendFinal(v);
},
),
const SizedBox(width: Spacing.xs),
Text(
'Auto-send',
style: TextStyle(color: context.conduitTheme.textSecondary),
),
],
),
),
],
),
),
// Microphone + waveform
Expanded(
child: LayoutBuilder(
builder: (context, viewport) {
final isUltra = media.size.height < 560;
final double micSize = isUltra ? 64 : (isCompact ? 80 : 100);
final double micIconSize = isUltra ? 26 : (isCompact ? 32 : 40);
// Extra top padding so scale animation (up to 1.2x) never clips
final double topPaddingForScale = ((micSize * 1.2) - micSize) / 2 + 8;
final content = Center(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Top spacer (baseline); additional padding handled by scroll view
SizedBox(height: isUltra ? Spacing.sm : Spacing.md),
// Microphone control
GestureDetector(
onTapDown: _holdToTalk
? (_) {
if (!_isListening) _startListening();
}
: null,
onTapUp: _holdToTalk
? (_) {
if (_isListening) _stopListening();
}
: null,
onTapCancel: _holdToTalk
? () {
if (_isListening) _stopListening();
}
: null,
onTap: () =>
_holdToTalk
? null
: (_isListening ? _stopListening() : _startListening()),
child: Container(
width: micSize,
height: micSize,
decoration: BoxDecoration(
color: _isListening
? context.conduitTheme.error.withValues(
alpha: 0.2,
)
: context.conduitTheme.surfaceBackground
.withValues(alpha: Alpha.subtle),
shape: BoxShape.circle,
border: Border.all(
color: _isListening
? context.conduitTheme.error.withValues(
alpha: 0.5,
)
: context.conduitTheme.dividerColor,
width: 2,
),
),
child: Icon(
_isListening
? (Platform.isIOS
? CupertinoIcons.mic_fill
: Icons.mic)
: (Platform.isIOS
? CupertinoIcons.mic_off
: Icons.mic_off),
size: micIconSize,
color: _isListening
? context.conduitTheme.error
: context.conduitTheme.iconSecondary,
),
),
)
.animate(
onPlay: (controller) =>
_isListening ? controller.repeat() : null,
)
.scale(
duration: const Duration(milliseconds: 1000),
begin: const Offset(1, 1),
end: const Offset(1.2, 1.2),
)
.then()
.scale(
duration: const Duration(milliseconds: 1000),
begin: const Offset(1.2, 1.2),
end: const Offset(1, 1),
),
SizedBox(height: isUltra ? Spacing.xs : (isCompact ? Spacing.sm : Spacing.md)),
// Simple animated bars waveform based on intensity proxy
SizedBox(
height: isUltra ? 18 : (isCompact ? 24 : 32),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
// Toggles row: Hold to talk, Auto-send
Padding(
padding: const EdgeInsets.only(bottom: Spacing.sm),
child: Row(
children: [
Expanded(
child: Row(
key: ValueKey<int>(_intensity),
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(isUltra ? 10 : 12, (i) {
final normalized = ((_intensity + i) % 10) / 10.0;
final base = isUltra ? 4 : (isCompact ? 6 : 8);
final range = isUltra ? 14 : (isCompact ? 18 : 24);
final barHeight = base + (normalized * range);
return Container(
width: isUltra ? 2.5 : (isCompact ? 3 : 4),
height: barHeight,
margin: EdgeInsets.symmetric(horizontal: isUltra ? 1 : (isCompact ? 1.5 : 2)),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary
.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(2),
),
);
}),
),
),
),
SizedBox(height: isUltra ? Spacing.sm : (isCompact ? Spacing.md : Spacing.xl)),
// Recognized text / Transcribing state with Clear action
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: media.size.height * (isUltra ? 0.13 : (isCompact ? 0.16 : 0.2)),
minHeight: isUltra ? 56 : (isCompact ? 64 : 80),
),
child: ConduitCard(
isCompact: isCompact,
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.md),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Inline clear action aligned to the end
Row(
children: [
Text(
'Transcript',
style: TextStyle(
fontSize: AppTypography.labelSmall,
fontWeight: FontWeight.w600,
color: context.conduitTheme.textSecondary,
),
),
const Spacer(),
ConduitIconButton(
icon: Icons.close,
isCompact: true,
tooltip: 'Clear',
onPressed: _recognizedText.isNotEmpty && !_isTranscribing
? () {
setState(() => _recognizedText = '');
}
: null,
),
],
_buildThemedSwitch(
value: _holdToTalk,
onChanged: (v) async {
setState(() => _holdToTalk = v);
await ref
.read(appSettingsProvider.notifier)
.setVoiceHoldToTalk(v);
},
),
const SizedBox(height: Spacing.xs),
if (_isTranscribing)
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConduitLoadingIndicator(
size: isUltra ? 14 : (isCompact ? 16 : 18),
isCompact: true,
),
const SizedBox(width: Spacing.xs),
Text(
'Transcribing…',
style: TextStyle(
fontSize: isUltra
? AppTypography.bodySmall
: (isCompact
? AppTypography.bodyMedium
: AppTypography.bodyLarge),
color: context.conduitTheme.textSecondary,
),
),
],
),
)
else
Flexible(
child: SingleChildScrollView(
child: Text(
_recognizedText.isEmpty
? (_isListening
? (_voiceService.hasLocalStt
? 'Speak now…'
: 'Recording…')
: 'Tap Start to begin')
: _recognizedText,
style: TextStyle(
fontSize: isUltra
? AppTypography.bodySmall
: (isCompact
? AppTypography.bodyMedium
: AppTypography.bodyLarge),
color: _recognizedText.isEmpty
? context.conduitTheme.inputPlaceholder
: context.conduitTheme.textPrimary,
height: 1.4,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: Spacing.xs),
Text(
'Hold to talk',
style: TextStyle(
color: context.conduitTheme.textSecondary,
),
),
],
),
),
),
],
),
);
// Make scrollable if content exceeds available height
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(top: topPaddingForScale),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewport.maxHeight),
child: content,
),
);
},
),
),
// Action buttons
Builder(builder: (context) {
final showStartStop = !_holdToTalk;
final showSend = !_autoSendFinal;
if (!showStartStop && !showSend) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(top: isCompact ? Spacing.sm : Spacing.md),
child: Row(
children: [
if (showStartStop) ...[
Expanded(
child: ConduitButton(
text: _isListening ? 'Stop' : 'Start',
isSecondary: true,
isCompact: isCompact,
onPressed: _isListening ? _stopListening : _startListening,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildThemedSwitch(
value: _autoSendFinal,
onChanged: (v) async {
setState(() => _autoSendFinal = v);
await ref
.read(appSettingsProvider.notifier)
.setVoiceAutoSendFinal(v);
},
),
const SizedBox(width: Spacing.xs),
Text(
'Auto-send',
style: TextStyle(
color: context.conduitTheme.textSecondary,
),
),
],
),
),
],
if (showStartStop && showSend) const SizedBox(width: Spacing.xs),
if (showSend) ...[
Expanded(
child: ConduitButton(
text: 'Send',
isCompact: isCompact,
onPressed: _recognizedText.isNotEmpty ? _sendText : null,
),
),
],
],
),
),
);
}),
],
),
// Microphone + waveform
Expanded(
child: LayoutBuilder(
builder: (context, viewport) {
final isUltra = media.size.height < 560;
final double micSize = isUltra
? 64
: (isCompact ? 80 : 100);
final double micIconSize = isUltra
? 26
: (isCompact ? 32 : 40);
// Extra top padding so scale animation (up to 1.2x) never clips
final double topPaddingForScale =
((micSize * 1.2) - micSize) / 2 + 8;
final content = Center(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Top spacer (baseline); additional padding handled by scroll view
SizedBox(height: isUltra ? Spacing.sm : Spacing.md),
// Microphone control
GestureDetector(
onTapDown: _holdToTalk
? (_) {
if (!_isListening) _startListening();
}
: null,
onTapUp: _holdToTalk
? (_) {
if (_isListening) _stopListening();
}
: null,
onTapCancel: _holdToTalk
? () {
if (_isListening) _stopListening();
}
: null,
onTap: () => _holdToTalk
? null
: (_isListening
? _stopListening()
: _startListening()),
child: Container(
width: micSize,
height: micSize,
decoration: BoxDecoration(
color: _isListening
? context.conduitTheme.error.withValues(
alpha: 0.2,
)
: context.conduitTheme.surfaceBackground
.withValues(alpha: Alpha.subtle),
shape: BoxShape.circle,
border: Border.all(
color: _isListening
? context.conduitTheme.error
.withValues(alpha: 0.5)
: context.conduitTheme.dividerColor,
width: 2,
),
),
child: Icon(
_isListening
? (Platform.isIOS
? CupertinoIcons.mic_fill
: Icons.mic)
: (Platform.isIOS
? CupertinoIcons.mic_off
: Icons.mic_off),
size: micIconSize,
color: _isListening
? context.conduitTheme.error
: context.conduitTheme.iconSecondary,
),
),
)
.animate(
onPlay: (controller) =>
_isListening ? controller.repeat() : null,
)
.scale(
duration: const Duration(milliseconds: 1000),
begin: const Offset(1, 1),
end: const Offset(1.2, 1.2),
)
.then()
.scale(
duration: const Duration(milliseconds: 1000),
begin: const Offset(1.2, 1.2),
end: const Offset(1, 1),
),
SizedBox(
height: isUltra
? Spacing.xs
: (isCompact ? Spacing.sm : Spacing.md),
),
// Simple animated bars waveform based on intensity proxy
SizedBox(
height: isUltra ? 18 : (isCompact ? 24 : 32),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: Row(
key: ValueKey<int>(_intensity),
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(isUltra ? 10 : 12, (i) {
final normalized =
((_intensity + i) % 10) / 10.0;
final base = isUltra
? 4
: (isCompact ? 6 : 8);
final range = isUltra
? 14
: (isCompact ? 18 : 24);
final barHeight = base + (normalized * range);
return Container(
width: isUltra ? 2.5 : (isCompact ? 3 : 4),
height: barHeight,
margin: EdgeInsets.symmetric(
horizontal: isUltra
? 1
: (isCompact ? 1.5 : 2),
),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary
.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(2),
),
);
}),
),
),
),
SizedBox(
height: isUltra
? Spacing.sm
: (isCompact ? Spacing.md : Spacing.xl),
),
// Recognized text / Transcribing state with Clear action
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
media.size.height *
(isUltra ? 0.13 : (isCompact ? 0.16 : 0.2)),
minHeight: isUltra ? 56 : (isCompact ? 64 : 80),
),
child: ConduitCard(
isCompact: isCompact,
padding: EdgeInsets.all(
isCompact ? Spacing.md : Spacing.md,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Inline clear action aligned to the end
Row(
children: [
Text(
'Transcript',
style: TextStyle(
fontSize: AppTypography.labelSmall,
fontWeight: FontWeight.w600,
color: context
.conduitTheme
.textSecondary,
),
),
const Spacer(),
ConduitIconButton(
icon: Icons.close,
isCompact: true,
tooltip: 'Clear',
onPressed:
_recognizedText.isNotEmpty &&
!_isTranscribing
? () {
setState(
() => _recognizedText = '',
);
}
: null,
),
],
),
const SizedBox(height: Spacing.xs),
if (_isTranscribing)
Center(
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
ConduitLoadingIndicator(
size: isUltra
? 14
: (isCompact ? 16 : 18),
isCompact: true,
),
const SizedBox(width: Spacing.xs),
Text(
'Transcribing…',
style: TextStyle(
fontSize: isUltra
? AppTypography.bodySmall
: (isCompact
? AppTypography
.bodyMedium
: AppTypography
.bodyLarge),
color: context
.conduitTheme
.textSecondary,
),
),
],
),
)
else
Flexible(
child: SingleChildScrollView(
child: Text(
_recognizedText.isEmpty
? (_isListening
? (_voiceService.hasLocalStt
? 'Speak now…'
: 'Recording…')
: 'Tap Start to begin')
: _recognizedText,
style: TextStyle(
fontSize: isUltra
? AppTypography.bodySmall
: (isCompact
? AppTypography.bodyMedium
: AppTypography
.bodyLarge),
color: _recognizedText.isEmpty
? context
.conduitTheme
.inputPlaceholder
: context
.conduitTheme
.textPrimary,
height: 1.4,
),
textAlign: TextAlign.center,
),
),
),
],
),
),
),
],
),
);
// Make scrollable if content exceeds available height
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(top: topPaddingForScale),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: viewport.maxHeight,
),
child: content,
),
);
},
),
),
// Action buttons
Builder(
builder: (context) {
final showStartStop = !_holdToTalk;
final showSend = !_autoSendFinal;
if (!showStartStop && !showSend) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(
top: isCompact ? Spacing.sm : Spacing.md,
),
child: Row(
children: [
if (showStartStop) ...[
Expanded(
child: ConduitButton(
text: _isListening ? 'Stop' : 'Start',
isSecondary: true,
isCompact: isCompact,
onPressed: _isListening
? _stopListening
: _startListening,
),
),
],
if (showStartStop && showSend)
const SizedBox(width: Spacing.xs),
if (showSend) ...[
Expanded(
child: ConduitButton(
text: 'Send',
isCompact: isCompact,
onPressed: _recognizedText.isNotEmpty
? _sendText
: null,
),
),
],
],
),
);
},
),
],
),
),
),
);

View File

@@ -11,6 +11,7 @@ import 'dart:async';
import '../providers/chat_providers.dart';
import '../../tools/widgets/unified_tools_modal.dart';
import '../../tools/providers/tools_providers.dart';
import '../../../core/providers/app_providers.dart';
import '../../../shared/utils/platform_utils.dart';
import 'package:conduit/l10n/app_localizations.dart';
@@ -197,6 +198,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
messages.last.isStreaming;
final stopGeneration = ref.read(stopGenerationProvider);
final webSearchEnabled = ref.watch(webSearchEnabledProvider);
final imageGenEnabled = ref.watch(imageGenerationEnabledProvider);
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
return Container(
// Transparent wrapper so rounded corners are visible against page background
color: Colors.transparent,
@@ -265,7 +270,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
onTap: widget.enabled
? _showAttachmentOptions
: null,
tooltip: AppLocalizations.of(context)!.addAttachment,
tooltip: AppLocalizations.of(
context,
)!.addAttachment,
),
const SizedBox(width: Spacing.sm),
],
@@ -273,8 +280,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
Expanded(
child: Semantics(
textField: true,
label: AppLocalizations.of(context)!.messageInputLabel,
hint: AppLocalizations.of(context)!.messageInputHint,
label: AppLocalizations.of(
context,
)!.messageInputLabel,
hint: AppLocalizations.of(
context,
)!.messageInputHint,
child: TextField(
controller: _controller,
focusNode: _focusNode,
@@ -292,7 +303,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
color: context.conduitTheme.inputText,
),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.messageHintText,
hintText: AppLocalizations.of(
context,
)!.messageHintText,
hintStyle: TextStyle(
color:
context.conduitTheme.inputPlaceholder,
@@ -364,26 +377,76 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
onTap: widget.enabled
? _showAttachmentOptions
: null,
tooltip: AppLocalizations.of(context)!.addAttachment,
tooltip: AppLocalizations.of(
context,
)!.addAttachment,
),
const SizedBox(width: Spacing.sm),
// Quick pills: wrap in horizontal scroller to prevent overflow
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
children: [
_buildPillButton(
icon: Platform.isIOS
? CupertinoIcons.search
: Icons.search,
label: 'Web',
isActive: webSearchEnabled,
onTap: widget.enabled
? () {
ref
.read(
webSearchEnabledProvider
.notifier,
)
.state =
!webSearchEnabled;
}
: null,
),
if (imageGenAvailable) ...[
const SizedBox(width: Spacing.sm),
_buildPillButton(
icon: Platform.isIOS
? CupertinoIcons.photo
: Icons.image,
label: 'Image Gen',
isActive: imageGenEnabled,
onTap: widget.enabled
? () {
ref
.read(
imageGenerationEnabledProvider
.notifier,
)
.state =
!imageGenEnabled;
}
: null,
),
],
],
),
),
),
const SizedBox(width: Spacing.sm),
// Tools button
_buildRoundButton(
icon: Icons.build,
icon: Icons.more_horiz,
onTap: widget.enabled
? () {
_showUnifiedToolsModal();
}
? _showUnifiedToolsModal
: null,
tooltip: AppLocalizations.of(context)!.tools,
isActive:
ref
.watch(selectedToolIdsProvider)
.isNotEmpty ||
ref.watch(webSearchEnabledProvider) ||
ref.watch(imageGenerationEnabledProvider),
webSearchEnabled ||
imageGenEnabled,
),
const Spacer(),
const SizedBox(width: Spacing.sm),
// Microphone button: call provided callback for premium voice UI
_buildRoundButton(
icon: Platform.isIOS
@@ -392,7 +455,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
onTap: widget.enabled
? widget.onVoiceInput
: null,
tooltip: AppLocalizations.of(context)!.voiceInput,
tooltip: AppLocalizations.of(
context,
)!.voiceInput,
isActive: _isRecording,
),
const SizedBox(width: Spacing.sm),
@@ -437,7 +502,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
color: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
side: BorderSide(color: context.conduitTheme.error, width: BorderWidth.regular),
side: BorderSide(
color: context.conduitTheme.error,
width: BorderWidth.regular,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(radius),
@@ -483,9 +551,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// Default SEND variant
return Tooltip(
message: enabled ? AppLocalizations.of(context)!.sendMessage : AppLocalizations.of(context)!.send,
message: enabled
? AppLocalizations.of(context)!.sendMessage
: AppLocalizations.of(context)!.send,
child: Opacity(
opacity: enabled ? Alpha.primary : Alpha.disabled,
opacity: enabled ? Alpha.primary : Alpha.disabled,
child: IgnorePointer(
ignoring: !enabled,
child: Material(
@@ -495,7 +565,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
side: BorderSide(
color: enabled
? context.conduitTheme.cardBorder
: context.conduitTheme.cardBorder.withValues(alpha: Alpha.medium),
: context.conduitTheme.cardBorder.withValues(
alpha: Alpha.medium,
),
width: BorderWidth.regular,
),
),
@@ -520,7 +592,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
size: IconSize.medium,
color: enabled
? context.conduitTheme.textPrimary
: context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled),
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
),
),
),
),
@@ -549,8 +623,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
alpha: Alpha.buttonHover + Alpha.subtle,
)
: showBackground
? context.conduitTheme.cardBorder
: Colors.transparent,
? context.conduitTheme.cardBorder
: Colors.transparent,
width: BorderWidth.regular,
),
),
@@ -563,37 +637,90 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
onTap();
},
child: Container(
width: TouchTarget.comfortable,
height: TouchTarget.comfortable,
decoration: BoxDecoration(
color: isActive
? context.conduitTheme.textPrimary.withValues(
alpha: Alpha.buttonHover,
)
: showBackground
? context.conduitTheme.cardBackground
: Colors.transparent,
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
boxShadow: (isActive || showBackground)
? ConduitShadows.button
: null,
),
child: Icon(
icon,
size: IconSize.medium,
color: widget.enabled
? (isActive
? context.conduitTheme.textPrimary
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
))
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
),
width: TouchTarget.comfortable,
height: TouchTarget.comfortable,
decoration: BoxDecoration(
color: isActive
? context.conduitTheme.textPrimary.withValues(
alpha: Alpha.buttonHover,
)
: showBackground
? context.conduitTheme.cardBackground
: Colors.transparent,
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
boxShadow: (isActive || showBackground)
? ConduitShadows.button
: null,
),
child: Icon(
icon,
size: IconSize.medium,
color: widget.enabled
? (isActive
? context.conduitTheme.textPrimary
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
))
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
),
),
),
),
),
));
);
}
Widget _buildPillButton({
required IconData icon,
required String label,
required bool isActive,
VoidCallback? onTap,
}) {
return Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
side: BorderSide(
color: isActive
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBorder,
width: BorderWidth.regular,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
onTap: onTap == null
? null
: () {
HapticFeedback.selectionClick();
onTap();
},
child: Container(
height: TouchTarget.comfortable, // exact height match
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: Spacing.md),
decoration: BoxDecoration(
color: isActive
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
// Reduce perceived height variance: only show shadow when active
boxShadow: isActive ? ConduitShadows.button : null,
),
child: Center(
child: Text(
label,
style: AppTypography.labelStyle.copyWith(
color: isActive
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary,
),
),
),
),
),
);
}
void _showAttachmentOptions() {
@@ -626,38 +753,43 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
children: [
Expanded(
child: _buildAttachmentOption(
icon: Platform.isIOS ? CupertinoIcons.doc : Icons.attach_file,
label: AppLocalizations.of(context)!.file,
onTap: () {
HapticFeedback.lightImpact();
Navigator.pop(context); // Close modal
widget.onFileAttachment?.call();
},
)),
icon: Platform.isIOS
? CupertinoIcons.doc
: Icons.attach_file,
label: AppLocalizations.of(context)!.file,
onTap: () {
HapticFeedback.lightImpact();
Navigator.pop(context); // Close modal
widget.onFileAttachment?.call();
},
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: _buildAttachmentOption(
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
label: AppLocalizations.of(context)!.photo,
onTap: () {
HapticFeedback.lightImpact();
Navigator.pop(context); // Close modal
widget.onImageAttachment?.call();
},
)),
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
label: AppLocalizations.of(context)!.photo,
onTap: () {
HapticFeedback.lightImpact();
Navigator.pop(context); // Close modal
widget.onImageAttachment?.call();
},
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: _buildAttachmentOption(
icon: Platform.isIOS
? CupertinoIcons.camera
: Icons.camera_alt,
label: AppLocalizations.of(context)!.camera,
onTap: () {
HapticFeedback.lightImpact();
Navigator.pop(context); // Close modal
widget.onCameraCapture?.call();
},
)),
icon: Platform.isIOS
? CupertinoIcons.camera
: Icons.camera_alt,
label: AppLocalizations.of(context)!.camera,
onTap: () {
HapticFeedback.lightImpact();
Navigator.pop(context); // Close modal
widget.onCameraCapture?.call();
},
),
),
],
),
const SizedBox(height: Spacing.lg),

View File

@@ -0,0 +1,758 @@
import 'dart:async';
import 'dart:io' show File, Platform;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/services/platform_service.dart' as ps;
import '../../../core/services/settings_service.dart';
import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../../../shared/widgets/sheet_handle.dart';
import '../services/voice_input_service.dart';
class VoiceInputSheet extends ConsumerStatefulWidget {
final void Function(String text) onTextReceived;
const VoiceInputSheet({super.key, required this.onTextReceived});
@override
ConsumerState<VoiceInputSheet> createState() => _VoiceInputSheetState();
}
class _VoiceInputSheetState extends ConsumerState<VoiceInputSheet> {
late final VoiceInputService _voiceService;
StreamSubscription<int>? _intensitySub;
StreamSubscription<String>? _textSub;
bool _isListening = false;
bool _isTranscribing = false;
int _intensity = 0; // 0..10
String _recognizedText = '';
int _elapsedSeconds = 0;
Timer? _elapsedTimer;
bool _holdToTalk = false;
bool _autoSendFinal = false;
String _languageTag = 'en';
// Simplified: remove explicit mode selector and rely on a single toggle
// Hold-to-talk: true → push-to-talk; false → continuous
@override
void initState() {
super.initState();
_voiceService = ref.read(voiceInputServiceProvider);
// Initialize language
try {
final preset = _voiceService.selectedLocaleId;
_languageTag =
(preset ??
WidgetsBinding.instance.platformDispatcher.locale
.toLanguageTag())
.split(RegExp('[-_]'))
.first
.toLowerCase();
} catch (_) {
_languageTag = 'en';
}
// Load persisted voice settings
final settings = ref.read(appSettingsProvider);
_holdToTalk = settings.voiceHoldToTalk;
_autoSendFinal = settings.voiceAutoSendFinal;
if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) {
_voiceService.setLocale(settings.voiceLocaleId);
_languageTag = settings.voiceLocaleId!
.split(RegExp('[-_]'))
.first
.toLowerCase();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_holdToTalk && !_isListening) {
_startListening();
}
});
}
@override
void dispose() {
_intensitySub?.cancel();
_textSub?.cancel();
_elapsedTimer?.cancel();
super.dispose();
}
Future<void> _startListening() async {
setState(() {
_isListening = true;
_recognizedText = '';
_elapsedSeconds = 0;
});
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.medium,
hapticEnabled: hapticEnabled,
);
try {
final ok = await _voiceService.initialize();
if (!ok) throw Exception('Voice service unavailable');
if (!_voiceService.hasLocalStt) {
final mic = await _voiceService.checkPermissions();
if (!mic) throw Exception('Microphone permission not granted');
}
_elapsedTimer?.cancel();
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted || !_isListening) {
t.cancel();
return;
}
setState(() => _elapsedSeconds += 1);
});
final stream = _voiceService.startListening();
_intensitySub = _voiceService.intensityStream.listen((value) {
if (!mounted) return;
setState(() => _intensity = value);
});
_textSub = stream.listen(
(text) {
if (text.startsWith('[[AUDIO_FILE_PATH]]:')) {
final path = text.split(':').skip(1).join(':');
_transcribeRecordedFile(path);
} else {
setState(() => _recognizedText = text);
}
},
onDone: () {
setState(() => _isListening = false);
_elapsedTimer?.cancel();
if (_autoSendFinal && _recognizedText.trim().isNotEmpty) {
_sendText();
}
},
onError: (_) {
setState(() => _isListening = false);
_elapsedTimer?.cancel();
final h = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.warning,
hapticEnabled: h,
);
},
);
} catch (_) {
setState(() => _isListening = false);
}
}
Future<void> _stopListening() async {
_intensitySub?.cancel();
_intensitySub = null;
await _voiceService.stopListening();
_elapsedTimer?.cancel();
if (mounted) setState(() => _isListening = false);
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.selection,
hapticEnabled: hapticEnabled,
);
}
Future<void> _transcribeRecordedFile(String filePath) async {
try {
setState(() => _isTranscribing = true);
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('API service unavailable');
final bytes = await File(filePath).readAsBytes();
String? language;
try {
language = WidgetsBinding.instance.platformDispatcher.locale
.toLanguageTag();
} catch (_) {
language = 'en-US';
}
final text = await api.transcribeAudio(
bytes.toList(),
language: language,
);
if (!mounted) return;
setState(() {
_recognizedText = text;
_isListening = false;
});
if (_autoSendFinal && _recognizedText.trim().isNotEmpty) {
_sendText();
}
} catch (_) {
if (!mounted) return;
setState(() => _isListening = false);
} finally {
if (mounted) setState(() => _isTranscribing = false);
}
}
void _sendText() {
if (_recognizedText.trim().isEmpty) return;
final hapticEnabled = ref.read(hapticEnabledProvider);
ps.PlatformService.hapticFeedbackWithSettings(
type: ps.HapticType.success,
hapticEnabled: hapticEnabled,
);
widget.onTextReceived(_recognizedText.trim());
Navigator.of(context).pop();
}
String _formatSeconds(int seconds) {
final m = (seconds ~/ 60).toString().padLeft(1, '0');
final s = (seconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
Future<void> _pickLanguage() async {
if (!_voiceService.hasLocalStt) return;
final locales = _voiceService.locales;
if (locales.isEmpty || !mounted) return;
final selected = await showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
return Container(
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.bottomSheet),
),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
boxShadow: ConduitShadows.modal,
),
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
child: SafeArea(
top: false,
child: ListView.separated(
shrinkWrap: true,
itemCount: locales.length,
separatorBuilder: (_, __) =>
Divider(height: 1, color: context.conduitTheme.dividerColor),
itemBuilder: (ctx, i) {
final l = locales[i];
final isSelected = l.localeId == _voiceService.selectedLocaleId;
return ListTile(
title: Text(
l.name,
style: TextStyle(color: context.conduitTheme.textPrimary),
),
subtitle: Text(
l.localeId,
style: TextStyle(color: context.conduitTheme.textSecondary),
),
trailing: isSelected
? Icon(
Icons.check,
color: context.conduitTheme.buttonPrimary,
)
: null,
onTap: () => Navigator.pop(ctx, l.localeId),
);
},
),
),
);
},
);
if (selected != null && mounted) {
setState(() {
_voiceService.setLocale(selected);
_languageTag = selected.split(RegExp('[-_]')).first.toLowerCase();
});
await ref.read(appSettingsProvider.notifier).setVoiceLocaleId(selected);
if (_isListening) {
await _voiceService.stopListening();
_startListening();
}
}
}
Widget _buildWaveform({required bool isCompact, required bool isUltra}) {
final barCount = isUltra ? 10 : 12;
final base = isUltra ? 4 : (isCompact ? 6 : 8);
final range = isUltra ? 14 : (isCompact ? 18 : 24);
return SizedBox(
height: isUltra ? 18 : (isCompact ? 24 : 32),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: Row(
key: ValueKey<int>(_intensity),
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(barCount, (i) {
final normalized = ((_intensity + i) % 10) / 10.0;
final barHeight = base + (normalized * range);
return Container(
width: isUltra ? 2.5 : (isCompact ? 3 : 4),
height: barHeight,
margin: EdgeInsets.symmetric(
horizontal: isUltra ? 1 : (isCompact ? 1.5 : 2),
),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: 0.7,
),
borderRadius: BorderRadius.circular(2),
),
);
}),
),
),
);
}
// Mode selector removed for simplicity
@override
Widget build(BuildContext context) {
final media = MediaQuery.of(context);
final isCompact = media.size.height < 680;
return Container(
height: media.size.height * (isCompact ? 0.45 : 0.6),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.bottomSheet),
),
border: Border.all(color: context.conduitTheme.dividerColor, width: 1),
boxShadow: ConduitShadows.modal,
),
child: SafeArea(
top: false,
bottom: true,
child: Padding(
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
child: Column(
children: [
const SheetHandle(),
Padding(
padding: const EdgeInsets.only(
top: Spacing.md,
bottom: Spacing.md,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_isTranscribing
? 'Transcribing…'
: _isListening
? (_voiceService.hasLocalStt
? 'Listening…'
: 'Recording…')
: 'Voice',
style: TextStyle(
fontSize: AppTypography.headlineMedium,
fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary,
),
),
Row(
children: [
GestureDetector(
onTap: _voiceService.hasLocalStt
? _pickLanguage
: null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.xs,
vertical: 4,
),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
AppBorderRadius.badge,
),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.thin,
),
),
child: Row(
children: [
Text(
_languageTag.toUpperCase(),
style: TextStyle(
fontSize: AppTypography.labelSmall,
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
if (_voiceService.hasLocalStt) ...[
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 16,
color: context.conduitTheme.iconSecondary,
),
],
],
),
),
),
const SizedBox(width: Spacing.sm),
AnimatedOpacity(
opacity: _isListening ? 1 : 0.6,
duration: AnimationDuration.fast,
child: Text(
_formatSeconds(_elapsedSeconds),
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: Spacing.sm),
ConduitIconButton(
icon: Platform.isIOS
? CupertinoIcons.xmark
: Icons.close,
tooltip: 'Close',
isCompact: true,
onPressed: () => Navigator.of(context).pop(),
),
],
),
],
),
),
// Single-line controls
Row(
children: [
ps.PlatformService.getPlatformSwitch(
value: _holdToTalk,
onChanged: (v) async {
setState(() => _holdToTalk = v);
await ref
.read(appSettingsProvider.notifier)
.setVoiceHoldToTalk(v);
if (!_holdToTalk && !_isListening) {
_startListening();
}
if (_holdToTalk && _isListening) {
_stopListening();
}
},
activeColor: context.conduitTheme.buttonPrimary,
),
const SizedBox(width: Spacing.xs),
Text(
'Hold to talk',
style: TextStyle(color: context.conduitTheme.textSecondary),
),
const Spacer(),
ps.PlatformService.getPlatformSwitch(
value: _autoSendFinal,
onChanged: (v) async {
setState(() => _autoSendFinal = v);
await ref
.read(appSettingsProvider.notifier)
.setVoiceAutoSendFinal(v);
},
activeColor: context.conduitTheme.buttonPrimary,
),
const SizedBox(width: Spacing.xs),
Text(
'Auto-send',
style: TextStyle(color: context.conduitTheme.textSecondary),
),
],
),
Expanded(
child: LayoutBuilder(
builder: (context, viewport) {
final isUltra = media.size.height < 560;
final double micSize = isUltra
? 72
: (isCompact ? 88 : 104);
final double micIconSize = isUltra
? 28
: (isCompact ? 34 : 40);
final double topPaddingForScale =
((micSize * 1.2) - micSize) / 2 + 8;
final content = Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: isUltra ? Spacing.sm : Spacing.md),
GestureDetector(
onTapDown: _holdToTalk
? (_) {
if (!_isListening) _startListening();
}
: null,
onTapUp: _holdToTalk
? (_) {
if (_isListening) _stopListening();
}
: null,
onTapCancel: _holdToTalk
? () {
if (_isListening) _stopListening();
}
: null,
onTap: () => _holdToTalk
? null
: (_isListening
? _stopListening()
: _startListening()),
child: Semantics(
button: true,
label: _isListening
? 'Stop listening'
: 'Start listening',
child: Stack(
alignment: Alignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 120),
width:
micSize + (_intensity * 2).toDouble(),
height:
micSize + (_intensity * 2).toDouble(),
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: _isListening
? [
BoxShadow(
color: context
.conduitTheme
.buttonPrimary
.withValues(alpha: 0.25),
blurRadius:
24 + _intensity.toDouble(),
spreadRadius: 2,
),
]
: null,
),
),
// Middle ring removed for simpler look
Container(
width: micSize,
height: micSize,
decoration: BoxDecoration(
color: _isListening
? context.conduitTheme.buttonPrimary
.withValues(alpha: 0.15)
: context
.conduitTheme
.surfaceBackground
.withValues(
alpha: Alpha.subtle,
),
shape: BoxShape.circle,
border: Border.all(
color: _isListening
? context.conduitTheme.buttonPrimary
: context.conduitTheme.dividerColor,
width: 2,
),
),
child: Icon(
_isListening
? (Platform.isIOS
? CupertinoIcons.mic_fill
: Icons.mic)
: (Platform.isIOS
? CupertinoIcons.mic_off
: Icons.mic_off),
size: micIconSize,
color: _isListening
? context.conduitTheme.buttonPrimary
: context.conduitTheme.iconSecondary,
),
),
],
),
),
),
const SizedBox(height: Spacing.sm),
_buildWaveform(
isCompact: isCompact,
isUltra: isUltra,
),
SizedBox(
height: isUltra
? Spacing.sm
: (isCompact ? Spacing.md : Spacing.xl),
),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
media.size.height *
(isUltra ? 0.13 : (isCompact ? 0.16 : 0.2)),
minHeight: isUltra ? 56 : (isCompact ? 64 : 80),
),
child: ConduitCard(
isCompact: isCompact,
padding: EdgeInsets.all(
isCompact ? Spacing.md : Spacing.md,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(
'Transcript',
style: TextStyle(
fontSize: AppTypography.labelSmall,
fontWeight: FontWeight.w600,
color: context
.conduitTheme
.textSecondary,
),
),
const Spacer(),
ConduitIconButton(
icon: Icons.close,
isCompact: true,
tooltip: 'Clear',
onPressed:
_recognizedText.isNotEmpty &&
!_isTranscribing
? () => setState(
() => _recognizedText = '',
)
: null,
),
],
),
const SizedBox(height: Spacing.xs),
if (_isTranscribing)
Center(
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
ConduitLoadingIndicator(
size: isUltra
? 14
: (isCompact ? 16 : 18),
isCompact: true,
),
const SizedBox(width: Spacing.xs),
Text(
'Transcribing…',
style: TextStyle(
fontSize: isUltra
? AppTypography.bodySmall
: (isCompact
? AppTypography
.bodyMedium
: AppTypography
.bodyLarge),
color: context
.conduitTheme
.textSecondary,
),
),
],
),
)
else
Flexible(
child: SingleChildScrollView(
child: Text(
_recognizedText.isEmpty
? (_isListening
? (_voiceService.hasLocalStt
? 'Speak now…'
: 'Recording…')
: 'Tap Start to begin')
: _recognizedText,
style: TextStyle(
fontSize: isUltra
? AppTypography.bodySmall
: (isCompact
? AppTypography.bodyMedium
: AppTypography
.bodyLarge),
color: _recognizedText.isEmpty
? context
.conduitTheme
.inputPlaceholder
: context
.conduitTheme
.textPrimary,
height: 1.4,
),
textAlign: TextAlign.center,
),
),
),
],
),
),
),
],
),
);
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(top: topPaddingForScale),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: viewport.maxHeight,
),
child: content,
),
);
},
),
),
const SizedBox(height: Spacing.md),
Row(
children: [
Expanded(
child: ConduitButton(
text: _isListening ? 'Stop' : 'Start',
isSecondary: true,
isCompact: isCompact,
onPressed: _isListening
? _stopListening
: _startListening,
),
),
const SizedBox(width: Spacing.xs),
Expanded(
child: ConduitButton(
text: 'Send',
isCompact: isCompact,
onPressed: _recognizedText.isNotEmpty ? _sendText : null,
),
),
],
),
],
),
),
),
);
}
}

View File

@@ -9,6 +9,7 @@ import '../../chat/providers/chat_providers.dart';
import '../../../core/providers/app_providers.dart';
import '../providers/tools_providers.dart';
import '../../../shared/widgets/sheet_handle.dart';
import '../../chat/views/chat_page_helpers.dart';
class UnifiedToolsModal extends ConsumerStatefulWidget {
const UnifiedToolsModal({super.key});
@@ -33,7 +34,10 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.bottomSheet),
),
border: Border.all(color: theme.dividerColor, width: BorderWidth.regular),
border: Border.all(
color: theme.dividerColor,
width: BorderWidth.regular,
),
boxShadow: ConduitShadows.modal,
),
child: SafeArea(
@@ -48,23 +52,48 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Handle bar (standardized)
const SheetHandle(),
const SizedBox(height: Spacing.md),
// Removed header for minimal, focused layout
// Full tiles for Web and Image features
Column(
children: [
_buildFeatureTile(
title: 'Web Search',
description:
'Let the assistant search the internet while answering.',
icon: Platform.isIOS
? CupertinoIcons.search
: Icons.search,
isActive: webSearchEnabled,
onTap: () {
HapticFeedback.lightImpact();
ref.read(webSearchEnabledProvider.notifier).state =
!webSearchEnabled;
},
),
if (imageGenAvailable)
_buildFeatureTile(
title: 'Image Generation',
description:
'Generate images from your prompt and attach them.',
icon: Platform.isIOS
? CupertinoIcons.photo
: Icons.image,
isActive: imageGenEnabled,
onTap: () {
HapticFeedback.lightImpact();
ref
.read(imageGenerationEnabledProvider.notifier)
.state =
!imageGenEnabled;
},
),
],
),
const SizedBox(height: Spacing.lg),
// Web Search Toggle
_buildWebSearchToggle(webSearchEnabled),
const SizedBox(height: Spacing.md),
// Image Generation Toggle (conditionally shown)
if (imageGenAvailable) ...[
_buildImageGenerationToggle(imageGenEnabled),
const SizedBox(height: Spacing.md),
],
// Tools Section
// All tools as selectable tiles (model selector style)
toolsAsync.when(
data: (tools) {
if (tools.isEmpty) {
@@ -79,20 +108,29 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('Available Tools', tools.length),
const SizedBox(height: Spacing.sm),
...tools.map(
(tool) => Padding(
padding: const EdgeInsets.only(bottom: Spacing.sm),
child: _buildToolCard(
tool,
selectedToolIds.contains(tool.id),
),
),
),
],
children: tools.map((tool) {
final isSelected = selectedToolIds.contains(tool.id);
return _buildToolTile(
tool,
isSelected,
onTap: () {
HapticFeedback.lightImpact();
final currentIds = ref.read(
selectedToolIdsProvider,
);
if (isSelected) {
ref
.read(selectedToolIdsProvider.notifier)
.state = currentIds
.where((id) => id != tool.id)
.toList();
} else {
ref.read(selectedToolIdsProvider.notifier).state =
[...currentIds, tool.id];
}
},
);
}).toList(),
);
},
loading: () => _buildNeutralCard(
@@ -137,294 +175,9 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
);
}
Widget _buildSectionHeader(String title, int count) {
final theme = context.conduitTheme;
return Row(
children: [
Text(
title,
style: AppTypography.bodySmallStyle.copyWith(
fontWeight: FontWeight.w600,
color: theme.textSecondary,
letterSpacing: 0.2,
),
),
const SizedBox(width: Spacing.xs),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.surfaceBackground.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
border: Border.all(color: theme.dividerColor, width: BorderWidth.thin),
),
child: Text(
'$count',
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textSecondary,
),
),
),
],
);
}
// Legacy header removed in simplified design
Widget _buildWebSearchToggle(bool webSearchEnabled) {
return Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
side: BorderSide(
color: webSearchEnabled
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBorder,
width: BorderWidth.regular,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
onTap: () {
HapticFeedback.lightImpact();
ref.read(webSearchEnabledProvider.notifier).state = !webSearchEnabled;
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color: webSearchEnabled
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Row(
children: [
Icon(
webSearchEnabled
? (Platform.isIOS ? CupertinoIcons.globe : Icons.public)
: (Platform.isIOS ? CupertinoIcons.search : Icons.search),
size: IconSize.medium,
color: webSearchEnabled
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
),
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Web Search',
style: AppTypography.labelStyle.copyWith(
color: webSearchEnabled
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
Text(
webSearchEnabled
? 'I can search the internet for information'
: 'Enable to search the web for answers',
style: AppTypography.captionStyle.copyWith(
color: webSearchEnabled
? context.conduitTheme.buttonPrimaryText.withValues(
alpha: Alpha.strong,
)
: context.conduitTheme.textSecondary,
),
),
],
),
),
Icon(
webSearchEnabled ? Icons.toggle_on : Icons.toggle_off,
size: IconSize.large,
color: webSearchEnabled
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textSecondary,
),
],
),
),
),
);
}
Widget _buildImageGenerationToggle(bool imageGenEnabled) {
return Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
side: BorderSide(
color: imageGenEnabled
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBorder,
width: BorderWidth.regular,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
onTap: () {
HapticFeedback.lightImpact();
ref.read(imageGenerationEnabledProvider.notifier).state =
!imageGenEnabled;
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color: imageGenEnabled
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Row(
children: [
Icon(
Platform.isIOS ? CupertinoIcons.photo : Icons.image,
size: IconSize.medium,
color: imageGenEnabled
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
),
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Image Generation',
style: AppTypography.labelStyle.copyWith(
color: imageGenEnabled
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
Text(
imageGenEnabled
? 'I can generate images from your prompt'
: 'Enable to generate images with your request',
style: AppTypography.captionStyle.copyWith(
color: imageGenEnabled
? context.conduitTheme.buttonPrimaryText.withValues(
alpha: Alpha.strong,
)
: context.conduitTheme.textSecondary,
),
),
],
),
),
Icon(
imageGenEnabled ? Icons.toggle_on : Icons.toggle_off,
size: IconSize.large,
color: imageGenEnabled
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textSecondary,
),
],
),
),
),
);
}
Widget _buildToolCard(Tool tool, bool isSelected) {
return Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
side: BorderSide(
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBorder,
width: BorderWidth.regular,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
onTap: () {
HapticFeedback.lightImpact();
final currentIds = ref.read(selectedToolIdsProvider);
if (isSelected) {
ref.read(selectedToolIdsProvider.notifier).state = currentIds
.where((id) => id != tool.id)
.toList();
} else {
ref.read(selectedToolIdsProvider.notifier).state = [
...currentIds,
tool.id,
];
}
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Row(
children: [
Icon(
_getToolIcon(tool),
size: IconSize.medium,
color: isSelected
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
),
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tool.name,
style: AppTypography.labelStyle.copyWith(
color: isSelected
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
if (tool.meta?['description'] != null &&
tool.meta!['description'].toString().isNotEmpty)
Text(
tool.meta!['description'].toString(),
style: AppTypography.captionStyle.copyWith(
color: isSelected
? context.conduitTheme.buttonPrimaryText.withValues(
alpha: Alpha.strong,
)
: context.conduitTheme.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
isSelected ? Icons.toggle_on : Icons.toggle_off,
size: IconSize.large,
color: isSelected
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textSecondary,
),
],
),
),
),
);
}
// Removed legacy builders (kept earlier for reference)
IconData _getToolIcon(Tool tool) {
final toolName = tool.name.toLowerCase();
@@ -449,4 +202,251 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
return Icons.build;
}
}
Widget _buildFeatureTile({
required String title,
required String description,
required IconData icon,
required bool isActive,
required VoidCallback onTap,
}) {
return PressableScale(
onTap: onTap,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Container(
margin: const EdgeInsets.only(bottom: Spacing.md),
decoration: BoxDecoration(
gradient: isActive
? LinearGradient(
colors: [
context.conduitTheme.buttonPrimary.withValues(alpha: 0.2),
context.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
],
)
: null,
color: isActive
? null
: context.conduitTheme.surfaceBackground.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: isActive
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.5)
: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
boxShadow: isActive ? ConduitShadows.card : null,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: 0.15,
),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Icon(
icon,
color: context.conduitTheme.buttonPrimary,
size: 16,
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
fontSize: AppTypography.bodyMedium,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: Spacing.xs),
Text(
description,
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.labelSmall,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: Spacing.md),
AnimatedOpacity(
opacity: isActive ? 1 : 0.6,
duration: AnimationDuration.fast,
child: Container(
padding: const EdgeInsets.all(Spacing.xxs),
decoration: BoxDecoration(
color: isActive
? context.conduitTheme.buttonPrimary
: context.conduitTheme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: isActive
? context.conduitTheme.buttonPrimary.withValues(
alpha: 0.6,
)
: context.conduitTheme.dividerColor,
),
),
child: Icon(
isActive
? (Platform.isIOS
? CupertinoIcons.check_mark
: Icons.check)
: (Platform.isIOS ? CupertinoIcons.add : Icons.add),
color: isActive
? context.conduitTheme.textInverse
: context.conduitTheme.iconSecondary,
size: 14,
),
),
),
],
),
),
),
);
}
Widget _buildToolTile(
Tool tool,
bool isSelected, {
required VoidCallback onTap,
}) {
return PressableScale(
onTap: onTap,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Container(
margin: const EdgeInsets.only(bottom: Spacing.md),
decoration: BoxDecoration(
gradient: isSelected
? LinearGradient(
colors: [
context.conduitTheme.buttonPrimary.withValues(alpha: 0.2),
context.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
],
)
: null,
color: isSelected
? null
: context.conduitTheme.surfaceBackground.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.5)
: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
boxShadow: isSelected ? ConduitShadows.card : null,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: 0.15,
),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Icon(
_getToolIcon(tool),
color: context.conduitTheme.buttonPrimary,
size: 16,
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tool.name,
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
fontSize: AppTypography.bodyMedium,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (tool.meta?['description'] != null &&
(tool.meta!['description'] as String).isNotEmpty) ...[
const SizedBox(height: Spacing.xs),
Text(
tool.meta!['description'],
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.labelSmall,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: Spacing.md),
AnimatedOpacity(
opacity: isSelected ? 1 : 0.6,
duration: AnimationDuration.fast,
child: Container(
padding: const EdgeInsets.all(Spacing.xxs),
decoration: BoxDecoration(
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(
alpha: 0.6,
)
: context.conduitTheme.dividerColor,
),
),
child: Icon(
isSelected
? (Platform.isIOS
? CupertinoIcons.check_mark
: Icons.check)
: (Platform.isIOS ? CupertinoIcons.add : Icons.add),
color: isSelected
? context.conduitTheme.textInverse
: context.conduitTheme.iconSecondary,
size: 14,
),
),
),
],
),
),
),
);
}
// Removed small pill builder; using full tiles for consistency
}