From 265c7026afd18493a9c968b897d72801fbd07959 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:31:08 +0530 Subject: [PATCH] refactor: improve chat input and message list layout, enhance keyboard visibility handling --- lib/features/chat/views/chat_page.dart | 92 +-- .../chat/widgets/modern_chat_input.dart | 583 +++++++++--------- 2 files changed, 342 insertions(+), 333 deletions(-) diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index d52e83b..f03e132 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -9,7 +9,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform, File; import 'dart:async'; -import 'dart:ui' show ImageFilter; import '../../../core/providers/app_providers.dart'; import '../providers/chat_providers.dart'; import '../../../core/utils/debug_logger.dart'; @@ -941,6 +940,9 @@ class _ChatPageState extends ConsumerState { // Watch reviewer mode and auto-select model if needed final isReviewerMode = ref.watch(reviewerModeProvider); + // Keyboard visibility + final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; + // Auto-select model when in reviewer mode with no selection if (isReviewerMode && selectedModel == null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1340,7 +1342,9 @@ class _ChatPageState extends ConsumerState { behavior: HitTestBehavior.opaque, onTap: () => FocusManager.instance.primaryFocus?.unfocus(), - child: _buildMessagesList(theme), + child: RepaintBoundary( + child: _buildMessagesList(theme), + ), ), ), ), @@ -1352,17 +1356,19 @@ class _ChatPageState extends ConsumerState { const ChatOfflineOverlay(), // Modern Input (root matches input background including safe area) - ModernChatInput( - enabled: - selectedModel != null && - (isOnline || ref.watch(reviewerModeProvider)), - onSendMessage: (text) => - _handleMessageSend(text, selectedModel), - onVoiceInput: null, - onFileAttachment: _handleFileAttachment, - onImageAttachment: _handleImageAttachment, - onCameraCapture: () => - _handleImageAttachment(fromCamera: true), + 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), + ), ), ], ), @@ -1390,44 +1396,42 @@ class _ChatPageState extends ConsumerState { }, child: (_showScrollToBottom && + !keyboardVisible && ref.watch(chatMessagesProvider).isNotEmpty) ? ClipRRect( key: const ValueKey('scroll_to_bottom_visible'), borderRadius: BorderRadius.circular( AppBorderRadius.floatingButton, ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - 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: 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, ), - 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), - ), + 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/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index c4d799c..1332bf1 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -231,7 +231,6 @@ class _ModernChatInputState extends ConsumerState children: [ // Main input area with unified 2-row design Container( - clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: context.conduitTheme.inputBackground, borderRadius: const BorderRadius.vertical( @@ -269,137 +268,22 @@ class _ModernChatInputState extends ConsumerState alignment: Alignment.topCenter, child: SingleChildScrollView( physics: const ClampingScrollPhysics(), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Collapsed/Expanded top row: text input with left/right buttons in collapsed - Padding( - padding: const EdgeInsets.only( - left: Spacing.inputPadding, - right: Spacing.inputPadding, - top: Spacing.inputPadding, - bottom: Spacing.inputPadding, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (!_isExpanded) ...[ - _buildRoundButton( - icon: Icons.add, - onTap: widget.enabled - ? _showAttachmentOptions - : null, - tooltip: AppLocalizations.of( - context, - )!.addAttachment, - showBackground: false, - iconSize: IconSize.large + 2.0, - ), - const SizedBox(width: Spacing.xs), - ] else ...[ - // When expanded, the left padding was reduced to move the plus button. - // Add back spacing so the text field aligns comfortably from the edge. - SizedBox( - width: Spacing.inputPadding - Spacing.xs, - ), - ], - // Text input expands to fill - Expanded( - child: Semantics( - textField: true, - label: AppLocalizations.of( - context, - )!.messageInputLabel, - hint: AppLocalizations.of( - context, - )!.messageInputHint, - child: TextField( - controller: _controller, - focusNode: _focusNode, - enabled: widget.enabled, - autofocus: false, - maxLines: _isExpanded ? null : 1, - keyboardType: TextInputType.multiline, - textCapitalization: - TextCapitalization.sentences, - textInputAction: TextInputAction.newline, - showCursor: true, - cursorColor: context.conduitTheme.inputText, - style: AppTypography.chatMessageStyle - .copyWith( - color: context.conduitTheme.inputText, - ), - decoration: InputDecoration( - hintText: AppLocalizations.of( - context, - )!.messageHintText, - hintStyle: TextStyle( - color: context - .conduitTheme - .inputPlaceholder, - fontSize: AppTypography.bodyLarge, - fontWeight: _isRecording - ? FontWeight.w500 - : FontWeight.w400, - fontStyle: _isRecording - ? FontStyle.italic - : FontStyle.normal, - ), - // Ensure the text field background matches its parent container - // and does not use the global InputDecorationTheme fill - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true, - alignLabelWithHint: true, - ), - // Removed onChanged setState to reduce rebuilds - onSubmitted: (_) => _sendMessage(), - onTap: () { - if (!widget.enabled) return; - if (!_isExpanded) { - _setExpanded(true); - WidgetsBinding.instance - .addPostFrameCallback((_) { - if (!mounted) return; - _ensureFocusedIfEnabled(); - }); - } else { - _ensureFocusedIfEnabled(); - } - }, - ), - ), - ), - if (!_isExpanded) ...[ - const SizedBox(width: Spacing.sm), - // Primary action button (Send/Stop) when collapsed - _buildPrimaryButton( - _hasText, - isGenerating, - stopGeneration, - ), - ], - ], - ), - ), - - // Expanded bottom row with additional options - if (_isExpanded) ...[ - Container( + child: RepaintBoundary( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Collapsed/Expanded top row: text input with left/right buttons in collapsed + Padding( padding: const EdgeInsets.only( left: Spacing.inputPadding, right: Spacing.inputPadding, + top: Spacing.inputPadding, bottom: Spacing.inputPadding, ), - child: FadeTransition( - opacity: _expandController, - child: Row( - children: [ + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!_isExpanded) ...[ _buildRoundButton( icon: Icons.add, onTap: widget.enabled @@ -412,197 +296,318 @@ class _ModernChatInputState extends ConsumerState iconSize: IconSize.large + 2.0, ), const SizedBox(width: Spacing.xs), - // Quick pills: no scroll, clip text within fixed max width - Expanded( - child: Row( - children: [ - Expanded( - flex: 2, - child: _buildPillButton( - icon: Platform.isIOS - ? CupertinoIcons.search - : Icons.search, - label: AppLocalizations.of( - context, - )!.web, - isActive: webSearchEnabled, - onTap: widget.enabled - ? () { - ref - .read( - webSearchEnabledProvider - .notifier, - ) - .state = - !webSearchEnabled; - } - : null, + ] else ...[ + // When expanded, the left padding was reduced to move the plus button. + // Add back spacing so the text field aligns comfortably from the edge. + SizedBox( + width: Spacing.inputPadding - Spacing.xs, + ), + ], + // Text input expands to fill + Expanded( + child: Semantics( + textField: true, + label: AppLocalizations.of( + context, + )!.messageInputLabel, + hint: AppLocalizations.of( + context, + )!.messageInputHint, + child: TextField( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + autofocus: false, + maxLines: _isExpanded ? null : 1, + keyboardType: TextInputType.multiline, + textCapitalization: + TextCapitalization.sentences, + textInputAction: TextInputAction.newline, + showCursor: true, + cursorColor: + context.conduitTheme.inputText, + style: AppTypography.chatMessageStyle + .copyWith( + color: + context.conduitTheme.inputText, ), + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.messageHintText, + hintStyle: TextStyle( + color: context + .conduitTheme + .inputPlaceholder, + fontSize: AppTypography.bodyLarge, + fontWeight: _isRecording + ? FontWeight.w500 + : FontWeight.w400, + fontStyle: _isRecording + ? FontStyle.italic + : FontStyle.normal, ), - if (imageGenAvailable) ...[ - const SizedBox(width: Spacing.xs), + // Ensure the text field background matches its parent container + // and does not use the global InputDecorationTheme fill + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + alignLabelWithHint: true, + ), + // Removed onChanged setState to reduce rebuilds + onSubmitted: (_) => _sendMessage(), + onTap: () { + if (!widget.enabled) return; + if (!_isExpanded) { + _setExpanded(true); + WidgetsBinding.instance + .addPostFrameCallback((_) { + if (!mounted) return; + _ensureFocusedIfEnabled(); + }); + } else { + _ensureFocusedIfEnabled(); + } + }, + ), + ), + ), + if (!_isExpanded) ...[ + const SizedBox(width: Spacing.sm), + // Primary action button (Send/Stop) when collapsed + _buildPrimaryButton( + _hasText, + isGenerating, + stopGeneration, + ), + ], + ], + ), + ), + + // Expanded bottom row with additional options + if (_isExpanded) ...[ + Container( + padding: const EdgeInsets.only( + left: Spacing.inputPadding, + right: Spacing.inputPadding, + bottom: Spacing.inputPadding, + ), + child: FadeTransition( + opacity: _expandController, + child: Row( + children: [ + _buildRoundButton( + icon: Icons.add, + onTap: widget.enabled + ? _showAttachmentOptions + : null, + tooltip: AppLocalizations.of( + context, + )!.addAttachment, + showBackground: false, + iconSize: IconSize.large + 2.0, + ), + const SizedBox(width: Spacing.xs), + // Quick pills: no scroll, clip text within fixed max width + Expanded( + child: Row( + children: [ Expanded( - flex: 3, + flex: 2, child: _buildPillButton( icon: Platform.isIOS - ? CupertinoIcons.photo - : Icons.image, + ? CupertinoIcons.search + : Icons.search, label: AppLocalizations.of( context, - )!.imageGen, - isActive: imageGenEnabled, + )!.web, + isActive: webSearchEnabled, onTap: widget.enabled ? () { ref .read( - imageGenerationEnabledProvider + webSearchEnabledProvider .notifier, ) .state = - !imageGenEnabled; + !webSearchEnabled; } : null, ), ), + if (imageGenAvailable) ...[ + const SizedBox(width: Spacing.xs), + Expanded( + flex: 3, + child: _buildPillButton( + icon: Platform.isIOS + ? CupertinoIcons.photo + : Icons.image, + label: AppLocalizations.of( + context, + )!.imageGen, + isActive: imageGenEnabled, + onTap: widget.enabled + ? () { + ref + .read( + imageGenerationEnabledProvider + .notifier, + ) + .state = + !imageGenEnabled; + } + : null, + ), + ), + ], + 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.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, + ), + ), + ), + 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), - _buildRoundButton( - icon: Icons.more_horiz, - onTap: widget.enabled - ? _showUnifiedToolsModal - : null, - tooltip: AppLocalizations.of( - context, - )!.tools, - isActive: - ref - .watch( - selectedToolIdsProvider, - ) - .isNotEmpty || - webSearchEnabled || - imageGenEnabled, + // Primary action button (Send/Stop) when expanded + _buildPrimaryButton( + _hasText, + isGenerating, + stopGeneration, ), ], ), - ), - 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, - ), - ), - ), - 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, + // Debug button for testing on-device STT (enable by changing false to true) + // ignore: dead_code + if (false) ...[ + const SizedBox(width: Spacing.sm), + _buildRoundButton( + icon: Icons.bug_report, + onTap: widget.enabled + ? () async { + final result = + await _voiceService + .testOnDeviceStt(); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + 'STT Test: $result', + ), + duration: const Duration( + seconds: 5, + ), + ), + ); + } + } + : null, + tooltip: 'Test On-Device STT', ), ], - ), - // Debug button for testing on-device STT (enable by changing false to true) - // ignore: dead_code - if (false) ...[ - const SizedBox(width: Spacing.sm), - _buildRoundButton( - icon: Icons.bug_report, - onTap: widget.enabled - ? () async { - final result = await _voiceService - .testOnDeviceStt(); - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - 'STT Test: $result', - ), - duration: const Duration( - seconds: 5, - ), - ), - ); - } - } - : null, - tooltip: 'Test On-Device STT', - ), + // removed duplicate send button; now only in right cluster ], - // removed duplicate send button; now only in right cluster - ], + ), ), ), - ), + ], ], - ], + ), ), ), ),