refactor: tools design
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
758
lib/features/chat/widgets/voice_input_sheet.dart
Normal file
758
lib/features/chat/widgets/voice_input_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user