diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 19d979f..457fcc6 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform; +import 'dart:ui' show ImageFilter; import '../../../shared/widgets/responsive_drawer_layout.dart'; import '../../navigation/widgets/chats_drawer.dart'; import 'dart:async'; @@ -968,6 +969,8 @@ class _ChatPageState extends ConsumerState { // Use slivers to align with the actual messages view. // Do not attach the primary scroll controller here to avoid // AnimatedSwitcher attaching the same controller twice. + // Add bottom padding to account for floating input overlay. + final bottomPadding = Spacing.lg + _inputHeight; return CustomScrollView( key: const ValueKey('loading_messages'), controller: null, @@ -976,11 +979,11 @@ class _ChatPageState extends ConsumerState { cacheExtent: 300, slivers: [ SliverPadding( - padding: const EdgeInsets.fromLTRB( + padding: EdgeInsets.fromLTRB( Spacing.lg, Spacing.md, Spacing.lg, - Spacing.lg, + bottomPadding, ), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { @@ -1097,6 +1100,8 @@ class _ChatPageState extends ConsumerState { }); } + // Add bottom padding to account for floating input overlay. + final bottomPadding = Spacing.lg + _inputHeight; return CustomScrollView( key: const ValueKey('actual_messages'), controller: _scrollController, @@ -1105,11 +1110,11 @@ class _ChatPageState extends ConsumerState { cacheExtent: 600, slivers: [ SliverPadding( - padding: const EdgeInsets.fromLTRB( + padding: EdgeInsets.fromLTRB( Spacing.lg, Spacing.md, Spacing.lg, - Spacing.lg, + bottomPadding, ), sliver: OptimizedSliverList( items: messages, @@ -1349,6 +1354,8 @@ class _ChatPageState extends ConsumerState { final greetingText = resolvedGreetingName != null ? l10n.onboardStartTitle(resolvedGreetingName) : null; + // Add bottom padding to account for floating input overlay. + final bottomPadding = _inputHeight; return LayoutBuilder( builder: (context, constraints) { final greetingDisplay = greetingText ?? ''; @@ -1360,7 +1367,12 @@ class _ChatPageState extends ConsumerState { width: double.infinity, height: constraints.maxHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.lg), + padding: EdgeInsets.fromLTRB( + Spacing.lg, + 0, + Spacing.lg, + bottomPadding, + ), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, @@ -1964,94 +1976,120 @@ class _ChatPageState extends ConsumerState { }, child: Stack( children: [ - Column( - children: [ - // Messages Area with pull-to-refresh - Expanded( - child: ConduitRefreshIndicator( - onRefresh: () async { - // Reload active conversation messages from server - final api = ref.read(apiServiceProvider); - final active = ref.read( - activeConversationProvider, + // Messages Area fills entire space with pull-to-refresh + Positioned.fill( + child: ConduitRefreshIndicator( + onRefresh: () async { + // Reload active conversation messages from server + final api = ref.read(apiServiceProvider); + final active = ref.read(activeConversationProvider); + if (api != null && active != null) { + try { + final full = await api.getConversation( + active.id, ); - if (api != null && active != null) { - try { - final full = await api.getConversation( - active.id, - ); - ref - .read( - activeConversationProvider.notifier, - ) - .set(full); - } catch (e) { - DebugLogger.log( - 'Failed to refresh conversation: $e', - scope: 'chat/page', - ); - } - } - - // Also refresh the conversations list to reconcile missed events - // and keep timestamps/order in sync with the server. - try { - refreshConversationsCache(ref); - // Best-effort await to stabilize UI; ignore errors. - await ref.read(conversationsProvider.future); - } catch (_) {} - - // Add small delay for better UX feedback - await Future.delayed( - const Duration(milliseconds: 300), + ref + .read(activeConversationProvider.notifier) + .set(full); + } catch (e) { + DebugLogger.log( + 'Failed to refresh conversation: $e', + scope: 'chat/page', ); - }, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - FocusManager.instance.primaryFocus?.unfocus(); - try { - SystemChannels.textInput.invokeMethod( - 'TextInput.hide', - ); - } catch (_) {} - }, - child: RepaintBoundary( - child: _buildMessagesList(theme), + } + } + + // Also refresh the conversations list to reconcile missed events + // and keep timestamps/order in sync with the server. + try { + refreshConversationsCache(ref); + // Best-effort await to stabilize UI; ignore errors. + await ref.read(conversationsProvider.future); + } catch (_) {} + + // Add small delay for better UX feedback + await Future.delayed( + const Duration(milliseconds: 300), + ); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + FocusManager.instance.primaryFocus?.unfocus(); + try { + SystemChannels.textInput.invokeMethod( + 'TextInput.hide', + ); + } catch (_) {} + }, + child: RepaintBoundary( + child: _buildMessagesList(theme), + ), + ), + ), + ), + + // Floating input area with attachments and blur background + Positioned( + left: 0, + right: 0, + bottom: 0, + child: RepaintBoundary( + child: MeasureSize( + onChange: (size) { + if (mounted) { + setState(() { + _inputHeight = size.height; + }); + } + }, + child: Container( + decoration: BoxDecoration( + // Gradient fade from transparent to solid background + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.4, 1.0], + colors: [ + theme.scaffoldBackgroundColor.withValues( + alpha: 0.0, + ), + theme.scaffoldBackgroundColor.withValues( + alpha: 0.85, + ), + theme.scaffoldBackgroundColor, + ], ), ), - ), - ), - - // File attachments - const FileAttachmentWidget(), - const ContextAttachmentWidget(), - - // Modern Input (root matches input background including safe area) - RepaintBoundary( - child: MeasureSize( - onChange: (size) { - if (mounted) { - setState(() { - _inputHeight = size.height; - }); - } - }, - child: ModernChatInput( - onSendMessage: (text) => - _handleMessageSend(text, selectedModel), - onVoiceInput: null, - onVoiceCall: _handleVoiceCall, - onFileAttachment: _handleFileAttachment, - onImageAttachment: _handleImageAttachment, - onCameraCapture: () => - _handleImageAttachment(fromCamera: true), - onWebAttachment: _promptAttachWebpage, - onPastedAttachments: _handlePastedAttachments, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Top padding for gradient fade area + const SizedBox(height: Spacing.xl), + // File attachments + const FileAttachmentWidget(), + const ContextAttachmentWidget(), + // Modern Input + ModernChatInput( + onSendMessage: (text) => + _handleMessageSend(text, selectedModel), + onVoiceInput: null, + onVoiceCall: _handleVoiceCall, + onFileAttachment: _handleFileAttachment, + onImageAttachment: _handleImageAttachment, + onCameraCapture: () => + _handleImageAttachment( + fromCamera: true, + ), + onWebAttachment: _promptAttachWebpage, + onPastedAttachments: + _handlePastedAttachments, + ), + ], ), ), ), - ], + ), ), // Floating Scroll to Bottom Button with smooth appear/disappear @@ -2093,39 +2131,61 @@ class _ChatPageState extends ConsumerState { 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( - context, - ), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 16, + sigmaY: 16, ), - 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, + child: Container( + decoration: BoxDecoration( + // Use same high-contrast colors as floating input + color: + theme.brightness == + Brightness.dark + ? Color.lerp( + context + .conduitTheme + .cardBackground, + Colors.white, + 0.08, + )!.withValues(alpha: 0.85) + : Color.lerp( + context + .conduitTheme + .inputBackground, + Colors.black, + 0.06, + )!.withValues(alpha: 0.85), + border: Border.all( color: context .conduitTheme - .iconPrimary - .withValues(alpha: 0.9), + .cardBorder + .withValues(alpha: 0.55), + width: BorderWidth.thin, + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.floatingButton, + ), + boxShadow: ConduitShadows.button( + context, + ), + ), + 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 836459b..dd7ec04 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -9,7 +9,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:io' show Platform; import 'dart:async'; -import 'dart:ui'; import 'dart:math' as math; import '../providers/chat_providers.dart'; import '../services/clipboard_attachment_service.dart'; @@ -1073,10 +1072,10 @@ class _ModernChatInputState extends ConsumerState final Brightness brightness = Theme.of(context).brightness; final bool isActive = _focusNode.hasFocus || _hasText; - final Color composerSurface = context.conduitTheme.inputBackground; + // Use high-contrast background for floating input final Color composerBackground = brightness == Brightness.dark - ? composerSurface.withValues(alpha: 0.78) - : context.conduitTheme.surfaceContainerHighest; + ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; final Color placeholderBase = context.conduitTheme.inputText.withValues( alpha: 0.64, ); @@ -1087,7 +1086,7 @@ class _ModernChatInputState extends ConsumerState context.conduitTheme.inputBorder, context.conduitTheme.inputBorderFocused, isActive ? 1.0 : 0.0, - )!.withValues(alpha: brightness == Brightness.dark ? 0.55 : 0.45); + )!.withValues(alpha: brightness == Brightness.dark ? 0.65 : 0.55); final Color shellShadowColor = context.conduitTheme.cardShadow.withValues( alpha: brightness == Brightness.dark ? 0.22 + (isActive ? 0.08 : 0.0) @@ -1209,21 +1208,17 @@ class _ModernChatInputState extends ConsumerState ); final BoxDecoration shellDecoration = BoxDecoration( - color: showCompactComposer ? Colors.transparent : composerBackground, + color: composerBackground, borderRadius: shellRadius, - border: showCompactComposer - ? null - : Border.all(color: outlineColor, width: BorderWidth.thin), - boxShadow: showCompactComposer - ? const [] - : [ - BoxShadow( - color: shellShadowColor, - blurRadius: 12 + (isActive ? 4 : 0), - spreadRadius: -2, - offset: const Offset(0, -2), - ), - ], + border: Border.all(color: outlineColor, width: BorderWidth.thin), + boxShadow: [ + BoxShadow( + color: shellShadowColor, + blurRadius: 12 + (isActive ? 4 : 0), + spreadRadius: -2, + offset: const Offset(0, -2), + ), + ], ); final List composerChildren = [ @@ -1238,82 +1233,7 @@ class _ModernChatInputState extends ConsumerState ), child: _buildPromptOverlay(context), ), - if (showCompactComposer) - Padding( - key: const ValueKey('composer-compact'), - padding: const EdgeInsets.fromLTRB( - Spacing.screenPadding, - Spacing.xs, - Spacing.screenPadding, - Spacing.sm, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _buildOverflowButton( - tooltip: AppLocalizations.of(context)!.more, - webSearchActive: webSearchEnabled, - imageGenerationActive: imageGenEnabled, - toolsActive: selectedToolIds.isNotEmpty, - filtersActive: selectedFilterIds.isNotEmpty, - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.25, - ), - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - constraints: const BoxConstraints( - minHeight: TouchTarget.input, - ), - decoration: BoxDecoration( - color: composerSurface.withValues( - alpha: brightness == Brightness.dark ? 0.9 : 0.2, - ), - borderRadius: BorderRadius.circular(_composerRadius), - border: Border.all( - color: outlineColor.withValues( - alpha: brightness == Brightness.dark ? 0.32 : 0.2, - ), - width: BorderWidth.micro, - ), - ), - child: Row( - children: [ - Expanded( - child: _buildComposerTextField( - brightness: brightness, - sendOnEnter: sendOnEnter, - placeholderBase: placeholderBase, - placeholderFocused: placeholderFocused, - contentPadding: const EdgeInsets.symmetric( - vertical: Spacing.xs, - ), - isActive: isActive, - ), - ), - if (!_hasText && voiceAvailable && !isGenerating) - _buildInlineMicIcon(voiceAvailable), - ], - ), - ), - ), - ), - const SizedBox(width: Spacing.sm), - _buildPrimaryButton( - _hasText, - isGenerating, - stopGeneration, - voiceAvailable, - ), - ], - ), - ) - else ...[ + if (!showCompactComposer) ...[ Padding( key: const ValueKey('composer-expanded-input'), padding: const EdgeInsets.fromLTRB( @@ -1405,29 +1325,91 @@ class _ModernChatInputState extends ConsumerState ], ]; + // For compact mode, render text field shell with floating buttons on sides + if (showCompactComposer) { + // Build the text field shell + Widget textFieldShell = AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + constraints: const BoxConstraints(minHeight: TouchTarget.input), + decoration: shellDecoration, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.25, + ), + child: Row( + children: [ + Expanded( + child: _buildComposerTextField( + brightness: brightness, + sendOnEnter: sendOnEnter, + placeholderBase: placeholderBase, + placeholderFocused: placeholderFocused, + contentPadding: const EdgeInsets.symmetric( + vertical: Spacing.xs, + ), + isActive: isActive, + ), + ), + if (!_hasText && voiceAvailable && !isGenerating) + _buildInlineMicIcon(voiceAvailable), + ], + ), + ), + ); + + final bottomPadding = MediaQuery.of(context).viewPadding.bottom; + return Padding( + padding: EdgeInsets.fromLTRB( + Spacing.screenPadding, + 0, + Spacing.screenPadding, + bottomPadding + Spacing.md, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildOverflowButton( + tooltip: AppLocalizations.of(context)!.more, + webSearchActive: webSearchEnabled, + imageGenerationActive: imageGenEnabled, + toolsActive: selectedToolIds.isNotEmpty, + filtersActive: selectedFilterIds.isNotEmpty, + ), + const SizedBox(width: Spacing.sm), + Expanded(child: textFieldShell), + const SizedBox(width: Spacing.sm), + _buildPrimaryButton( + _hasText, + isGenerating, + stopGeneration, + voiceAvailable, + ), + ], + ), + ); + } + + // For expanded mode with quick pills, use the full shell Widget shell = AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, decoration: shellDecoration, - width: double.infinity, - child: SafeArea( - top: false, - bottom: true, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.4, - ), - child: AnimatedSize( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: SingleChildScrollView( - physics: const ClampingScrollPhysics(), - child: RepaintBoundary( - child: Column( - mainAxisSize: MainAxisSize.min, - children: composerChildren, - ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: AnimatedSize( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: RepaintBoundary( + child: Column( + mainAxisSize: MainAxisSize.min, + children: composerChildren, ), ), ), @@ -1435,20 +1417,16 @@ class _ModernChatInputState extends ConsumerState ), ); - if (brightness == Brightness.dark && !showCompactComposer) { - shell = ClipRRect( - borderRadius: shellRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: shell, - ), - ); - } - - return Container( - color: Colors.transparent, - padding: EdgeInsets.zero, - child: Column(mainAxisSize: MainAxisSize.min, children: [shell]), + // Wrap with padding for floating effect, accounting for safe area + final bottomPadding = MediaQuery.of(context).viewPadding.bottom; + return Padding( + padding: EdgeInsets.fromLTRB( + Spacing.sm, + 0, + Spacing.sm, + bottomPadding + Spacing.md, + ), + child: shell, ); } @@ -1688,9 +1666,11 @@ class _ModernChatInputState extends ConsumerState : (activeColor ?? context.conduitTheme.textPrimary.withValues(alpha: Alpha.strong)); + // Use high-contrast background for floating button final Brightness brightness = Theme.of(context).brightness; - final Color baseBackground = context.conduitTheme.inputBackground - .withValues(alpha: brightness == Brightness.dark ? 0.9 : 0.2); + final Color baseBackground = brightness == Brightness.dark + ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! + : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; final Color backgroundColor = !enabled ? baseBackground.withValues(alpha: Alpha.disabled) : isActive