From fe1e03c198fcff75e48c358bfa3c2fa813ed7198 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:47:54 +0530 Subject: [PATCH] refactor: implement SlideDrawer for enhanced chat page navigation - Introduced SlideDrawer to replace the traditional drawer, providing a smoother and more interactive user experience. - Updated ChatPage to utilize SlideDrawer, allowing for customizable drawer behavior and improved responsiveness. - Refactored the app bar and navigation logic to accommodate the new slide drawer, enhancing overall layout and usability. - Removed deprecated imports and streamlined the code for better maintainability. --- lib/features/chat/views/chat_page.dart | 922 +++++++++++++------------ lib/shared/widgets/slide_drawer.dart | 234 +++++++ 2 files changed, 715 insertions(+), 441 deletions(-) create mode 100644 lib/shared/widgets/slide_drawer.dart diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 80d6d53..d6bbd61 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -8,6 +8,8 @@ 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 '../../../shared/widgets/slide_drawer.dart'; +import '../../navigation/widgets/chats_drawer.dart'; import 'dart:async'; import '../../../core/providers/app_providers.dart'; import '../providers/chat_providers.dart'; @@ -21,14 +23,12 @@ import '../widgets/user_message_bubble.dart'; import '../widgets/assistant_message_widget.dart' as assistant; import '../widgets/streaming_title_text.dart'; import '../widgets/file_attachment_widget.dart'; -// import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input import '../services/voice_input_service.dart'; import '../services/file_attachment_service.dart'; import 'voice_call_page.dart'; import 'package:path/path.dart' as path; import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; -import '../../navigation/widgets/chats_drawer.dart'; import '../../../core/models/chat_message.dart'; import '../../../core/models/model.dart'; import '../../../shared/widgets/loading_states.dart'; @@ -43,7 +43,6 @@ import '../../../shared/widgets/modal_safe_area.dart'; import '../../../core/services/settings_service.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/widgets/model_avatar.dart'; -// Removed unused PlatformUtils import import '../../../core/services/platform_service.dart' as ps; import 'package:flutter/gestures.dart' show DragStartBehavior; @@ -1164,475 +1163,516 @@ class _ChatPageState extends ConsumerState { } } }, - child: Scaffold( - backgroundColor: context.conduitTheme.surfaceBackground, - // Left navigation drawer with draggable edge open (native, finger-following) - drawerEnableOpenDragGesture: true, - drawerDragStartBehavior: DragStartBehavior.down, - drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5, - drawerScrimColor: context.colorTokens.overlayStrong, - drawer: Drawer( - width: (MediaQuery.of(context).size.width * 0.80).clamp( - 280.0, - 420.0, - ), - backgroundColor: context.conduitTheme.surfaceBackground, - child: SafeArea( - top: true, - bottom: true, - left: false, - right: false, - child: const ChatsDrawer(), - ), - ), - appBar: AppBar( - backgroundColor: context.conduitTheme.surfaceBackground, - elevation: Elevation.none, - surfaceTintColor: Colors.transparent, - shadowColor: Colors.transparent, - toolbarHeight: kToolbarHeight + 8, - centerTitle: true, - titleSpacing: 0.0, - leading: _isSelectionMode - ? IconButton( - icon: Icon( - Platform.isIOS ? CupertinoIcons.xmark : Icons.close, - color: context.conduitTheme.textPrimary, - size: IconSize.appBar, - ), - onPressed: _clearSelection, - ) - : Builder( - builder: (ctx) => Padding( - padding: const EdgeInsets.only( - left: Spacing.inputPadding, - ), - child: IconButton( - onPressed: () { - // Open left drawer instead of bottom sheet - Scaffold.of(ctx).openDrawer(); - }, - icon: Icon( - Platform.isIOS - ? CupertinoIcons.line_horizontal_3 - : Icons.menu, - color: context.conduitTheme.textPrimary, - size: IconSize.appBar, - ), - ), - ), - ), - title: _isSelectionMode - ? Text( - '${_selectedMessageIds.length} selected', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w500, - ), - ) - : GestureDetector( - onTap: () async { - final modelsAsync = ref.read(modelsProvider); + child: Builder( + builder: (outerCtx) { + final size = MediaQuery.of(outerCtx).size; + final isTablet = size.shortestSide >= 600; + final maxFraction = isTablet ? 0.42 : 0.84; + final edgeFraction = isTablet ? 0.36 : 0.50; // large phone edge + final scrim = Platform.isIOS + ? context.colorTokens.overlayMedium + : context.colorTokens.overlayStrong; - // Handle all async states properly - if (modelsAsync.isLoading) { - // If still loading, wait for it to complete - try { - final models = await ref.read(modelsProvider.future); - // Check mounted and use context immediately together - if (!mounted) return; - // ignore: use_build_context_synchronously - _showModelDropdown(context, ref, models); - } catch (e) { - DebugLogger.error( - 'model-load-failed', - scope: 'chat/model-selector', - error: e, - ); - } - } else if (modelsAsync.hasValue) { - // If we have data, show immediately (no async gap) - _showModelDropdown(context, ref, modelsAsync.value!); - } else if (modelsAsync.hasError) { - // If there's an error, try to refresh and load - try { - ref.invalidate(modelsProvider); - final models = await ref.read(modelsProvider.future); - // Check mounted and use context immediately together - if (!mounted) return; - // ignore: use_build_context_synchronously - _showModelDropdown(context, ref, models); - } catch (e) { - DebugLogger.error( - 'model-refresh-failed', - scope: 'chat/model-selector', - error: e, - ); - } - } - }, - onLongPress: () { - final conversation = ref.read(activeConversationProvider); - if (conversation == null) return; - showConversationContextMenu( - context: context, - ref: ref, - conversation: conversation, - ); - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: displayConversationTitle != null - ? Column( - key: ValueKey( - displayConversationTitle, - ), - mainAxisSize: MainAxisSize.min, - children: [ - StreamingTitleText( - title: displayConversationTitle, - style: AppTypography.headlineSmallStyle - .copyWith( + return SlideDrawer( + maxFraction: maxFraction, + edgeFraction: edgeFraction, + settleFraction: 0.06, // even gentler settle for instant open feel + scrimColor: scrim, + drawer: SafeArea( + top: true, + bottom: true, + left: false, + right: false, + child: const ChatsDrawer(), + ), + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + // Replace Scaffold drawer with a tunable slide drawer for gentler snap behavior. + drawerEnableOpenDragGesture: false, + drawerDragStartBehavior: DragStartBehavior.down, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: Elevation.none, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + toolbarHeight: kToolbarHeight + 8, + centerTitle: true, + titleSpacing: 0.0, + leading: _isSelectionMode + ? IconButton( + icon: Icon( + Platform.isIOS ? CupertinoIcons.xmark : Icons.close, + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + onPressed: _clearSelection, + ) + : Builder( + builder: (ctx) => Padding( + padding: const EdgeInsets.only( + left: Spacing.inputPadding, + ), + child: IconButton( + onPressed: () { + // Open slide drawer + SlideDrawer.of(ctx)?.open(); + }, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.line_horizontal_3 + : Icons.menu, + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + ), + ), + ), + title: _isSelectionMode + ? Text( + '${_selectedMessageIds.length} selected', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ) + : GestureDetector( + onTap: () async { + final modelsAsync = ref.read(modelsProvider); + + // Handle all async states properly + if (modelsAsync.isLoading) { + // If still loading, wait for it to complete + try { + final models = await ref.read( + modelsProvider.future, + ); + // Check mounted and use context immediately together + if (!mounted) return; + // ignore: use_build_context_synchronously + _showModelDropdown(context, ref, models); + } catch (e) { + DebugLogger.error( + 'model-load-failed', + scope: 'chat/model-selector', + error: e, + ); + } + } else if (modelsAsync.hasValue) { + // If we have data, show immediately (no async gap) + _showModelDropdown( + context, + ref, + modelsAsync.value!, + ); + } else if (modelsAsync.hasError) { + // If there's an error, try to refresh and load + try { + ref.invalidate(modelsProvider); + final models = await ref.read( + modelsProvider.future, + ); + // Check mounted and use context immediately together + if (!mounted) return; + // ignore: use_build_context_synchronously + _showModelDropdown(context, ref, models); + } catch (e) { + DebugLogger.error( + 'model-refresh-failed', + scope: 'chat/model-selector', + error: e, + ); + } + } + }, + onLongPress: () { + final conversation = ref.read( + activeConversationProvider, + ); + if (conversation == null) return; + showConversationContextMenu( + context: context, + ref: ref, + conversation: conversation, + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: displayConversationTitle != null + ? Column( + key: ValueKey( + displayConversationTitle, + ), + mainAxisSize: MainAxisSize.min, + children: [ + StreamingTitleText( + title: displayConversationTitle, + style: AppTypography + .headlineSmallStyle + .copyWith( + color: context + .conduitTheme + .textPrimary, + fontWeight: FontWeight.w600, + fontSize: 18, + height: 1.3, + ), + cursorColor: context + .conduitTheme + .textPrimary + .withValues(alpha: 0.8), + ), + const SizedBox(height: Spacing.xs), + ], + ) + : const SizedBox.shrink( + key: ValueKey('empty-title'), + ), + ), + Transform.translate( + offset: const Offset(0, 0), + child: () { + final row = Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: 0.0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( color: context .conduitTheme - .textPrimary, - fontWeight: FontWeight.w600, - fontSize: 18, - height: 1.3, + .surfaceBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + border: Border.all( + color: context + .conduitTheme + .dividerColor, + width: BorderWidth.thin, + ), ), - cursorColor: context - .conduitTheme - .textPrimary - .withValues(alpha: 0.8), - ), - const SizedBox(height: Spacing.xs), - ], - ) - : const SizedBox.shrink( - key: ValueKey('empty-title'), - ), - ), - Transform.translate( - offset: const Offset(0, 0), - child: () { - final row = Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Opacity( - opacity: 0.0, + child: Icon( + Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.keyboard_arrow_down, + color: context + .conduitTheme + .iconSecondary, + size: IconSize.small, + ), + ), + ), + const SizedBox(width: Spacing.xs), + Flexible( + child: MiddleEllipsisText( + modelLabel, + style: modelTextStyle, + textAlign: TextAlign.center, + semanticsLabel: modelLabel, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + decoration: BoxDecoration( + color: context + .conduitTheme + .surfaceBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), + border: Border.all( + color: context + .conduitTheme + .dividerColor, + width: BorderWidth.thin, + ), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.keyboard_arrow_down, + color: context + .conduitTheme + .iconSecondary, + size: IconSize.small, + ), + ), + ], + ); + return hasConversationTitle + ? SizedBox(height: 24, child: row) + : row; + }(), + ), + if (isReviewerMode) + Padding( + padding: const EdgeInsets.only(top: 2.0), child: Container( padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: Spacing.xxs, + horizontal: Spacing.sm, + vertical: 1.0, ), decoration: BoxDecoration( - color: context - .conduitTheme - .surfaceBackground - .withValues(alpha: 0.3), + color: context.conduitTheme.success + .withValues(alpha: 0.1), borderRadius: BorderRadius.circular( AppBorderRadius.badge, ), border: Border.all( - color: - context.conduitTheme.dividerColor, + color: context.conduitTheme.success + .withValues(alpha: 0.3), width: BorderWidth.thin, ), ), - child: Icon( - Platform.isIOS - ? CupertinoIcons.chevron_down - : Icons.keyboard_arrow_down, - color: context.conduitTheme.iconSecondary, - size: IconSize.small, + child: Text( + 'REVIEWER MODE', + style: AppTypography.captionStyle + .copyWith( + color: context.conduitTheme.success, + fontWeight: FontWeight.w600, + fontSize: 9, + ), ), ), ), - const SizedBox(width: Spacing.xs), - Flexible( - child: MiddleEllipsisText( - modelLabel, - style: modelTextStyle, - textAlign: TextAlign.center, - semanticsLabel: modelLabel, - ), - ), - const SizedBox(width: Spacing.xs), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: Spacing.xxs, - ), - decoration: BoxDecoration( - color: context - .conduitTheme - .surfaceBackground - .withValues(alpha: 0.3), - borderRadius: BorderRadius.circular( - AppBorderRadius.badge, - ), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.thin, - ), - ), - child: Icon( - Platform.isIOS - ? CupertinoIcons.chevron_down - : Icons.keyboard_arrow_down, - color: context.conduitTheme.iconSecondary, - size: IconSize.small, - ), - ), - ], - ); - return hasConversationTitle - ? SizedBox(height: 24, child: row) - : row; - }(), - ), - if (isReviewerMode) - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: 1.0, - ), - decoration: BoxDecoration( - color: context.conduitTheme.success.withValues( - alpha: 0.1, - ), - borderRadius: BorderRadius.circular( - AppBorderRadius.badge, - ), - border: Border.all( - color: context.conduitTheme.success - .withValues(alpha: 0.3), - width: BorderWidth.thin, - ), - ), - child: Text( - 'REVIEWER MODE', - style: AppTypography.captionStyle.copyWith( - color: context.conduitTheme.success, - fontWeight: FontWeight.w600, - fontSize: 9, - ), - ), - ), - ), - ], - ), - ), - actions: [ - if (!_isSelectionMode) ...[ - Padding( - padding: const EdgeInsets.only(right: Spacing.inputPadding), - child: IconButton( - icon: Icon( - Platform.isIOS - ? CupertinoIcons.create - : Icons.add_comment, - color: context.conduitTheme.textPrimary, - size: IconSize.appBar, - ), - onPressed: _handleNewChat, - tooltip: AppLocalizations.of(context)!.newChat, - ), - ), - ] else ...[ - IconButton( - icon: Icon( - Platform.isIOS ? CupertinoIcons.delete : Icons.delete, - color: context.conduitTheme.error, - size: IconSize.appBar, - ), - onPressed: _deleteSelectedMessages, - ), - ], - ], - ), - body: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - FocusManager.instance.primaryFocus?.unfocus(); - try { - SystemChannels.textInput.invokeMethod('TextInput.hide'); - } catch (_) {} - }, - 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); - 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), - ); - }, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - FocusManager.instance.primaryFocus?.unfocus(); - try { - SystemChannels.textInput.invokeMethod( - 'TextInput.hide', - ); - } catch (_) {} - }, - child: RepaintBoundary( - child: _buildMessagesList(theme), + ], ), ), - ), - ), - - // File attachments - const FileAttachmentWidget(), - - // 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), + actions: [ + if (!_isSelectionMode) ...[ + Padding( + padding: const EdgeInsets.only( + right: Spacing.inputPadding, + ), + child: IconButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.create + : Icons.add_comment, + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + onPressed: _handleNewChat, + tooltip: AppLocalizations.of(context)!.newChat, ), ), - ), + ] else ...[ + IconButton( + icon: Icon( + Platform.isIOS ? CupertinoIcons.delete : Icons.delete, + color: context.conduitTheme.error, + size: IconSize.appBar, + ), + onPressed: _deleteSelectedMessages, + ), + ], ], ), + body: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + FocusManager.instance.primaryFocus?.unfocus(); + try { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } catch (_) {} + }, + 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, + ); + 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', + ); + } + } - // Floating Scroll to Bottom Button with smooth appear/disappear - Positioned( - bottom: - ((_inputHeight > 0) - ? _inputHeight - : (Spacing.xxl + Spacing.xxxl)) + - Spacing.sm, - left: 0, - right: 0, - child: AnimatedSwitcher( - duration: AnimationDuration.microInteraction, - switchInCurve: AnimationCurves.microInteraction, - switchOutCurve: AnimationCurves.microInteraction, - transitionBuilder: (child, animation) { - final slideAnimation = Tween( - begin: const Offset(0, 0.15), - end: Offset.zero, - ).animate(animation); - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: slideAnimation, - child: child, - ), - ); - }, - child: - (_showScrollToBottom && - !keyboardVisible && - canScroll && - ref.watch(chatMessagesProvider).isNotEmpty) - ? Center( - key: const ValueKey('scroll_to_bottom_visible'), - child: ClipRRect( - 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: 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), - ), - ), + // 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), ), ), ), - ) - : const SizedBox.shrink( - key: ValueKey('scroll_to_bottom_hidden'), ), + + // File attachments + const FileAttachmentWidget(), + + // 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), + ), + ), + ), + ], + ), + + // Floating Scroll to Bottom Button with smooth appear/disappear + Positioned( + bottom: + ((_inputHeight > 0) + ? _inputHeight + : (Spacing.xxl + Spacing.xxxl)) + + Spacing.sm, + left: 0, + right: 0, + child: AnimatedSwitcher( + duration: AnimationDuration.microInteraction, + switchInCurve: AnimationCurves.microInteraction, + switchOutCurve: AnimationCurves.microInteraction, + transitionBuilder: (child, animation) { + final slideAnimation = Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(animation); + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: slideAnimation, + child: child, + ), + ); + }, + child: + (_showScrollToBottom && + !keyboardVisible && + canScroll && + ref.watch(chatMessagesProvider).isNotEmpty) + ? Center( + key: const ValueKey( + 'scroll_to_bottom_visible', + ), + child: ClipRRect( + 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: 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), + ), + ), + ), + ), + ), + ) + : const SizedBox.shrink( + key: ValueKey('scroll_to_bottom_hidden'), + ), + ), + ), + // Edge overlay removed; rely on native interactive drawer drag + ], ), ), - // Edge overlay removed; rely on native interactive drawer drag - ], - ), - ), - ), // Scaffold + ), // Scaffold inside SlideDrawer + ); + }, + ), ), // PopScope ); // ErrorBoundary } diff --git a/lib/shared/widgets/slide_drawer.dart b/lib/shared/widgets/slide_drawer.dart new file mode 100644 index 0000000..fa3f7c3 --- /dev/null +++ b/lib/shared/widgets/slide_drawer.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'dart:ui' as ui; +import '../../shared/theme/theme_extensions.dart'; + +class SlideDrawer extends StatefulWidget { + final Widget child; + final Widget drawer; + final double maxFraction; // 0..1 of screen width + final double edgeFraction; // 0..1 active edge width for open gesture + final double settleFraction; // threshold to settle open on release + final Duration duration; + final Curve curve; + final Color? scrimColor; + // When true, opening the drawer pushes the content to the right + // instead of overlaying above it. + final bool pushContent; + // Max scale reduction for pushed content at full open (e.g., 0.02 => 98%). + final double contentScaleDelta; + // Max blur sigma applied to pushed content at full open. + final double contentBlurSigma; + + const SlideDrawer({ + super.key, + required this.child, + required this.drawer, + this.maxFraction = 0.84, + this.edgeFraction = 0.5, + this.settleFraction = 0.12, + this.duration = const Duration(milliseconds: 180), + this.curve = Curves.fastOutSlowIn, + this.scrimColor, + this.pushContent = true, + this.contentScaleDelta = 0.02, + this.contentBlurSigma = 2.0, + }); + + static SlideDrawerState? of(BuildContext context) => + context.findAncestorStateOfType(); + + @override + State createState() => SlideDrawerState(); +} + +class SlideDrawerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + value: 0.0, + ); + + double get _panelWidth => + (MediaQuery.of(context).size.width * widget.maxFraction).clamp( + 280.0, + 520.0, + ); + + double get _edgeWidth => + MediaQuery.of(context).size.width * widget.edgeFraction; + + bool get isOpen => _controller.value == 1.0; + + Future _animateTo(double target, {double velocity = 0.0}) async { + final current = _controller.value; + final distance = (current - target).abs().clamp(0.0, 1.0); + // Smooth, distance-based duration so snaps don't feel abrupt. + final baseMs = widget.duration.inMilliseconds; + final normSpeed = (velocity.abs() / (_panelWidth + 0.001)).clamp(0.0, 4.0); + // Higher velocity => shorter duration. + final ms = (baseMs * distance / (1.0 + 1.5 * normSpeed)) + .clamp(90, baseMs) + .round(); + final curve = target > current + ? (normSpeed > 0.5 ? Curves.linearToEaseOut : Curves.easeOutCubic) + : (normSpeed > 0.5 ? Curves.easeInToLinear : Curves.easeInCubic); + await _controller.animateTo( + target, + duration: Duration(milliseconds: ms), + curve: curve, + ); + } + + void open({double velocity = 0.0}) => _animateTo(1.0, velocity: velocity); + void close({double velocity = 0.0}) => _animateTo(0.0, velocity: velocity); + void toggle() => isOpen ? close() : open(); + + double _startValue = 0.0; + + void _onDragStart(DragStartDetails d) { + _startValue = _controller.value; + } + + void _onDragUpdate(DragUpdateDetails d) { + final delta = d.primaryDelta ?? 0.0; + final next = (_startValue + delta / _panelWidth).clamp(0.0, 1.0); + _controller.value = next; + _startValue = next; + } + + void _onDragEnd(DragEndDetails d) { + final vx = d.primaryVelocity ?? 0.0; + final vMag = vx.abs(); + // Fling assistance first. + if (vMag > 300.0) { + if (vx > 0) { + open(velocity: vMag); + } else { + close(velocity: vMag); + } + return; + } + // Gentle settle threshold (less aggressive snap-back). + if (_controller.value >= widget.settleFraction) { + open(velocity: vMag); + } else { + close(velocity: vMag); + } + } + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final scrim = widget.scrimColor ?? context.colorTokens.overlayStrong; + + return Stack( + children: [ + // Content (optionally pushed by the drawer) + Positioned.fill( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final t = _controller.value; + final dx = (widget.pushContent ? _panelWidth * t : 0.0) + .roundToDouble(); // snap to pixel to avoid jitter + final scale = + 1.0 - + (widget.pushContent + ? (widget.contentScaleDelta.clamp(0.0, 0.2) * t) + : 0.0); + final blurSigma = + (widget.pushContent + ? (widget.contentBlurSigma.clamp(0.0, 8.0) * t) + : 0.0) + .toDouble(); + Widget content = widget.child; + if (blurSigma > 0.0) { + content = ImageFiltered( + imageFilter: ui.ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: content, + ); + } + content = Transform.scale( + scale: scale, + alignment: Alignment.centerLeft, + child: content, + ); + content = Transform.translate( + offset: Offset(dx, 0), + child: content, + ); + return content; + }, + ), + ), + + // Edge gesture region to open + Positioned( + left: 0, + top: 0, + bottom: 0, + width: _edgeWidth, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + ), + ), + + // Scrim + panel when animating or open + AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final t = _controller.value; + final ignoring = t == 0.0; + return IgnorePointer( + ignoring: ignoring, + child: Stack( + children: [ + // Scrim + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: close, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: ColoredBox( + color: scrim.withValues(alpha: 0.6 * t), + ), + ), + ), + // Panel (capture horizontal drags to close) + Positioned( + left: -_panelWidth * (1.0 - t), + top: 0, + bottom: 0, + width: _panelWidth, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: RepaintBoundary( + child: Material( + color: theme.surfaceBackground, + elevation: 8, + child: widget.drawer, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ], + ); + } +}