diff --git a/lib/core/widgets/error_boundary.dart b/lib/core/widgets/error_boundary.dart index b37950e..a9edb20 100644 --- a/lib/core/widgets/error_boundary.dart +++ b/lib/core/widgets/error_boundary.dart @@ -110,46 +110,49 @@ class _ErrorBoundaryState extends ConsumerState { textDirection: TextDirection.ltr, child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 64, - color: context.conduitTheme.error, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context)!.errorMessage, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: context.conduitTheme.textPrimary, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: context.conduitTheme.error, ), - ), - const SizedBox(height: 8), - Text( - enhancedErrorService.getUserMessage(_error!), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: context.conduitTheme.textSecondary, + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)?.errorMessage ?? + 'An unexpected error occurred', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: context.conduitTheme.textPrimary, + ), ), - ), - if (widget.allowRetry) ...[ - const SizedBox(height: 24), - FilledButton.icon( - onPressed: _retry, - icon: const Icon(Icons.refresh), - label: Text(AppLocalizations.of(context)!.retry), + const SizedBox(height: 8), + Text( + enhancedErrorService.getUserMessage(_error!), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.conduitTheme.textSecondary, + ), ), + if (widget.allowRetry) ...[ + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _retry, + icon: const Icon(Icons.refresh), + label: Text( + AppLocalizations.of(context)?.retry ?? 'Retry', + ), + ), + ], ], - ], + ), ), ), ), - ), - ); + ); } // Wrap child in error handler diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 0e05440..277b348 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -16,6 +16,7 @@ import '../widgets/modern_chat_input.dart'; import '../widgets/user_message_bubble.dart'; import '../widgets/assistant_message_widget.dart' as assistant; import '../widgets/file_attachment_widget.dart'; +import '../widgets/voice_input_sheet.dart'; import '../services/voice_input_service.dart'; import '../services/file_attachment_service.dart'; import '../../tools/providers/tools_providers.dart'; @@ -170,10 +171,12 @@ class _ChatPageState extends ConsumerState { } Future _checkAndLoadDemoConversation() async { + if (!mounted) return; final isReviewerMode = ref.read(reviewerModeProvider); if (!isReviewerMode) return; // Check if there's already an active conversation + if (!mounted) return; final activeConversation = ref.read(activeConversationProvider); if (activeConversation != null) { DebugLogger.log( @@ -183,10 +186,12 @@ class _ChatPageState extends ConsumerState { } // Force refresh conversations provider to ensure we get the demo conversations + if (!mounted) return; ref.invalidate(conversationsProvider); // Try to load demo conversation for (int i = 0; i < 10; i++) { + if (!mounted) return; final conversationsAsync = ref.read(conversationsProvider); if (conversationsAsync.hasValue && conversationsAsync.value!.isNotEmpty) { @@ -196,6 +201,7 @@ class _ChatPageState extends ConsumerState { orElse: () => conversationsAsync.value!.first, ); + if (!mounted) return; ref.read(activeConversationProvider.notifier).state = welcomeConv; debugPrint('Auto-loaded demo conversation'); return; @@ -204,6 +210,7 @@ class _ChatPageState extends ConsumerState { // If conversations are still loading, wait a bit and retry if (conversationsAsync.isLoading || i == 0) { await Future.delayed(const Duration(milliseconds: 200)); + if (!mounted) return; continue; } @@ -223,11 +230,14 @@ class _ChatPageState extends ConsumerState { // Initialize chat page components WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; // First, ensure a model is selected await _checkAndAutoSelectModel(); + if (!mounted) return; // Then check for demo conversation in reviewer mode await _checkAndLoadDemoConversation(); + if (!mounted) return; // Finally, show onboarding if needed await _checkAndShowOnboarding(); @@ -311,7 +321,7 @@ class _ChatPageState extends ConsumerState { context: context, backgroundColor: Colors.transparent, isScrollControlled: true, - builder: (context) => _VoiceInputSheet( + builder: (context) => VoiceInputSheet( onTextReceived: (text) { if (text.isNotEmpty) { final selectedModel = ref.read(selectedModelProvider); @@ -976,7 +986,10 @@ class _ChatPageState extends ConsumerState { drawerEnableOpenDragGesture: true, drawerEdgeDragWidth: 32, drawer: Drawer( - width: (MediaQuery.of(context).size.width * 0.88).clamp(280.0, 420.0), + width: (MediaQuery.of(context).size.width * 0.88).clamp( + 280.0, + 420.0, + ), backgroundColor: context.conduitTheme.surfaceBackground, child: const SafeArea(child: ChatsDrawer()), ), @@ -1822,7 +1835,10 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> { _autoSendFinal = settings.voiceAutoSendFinal; if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) { _voiceService.setLocale(settings.voiceLocaleId); - _languageTag = settings.voiceLocaleId!.split(RegExp('[-_]')).first.toLowerCase(); + _languageTag = settings.voiceLocaleId! + .split(RegExp('[-_]')) + .first + .toLowerCase(); } } @@ -2022,12 +2038,14 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> { children: [ const SheetHandle(), const SizedBox(height: Spacing.md), - Text('Select Language', - style: TextStyle( - fontSize: AppTypography.headlineSmall, - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - )), + Text( + 'Select Language', + style: TextStyle( + fontSize: AppTypography.headlineSmall, + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), const SizedBox(height: Spacing.sm), Flexible( child: ListView.separated( @@ -2039,18 +2057,26 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> { ), itemBuilder: (ctx, i) { final l = locales[i]; - final isSelected = l.localeId == _voiceService.selectedLocaleId; + final isSelected = + l.localeId == _voiceService.selectedLocaleId; return ListTile( title: Text( l.name, - style: TextStyle(color: context.conduitTheme.textPrimary), + style: TextStyle( + color: context.conduitTheme.textPrimary, + ), ), subtitle: Text( l.localeId, - style: TextStyle(color: context.conduitTheme.textSecondary), + style: TextStyle( + color: context.conduitTheme.textSecondary, + ), ), trailing: isSelected - ? Icon(Icons.check, color: context.conduitTheme.buttonPrimary) + ? Icon( + Icons.check, + color: context.conduitTheme.buttonPrimary, + ) : null, onTap: () => Navigator.pop(ctx, l.localeId), ); @@ -2118,410 +2144,482 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> { child: Padding( padding: const EdgeInsets.all(Spacing.bottomSheetPadding), child: Column( - children: [ - // Handle bar - const SheetHandle(), + children: [ + // Handle bar + const SheetHandle(), - // Header: Title + timer + language chip + // Header: Title + timer + language chip Padding( - padding: const EdgeInsets.only(top: Spacing.md, bottom: Spacing.md), + padding: const EdgeInsets.only( + top: Spacing.md, + bottom: Spacing.md, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - _isTranscribing - ? 'Transcribing…' - : _isListening - ? (_voiceService.hasLocalStt ? 'Listening…' : 'Recording…') + _isTranscribing + ? 'Transcribing…' + : _isListening + ? (_voiceService.hasLocalStt + ? 'Listening…' + : 'Recording…') : 'Voice', - style: TextStyle( - fontSize: AppTypography.headlineMedium, - fontWeight: FontWeight.w600, - color: context.conduitTheme.textPrimary, - ), - ), - Row( - children: [ - // Language chip - GestureDetector( - onTap: _voiceService.hasLocalStt ? _pickLanguage : null, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: 4, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground - .withValues(alpha: 0.4), - borderRadius: BorderRadius.circular( - AppBorderRadius.badge, - ), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.thin, - ), - ), - child: Row( - children: [ - Text( - _languageTag.toUpperCase(), - style: TextStyle( - fontSize: AppTypography.labelSmall, - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w600, + style: TextStyle( + fontSize: AppTypography.headlineMedium, + fontWeight: FontWeight.w600, + color: context.conduitTheme.textPrimary, + ), + ), + Row( + children: [ + // Language chip + GestureDetector( + onTap: _voiceService.hasLocalStt + ? _pickLanguage + : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: 4, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground + .withValues(alpha: 0.4), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, ), ), - if (_voiceService.hasLocalStt) ...[ - const SizedBox(width: 4), - Icon( - Icons.arrow_drop_down, - size: 16, - color: context.conduitTheme.iconSecondary, - ), - ], - ], + child: Row( + children: [ + Text( + _languageTag.toUpperCase(), + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + if (_voiceService.hasLocalStt) ...[ + const SizedBox(width: 4), + Icon( + Icons.arrow_drop_down, + size: 16, + color: context.conduitTheme.iconSecondary, + ), + ], + ], + ), + ), ), - ), - ), - const SizedBox(width: Spacing.sm), - // Timer - AnimatedOpacity( - opacity: _isListening ? 1 : 0.6, - duration: AnimationDuration.fast, - child: Text( - _formatSeconds(_elapsedSeconds), - style: TextStyle( - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w600, + const SizedBox(width: Spacing.sm), + // Timer + AnimatedOpacity( + opacity: _isListening ? 1 : 0.6, + duration: AnimationDuration.fast, + child: Text( + _formatSeconds(_elapsedSeconds), + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), ), - ), - ), - const SizedBox(width: Spacing.sm), - // Close sheet - ConduitIconButton( - icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close, - tooltip: 'Close', - isCompact: true, - onPressed: () => Navigator.of(context).pop(), + const SizedBox(width: Spacing.sm), + // Close sheet + ConduitIconButton( + icon: Platform.isIOS + ? CupertinoIcons.xmark + : Icons.close, + tooltip: 'Close', + isCompact: true, + onPressed: () => Navigator.of(context).pop(), + ), + ], ), ], ), - ], - ), - ), + ), - // Toggles row: Hold to talk, Auto-send - Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: Row( - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildThemedSwitch( - value: _holdToTalk, - onChanged: (v) async { - setState(() => _holdToTalk = v); - await ref.read(appSettingsProvider.notifier).setVoiceHoldToTalk(v); - }, - ), - const SizedBox(width: Spacing.xs), - Text( - 'Hold to talk', - style: TextStyle(color: context.conduitTheme.textSecondary), - ), - ], - ), - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _buildThemedSwitch( - value: _autoSendFinal, - onChanged: (v) async { - setState(() => _autoSendFinal = v); - await ref.read(appSettingsProvider.notifier).setVoiceAutoSendFinal(v); - }, - ), - const SizedBox(width: Spacing.xs), - Text( - 'Auto-send', - style: TextStyle(color: context.conduitTheme.textSecondary), - ), - ], - ), - ), - ], - ), - ), - - // Microphone + waveform - Expanded( - child: LayoutBuilder( - builder: (context, viewport) { - final isUltra = media.size.height < 560; - final double micSize = isUltra ? 64 : (isCompact ? 80 : 100); - final double micIconSize = isUltra ? 26 : (isCompact ? 32 : 40); - // Extra top padding so scale animation (up to 1.2x) never clips - final double topPaddingForScale = ((micSize * 1.2) - micSize) / 2 + 8; - - final content = Center( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Top spacer (baseline); additional padding handled by scroll view - SizedBox(height: isUltra ? Spacing.sm : Spacing.md), - // Microphone control - GestureDetector( - onTapDown: _holdToTalk - ? (_) { - if (!_isListening) _startListening(); - } - : null, - onTapUp: _holdToTalk - ? (_) { - if (_isListening) _stopListening(); - } - : null, - onTapCancel: _holdToTalk - ? () { - if (_isListening) _stopListening(); - } - : null, - onTap: () => - _holdToTalk - ? null - : (_isListening ? _stopListening() : _startListening()), - child: Container( - width: micSize, - height: micSize, - decoration: BoxDecoration( - color: _isListening - ? context.conduitTheme.error.withValues( - alpha: 0.2, - ) - : context.conduitTheme.surfaceBackground - .withValues(alpha: Alpha.subtle), - shape: BoxShape.circle, - border: Border.all( - color: _isListening - ? context.conduitTheme.error.withValues( - alpha: 0.5, - ) - : context.conduitTheme.dividerColor, - width: 2, - ), - ), - child: Icon( - _isListening - ? (Platform.isIOS - ? CupertinoIcons.mic_fill - : Icons.mic) - : (Platform.isIOS - ? CupertinoIcons.mic_off - : Icons.mic_off), - size: micIconSize, - color: _isListening - ? context.conduitTheme.error - : context.conduitTheme.iconSecondary, - ), - ), - ) - .animate( - onPlay: (controller) => - _isListening ? controller.repeat() : null, - ) - .scale( - duration: const Duration(milliseconds: 1000), - begin: const Offset(1, 1), - end: const Offset(1.2, 1.2), - ) - .then() - .scale( - duration: const Duration(milliseconds: 1000), - begin: const Offset(1.2, 1.2), - end: const Offset(1, 1), - ), - - SizedBox(height: isUltra ? Spacing.xs : (isCompact ? Spacing.sm : Spacing.md)), - // Simple animated bars waveform based on intensity proxy - SizedBox( - height: isUltra ? 18 : (isCompact ? 24 : 32), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 150), + // Toggles row: Hold to talk, Auto-send + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Row( + children: [ + Expanded( child: Row( - key: ValueKey(_intensity), - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(isUltra ? 10 : 12, (i) { - final normalized = ((_intensity + i) % 10) / 10.0; - final base = isUltra ? 4 : (isCompact ? 6 : 8); - final range = isUltra ? 14 : (isCompact ? 18 : 24); - final barHeight = base + (normalized * range); - return Container( - width: isUltra ? 2.5 : (isCompact ? 3 : 4), - height: barHeight, - margin: EdgeInsets.symmetric(horizontal: isUltra ? 1 : (isCompact ? 1.5 : 2)), - decoration: BoxDecoration( - color: context.conduitTheme.buttonPrimary - .withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(2), - ), - ); - }), - ), - ), - ), - SizedBox(height: isUltra ? Spacing.sm : (isCompact ? Spacing.md : Spacing.xl)), - - // Recognized text / Transcribing state with Clear action - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: media.size.height * (isUltra ? 0.13 : (isCompact ? 0.16 : 0.2)), - minHeight: isUltra ? 56 : (isCompact ? 64 : 80), - ), - child: ConduitCard( - isCompact: isCompact, - padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.md), - child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Inline clear action aligned to the end - Row( - children: [ - Text( - 'Transcript', - style: TextStyle( - fontSize: AppTypography.labelSmall, - fontWeight: FontWeight.w600, - color: context.conduitTheme.textSecondary, - ), - ), - const Spacer(), - ConduitIconButton( - icon: Icons.close, - isCompact: true, - tooltip: 'Clear', - onPressed: _recognizedText.isNotEmpty && !_isTranscribing - ? () { - setState(() => _recognizedText = ''); - } - : null, - ), - ], + _buildThemedSwitch( + value: _holdToTalk, + onChanged: (v) async { + setState(() => _holdToTalk = v); + await ref + .read(appSettingsProvider.notifier) + .setVoiceHoldToTalk(v); + }, ), - const SizedBox(height: Spacing.xs), - if (_isTranscribing) - Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConduitLoadingIndicator( - size: isUltra ? 14 : (isCompact ? 16 : 18), - isCompact: true, - ), - const SizedBox(width: Spacing.xs), - Text( - 'Transcribing…', - style: TextStyle( - fontSize: isUltra - ? AppTypography.bodySmall - : (isCompact - ? AppTypography.bodyMedium - : AppTypography.bodyLarge), - color: context.conduitTheme.textSecondary, - ), - ), - ], - ), - ) - else - Flexible( - child: SingleChildScrollView( - child: Text( - _recognizedText.isEmpty - ? (_isListening - ? (_voiceService.hasLocalStt - ? 'Speak now…' - : 'Recording…') - : 'Tap Start to begin') - : _recognizedText, - style: TextStyle( - fontSize: isUltra - ? AppTypography.bodySmall - : (isCompact - ? AppTypography.bodyMedium - : AppTypography.bodyLarge), - color: _recognizedText.isEmpty - ? context.conduitTheme.inputPlaceholder - : context.conduitTheme.textPrimary, - height: 1.4, - ), - textAlign: TextAlign.center, - ), - ), + const SizedBox(width: Spacing.xs), + Text( + 'Hold to talk', + style: TextStyle( + color: context.conduitTheme.textSecondary, ), + ), ], ), ), - ), - ], - ), - ); - - // Make scrollable if content exceeds available height - return SingleChildScrollView( - physics: const ClampingScrollPhysics(), - padding: EdgeInsets.only(top: topPaddingForScale), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: viewport.maxHeight), - child: content, - ), - ); - }, - ), - ), - - // Action buttons - Builder(builder: (context) { - final showStartStop = !_holdToTalk; - final showSend = !_autoSendFinal; - if (!showStartStop && !showSend) { - return const SizedBox.shrink(); - } - return Padding( - padding: EdgeInsets.only(top: isCompact ? Spacing.sm : Spacing.md), - child: Row( - children: [ - if (showStartStop) ...[ Expanded( - child: ConduitButton( - text: _isListening ? 'Stop' : 'Start', - isSecondary: true, - isCompact: isCompact, - onPressed: _isListening ? _stopListening : _startListening, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _buildThemedSwitch( + value: _autoSendFinal, + onChanged: (v) async { + setState(() => _autoSendFinal = v); + await ref + .read(appSettingsProvider.notifier) + .setVoiceAutoSendFinal(v); + }, + ), + const SizedBox(width: Spacing.xs), + Text( + 'Auto-send', + style: TextStyle( + color: context.conduitTheme.textSecondary, + ), + ), + ], ), ), ], - if (showStartStop && showSend) const SizedBox(width: Spacing.xs), - if (showSend) ...[ - Expanded( - child: ConduitButton( - text: 'Send', - isCompact: isCompact, - onPressed: _recognizedText.isNotEmpty ? _sendText : null, - ), - ), - ], - ], + ), ), - ); - }), - ], - ), + + // Microphone + waveform + Expanded( + child: LayoutBuilder( + builder: (context, viewport) { + final isUltra = media.size.height < 560; + final double micSize = isUltra + ? 64 + : (isCompact ? 80 : 100); + final double micIconSize = isUltra + ? 26 + : (isCompact ? 32 : 40); + // Extra top padding so scale animation (up to 1.2x) never clips + final double topPaddingForScale = + ((micSize * 1.2) - micSize) / 2 + 8; + + final content = Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Top spacer (baseline); additional padding handled by scroll view + SizedBox(height: isUltra ? Spacing.sm : Spacing.md), + // Microphone control + GestureDetector( + onTapDown: _holdToTalk + ? (_) { + if (!_isListening) _startListening(); + } + : null, + onTapUp: _holdToTalk + ? (_) { + if (_isListening) _stopListening(); + } + : null, + onTapCancel: _holdToTalk + ? () { + if (_isListening) _stopListening(); + } + : null, + onTap: () => _holdToTalk + ? null + : (_isListening + ? _stopListening() + : _startListening()), + child: Container( + width: micSize, + height: micSize, + decoration: BoxDecoration( + color: _isListening + ? context.conduitTheme.error.withValues( + alpha: 0.2, + ) + : context.conduitTheme.surfaceBackground + .withValues(alpha: Alpha.subtle), + shape: BoxShape.circle, + border: Border.all( + color: _isListening + ? context.conduitTheme.error + .withValues(alpha: 0.5) + : context.conduitTheme.dividerColor, + width: 2, + ), + ), + child: Icon( + _isListening + ? (Platform.isIOS + ? CupertinoIcons.mic_fill + : Icons.mic) + : (Platform.isIOS + ? CupertinoIcons.mic_off + : Icons.mic_off), + size: micIconSize, + color: _isListening + ? context.conduitTheme.error + : context.conduitTheme.iconSecondary, + ), + ), + ) + .animate( + onPlay: (controller) => + _isListening ? controller.repeat() : null, + ) + .scale( + duration: const Duration(milliseconds: 1000), + begin: const Offset(1, 1), + end: const Offset(1.2, 1.2), + ) + .then() + .scale( + duration: const Duration(milliseconds: 1000), + begin: const Offset(1.2, 1.2), + end: const Offset(1, 1), + ), + + SizedBox( + height: isUltra + ? Spacing.xs + : (isCompact ? Spacing.sm : Spacing.md), + ), + // Simple animated bars waveform based on intensity proxy + SizedBox( + height: isUltra ? 18 : (isCompact ? 24 : 32), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: Row( + key: ValueKey(_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, + ), + ), + ], + ], + ), + ); + }, + ), + ], + ), ), ), ); diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 87dbcf9..42ab4bf 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -11,6 +11,7 @@ import 'dart:async'; import '../providers/chat_providers.dart'; import '../../tools/widgets/unified_tools_modal.dart'; import '../../tools/providers/tools_providers.dart'; +import '../../../core/providers/app_providers.dart'; import '../../../shared/utils/platform_utils.dart'; import 'package:conduit/l10n/app_localizations.dart'; @@ -197,6 +198,10 @@ class _ModernChatInputState extends ConsumerState messages.last.isStreaming; final stopGeneration = ref.read(stopGenerationProvider); + final webSearchEnabled = ref.watch(webSearchEnabledProvider); + final imageGenEnabled = ref.watch(imageGenerationEnabledProvider); + final imageGenAvailable = ref.watch(imageGenerationAvailableProvider); + return Container( // Transparent wrapper so rounded corners are visible against page background color: Colors.transparent, @@ -265,7 +270,9 @@ class _ModernChatInputState extends ConsumerState onTap: widget.enabled ? _showAttachmentOptions : null, - tooltip: AppLocalizations.of(context)!.addAttachment, + tooltip: AppLocalizations.of( + context, + )!.addAttachment, ), const SizedBox(width: Spacing.sm), ], @@ -273,8 +280,12 @@ class _ModernChatInputState extends ConsumerState Expanded( child: Semantics( textField: true, - label: AppLocalizations.of(context)!.messageInputLabel, - hint: AppLocalizations.of(context)!.messageInputHint, + label: AppLocalizations.of( + context, + )!.messageInputLabel, + hint: AppLocalizations.of( + context, + )!.messageInputHint, child: TextField( controller: _controller, focusNode: _focusNode, @@ -292,7 +303,9 @@ class _ModernChatInputState extends ConsumerState color: context.conduitTheme.inputText, ), decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.messageHintText, + hintText: AppLocalizations.of( + context, + )!.messageHintText, hintStyle: TextStyle( color: context.conduitTheme.inputPlaceholder, @@ -364,26 +377,76 @@ class _ModernChatInputState extends ConsumerState onTap: widget.enabled ? _showAttachmentOptions : null, - tooltip: AppLocalizations.of(context)!.addAttachment, + tooltip: AppLocalizations.of( + context, + )!.addAttachment, + ), + const SizedBox(width: Spacing.sm), + // Quick pills: wrap in horizontal scroller to prevent overflow + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + children: [ + _buildPillButton( + icon: Platform.isIOS + ? CupertinoIcons.search + : Icons.search, + label: 'Web', + isActive: webSearchEnabled, + onTap: widget.enabled + ? () { + ref + .read( + webSearchEnabledProvider + .notifier, + ) + .state = + !webSearchEnabled; + } + : null, + ), + if (imageGenAvailable) ...[ + const SizedBox(width: Spacing.sm), + _buildPillButton( + icon: Platform.isIOS + ? CupertinoIcons.photo + : Icons.image, + label: 'Image Gen', + isActive: imageGenEnabled, + onTap: widget.enabled + ? () { + ref + .read( + imageGenerationEnabledProvider + .notifier, + ) + .state = + !imageGenEnabled; + } + : null, + ), + ], + ], + ), + ), ), const SizedBox(width: Spacing.sm), - // Tools button _buildRoundButton( - icon: Icons.build, + icon: Icons.more_horiz, onTap: widget.enabled - ? () { - _showUnifiedToolsModal(); - } + ? _showUnifiedToolsModal : null, tooltip: AppLocalizations.of(context)!.tools, isActive: ref .watch(selectedToolIdsProvider) .isNotEmpty || - ref.watch(webSearchEnabledProvider) || - ref.watch(imageGenerationEnabledProvider), + webSearchEnabled || + imageGenEnabled, ), - const Spacer(), + const SizedBox(width: Spacing.sm), // Microphone button: call provided callback for premium voice UI _buildRoundButton( icon: Platform.isIOS @@ -392,7 +455,9 @@ class _ModernChatInputState extends ConsumerState onTap: widget.enabled ? widget.onVoiceInput : null, - tooltip: AppLocalizations.of(context)!.voiceInput, + tooltip: AppLocalizations.of( + context, + )!.voiceInput, isActive: _isRecording, ), const SizedBox(width: Spacing.sm), @@ -437,7 +502,10 @@ class _ModernChatInputState extends ConsumerState color: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radius), - side: BorderSide(color: context.conduitTheme.error, width: BorderWidth.regular), + side: BorderSide( + color: context.conduitTheme.error, + width: BorderWidth.regular, + ), ), child: InkWell( borderRadius: BorderRadius.circular(radius), @@ -483,9 +551,11 @@ class _ModernChatInputState extends ConsumerState // Default SEND variant return Tooltip( - message: enabled ? AppLocalizations.of(context)!.sendMessage : AppLocalizations.of(context)!.send, + message: enabled + ? AppLocalizations.of(context)!.sendMessage + : AppLocalizations.of(context)!.send, child: Opacity( - opacity: enabled ? Alpha.primary : Alpha.disabled, + opacity: enabled ? Alpha.primary : Alpha.disabled, child: IgnorePointer( ignoring: !enabled, child: Material( @@ -495,7 +565,9 @@ class _ModernChatInputState extends ConsumerState side: BorderSide( color: enabled ? context.conduitTheme.cardBorder - : context.conduitTheme.cardBorder.withValues(alpha: Alpha.medium), + : context.conduitTheme.cardBorder.withValues( + alpha: Alpha.medium, + ), width: BorderWidth.regular, ), ), @@ -520,7 +592,9 @@ class _ModernChatInputState extends ConsumerState size: IconSize.medium, color: enabled ? context.conduitTheme.textPrimary - : context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled), + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.disabled, + ), ), ), ), @@ -549,8 +623,8 @@ class _ModernChatInputState extends ConsumerState alpha: Alpha.buttonHover + Alpha.subtle, ) : showBackground - ? context.conduitTheme.cardBorder - : Colors.transparent, + ? context.conduitTheme.cardBorder + : Colors.transparent, width: BorderWidth.regular, ), ), @@ -563,37 +637,90 @@ class _ModernChatInputState extends ConsumerState onTap(); }, child: Container( - width: TouchTarget.comfortable, - height: TouchTarget.comfortable, - decoration: BoxDecoration( - color: isActive - ? context.conduitTheme.textPrimary.withValues( - alpha: Alpha.buttonHover, - ) - : showBackground - ? context.conduitTheme.cardBackground - : Colors.transparent, - borderRadius: BorderRadius.circular(AppBorderRadius.xl), - boxShadow: (isActive || showBackground) - ? ConduitShadows.button - : null, - ), - child: Icon( - icon, - size: IconSize.medium, - color: widget.enabled - ? (isActive - ? context.conduitTheme.textPrimary - : context.conduitTheme.textPrimary.withValues( - alpha: Alpha.strong, - )) - : context.conduitTheme.textPrimary.withValues( - alpha: Alpha.disabled, - ), + width: TouchTarget.comfortable, + height: TouchTarget.comfortable, + decoration: BoxDecoration( + color: isActive + ? context.conduitTheme.textPrimary.withValues( + alpha: Alpha.buttonHover, + ) + : showBackground + ? context.conduitTheme.cardBackground + : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + boxShadow: (isActive || showBackground) + ? ConduitShadows.button + : null, + ), + child: Icon( + icon, + size: IconSize.medium, + color: widget.enabled + ? (isActive + ? context.conduitTheme.textPrimary + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.strong, + )) + : context.conduitTheme.textPrimary.withValues( + alpha: Alpha.disabled, + ), + ), ), ), ), - )); + ); + } + + Widget _buildPillButton({ + required IconData icon, + required String label, + required bool isActive, + VoidCallback? onTap, + }) { + return Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + side: BorderSide( + color: isActive + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + onTap: onTap == null + ? null + : () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: Container( + height: TouchTarget.comfortable, // exact height match + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + decoration: BoxDecoration( + color: isActive + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + // Reduce perceived height variance: only show shadow when active + boxShadow: isActive ? ConduitShadows.button : null, + ), + child: Center( + child: Text( + label, + style: AppTypography.labelStyle.copyWith( + color: isActive + ? context.conduitTheme.buttonPrimaryText + : context.conduitTheme.textPrimary, + ), + ), + ), + ), + ), + ); } void _showAttachmentOptions() { @@ -626,38 +753,43 @@ class _ModernChatInputState extends ConsumerState children: [ Expanded( child: _buildAttachmentOption( - icon: Platform.isIOS ? CupertinoIcons.doc : Icons.attach_file, - label: AppLocalizations.of(context)!.file, - onTap: () { - HapticFeedback.lightImpact(); - Navigator.pop(context); // Close modal - widget.onFileAttachment?.call(); - }, - )), + icon: Platform.isIOS + ? CupertinoIcons.doc + : Icons.attach_file, + label: AppLocalizations.of(context)!.file, + onTap: () { + HapticFeedback.lightImpact(); + Navigator.pop(context); // Close modal + widget.onFileAttachment?.call(); + }, + ), + ), const SizedBox(width: Spacing.md), Expanded( child: _buildAttachmentOption( - icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, - label: AppLocalizations.of(context)!.photo, - onTap: () { - HapticFeedback.lightImpact(); - Navigator.pop(context); // Close modal - widget.onImageAttachment?.call(); - }, - )), + icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, + label: AppLocalizations.of(context)!.photo, + onTap: () { + HapticFeedback.lightImpact(); + Navigator.pop(context); // Close modal + widget.onImageAttachment?.call(); + }, + ), + ), const SizedBox(width: Spacing.md), Expanded( child: _buildAttachmentOption( - icon: Platform.isIOS - ? CupertinoIcons.camera - : Icons.camera_alt, - label: AppLocalizations.of(context)!.camera, - onTap: () { - HapticFeedback.lightImpact(); - Navigator.pop(context); // Close modal - widget.onCameraCapture?.call(); - }, - )), + icon: Platform.isIOS + ? CupertinoIcons.camera + : Icons.camera_alt, + label: AppLocalizations.of(context)!.camera, + onTap: () { + HapticFeedback.lightImpact(); + Navigator.pop(context); // Close modal + widget.onCameraCapture?.call(); + }, + ), + ), ], ), const SizedBox(height: Spacing.lg), diff --git a/lib/features/chat/widgets/voice_input_sheet.dart b/lib/features/chat/widgets/voice_input_sheet.dart new file mode 100644 index 0000000..bc5cb34 --- /dev/null +++ b/lib/features/chat/widgets/voice_input_sheet.dart @@ -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 createState() => _VoiceInputSheetState(); +} + +class _VoiceInputSheetState extends ConsumerState { + late final VoiceInputService _voiceService; + StreamSubscription? _intensitySub; + StreamSubscription? _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 _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 _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 _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 _pickLanguage() async { + if (!_voiceService.hasLocalStt) return; + final locales = _voiceService.locales; + if (locales.isEmpty || !mounted) return; + final selected = await showModalBottomSheet( + 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(_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, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/tools/widgets/unified_tools_modal.dart b/lib/features/tools/widgets/unified_tools_modal.dart index ac3dc85..73c0da1 100644 --- a/lib/features/tools/widgets/unified_tools_modal.dart +++ b/lib/features/tools/widgets/unified_tools_modal.dart @@ -9,6 +9,7 @@ import '../../chat/providers/chat_providers.dart'; import '../../../core/providers/app_providers.dart'; import '../providers/tools_providers.dart'; import '../../../shared/widgets/sheet_handle.dart'; +import '../../chat/views/chat_page_helpers.dart'; class UnifiedToolsModal extends ConsumerStatefulWidget { const UnifiedToolsModal({super.key}); @@ -33,7 +34,10 @@ class _UnifiedToolsModalState extends ConsumerState { borderRadius: const BorderRadius.vertical( top: Radius.circular(AppBorderRadius.bottomSheet), ), - border: Border.all(color: theme.dividerColor, width: BorderWidth.regular), + border: Border.all( + color: theme.dividerColor, + width: BorderWidth.regular, + ), boxShadow: ConduitShadows.modal, ), child: SafeArea( @@ -48,23 +52,48 @@ class _UnifiedToolsModalState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Handle bar (standardized) const SheetHandle(), const SizedBox(height: Spacing.md), - // Removed header for minimal, focused layout + // Full tiles for Web and Image features + Column( + children: [ + _buildFeatureTile( + title: 'Web Search', + description: + 'Let the assistant search the internet while answering.', + icon: Platform.isIOS + ? CupertinoIcons.search + : Icons.search, + isActive: webSearchEnabled, + onTap: () { + HapticFeedback.lightImpact(); + ref.read(webSearchEnabledProvider.notifier).state = + !webSearchEnabled; + }, + ), + if (imageGenAvailable) + _buildFeatureTile( + title: 'Image Generation', + description: + 'Generate images from your prompt and attach them.', + icon: Platform.isIOS + ? CupertinoIcons.photo + : Icons.image, + isActive: imageGenEnabled, + onTap: () { + HapticFeedback.lightImpact(); + ref + .read(imageGenerationEnabledProvider.notifier) + .state = + !imageGenEnabled; + }, + ), + ], + ), + const SizedBox(height: Spacing.lg), - // Web Search Toggle - _buildWebSearchToggle(webSearchEnabled), - const SizedBox(height: Spacing.md), - - // Image Generation Toggle (conditionally shown) - if (imageGenAvailable) ...[ - _buildImageGenerationToggle(imageGenEnabled), - const SizedBox(height: Spacing.md), - ], - - // Tools Section + // All tools as selectable tiles (model selector style) toolsAsync.when( data: (tools) { if (tools.isEmpty) { @@ -79,20 +108,29 @@ class _UnifiedToolsModalState extends ConsumerState { } return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader('Available Tools', tools.length), - const SizedBox(height: Spacing.sm), - ...tools.map( - (tool) => Padding( - padding: const EdgeInsets.only(bottom: Spacing.sm), - child: _buildToolCard( - tool, - selectedToolIds.contains(tool.id), - ), - ), - ), - ], + children: tools.map((tool) { + final isSelected = selectedToolIds.contains(tool.id); + return _buildToolTile( + tool, + isSelected, + onTap: () { + HapticFeedback.lightImpact(); + final currentIds = ref.read( + selectedToolIdsProvider, + ); + if (isSelected) { + ref + .read(selectedToolIdsProvider.notifier) + .state = currentIds + .where((id) => id != tool.id) + .toList(); + } else { + ref.read(selectedToolIdsProvider.notifier).state = + [...currentIds, tool.id]; + } + }, + ); + }).toList(), ); }, loading: () => _buildNeutralCard( @@ -137,294 +175,9 @@ class _UnifiedToolsModalState extends ConsumerState { ); } - Widget _buildSectionHeader(String title, int count) { - final theme = context.conduitTheme; - return Row( - children: [ - Text( - title, - style: AppTypography.bodySmallStyle.copyWith( - fontWeight: FontWeight.w600, - color: theme.textSecondary, - letterSpacing: 0.2, - ), - ), - const SizedBox(width: Spacing.xs), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - border: Border.all(color: theme.dividerColor, width: BorderWidth.thin), - ), - child: Text( - '$count', - style: AppTypography.bodySmallStyle.copyWith( - color: theme.textSecondary, - ), - ), - ), - ], - ); - } + // Legacy header removed in simplified design - Widget _buildWebSearchToggle(bool webSearchEnabled) { - return Material( - color: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - side: BorderSide( - color: webSearchEnabled - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBorder, - width: BorderWidth.regular, - ), - ), - child: InkWell( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - onTap: () { - HapticFeedback.lightImpact(); - ref.read(webSearchEnabledProvider.notifier).state = !webSearchEnabled; - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: webSearchEnabled - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - ), - child: Row( - children: [ - Icon( - webSearchEnabled - ? (Platform.isIOS ? CupertinoIcons.globe : Icons.public) - : (Platform.isIOS ? CupertinoIcons.search : Icons.search), - size: IconSize.medium, - color: webSearchEnabled - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textPrimary.withValues( - alpha: Alpha.strong, - ), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Web Search', - style: AppTypography.labelStyle.copyWith( - color: webSearchEnabled - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - Text( - webSearchEnabled - ? 'I can search the internet for information' - : 'Enable to search the web for answers', - style: AppTypography.captionStyle.copyWith( - color: webSearchEnabled - ? context.conduitTheme.buttonPrimaryText.withValues( - alpha: Alpha.strong, - ) - : context.conduitTheme.textSecondary, - ), - ), - ], - ), - ), - Icon( - webSearchEnabled ? Icons.toggle_on : Icons.toggle_off, - size: IconSize.large, - color: webSearchEnabled - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textSecondary, - ), - ], - ), - ), - ), - ); - } - - Widget _buildImageGenerationToggle(bool imageGenEnabled) { - return Material( - color: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - side: BorderSide( - color: imageGenEnabled - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBorder, - width: BorderWidth.regular, - ), - ), - child: InkWell( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - onTap: () { - HapticFeedback.lightImpact(); - ref.read(imageGenerationEnabledProvider.notifier).state = - !imageGenEnabled; - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: imageGenEnabled - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - ), - child: Row( - children: [ - Icon( - Platform.isIOS ? CupertinoIcons.photo : Icons.image, - size: IconSize.medium, - color: imageGenEnabled - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textPrimary.withValues( - alpha: Alpha.strong, - ), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Image Generation', - style: AppTypography.labelStyle.copyWith( - color: imageGenEnabled - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - Text( - imageGenEnabled - ? 'I can generate images from your prompt' - : 'Enable to generate images with your request', - style: AppTypography.captionStyle.copyWith( - color: imageGenEnabled - ? context.conduitTheme.buttonPrimaryText.withValues( - alpha: Alpha.strong, - ) - : context.conduitTheme.textSecondary, - ), - ), - ], - ), - ), - Icon( - imageGenEnabled ? Icons.toggle_on : Icons.toggle_off, - size: IconSize.large, - color: imageGenEnabled - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textSecondary, - ), - ], - ), - ), - ), - ); - } - - Widget _buildToolCard(Tool tool, bool isSelected) { - return Material( - color: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - side: BorderSide( - color: isSelected - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBorder, - width: BorderWidth.regular, - ), - ), - child: InkWell( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - onTap: () { - HapticFeedback.lightImpact(); - final currentIds = ref.read(selectedToolIdsProvider); - if (isSelected) { - ref.read(selectedToolIdsProvider.notifier).state = currentIds - .where((id) => id != tool.id) - .toList(); - } else { - ref.read(selectedToolIdsProvider.notifier).state = [ - ...currentIds, - tool.id, - ]; - } - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: isSelected - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.md), - ), - child: Row( - children: [ - Icon( - _getToolIcon(tool), - size: IconSize.medium, - color: isSelected - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textPrimary.withValues( - alpha: Alpha.strong, - ), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tool.name, - style: AppTypography.labelStyle.copyWith( - color: isSelected - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - if (tool.meta?['description'] != null && - tool.meta!['description'].toString().isNotEmpty) - Text( - tool.meta!['description'].toString(), - style: AppTypography.captionStyle.copyWith( - color: isSelected - ? context.conduitTheme.buttonPrimaryText.withValues( - alpha: Alpha.strong, - ) - : context.conduitTheme.textSecondary, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon( - isSelected ? Icons.toggle_on : Icons.toggle_off, - size: IconSize.large, - color: isSelected - ? context.conduitTheme.buttonPrimaryText - : context.conduitTheme.textSecondary, - ), - ], - ), - ), - ), - ); - } + // Removed legacy builders (kept earlier for reference) IconData _getToolIcon(Tool tool) { final toolName = tool.name.toLowerCase(); @@ -449,4 +202,251 @@ class _UnifiedToolsModalState extends ConsumerState { 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 }