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

@@ -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) {

View File

@@ -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<ChatPage> {
final Set<String> _selectedMessageIds = <String>{};
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<ChatPage> {
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<ChatPage> {
// 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<ChatPage> {
// 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<ChatPage> {
),
);
},
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),
),
),
),
),

View File

@@ -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<AssistantMessageWidget>
String _contentSansDetails = '';
bool _allowTypingIndicator = false;
Timer? _typingGateTimer;
// press state handled by shared ChatActionButton
@override
void initState() {
@@ -911,39 +913,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
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);
}
}

View File

@@ -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<UserMessageBubble>
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<UserMessageBubble>
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() {