diff --git a/lib/core/services/socket_service.dart b/lib/core/services/socket_service.dart index 72a2711..7c79ef6 100644 --- a/lib/core/services/socket_service.dart +++ b/lib/core/services/socket_service.dart @@ -1,16 +1,16 @@ -import 'package:socket_io_client/socket_io_client.dart' as IO; +import 'package:socket_io_client/socket_io_client.dart' as io; import 'package:flutter/foundation.dart'; import '../models/server_config.dart'; class SocketService { final ServerConfig serverConfig; final String? authToken; - IO.Socket? _socket; + io.Socket? _socket; SocketService({required this.serverConfig, required this.authToken}); String? get sessionId => _socket?.id; - IO.Socket? get socket => _socket; + io.Socket? get socket => _socket; bool get isConnected => _socket?.connected == true; @@ -24,9 +24,9 @@ class SocketService { final base = serverConfig.url.replaceFirst(RegExp(r'/+$'), ''); final path = '/ws/socket.io'; - _socket = IO.io( + _socket = io.io( base, - IO.OptionBuilder() + io.OptionBuilder() .setTransports(['websocket']) .setPath(path) .setExtraHeaders( diff --git a/lib/features/chat/services/voice_input_service.dart b/lib/features/chat/services/voice_input_service.dart index 6e2f070..d9c4565 100644 --- a/lib/features/chat/services/voice_input_service.dart +++ b/lib/features/chat/services/voice_input_service.dart @@ -128,8 +128,9 @@ class VoiceInputService { // Test if speech recognition is available final supported = await _speech.isSupported(); - if (!supported) + if (!supported) { return 'Speech recognition service is not available on this device'; + } // Set language if available, then start and stop quickly if (_selectedLocaleId != null) { diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index efa194d..b4cd3ea 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -33,6 +33,7 @@ import 'chat_page_helpers.dart'; import '../../../shared/widgets/themed_dialogs.dart'; import '../../onboarding/views/onboarding_sheet.dart'; import '../../../shared/widgets/sheet_handle.dart'; +import '../../../shared/widgets/measure_size.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../../core/services/settings_service.dart'; // Removed unused PlatformUtils import @@ -53,6 +54,7 @@ class _ChatPageState extends ConsumerState { final Set _selectedMessageIds = {}; Timer? _scrollDebounceTimer; bool _isDeactivated = false; + double _inputHeight = 0; // dynamic input height to position scroll button String _formatModelDisplayName(String name) { var display = name.trim(); @@ -1028,7 +1030,7 @@ class _ChatPageState extends ConsumerState { drawerEnableOpenDragGesture: true, drawerDragStartBehavior: DragStartBehavior.down, drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5, - drawerScrimColor: Colors.black.withOpacity(0.32), + drawerScrimColor: Colors.black.withValues(alpha: 0.32), drawer: Drawer( width: (MediaQuery.of(context).size.width * 0.88).clamp( 280.0, @@ -1409,17 +1411,26 @@ class _ChatPageState extends ConsumerState { // Modern Input (root matches input background including safe area) RepaintBoundary( - child: ModernChatInput( - enabled: - selectedModel != null && - (isOnline || ref.watch(reviewerModeProvider)), - onSendMessage: (text) => - _handleMessageSend(text, selectedModel), - onVoiceInput: null, - onFileAttachment: _handleFileAttachment, - onImageAttachment: _handleImageAttachment, - onCameraCapture: () => - _handleImageAttachment(fromCamera: true), + child: MeasureSize( + onChange: (size) { + if (mounted) { + setState(() { + _inputHeight = size.height; + }); + } + }, + child: ModernChatInput( + enabled: + selectedModel != null && + (isOnline || ref.watch(reviewerModeProvider)), + onSendMessage: (text) => + _handleMessageSend(text, selectedModel), + onVoiceInput: null, + onFileAttachment: _handleFileAttachment, + onImageAttachment: _handleImageAttachment, + onCameraCapture: () => + _handleImageAttachment(fromCamera: true), + ), ), ), ], @@ -1427,8 +1438,9 @@ class _ChatPageState extends ConsumerState { // Floating Scroll to Bottom Button with smooth appear/disappear Positioned( - bottom: Spacing.xxl + Spacing.xxxl, - right: Spacing.lg, + bottom: ((_inputHeight > 0) ? _inputHeight : (Spacing.xxl + Spacing.xxxl)) + Spacing.sm, + left: 0, + right: 0, child: AnimatedSwitcher( duration: AnimationDuration.microInteraction, switchInCurve: AnimationCurves.microInteraction, @@ -1446,44 +1458,45 @@ class _ChatPageState extends ConsumerState { ), ); }, - child: - (_showScrollToBottom && + child: (_showScrollToBottom && !keyboardVisible && ref.watch(chatMessagesProvider).isNotEmpty) - ? ClipRRect( + ? Center( key: const ValueKey('scroll_to_bottom_visible'), - borderRadius: BorderRadius.circular( - AppBorderRadius.floatingButton, - ), - child: Container( - decoration: BoxDecoration( - color: context - .conduitTheme - .surfaceContainerHighest - .withValues(alpha: 0.75), - border: Border.all( - color: context.conduitTheme.cardBorder - .withValues(alpha: 0.3), - width: BorderWidth.regular, - ), - borderRadius: BorderRadius.circular( - AppBorderRadius.floatingButton, - ), - boxShadow: ConduitShadows.button, + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppBorderRadius.floatingButton, ), - child: SizedBox( - width: TouchTarget.button, - height: TouchTarget.button, - child: IconButton( - onPressed: _scrollToBottom, - splashRadius: 24, - icon: Icon( - Platform.isIOS - ? CupertinoIcons.arrow_down - : Icons.keyboard_arrow_down, - size: IconSize.lg, - color: context.conduitTheme.iconPrimary - .withValues(alpha: 0.9), + child: Container( + decoration: BoxDecoration( + color: context + .conduitTheme + .surfaceContainerHighest + .withValues(alpha: 0.75), + border: Border.all( + color: context.conduitTheme.cardBorder + .withValues(alpha: 0.3), + width: BorderWidth.regular, + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.floatingButton, + ), + boxShadow: ConduitShadows.button, + ), + child: SizedBox( + width: TouchTarget.button, + height: TouchTarget.button, + child: IconButton( + onPressed: _scrollToBottom, + splashRadius: 24, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.arrow_down + : Icons.keyboard_arrow_down, + size: IconSize.lg, + color: context.conduitTheme.iconPrimary + .withValues(alpha: 0.9), + ), ), ), ), diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 72372f2..82ce420 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -12,6 +12,7 @@ import '../../../core/utils/tool_calls_parser.dart'; import 'enhanced_image_attachment.dart'; import 'package:conduit/l10n/app_localizations.dart'; import 'enhanced_attachment.dart'; +import 'package:conduit/shared/widgets/chat_action_button.dart'; class AssistantMessageWidget extends ConsumerStatefulWidget { final dynamic message; @@ -50,6 +51,7 @@ class _AssistantMessageWidgetState extends ConsumerState String _contentSansDetails = ''; bool _allowTypingIndicator = false; Timer? _typingGateTimer; + // press state handled by shared ChatActionButton @override void initState() { @@ -911,39 +913,6 @@ class _AssistantMessageWidgetState extends ConsumerState required String label, VoidCallback? onTap, }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: context.conduitTheme.textPrimary.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - border: Border.all( - color: context.conduitTheme.textPrimary.withValues(alpha: 0.08), - width: BorderWidth.regular, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: IconSize.sm, - color: context.conduitTheme.textPrimary.withValues(alpha: 0.8), - ), - const SizedBox(width: Spacing.xs), - Text( - label, - style: TextStyle( - fontSize: AppTypography.labelMedium, - color: context.conduitTheme.textPrimary.withValues(alpha: 0.8), - fontWeight: FontWeight.w500, - letterSpacing: 0.2, - ), - ), - ], - ), - ), - ); + return ChatActionButton(icon: icon, label: label, onTap: onTap); } } diff --git a/lib/features/chat/widgets/user_message_bubble.dart b/lib/features/chat/widgets/user_message_bubble.dart index 49a33b9..3f45a47 100644 --- a/lib/features/chat/widgets/user_message_bubble.dart +++ b/lib/features/chat/widgets/user_message_bubble.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform; import 'package:conduit/l10n/app_localizations.dart'; +import 'package:conduit/shared/widgets/chat_action_button.dart'; class UserMessageBubble extends ConsumerStatefulWidget { final dynamic message; @@ -42,6 +43,7 @@ class _UserMessageBubbleState extends ConsumerState bool _showActions = false; late AnimationController _fadeController; late AnimationController _slideController; + // press state handled by shared ChatActionButton @override void initState() { @@ -532,47 +534,7 @@ class _UserMessageBubbleState extends ConsumerState required String label, VoidCallback? onTap, }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.actionButtonPadding, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground.withValues( - alpha: Alpha.buttonHover, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.actionButton), - border: Border.all( - color: context.conduitTheme.textPrimary.withValues( - alpha: Alpha.subtle, - ), - width: BorderWidth.regular, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: IconSize.small, - color: context.conduitTheme.iconSecondary, - ), - const SizedBox(width: Spacing.xs), - Text( - label, - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - ), - ], - ), - ), - ).animate().scale( - duration: AnimationDuration.buttonPress, - curve: AnimationCurves.buttonPress, - ); + return ChatActionButton(icon: icon, label: label, onTap: onTap); } Widget _buildUserActionButtons() { diff --git a/lib/shared/services/tasks/outbound_task.dart b/lib/shared/services/tasks/outbound_task.dart index 17a4b86..0bab8c8 100644 --- a/lib/shared/services/tasks/outbound_task.dart +++ b/lib/shared/services/tasks/outbound_task.dart @@ -115,7 +115,19 @@ abstract class OutboundTask with _$OutboundTask { factory OutboundTask.fromJson(Map json) => _$OutboundTaskFromJson(json); - String get threadKey => (conversationId == null || conversationId!.isEmpty) - ? 'new' - : conversationId!; + // Provide a unified nullable conversationId across variants + String? get maybeConversationId => map( + sendTextMessage: (t) => t.conversationId, + uploadMedia: (t) => t.conversationId, + executeToolCall: (t) => t.conversationId, + generateImage: (t) => t.conversationId, + saveConversation: (t) => t.conversationId, + generateTitle: (t) => t.conversationId, + imageToDataUrl: (t) => t.conversationId, + ); + + String get threadKey => + (maybeConversationId == null || maybeConversationId!.isEmpty) + ? 'new' + : maybeConversationId!; } diff --git a/lib/shared/services/tasks/task_queue.dart b/lib/shared/services/tasks/task_queue.dart index 3d25845..cd305e4 100644 --- a/lib/shared/services/tasks/task_queue.dart +++ b/lib/shared/services/tasks/task_queue.dart @@ -100,7 +100,7 @@ class TaskQueueNotifier extends StateNotifier> { Future cancelByConversation(String conversationId) async { state = [ for (final t in state) - if ((t.conversationId ?? '') == conversationId && + if ((t.maybeConversationId ?? '') == conversationId && (t.status == TaskStatus.queued || t.status == TaskStatus.running)) t.copyWith( status: TaskStatus.cancelled, diff --git a/lib/shared/services/tasks/task_worker.dart b/lib/shared/services/tasks/task_worker.dart index 28dba6c..3991812 100644 --- a/lib/shared/services/tasks/task_worker.dart +++ b/lib/shared/services/tasks/task_worker.dart @@ -343,9 +343,13 @@ class TaskWorker { final b64 = base64Encode(bytes); final ext = path.extension(task.fileName).toLowerCase(); String mime = 'image/png'; - if (ext == '.jpg' || ext == '.jpeg') mime = 'image/jpeg'; - else if (ext == '.gif') mime = 'image/gif'; - else if (ext == '.webp') mime = 'image/webp'; + if (ext == '.jpg' || ext == '.jpeg') { + mime = 'image/jpeg'; + } else if (ext == '.gif') { + mime = 'image/gif'; + } else if (ext == '.webp') { + mime = 'image/webp'; + } final dataUrl = 'data:$mime;base64,$b64'; // Mark as completed with data URL as fileId diff --git a/lib/shared/widgets/chat_action_button.dart b/lib/shared/widgets/chat_action_button.dart new file mode 100644 index 0000000..b3829d7 --- /dev/null +++ b/lib/shared/widgets/chat_action_button.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:conduit/shared/theme/theme_extensions.dart'; +import 'package:conduit/core/services/platform_service.dart'; +import 'package:conduit/core/services/settings_service.dart'; + +class ChatActionButton extends ConsumerStatefulWidget { + final IconData icon; + final String label; + final VoidCallback? onTap; + final EdgeInsetsGeometry padding; + final BorderRadius? borderRadius; + + const ChatActionButton({ + super.key, + required this.icon, + required this.label, + this.onTap, + this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + this.borderRadius, + }); + + @override + ConsumerState createState() => _ChatActionButtonState(); +} + +class _ChatActionButtonState extends ConsumerState { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final hapticEnabled = ref.read(hapticEnabledProvider); + final radius = widget.borderRadius ?? BorderRadius.circular(AppBorderRadius.lg); + final overlay = theme.buttonPrimary.withValues(alpha: 0.08); + + return Tooltip( + message: widget.label, + waitDuration: const Duration(milliseconds: 600), + child: Semantics( + button: true, + label: widget.label, + child: AnimatedScale( + scale: _pressed ? 0.98 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: radius, + splashColor: overlay, + highlightColor: theme.textPrimary.withValues(alpha: 0.06), + onHighlightChanged: (v) => setState(() => _pressed = v), + onTap: widget.onTap == null + ? null + : () { + PlatformService.hapticFeedbackWithSettings( + type: HapticType.selection, + hapticEnabled: hapticEnabled, + ); + widget.onTap!(); + }, + child: Ink( + decoration: BoxDecoration( + color: theme.textPrimary.withValues(alpha: 0.04), + borderRadius: radius, + border: Border.all( + color: theme.textPrimary.withValues(alpha: 0.08), + width: BorderWidth.regular, + ), + ), + child: Padding( + padding: widget.padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.icon, + size: IconSize.sm, + color: theme.textPrimary.withValues(alpha: 0.8), + ), + const SizedBox(width: Spacing.xs), + Text( + widget.label, + style: TextStyle( + fontSize: AppTypography.labelMedium, + color: theme.textPrimary.withValues(alpha: 0.8), + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index e3c966d..0496ceb 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -43,7 +43,7 @@ class ConduitMarkdownConfig { LinkConfig( style: TextStyle( color: theme.buttonPrimary, - decoration: TextDecoration.underline, + decoration: TextDecoration.none, ), onTap: (url) async { if (await canLaunchUrlString(url)) { diff --git a/lib/shared/widgets/measure_size.dart b/lib/shared/widgets/measure_size.dart new file mode 100644 index 0000000..8e77a0b --- /dev/null +++ b/lib/shared/widgets/measure_size.dart @@ -0,0 +1,40 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +typedef OnWidgetSizeChange = void Function(Size size); + +class MeasureSize extends SingleChildRenderObjectWidget { + final OnWidgetSizeChange onChange; + + const MeasureSize({super.key, required this.onChange, required Widget child}) + : super(child: child); + + @override + RenderObject createRenderObject(BuildContext context) { + return _MeasureSizeRenderObject(onChange); + } + + @override + void updateRenderObject( + BuildContext context, covariant _MeasureSizeRenderObject renderObject) { + renderObject.onChange = onChange; + } +} + +class _MeasureSizeRenderObject extends RenderProxyBox { + _MeasureSizeRenderObject(this.onChange); + + OnWidgetSizeChange onChange; + Size? _oldSize; + + @override + void performLayout() { + super.performLayout(); + Size? newSize = child?.size; + if (_oldSize == newSize || newSize == null) return; + _oldSize = newSize; + WidgetsBinding.instance.addPostFrameCallback((_) { + onChange(newSize); + }); + } +}