refactor: optimize chat input layout and enhance drawer functionality with loading indicators

This commit is contained in:
cogwheel0
2025-08-25 15:50:25 +05:30
parent d6f96ff5c5
commit f934c59d19
4 changed files with 153 additions and 117 deletions

View File

@@ -287,8 +287,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
tooltip: AppLocalizations.of( tooltip: AppLocalizations.of(
context, context,
)!.addAttachment, )!.addAttachment,
showBackground: false,
iconSize: IconSize.large,
), ),
const SizedBox(width: Spacing.sm), const SizedBox(width: Spacing.xs),
], ],
// Text input expands to fill // Text input expands to fill
Expanded( Expanded(
@@ -395,14 +397,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
tooltip: AppLocalizations.of( tooltip: AppLocalizations.of(
context, context,
)!.addAttachment, )!.addAttachment,
showBackground: false,
iconSize: IconSize.large,
), ),
const SizedBox(width: Spacing.sm), const SizedBox(width: Spacing.xs),
// Quick pills: no scroll, clip text within fixed max width // Quick pills: no scroll, clip text within fixed max width
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
Flexible( Expanded(
fit: FlexFit.loose, flex: 2,
child: _buildPillButton( child: _buildPillButton(
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.search ? CupertinoIcons.search
@@ -425,9 +429,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
), ),
if (imageGenAvailable) ...[ if (imageGenAvailable) ...[
const SizedBox(width: Spacing.sm), const SizedBox(width: Spacing.xs),
Flexible( Expanded(
fit: FlexFit.loose, flex: 3,
child: _buildPillButton( child: _buildPillButton(
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.photo ? CupertinoIcons.photo
@@ -450,92 +454,108 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
), ),
], ],
const SizedBox(width: Spacing.xs),
_buildRoundButton(
icon: Icons.more_horiz,
onTap: widget.enabled
? _showUnifiedToolsModal
: null,
tooltip: AppLocalizations.of(
context,
)!.tools,
isActive:
ref
.watch(
selectedToolIdsProvider,
)
.isNotEmpty ||
webSearchEnabled ||
imageGenEnabled,
),
], ],
), ),
), ),
const SizedBox(width: Spacing.sm), const SizedBox(width: Spacing.xs),
_buildRoundButton( // Mic + Send cluster pinned to the right
icon: Icons.more_horiz, Row(
onTap: widget.enabled mainAxisSize: MainAxisSize.min,
? _showUnifiedToolsModal children: [
: null, // Microphone button: inline voice input toggle with animated intensity ring
tooltip: AppLocalizations.of( Builder(
context, builder: (context) {
)!.tools, const double buttonSize =
isActive: TouchTarget.comfortable;
ref final double t = _isRecording
.watch(selectedToolIdsProvider) ? (_intensity.clamp(0, 10) / 10.0)
.isNotEmpty || : 0.0;
webSearchEnabled || final double ringMaxExtra = 16.0;
imageGenEnabled, final double ringSize =
), buttonSize + (ringMaxExtra * t);
const SizedBox(width: Spacing.sm), final double ringOpacity =
// Microphone button: inline voice input toggle with animated intensity ring 0.15 + (0.35 * t);
Builder(
builder: (context) {
const double buttonSize =
TouchTarget.comfortable;
final double t = _isRecording
? (_intensity.clamp(0, 10) / 10.0)
: 0.0;
final double ringMaxExtra = 16.0;
final double ringSize =
buttonSize + (ringMaxExtra * t);
final double ringOpacity =
0.15 + (0.35 * t);
return SizedBox( return SizedBox(
width: buttonSize, width: buttonSize,
height: buttonSize, height: buttonSize,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
AnimatedContainer( AnimatedContainer(
duration: const Duration( duration: const Duration(
milliseconds: 120, milliseconds: 120,
), ),
width: ringSize, width: ringSize,
height: ringSize, height: ringSize,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: context color: context
.conduitTheme .conduitTheme
.buttonPrimary .buttonPrimary
.withValues( .withValues(
alpha: ringOpacity, alpha: ringOpacity,
), ),
), ),
),
Transform.scale(
scale: _isRecording
? 1.0 +
(_intensity.clamp(
0,
10,
) /
200)
: 1.0,
child: _buildRoundButton(
icon: Platform.isIOS
? CupertinoIcons
.mic_fill
: Icons.mic,
onTap:
(widget.enabled &&
voiceAvailable)
? _toggleVoice
: null,
tooltip:
AppLocalizations.of(
context,
)!.voiceInput,
isActive: _isRecording,
),
),
],
), ),
Transform.scale( );
scale: _isRecording },
? 1.0 + ),
(_intensity.clamp( const SizedBox(width: Spacing.xs),
0, // Primary action button (Send/Stop) when expanded
10, _buildPrimaryButton(
) / _hasText,
200) isGenerating,
: 1.0, stopGeneration,
child: _buildRoundButton( ),
icon: Platform.isIOS ],
? CupertinoIcons.mic_fill
: Icons.mic,
onTap:
(widget.enabled &&
voiceAvailable)
? _toggleVoice
: null,
tooltip: AppLocalizations.of(
context,
)!.voiceInput,
isActive: _isRecording,
),
),
],
),
);
},
), ),
const SizedBox(width: Spacing.sm),
// Debug button for testing on-device STT (enable by changing false to true) // Debug button for testing on-device STT (enable by changing false to true)
// ignore: dead_code // ignore: dead_code
if (false) ...[ if (false) ...[
@@ -565,13 +585,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
tooltip: 'Test On-Device STT', tooltip: 'Test On-Device STT',
), ),
], ],
const SizedBox(width: Spacing.sm), // removed duplicate send button; now only in right cluster
// Primary action button (Send/Stop) when expanded
_buildPrimaryButton(
_hasText,
isGenerating,
stopGeneration,
),
], ],
), ),
), ),
@@ -716,6 +730,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
String? tooltip, String? tooltip,
bool isActive = false, bool isActive = false,
bool showBackground = true, bool showBackground = true,
double? iconSize,
}) { }) {
return Tooltip( return Tooltip(
message: tooltip ?? '', message: tooltip ?? '',
@@ -760,7 +775,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
child: Icon( child: Icon(
icon, icon,
size: IconSize.medium, size: iconSize ?? IconSize.medium,
color: widget.enabled color: widget.enabled
? (isActive ? (isActive
? context.conduitTheme.textPrimary ? context.conduitTheme.textPrimary
@@ -814,18 +829,15 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
boxShadow: null, boxShadow: null,
), ),
child: Center( child: Center(
child: ConstrainedBox( child: Text(
constraints: const BoxConstraints(maxWidth: 140), label,
child: Text( maxLines: 1,
label, overflow: TextOverflow.ellipsis,
maxLines: 1, softWrap: false,
overflow: TextOverflow.ellipsis, style: AppTypography.labelStyle.copyWith(
softWrap: false, color: isActive
style: AppTypography.labelStyle.copyWith( ? context.conduitTheme.buttonPrimary
color: isActive : context.conduitTheme.textPrimary,
? context.conduitTheme.buttonPrimary
: context.conduitTheme.textPrimary,
),
), ),
), ),
), ),

View File

@@ -15,6 +15,7 @@ import '../../../shared/utils/ui_utils.dart';
import '../../../core/auth/auth_state_manager.dart'; import '../../../core/auth/auth_state_manager.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
import '../../../core/models/user.dart' as models; import '../../../core/models/user.dart' as models;
import '../../../shared/widgets/skeleton_loader.dart';
class ChatsDrawer extends ConsumerStatefulWidget { class ChatsDrawer extends ConsumerStatefulWidget {
const ChatsDrawer({super.key}); const ChatsDrawer({super.key});
@@ -30,6 +31,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
Timer? _debounce; Timer? _debounce;
String _query = ''; String _query = '';
bool _isLoadingConversation = false; bool _isLoadingConversation = false;
String? _pendingConversationId;
String? _dragHoverFolderId; String? _dragHoverFolderId;
bool _isDragging = false; bool _isDragging = false;
bool _draggingHasFolder = false; bool _draggingHasFolder = false;
@@ -88,7 +90,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
? CupertinoIcons.bubble_left ? CupertinoIcons.bubble_left
: Icons.add_comment, : Icons.add_comment,
color: theme.iconPrimary, color: theme.iconPrimary,
size: IconSize.listItem, size: IconSize.lg,
), ),
onPressed: () { onPressed: () {
chat.startNewChat(ref); chat.startNewChat(ref);
@@ -96,8 +98,8 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
}, },
tooltip: AppLocalizations.of(context)!.newChat, tooltip: AppLocalizations.of(context)!.newChat,
constraints: const BoxConstraints( constraints: const BoxConstraints(
minWidth: TouchTarget.listItem, minWidth: TouchTarget.comfortable,
minHeight: TouchTarget.listItem, minHeight: TouchTarget.comfortable,
), ),
), ),
], ],
@@ -272,8 +274,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
...convs.map( ...convs.map(
(c) => _buildTileFor(c, inFolder: true), (c) => _buildTileFor(c, inFolder: true),
), ),
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.xs),
], ],
const SizedBox(height: Spacing.xs),
], ],
); );
}).toList(); }).toList();
@@ -659,7 +662,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
Expanded( Expanded(
child: Text( child: Text(
name, name,
style: AppTypography.bodyLargeStyle.copyWith( style: AppTypography.standard.copyWith(
color: theme.textPrimary, color: theme.textPrimary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -935,10 +938,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final isActive = ref.watch(activeConversationProvider)?.id == conv.id; final isActive = ref.watch(activeConversationProvider)?.id == conv.id;
final title = conv.title?.isEmpty == true ? 'Chat' : (conv.title ?? 'Chat'); final title = conv.title?.isEmpty == true ? 'Chat' : (conv.title ?? 'Chat');
final theme = context.conduitTheme; final theme = context.conduitTheme;
final bool isLoadingSelected =
(_pendingConversationId == conv.id) &&
(ref.watch(chat.isLoadingConversationProvider) == true);
final tile = _ConversationTile( final tile = _ConversationTile(
title: title, title: title,
pinned: conv.pinned == true, pinned: conv.pinned == true,
selected: isActive, selected: isActive,
isLoading: isLoadingSelected,
onTap: _isLoadingConversation onTap: _isLoadingConversation
? null ? null
: () => _selectConversation(context, conv.id), : () => _selectConversation(context, conv.id),
@@ -1095,6 +1102,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
try { try {
// Mark global loading to show skeletons in chat // Mark global loading to show skeletons in chat
ref.read(chat.isLoadingConversationProvider.notifier).state = true; ref.read(chat.isLoadingConversationProvider.notifier).state = true;
_pendingConversationId = id;
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (api != null) { if (api != null) {
@@ -1109,10 +1117,12 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
// Clear global loading before closing drawer // Clear global loading before closing drawer
ref.read(chat.isLoadingConversationProvider.notifier).state = false; ref.read(chat.isLoadingConversationProvider.notifier).state = false;
_pendingConversationId = null;
if (mounted) navigator.maybePop(); if (mounted) navigator.maybePop();
} catch (_) { } catch (_) {
ref.read(chat.isLoadingConversationProvider.notifier).state = false; ref.read(chat.isLoadingConversationProvider.notifier).state = false;
_pendingConversationId = null;
if (mounted) navigator.maybePop(); if (mounted) navigator.maybePop();
} finally { } finally {
if (mounted) setState(() => _isLoadingConversation = false); if (mounted) setState(() => _isLoadingConversation = false);
@@ -1225,7 +1235,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
displayName, displayName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: AppTypography.bodyLargeStyle.copyWith( style: AppTypography.standard.copyWith(
color: theme.textPrimary, color: theme.textPrimary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -1495,6 +1505,7 @@ class _ConversationTile extends StatelessWidget {
final String title; final String title;
final bool pinned; final bool pinned;
final bool selected; final bool selected;
final bool isLoading;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
final VoidCallback? onMorePressed; final VoidCallback? onMorePressed;
@@ -1503,6 +1514,7 @@ class _ConversationTile extends StatelessWidget {
required this.title, required this.title,
required this.pinned, required this.pinned,
required this.selected, required this.selected,
required this.isLoading,
required this.onTap, required this.onTap,
this.onLongPress, this.onLongPress,
this.onMorePressed, this.onMorePressed,
@@ -1524,7 +1536,7 @@ class _ConversationTile extends StatelessWidget {
), ),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.circular(AppBorderRadius.md),
onTap: onTap, onTap: isLoading ? null : onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -1545,7 +1557,18 @@ class _ConversationTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
if (onMorePressed != null) if (isLoading)
SizedBox(
width: 72,
height: TouchTarget.small,
child: SkeletonLoader(
width: 72,
height: TouchTarget.small,
borderRadius: BorderRadius.circular(AppBorderRadius.chip),
isCompact: true,
),
)
else if (onMorePressed != null)
IconButton( IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,

View File

@@ -96,7 +96,7 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
), ),
], ],
), ),
const SizedBox(height: Spacing.lg), // Removed extra spacing between feature tiles and tools list
// All tools as selectable tiles (model selector style) // All tools as selectable tiles (model selector style)
toolsAsync.when( toolsAsync.when(

View File

@@ -177,7 +177,8 @@ class OfflineAwareButton extends ConsumerWidget {
return Tooltip( return Tooltip(
message: !enabled message: !enabled
? (offlineTooltip ?? AppLocalizations.of(context)!.featureRequiresInternet) ? (offlineTooltip ??
AppLocalizations.of(context)!.featureRequiresInternet)
: '', : '',
child: FilledButton(onPressed: enabled ? onPressed : null, child: child), child: FilledButton(onPressed: enabled ? onPressed : null, child: child),
); );