refactor: action buttons and scroll to bottom ui/ux

This commit is contained in:
cogwheel0
2025-09-02 20:43:57 +05:30
parent ad4a0cc340
commit 3c082ffc9e
11 changed files with 241 additions and 137 deletions

View File

@@ -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 'package:flutter/foundation.dart';
import '../models/server_config.dart'; import '../models/server_config.dart';
class SocketService { class SocketService {
final ServerConfig serverConfig; final ServerConfig serverConfig;
final String? authToken; final String? authToken;
IO.Socket? _socket; io.Socket? _socket;
SocketService({required this.serverConfig, required this.authToken}); SocketService({required this.serverConfig, required this.authToken});
String? get sessionId => _socket?.id; String? get sessionId => _socket?.id;
IO.Socket? get socket => _socket; io.Socket? get socket => _socket;
bool get isConnected => _socket?.connected == true; bool get isConnected => _socket?.connected == true;
@@ -24,9 +24,9 @@ class SocketService {
final base = serverConfig.url.replaceFirst(RegExp(r'/+$'), ''); final base = serverConfig.url.replaceFirst(RegExp(r'/+$'), '');
final path = '/ws/socket.io'; final path = '/ws/socket.io';
_socket = IO.io( _socket = io.io(
base, base,
IO.OptionBuilder() io.OptionBuilder()
.setTransports(['websocket']) .setTransports(['websocket'])
.setPath(path) .setPath(path)
.setExtraHeaders( .setExtraHeaders(

View File

@@ -128,8 +128,9 @@ class VoiceInputService {
// Test if speech recognition is available // Test if speech recognition is available
final supported = await _speech.isSupported(); final supported = await _speech.isSupported();
if (!supported) if (!supported) {
return 'Speech recognition service is not available on this device'; return 'Speech recognition service is not available on this device';
}
// Set language if available, then start and stop quickly // Set language if available, then start and stop quickly
if (_selectedLocaleId != null) { if (_selectedLocaleId != null) {

View File

@@ -33,6 +33,7 @@ import 'chat_page_helpers.dart';
import '../../../shared/widgets/themed_dialogs.dart'; import '../../../shared/widgets/themed_dialogs.dart';
import '../../onboarding/views/onboarding_sheet.dart'; import '../../onboarding/views/onboarding_sheet.dart';
import '../../../shared/widgets/sheet_handle.dart'; import '../../../shared/widgets/sheet_handle.dart';
import '../../../shared/widgets/measure_size.dart';
import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/widgets/conduit_components.dart';
import '../../../core/services/settings_service.dart'; import '../../../core/services/settings_service.dart';
// Removed unused PlatformUtils import // Removed unused PlatformUtils import
@@ -53,6 +54,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final Set<String> _selectedMessageIds = <String>{}; final Set<String> _selectedMessageIds = <String>{};
Timer? _scrollDebounceTimer; Timer? _scrollDebounceTimer;
bool _isDeactivated = false; bool _isDeactivated = false;
double _inputHeight = 0; // dynamic input height to position scroll button
String _formatModelDisplayName(String name) { String _formatModelDisplayName(String name) {
var display = name.trim(); var display = name.trim();
@@ -1028,7 +1030,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
drawerEnableOpenDragGesture: true, drawerEnableOpenDragGesture: true,
drawerDragStartBehavior: DragStartBehavior.down, drawerDragStartBehavior: DragStartBehavior.down,
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5, drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5,
drawerScrimColor: Colors.black.withOpacity(0.32), drawerScrimColor: Colors.black.withValues(alpha: 0.32),
drawer: Drawer( drawer: Drawer(
width: (MediaQuery.of(context).size.width * 0.88).clamp( width: (MediaQuery.of(context).size.width * 0.88).clamp(
280.0, 280.0,
@@ -1409,17 +1411,26 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Modern Input (root matches input background including safe area) // Modern Input (root matches input background including safe area)
RepaintBoundary( RepaintBoundary(
child: ModernChatInput( child: MeasureSize(
enabled: onChange: (size) {
selectedModel != null && if (mounted) {
(isOnline || ref.watch(reviewerModeProvider)), setState(() {
onSendMessage: (text) => _inputHeight = size.height;
_handleMessageSend(text, selectedModel), });
onVoiceInput: null, }
onFileAttachment: _handleFileAttachment, },
onImageAttachment: _handleImageAttachment, child: ModernChatInput(
onCameraCapture: () => enabled:
_handleImageAttachment(fromCamera: true), 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<ChatPage> {
// Floating Scroll to Bottom Button with smooth appear/disappear // Floating Scroll to Bottom Button with smooth appear/disappear
Positioned( Positioned(
bottom: Spacing.xxl + Spacing.xxxl, bottom: ((_inputHeight > 0) ? _inputHeight : (Spacing.xxl + Spacing.xxxl)) + Spacing.sm,
right: Spacing.lg, left: 0,
right: 0,
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: AnimationDuration.microInteraction, duration: AnimationDuration.microInteraction,
switchInCurve: AnimationCurves.microInteraction, switchInCurve: AnimationCurves.microInteraction,
@@ -1446,44 +1458,45 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
); );
}, },
child: child: (_showScrollToBottom &&
(_showScrollToBottom &&
!keyboardVisible && !keyboardVisible &&
ref.watch(chatMessagesProvider).isNotEmpty) ref.watch(chatMessagesProvider).isNotEmpty)
? ClipRRect( ? Center(
key: const ValueKey('scroll_to_bottom_visible'), key: const ValueKey('scroll_to_bottom_visible'),
borderRadius: BorderRadius.circular( child: ClipRRect(
AppBorderRadius.floatingButton, 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: SizedBox( child: Container(
width: TouchTarget.button, decoration: BoxDecoration(
height: TouchTarget.button, color: context
child: IconButton( .conduitTheme
onPressed: _scrollToBottom, .surfaceContainerHighest
splashRadius: 24, .withValues(alpha: 0.75),
icon: Icon( border: Border.all(
Platform.isIOS color: context.conduitTheme.cardBorder
? CupertinoIcons.arrow_down .withValues(alpha: 0.3),
: Icons.keyboard_arrow_down, width: BorderWidth.regular,
size: IconSize.lg, ),
color: context.conduitTheme.iconPrimary borderRadius: BorderRadius.circular(
.withValues(alpha: 0.9), 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),
),
), ),
), ),
), ),

View File

@@ -12,6 +12,7 @@ import '../../../core/utils/tool_calls_parser.dart';
import 'enhanced_image_attachment.dart'; import 'enhanced_image_attachment.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
import 'enhanced_attachment.dart'; import 'enhanced_attachment.dart';
import 'package:conduit/shared/widgets/chat_action_button.dart';
class AssistantMessageWidget extends ConsumerStatefulWidget { class AssistantMessageWidget extends ConsumerStatefulWidget {
final dynamic message; final dynamic message;
@@ -50,6 +51,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
String _contentSansDetails = ''; String _contentSansDetails = '';
bool _allowTypingIndicator = false; bool _allowTypingIndicator = false;
Timer? _typingGateTimer; Timer? _typingGateTimer;
// press state handled by shared ChatActionButton
@override @override
void initState() { void initState() {
@@ -911,39 +913,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
required String label, required String label,
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
return GestureDetector( return ChatActionButton(icon: icon, label: label, onTap: onTap);
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,
),
),
],
),
),
);
} }
} }

View File

@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
import 'package:conduit/shared/widgets/chat_action_button.dart';
class UserMessageBubble extends ConsumerStatefulWidget { class UserMessageBubble extends ConsumerStatefulWidget {
final dynamic message; final dynamic message;
@@ -42,6 +43,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
bool _showActions = false; bool _showActions = false;
late AnimationController _fadeController; late AnimationController _fadeController;
late AnimationController _slideController; late AnimationController _slideController;
// press state handled by shared ChatActionButton
@override @override
void initState() { void initState() {
@@ -532,47 +534,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
required String label, required String label,
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
return GestureDetector( return ChatActionButton(icon: icon, label: label, onTap: onTap);
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,
);
} }
Widget _buildUserActionButtons() { Widget _buildUserActionButtons() {

View File

@@ -115,7 +115,19 @@ abstract class OutboundTask with _$OutboundTask {
factory OutboundTask.fromJson(Map<String, dynamic> json) => factory OutboundTask.fromJson(Map<String, dynamic> json) =>
_$OutboundTaskFromJson(json); _$OutboundTaskFromJson(json);
String get threadKey => (conversationId == null || conversationId!.isEmpty) // Provide a unified nullable conversationId across variants
? 'new' String? get maybeConversationId => map(
: conversationId!; 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!;
} }

View File

@@ -100,7 +100,7 @@ class TaskQueueNotifier extends StateNotifier<List<OutboundTask>> {
Future<void> cancelByConversation(String conversationId) async { Future<void> cancelByConversation(String conversationId) async {
state = [ state = [
for (final t in state) for (final t in state)
if ((t.conversationId ?? '') == conversationId && if ((t.maybeConversationId ?? '') == conversationId &&
(t.status == TaskStatus.queued || t.status == TaskStatus.running)) (t.status == TaskStatus.queued || t.status == TaskStatus.running))
t.copyWith( t.copyWith(
status: TaskStatus.cancelled, status: TaskStatus.cancelled,

View File

@@ -343,9 +343,13 @@ class TaskWorker {
final b64 = base64Encode(bytes); final b64 = base64Encode(bytes);
final ext = path.extension(task.fileName).toLowerCase(); final ext = path.extension(task.fileName).toLowerCase();
String mime = 'image/png'; String mime = 'image/png';
if (ext == '.jpg' || ext == '.jpeg') mime = 'image/jpeg'; if (ext == '.jpg' || ext == '.jpeg') {
else if (ext == '.gif') mime = 'image/gif'; mime = 'image/jpeg';
else if (ext == '.webp') mime = 'image/webp'; } else if (ext == '.gif') {
mime = 'image/gif';
} else if (ext == '.webp') {
mime = 'image/webp';
}
final dataUrl = 'data:$mime;base64,$b64'; final dataUrl = 'data:$mime;base64,$b64';
// Mark as completed with data URL as fileId // Mark as completed with data URL as fileId

View File

@@ -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<ChatActionButton> createState() => _ChatActionButtonState();
}
class _ChatActionButtonState extends ConsumerState<ChatActionButton> {
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,
),
),
],
),
),
),
),
),
),
),
);
}
}

View File

@@ -43,7 +43,7 @@ class ConduitMarkdownConfig {
LinkConfig( LinkConfig(
style: TextStyle( style: TextStyle(
color: theme.buttonPrimary, color: theme.buttonPrimary,
decoration: TextDecoration.underline, decoration: TextDecoration.none,
), ),
onTap: (url) async { onTap: (url) async {
if (await canLaunchUrlString(url)) { if (await canLaunchUrlString(url)) {

View File

@@ -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);
});
}
}