From f934c59d19b1d25adfabe3fe0ca63821ff044adf Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:50:25 +0530 Subject: [PATCH] refactor: optimize chat input layout and enhance drawer functionality with loading indicators --- .../chat/widgets/modern_chat_input.dart | 226 +++++++++--------- .../navigation/widgets/chats_drawer.dart | 39 ++- .../tools/widgets/unified_tools_modal.dart | 2 +- lib/shared/widgets/offline_indicator.dart | 3 +- 4 files changed, 153 insertions(+), 117 deletions(-) diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index d85474b..da65542 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -287,8 +287,10 @@ class _ModernChatInputState extends ConsumerState tooltip: AppLocalizations.of( context, )!.addAttachment, + showBackground: false, + iconSize: IconSize.large, ), - const SizedBox(width: Spacing.sm), + const SizedBox(width: Spacing.xs), ], // Text input expands to fill Expanded( @@ -395,14 +397,16 @@ class _ModernChatInputState extends ConsumerState tooltip: AppLocalizations.of( context, )!.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 Expanded( child: Row( children: [ - Flexible( - fit: FlexFit.loose, + Expanded( + flex: 2, child: _buildPillButton( icon: Platform.isIOS ? CupertinoIcons.search @@ -425,9 +429,9 @@ class _ModernChatInputState extends ConsumerState ), ), if (imageGenAvailable) ...[ - const SizedBox(width: Spacing.sm), - Flexible( - fit: FlexFit.loose, + const SizedBox(width: Spacing.xs), + Expanded( + flex: 3, child: _buildPillButton( icon: Platform.isIOS ? CupertinoIcons.photo @@ -450,92 +454,108 @@ class _ModernChatInputState extends ConsumerState ), ), ], + 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), - _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), - // Microphone button: inline voice input toggle with animated intensity ring - 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); + const SizedBox(width: Spacing.xs), + // Mic + Send cluster pinned to the right + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Microphone button: inline voice input toggle with animated intensity ring + 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( - width: buttonSize, - height: buttonSize, - child: Stack( - alignment: Alignment.center, - children: [ - AnimatedContainer( - duration: const Duration( - milliseconds: 120, - ), - width: ringSize, - height: ringSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: context - .conduitTheme - .buttonPrimary - .withValues( - alpha: ringOpacity, - ), - ), + return SizedBox( + width: buttonSize, + height: buttonSize, + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedContainer( + duration: const Duration( + milliseconds: 120, + ), + width: ringSize, + height: ringSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context + .conduitTheme + .buttonPrimary + .withValues( + 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( - 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, - ), - ), - ], - ), - ); - }, + ); + }, + ), + const SizedBox(width: Spacing.xs), + // Primary action button (Send/Stop) when expanded + _buildPrimaryButton( + _hasText, + isGenerating, + stopGeneration, + ), + ], ), - const SizedBox(width: Spacing.sm), // Debug button for testing on-device STT (enable by changing false to true) // ignore: dead_code if (false) ...[ @@ -565,13 +585,7 @@ class _ModernChatInputState extends ConsumerState tooltip: 'Test On-Device STT', ), ], - const SizedBox(width: Spacing.sm), - // Primary action button (Send/Stop) when expanded - _buildPrimaryButton( - _hasText, - isGenerating, - stopGeneration, - ), + // removed duplicate send button; now only in right cluster ], ), ), @@ -716,6 +730,7 @@ class _ModernChatInputState extends ConsumerState String? tooltip, bool isActive = false, bool showBackground = true, + double? iconSize, }) { return Tooltip( message: tooltip ?? '', @@ -760,7 +775,7 @@ class _ModernChatInputState extends ConsumerState ), child: Icon( icon, - size: IconSize.medium, + size: iconSize ?? IconSize.medium, color: widget.enabled ? (isActive ? context.conduitTheme.textPrimary @@ -814,18 +829,15 @@ class _ModernChatInputState extends ConsumerState boxShadow: null, ), child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 140), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: AppTypography.labelStyle.copyWith( - color: isActive - ? context.conduitTheme.buttonPrimary - : context.conduitTheme.textPrimary, - ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: AppTypography.labelStyle.copyWith( + color: isActive + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.textPrimary, ), ), ), diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 9bbe6dd..7c57300 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -15,6 +15,7 @@ import '../../../shared/utils/ui_utils.dart'; import '../../../core/auth/auth_state_manager.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/models/user.dart' as models; +import '../../../shared/widgets/skeleton_loader.dart'; class ChatsDrawer extends ConsumerStatefulWidget { const ChatsDrawer({super.key}); @@ -30,6 +31,7 @@ class _ChatsDrawerState extends ConsumerState { Timer? _debounce; String _query = ''; bool _isLoadingConversation = false; + String? _pendingConversationId; String? _dragHoverFolderId; bool _isDragging = false; bool _draggingHasFolder = false; @@ -88,7 +90,7 @@ class _ChatsDrawerState extends ConsumerState { ? CupertinoIcons.bubble_left : Icons.add_comment, color: theme.iconPrimary, - size: IconSize.listItem, + size: IconSize.lg, ), onPressed: () { chat.startNewChat(ref); @@ -96,8 +98,8 @@ class _ChatsDrawerState extends ConsumerState { }, tooltip: AppLocalizations.of(context)!.newChat, constraints: const BoxConstraints( - minWidth: TouchTarget.listItem, - minHeight: TouchTarget.listItem, + minWidth: TouchTarget.comfortable, + minHeight: TouchTarget.comfortable, ), ), ], @@ -272,8 +274,9 @@ class _ChatsDrawerState extends ConsumerState { ...convs.map( (c) => _buildTileFor(c, inFolder: true), ), - const SizedBox(height: Spacing.sm), + const SizedBox(height: Spacing.xs), ], + const SizedBox(height: Spacing.xs), ], ); }).toList(); @@ -659,7 +662,7 @@ class _ChatsDrawerState extends ConsumerState { Expanded( child: Text( name, - style: AppTypography.bodyLargeStyle.copyWith( + style: AppTypography.standard.copyWith( color: theme.textPrimary, fontWeight: FontWeight.w600, ), @@ -935,10 +938,14 @@ class _ChatsDrawerState extends ConsumerState { final isActive = ref.watch(activeConversationProvider)?.id == conv.id; final title = conv.title?.isEmpty == true ? 'Chat' : (conv.title ?? 'Chat'); final theme = context.conduitTheme; + final bool isLoadingSelected = + (_pendingConversationId == conv.id) && + (ref.watch(chat.isLoadingConversationProvider) == true); final tile = _ConversationTile( title: title, pinned: conv.pinned == true, selected: isActive, + isLoading: isLoadingSelected, onTap: _isLoadingConversation ? null : () => _selectConversation(context, conv.id), @@ -1095,6 +1102,7 @@ class _ChatsDrawerState extends ConsumerState { try { // Mark global loading to show skeletons in chat ref.read(chat.isLoadingConversationProvider.notifier).state = true; + _pendingConversationId = id; final api = ref.read(apiServiceProvider); if (api != null) { @@ -1109,10 +1117,12 @@ class _ChatsDrawerState extends ConsumerState { // Clear global loading before closing drawer ref.read(chat.isLoadingConversationProvider.notifier).state = false; + _pendingConversationId = null; if (mounted) navigator.maybePop(); } catch (_) { ref.read(chat.isLoadingConversationProvider.notifier).state = false; + _pendingConversationId = null; if (mounted) navigator.maybePop(); } finally { if (mounted) setState(() => _isLoadingConversation = false); @@ -1225,7 +1235,7 @@ class _ChatsDrawerState extends ConsumerState { displayName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: AppTypography.bodyLargeStyle.copyWith( + style: AppTypography.standard.copyWith( color: theme.textPrimary, fontWeight: FontWeight.w600, ), @@ -1495,6 +1505,7 @@ class _ConversationTile extends StatelessWidget { final String title; final bool pinned; final bool selected; + final bool isLoading; final VoidCallback? onTap; final VoidCallback? onLongPress; final VoidCallback? onMorePressed; @@ -1503,6 +1514,7 @@ class _ConversationTile extends StatelessWidget { required this.title, required this.pinned, required this.selected, + required this.isLoading, required this.onTap, this.onLongPress, this.onMorePressed, @@ -1524,7 +1536,7 @@ class _ConversationTile extends StatelessWidget { ), child: InkWell( borderRadius: BorderRadius.circular(AppBorderRadius.md), - onTap: onTap, + onTap: isLoading ? null : onTap, onLongPress: onLongPress, child: Padding( padding: const EdgeInsets.symmetric( @@ -1545,7 +1557,18 @@ class _ConversationTile extends StatelessWidget { ), ), 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( visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, diff --git a/lib/features/tools/widgets/unified_tools_modal.dart b/lib/features/tools/widgets/unified_tools_modal.dart index c0715e1..abab1db 100644 --- a/lib/features/tools/widgets/unified_tools_modal.dart +++ b/lib/features/tools/widgets/unified_tools_modal.dart @@ -96,7 +96,7 @@ class _UnifiedToolsModalState extends ConsumerState { ), ], ), - const SizedBox(height: Spacing.lg), + // Removed extra spacing between feature tiles and tools list // All tools as selectable tiles (model selector style) toolsAsync.when( diff --git a/lib/shared/widgets/offline_indicator.dart b/lib/shared/widgets/offline_indicator.dart index 68742cb..048cd13 100644 --- a/lib/shared/widgets/offline_indicator.dart +++ b/lib/shared/widgets/offline_indicator.dart @@ -177,7 +177,8 @@ class OfflineAwareButton extends ConsumerWidget { return Tooltip( message: !enabled - ? (offlineTooltip ?? AppLocalizations.of(context)!.featureRequiresInternet) + ? (offlineTooltip ?? + AppLocalizations.of(context)!.featureRequiresInternet) : '', child: FilledButton(onPressed: enabled ? onPressed : null, child: child), );