refactor: tools design
This commit is contained in:
@@ -110,46 +110,49 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
|
|||||||
textDirection: TextDirection.ltr,
|
textDirection: TextDirection.ltr,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.error_outline,
|
Icons.error_outline,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: context.conduitTheme.error,
|
color: context.conduitTheme.error,
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.errorMessage,
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
Text(
|
AppLocalizations.of(context)?.errorMessage ??
|
||||||
enhancedErrorService.getUserMessage(_error!),
|
'An unexpected error occurred',
|
||||||
textAlign: TextAlign.center,
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
color: context.conduitTheme.textPrimary,
|
||||||
color: context.conduitTheme.textSecondary,
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
if (widget.allowRetry) ...[
|
Text(
|
||||||
const SizedBox(height: 24),
|
enhancedErrorService.getUserMessage(_error!),
|
||||||
FilledButton.icon(
|
textAlign: TextAlign.center,
|
||||||
onPressed: _retry,
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
icon: const Icon(Icons.refresh),
|
color: context.conduitTheme.textSecondary,
|
||||||
label: Text(AppLocalizations.of(context)!.retry),
|
),
|
||||||
),
|
),
|
||||||
|
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
|
// Wrap child in error handler
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../widgets/modern_chat_input.dart';
|
|||||||
import '../widgets/user_message_bubble.dart';
|
import '../widgets/user_message_bubble.dart';
|
||||||
import '../widgets/assistant_message_widget.dart' as assistant;
|
import '../widgets/assistant_message_widget.dart' as assistant;
|
||||||
import '../widgets/file_attachment_widget.dart';
|
import '../widgets/file_attachment_widget.dart';
|
||||||
|
import '../widgets/voice_input_sheet.dart';
|
||||||
import '../services/voice_input_service.dart';
|
import '../services/voice_input_service.dart';
|
||||||
import '../services/file_attachment_service.dart';
|
import '../services/file_attachment_service.dart';
|
||||||
import '../../tools/providers/tools_providers.dart';
|
import '../../tools/providers/tools_providers.dart';
|
||||||
@@ -170,10 +171,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkAndLoadDemoConversation() async {
|
Future<void> _checkAndLoadDemoConversation() async {
|
||||||
|
if (!mounted) return;
|
||||||
final isReviewerMode = ref.read(reviewerModeProvider);
|
final isReviewerMode = ref.read(reviewerModeProvider);
|
||||||
if (!isReviewerMode) return;
|
if (!isReviewerMode) return;
|
||||||
|
|
||||||
// Check if there's already an active conversation
|
// Check if there's already an active conversation
|
||||||
|
if (!mounted) return;
|
||||||
final activeConversation = ref.read(activeConversationProvider);
|
final activeConversation = ref.read(activeConversationProvider);
|
||||||
if (activeConversation != null) {
|
if (activeConversation != null) {
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
@@ -183,10 +186,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Force refresh conversations provider to ensure we get the demo conversations
|
// Force refresh conversations provider to ensure we get the demo conversations
|
||||||
|
if (!mounted) return;
|
||||||
ref.invalidate(conversationsProvider);
|
ref.invalidate(conversationsProvider);
|
||||||
|
|
||||||
// Try to load demo conversation
|
// Try to load demo conversation
|
||||||
for (int i = 0; i < 10; i++) {
|
for (int i = 0; i < 10; i++) {
|
||||||
|
if (!mounted) return;
|
||||||
final conversationsAsync = ref.read(conversationsProvider);
|
final conversationsAsync = ref.read(conversationsProvider);
|
||||||
|
|
||||||
if (conversationsAsync.hasValue && conversationsAsync.value!.isNotEmpty) {
|
if (conversationsAsync.hasValue && conversationsAsync.value!.isNotEmpty) {
|
||||||
@@ -196,6 +201,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
orElse: () => conversationsAsync.value!.first,
|
orElse: () => conversationsAsync.value!.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
ref.read(activeConversationProvider.notifier).state = welcomeConv;
|
ref.read(activeConversationProvider.notifier).state = welcomeConv;
|
||||||
debugPrint('Auto-loaded demo conversation');
|
debugPrint('Auto-loaded demo conversation');
|
||||||
return;
|
return;
|
||||||
@@ -204,6 +210,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
// If conversations are still loading, wait a bit and retry
|
// If conversations are still loading, wait a bit and retry
|
||||||
if (conversationsAsync.isLoading || i == 0) {
|
if (conversationsAsync.isLoading || i == 0) {
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
if (!mounted) return;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,11 +230,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
|
|
||||||
// Initialize chat page components
|
// Initialize chat page components
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!mounted) return;
|
||||||
// First, ensure a model is selected
|
// First, ensure a model is selected
|
||||||
await _checkAndAutoSelectModel();
|
await _checkAndAutoSelectModel();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
// Then check for demo conversation in reviewer mode
|
// Then check for demo conversation in reviewer mode
|
||||||
await _checkAndLoadDemoConversation();
|
await _checkAndLoadDemoConversation();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
// Finally, show onboarding if needed
|
// Finally, show onboarding if needed
|
||||||
await _checkAndShowOnboarding();
|
await _checkAndShowOnboarding();
|
||||||
@@ -311,7 +321,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (context) => _VoiceInputSheet(
|
builder: (context) => VoiceInputSheet(
|
||||||
onTextReceived: (text) {
|
onTextReceived: (text) {
|
||||||
if (text.isNotEmpty) {
|
if (text.isNotEmpty) {
|
||||||
final selectedModel = ref.read(selectedModelProvider);
|
final selectedModel = ref.read(selectedModelProvider);
|
||||||
@@ -976,7 +986,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
drawerEnableOpenDragGesture: true,
|
drawerEnableOpenDragGesture: true,
|
||||||
drawerEdgeDragWidth: 32,
|
drawerEdgeDragWidth: 32,
|
||||||
drawer: Drawer(
|
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,
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||||
child: const SafeArea(child: ChatsDrawer()),
|
child: const SafeArea(child: ChatsDrawer()),
|
||||||
),
|
),
|
||||||
@@ -1822,7 +1835,10 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
|
|||||||
_autoSendFinal = settings.voiceAutoSendFinal;
|
_autoSendFinal = settings.voiceAutoSendFinal;
|
||||||
if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) {
|
if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) {
|
||||||
_voiceService.setLocale(settings.voiceLocaleId);
|
_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: [
|
children: [
|
||||||
const SheetHandle(),
|
const SheetHandle(),
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
Text('Select Language',
|
Text(
|
||||||
style: TextStyle(
|
'Select Language',
|
||||||
fontSize: AppTypography.headlineSmall,
|
style: TextStyle(
|
||||||
color: context.conduitTheme.textPrimary,
|
fontSize: AppTypography.headlineSmall,
|
||||||
fontWeight: FontWeight.w600,
|
color: context.conduitTheme.textPrimary,
|
||||||
)),
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: Spacing.sm),
|
const SizedBox(height: Spacing.sm),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
@@ -2039,18 +2057,26 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
|
|||||||
),
|
),
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final l = locales[i];
|
final l = locales[i];
|
||||||
final isSelected = l.localeId == _voiceService.selectedLocaleId;
|
final isSelected =
|
||||||
|
l.localeId == _voiceService.selectedLocaleId;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
l.name,
|
l.name,
|
||||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
style: TextStyle(
|
||||||
|
color: context.conduitTheme.textPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
l.localeId,
|
l.localeId,
|
||||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
style: TextStyle(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
trailing: isSelected
|
trailing: isSelected
|
||||||
? Icon(Icons.check, color: context.conduitTheme.buttonPrimary)
|
? Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: context.conduitTheme.buttonPrimary,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => Navigator.pop(ctx, l.localeId),
|
onTap: () => Navigator.pop(ctx, l.localeId),
|
||||||
);
|
);
|
||||||
@@ -2118,410 +2144,482 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
|
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Handle bar
|
// Handle bar
|
||||||
const SheetHandle(),
|
const SheetHandle(),
|
||||||
|
|
||||||
// Header: Title + timer + language chip
|
// Header: Title + timer + language chip
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: Spacing.md, bottom: Spacing.md),
|
padding: const EdgeInsets.only(
|
||||||
|
top: Spacing.md,
|
||||||
|
bottom: Spacing.md,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_isTranscribing
|
_isTranscribing
|
||||||
? 'Transcribing…'
|
? 'Transcribing…'
|
||||||
: _isListening
|
: _isListening
|
||||||
? (_voiceService.hasLocalStt ? 'Listening…' : 'Recording…')
|
? (_voiceService.hasLocalStt
|
||||||
|
? 'Listening…'
|
||||||
|
: 'Recording…')
|
||||||
: 'Voice',
|
: 'Voice',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppTypography.headlineMedium,
|
fontSize: AppTypography.headlineMedium,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: context.conduitTheme.textPrimary,
|
color: context.conduitTheme.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Language chip
|
// Language chip
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _voiceService.hasLocalStt ? _pickLanguage : null,
|
onTap: _voiceService.hasLocalStt
|
||||||
child: Container(
|
? _pickLanguage
|
||||||
padding: const EdgeInsets.symmetric(
|
: null,
|
||||||
horizontal: Spacing.xs,
|
child: Container(
|
||||||
vertical: 4,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: Spacing.xs,
|
||||||
decoration: BoxDecoration(
|
vertical: 4,
|
||||||
color: context.conduitTheme.surfaceBackground
|
),
|
||||||
.withValues(alpha: 0.4),
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(
|
color: context.conduitTheme.surfaceBackground
|
||||||
AppBorderRadius.badge,
|
.withValues(alpha: 0.4),
|
||||||
),
|
borderRadius: BorderRadius.circular(
|
||||||
border: Border.all(
|
AppBorderRadius.badge,
|
||||||
color: context.conduitTheme.dividerColor,
|
),
|
||||||
width: BorderWidth.thin,
|
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) ...[
|
child: Row(
|
||||||
const SizedBox(width: 4),
|
children: [
|
||||||
Icon(
|
Text(
|
||||||
Icons.arrow_drop_down,
|
_languageTag.toUpperCase(),
|
||||||
size: 16,
|
style: TextStyle(
|
||||||
color: context.conduitTheme.iconSecondary,
|
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
|
||||||
const SizedBox(width: Spacing.sm),
|
AnimatedOpacity(
|
||||||
// Timer
|
opacity: _isListening ? 1 : 0.6,
|
||||||
AnimatedOpacity(
|
duration: AnimationDuration.fast,
|
||||||
opacity: _isListening ? 1 : 0.6,
|
child: Text(
|
||||||
duration: AnimationDuration.fast,
|
_formatSeconds(_elapsedSeconds),
|
||||||
child: Text(
|
style: TextStyle(
|
||||||
_formatSeconds(_elapsedSeconds),
|
color: context.conduitTheme.textSecondary,
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.w600,
|
||||||
color: context.conduitTheme.textSecondary,
|
),
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: Spacing.sm),
|
||||||
),
|
// Close sheet
|
||||||
const SizedBox(width: Spacing.sm),
|
ConduitIconButton(
|
||||||
// Close sheet
|
icon: Platform.isIOS
|
||||||
ConduitIconButton(
|
? CupertinoIcons.xmark
|
||||||
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
: Icons.close,
|
||||||
tooltip: 'Close',
|
tooltip: 'Close',
|
||||||
isCompact: true,
|
isCompact: true,
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Toggles row: Hold to talk, Auto-send
|
// Toggles row: Hold to talk, Auto-send
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: Spacing.sm),
|
padding: const EdgeInsets.only(bottom: Spacing.sm),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
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),
|
|
||||||
child: Row(
|
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,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
// Inline clear action aligned to the end
|
_buildThemedSwitch(
|
||||||
Row(
|
value: _holdToTalk,
|
||||||
children: [
|
onChanged: (v) async {
|
||||||
Text(
|
setState(() => _holdToTalk = v);
|
||||||
'Transcript',
|
await ref
|
||||||
style: TextStyle(
|
.read(appSettingsProvider.notifier)
|
||||||
fontSize: AppTypography.labelSmall,
|
.setVoiceHoldToTalk(v);
|
||||||
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),
|
const SizedBox(width: Spacing.xs),
|
||||||
if (_isTranscribing)
|
Text(
|
||||||
Center(
|
'Hold to talk',
|
||||||
child: Row(
|
style: TextStyle(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
color: context.conduitTheme.textSecondary,
|
||||||
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(
|
Expanded(
|
||||||
child: ConduitButton(
|
child: Row(
|
||||||
text: _isListening ? 'Stop' : 'Start',
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
isSecondary: true,
|
children: [
|
||||||
isCompact: isCompact,
|
_buildThemedSwitch(
|
||||||
onPressed: _isListening ? _stopListening : _startListening,
|
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 '../providers/chat_providers.dart';
|
||||||
import '../../tools/widgets/unified_tools_modal.dart';
|
import '../../tools/widgets/unified_tools_modal.dart';
|
||||||
import '../../tools/providers/tools_providers.dart';
|
import '../../tools/providers/tools_providers.dart';
|
||||||
|
import '../../../core/providers/app_providers.dart';
|
||||||
|
|
||||||
import '../../../shared/utils/platform_utils.dart';
|
import '../../../shared/utils/platform_utils.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
@@ -197,6 +198,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
messages.last.isStreaming;
|
messages.last.isStreaming;
|
||||||
final stopGeneration = ref.read(stopGenerationProvider);
|
final stopGeneration = ref.read(stopGenerationProvider);
|
||||||
|
|
||||||
|
final webSearchEnabled = ref.watch(webSearchEnabledProvider);
|
||||||
|
final imageGenEnabled = ref.watch(imageGenerationEnabledProvider);
|
||||||
|
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
// Transparent wrapper so rounded corners are visible against page background
|
// Transparent wrapper so rounded corners are visible against page background
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
@@ -265,7 +270,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
onTap: widget.enabled
|
onTap: widget.enabled
|
||||||
? _showAttachmentOptions
|
? _showAttachmentOptions
|
||||||
: null,
|
: null,
|
||||||
tooltip: AppLocalizations.of(context)!.addAttachment,
|
tooltip: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.addAttachment,
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.sm),
|
||||||
],
|
],
|
||||||
@@ -273,8 +280,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
textField: true,
|
textField: true,
|
||||||
label: AppLocalizations.of(context)!.messageInputLabel,
|
label: AppLocalizations.of(
|
||||||
hint: AppLocalizations.of(context)!.messageInputHint,
|
context,
|
||||||
|
)!.messageInputLabel,
|
||||||
|
hint: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.messageInputHint,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
@@ -292,7 +303,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
color: context.conduitTheme.inputText,
|
color: context.conduitTheme.inputText,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: AppLocalizations.of(context)!.messageHintText,
|
hintText: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.messageHintText,
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
color:
|
color:
|
||||||
context.conduitTheme.inputPlaceholder,
|
context.conduitTheme.inputPlaceholder,
|
||||||
@@ -364,26 +377,76 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
onTap: widget.enabled
|
onTap: widget.enabled
|
||||||
? _showAttachmentOptions
|
? _showAttachmentOptions
|
||||||
: null,
|
: 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),
|
const SizedBox(width: Spacing.sm),
|
||||||
// Tools button
|
|
||||||
_buildRoundButton(
|
_buildRoundButton(
|
||||||
icon: Icons.build,
|
icon: Icons.more_horiz,
|
||||||
onTap: widget.enabled
|
onTap: widget.enabled
|
||||||
? () {
|
? _showUnifiedToolsModal
|
||||||
_showUnifiedToolsModal();
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
tooltip: AppLocalizations.of(context)!.tools,
|
tooltip: AppLocalizations.of(context)!.tools,
|
||||||
isActive:
|
isActive:
|
||||||
ref
|
ref
|
||||||
.watch(selectedToolIdsProvider)
|
.watch(selectedToolIdsProvider)
|
||||||
.isNotEmpty ||
|
.isNotEmpty ||
|
||||||
ref.watch(webSearchEnabledProvider) ||
|
webSearchEnabled ||
|
||||||
ref.watch(imageGenerationEnabledProvider),
|
imageGenEnabled,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(width: Spacing.sm),
|
||||||
// Microphone button: call provided callback for premium voice UI
|
// Microphone button: call provided callback for premium voice UI
|
||||||
_buildRoundButton(
|
_buildRoundButton(
|
||||||
icon: Platform.isIOS
|
icon: Platform.isIOS
|
||||||
@@ -392,7 +455,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
onTap: widget.enabled
|
onTap: widget.enabled
|
||||||
? widget.onVoiceInput
|
? widget.onVoiceInput
|
||||||
: null,
|
: null,
|
||||||
tooltip: AppLocalizations.of(context)!.voiceInput,
|
tooltip: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.voiceInput,
|
||||||
isActive: _isRecording,
|
isActive: _isRecording,
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.sm),
|
||||||
@@ -437,7 +502,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
side: BorderSide(color: context.conduitTheme.error, width: BorderWidth.regular),
|
side: BorderSide(
|
||||||
|
color: context.conduitTheme.error,
|
||||||
|
width: BorderWidth.regular,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
@@ -483,9 +551,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
|
|
||||||
// Default SEND variant
|
// Default SEND variant
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: enabled ? AppLocalizations.of(context)!.sendMessage : AppLocalizations.of(context)!.send,
|
message: enabled
|
||||||
|
? AppLocalizations.of(context)!.sendMessage
|
||||||
|
: AppLocalizations.of(context)!.send,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: enabled ? Alpha.primary : Alpha.disabled,
|
opacity: enabled ? Alpha.primary : Alpha.disabled,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !enabled,
|
ignoring: !enabled,
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -495,7 +565,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: enabled
|
color: enabled
|
||||||
? context.conduitTheme.cardBorder
|
? context.conduitTheme.cardBorder
|
||||||
: context.conduitTheme.cardBorder.withValues(alpha: Alpha.medium),
|
: context.conduitTheme.cardBorder.withValues(
|
||||||
|
alpha: Alpha.medium,
|
||||||
|
),
|
||||||
width: BorderWidth.regular,
|
width: BorderWidth.regular,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -520,7 +592,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
size: IconSize.medium,
|
size: IconSize.medium,
|
||||||
color: enabled
|
color: enabled
|
||||||
? context.conduitTheme.textPrimary
|
? 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,
|
alpha: Alpha.buttonHover + Alpha.subtle,
|
||||||
)
|
)
|
||||||
: showBackground
|
: showBackground
|
||||||
? context.conduitTheme.cardBorder
|
? context.conduitTheme.cardBorder
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
width: BorderWidth.regular,
|
width: BorderWidth.regular,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -563,37 +637,90 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
onTap();
|
onTap();
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: TouchTarget.comfortable,
|
width: TouchTarget.comfortable,
|
||||||
height: TouchTarget.comfortable,
|
height: TouchTarget.comfortable,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isActive
|
color: isActive
|
||||||
? context.conduitTheme.textPrimary.withValues(
|
? context.conduitTheme.textPrimary.withValues(
|
||||||
alpha: Alpha.buttonHover,
|
alpha: Alpha.buttonHover,
|
||||||
)
|
)
|
||||||
: showBackground
|
: showBackground
|
||||||
? context.conduitTheme.cardBackground
|
? context.conduitTheme.cardBackground
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
|
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
|
||||||
boxShadow: (isActive || showBackground)
|
boxShadow: (isActive || showBackground)
|
||||||
? ConduitShadows.button
|
? ConduitShadows.button
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: IconSize.medium,
|
size: IconSize.medium,
|
||||||
color: widget.enabled
|
color: widget.enabled
|
||||||
? (isActive
|
? (isActive
|
||||||
? context.conduitTheme.textPrimary
|
? context.conduitTheme.textPrimary
|
||||||
: context.conduitTheme.textPrimary.withValues(
|
: context.conduitTheme.textPrimary.withValues(
|
||||||
alpha: Alpha.strong,
|
alpha: Alpha.strong,
|
||||||
))
|
))
|
||||||
: context.conduitTheme.textPrimary.withValues(
|
: context.conduitTheme.textPrimary.withValues(
|
||||||
alpha: Alpha.disabled,
|
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() {
|
void _showAttachmentOptions() {
|
||||||
@@ -626,38 +753,43 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildAttachmentOption(
|
child: _buildAttachmentOption(
|
||||||
icon: Platform.isIOS ? CupertinoIcons.doc : Icons.attach_file,
|
icon: Platform.isIOS
|
||||||
label: AppLocalizations.of(context)!.file,
|
? CupertinoIcons.doc
|
||||||
onTap: () {
|
: Icons.attach_file,
|
||||||
HapticFeedback.lightImpact();
|
label: AppLocalizations.of(context)!.file,
|
||||||
Navigator.pop(context); // Close modal
|
onTap: () {
|
||||||
widget.onFileAttachment?.call();
|
HapticFeedback.lightImpact();
|
||||||
},
|
Navigator.pop(context); // Close modal
|
||||||
)),
|
widget.onFileAttachment?.call();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: Spacing.md),
|
const SizedBox(width: Spacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildAttachmentOption(
|
child: _buildAttachmentOption(
|
||||||
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
|
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
|
||||||
label: AppLocalizations.of(context)!.photo,
|
label: AppLocalizations.of(context)!.photo,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
Navigator.pop(context); // Close modal
|
Navigator.pop(context); // Close modal
|
||||||
widget.onImageAttachment?.call();
|
widget.onImageAttachment?.call();
|
||||||
},
|
},
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: Spacing.md),
|
const SizedBox(width: Spacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildAttachmentOption(
|
child: _buildAttachmentOption(
|
||||||
icon: Platform.isIOS
|
icon: Platform.isIOS
|
||||||
? CupertinoIcons.camera
|
? CupertinoIcons.camera
|
||||||
: Icons.camera_alt,
|
: Icons.camera_alt,
|
||||||
label: AppLocalizations.of(context)!.camera,
|
label: AppLocalizations.of(context)!.camera,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
Navigator.pop(context); // Close modal
|
Navigator.pop(context); // Close modal
|
||||||
widget.onCameraCapture?.call();
|
widget.onCameraCapture?.call();
|
||||||
},
|
},
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.lg),
|
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 '../../../core/providers/app_providers.dart';
|
||||||
import '../providers/tools_providers.dart';
|
import '../providers/tools_providers.dart';
|
||||||
import '../../../shared/widgets/sheet_handle.dart';
|
import '../../../shared/widgets/sheet_handle.dart';
|
||||||
|
import '../../chat/views/chat_page_helpers.dart';
|
||||||
|
|
||||||
class UnifiedToolsModal extends ConsumerStatefulWidget {
|
class UnifiedToolsModal extends ConsumerStatefulWidget {
|
||||||
const UnifiedToolsModal({super.key});
|
const UnifiedToolsModal({super.key});
|
||||||
@@ -33,7 +34,10 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
borderRadius: const BorderRadius.vertical(
|
borderRadius: const BorderRadius.vertical(
|
||||||
top: Radius.circular(AppBorderRadius.bottomSheet),
|
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,
|
boxShadow: ConduitShadows.modal,
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@@ -48,23 +52,48 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Handle bar (standardized)
|
|
||||||
const SheetHandle(),
|
const SheetHandle(),
|
||||||
const SizedBox(height: Spacing.md),
|
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
|
// All tools as selectable tiles (model selector style)
|
||||||
_buildWebSearchToggle(webSearchEnabled),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
|
|
||||||
// Image Generation Toggle (conditionally shown)
|
|
||||||
if (imageGenAvailable) ...[
|
|
||||||
_buildImageGenerationToggle(imageGenEnabled),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Tools Section
|
|
||||||
toolsAsync.when(
|
toolsAsync.when(
|
||||||
data: (tools) {
|
data: (tools) {
|
||||||
if (tools.isEmpty) {
|
if (tools.isEmpty) {
|
||||||
@@ -79,20 +108,29 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: tools.map((tool) {
|
||||||
children: [
|
final isSelected = selectedToolIds.contains(tool.id);
|
||||||
_buildSectionHeader('Available Tools', tools.length),
|
return _buildToolTile(
|
||||||
const SizedBox(height: Spacing.sm),
|
tool,
|
||||||
...tools.map(
|
isSelected,
|
||||||
(tool) => Padding(
|
onTap: () {
|
||||||
padding: const EdgeInsets.only(bottom: Spacing.sm),
|
HapticFeedback.lightImpact();
|
||||||
child: _buildToolCard(
|
final currentIds = ref.read(
|
||||||
tool,
|
selectedToolIdsProvider,
|
||||||
selectedToolIds.contains(tool.id),
|
);
|
||||||
),
|
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(
|
loading: () => _buildNeutralCard(
|
||||||
@@ -137,294 +175,9 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionHeader(String title, int count) {
|
// Legacy header removed in simplified design
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildWebSearchToggle(bool webSearchEnabled) {
|
// Removed legacy builders (kept earlier for reference)
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getToolIcon(Tool tool) {
|
IconData _getToolIcon(Tool tool) {
|
||||||
final toolName = tool.name.toLowerCase();
|
final toolName = tool.name.toLowerCase();
|
||||||
@@ -449,4 +202,251 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
return Icons.build;
|
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