From dea53593bae985c55f7731e44a030835af848199 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:21:13 +0530 Subject: [PATCH] refactor: replace SlideDrawer with ResponsiveDrawerLayout for improved responsiveness - Updated ChatPage and ChatsDrawer to utilize ResponsiveDrawerLayout instead of SlideDrawer, enhancing the drawer's adaptability across devices. - Removed the SlideDrawer implementation, streamlining the codebase and improving maintainability. - Adjusted drawer opening and closing logic to align with the new layout structure, ensuring a smoother user experience on both mobile and tablet devices. --- lib/features/chat/views/chat_page.dart | 74 ++++++------ .../navigation/widgets/chats_drawer.dart | 5 +- ...wer.dart => responsive_drawer_layout.dart} | 110 +++++++++++++----- 3 files changed, 126 insertions(+), 63 deletions(-) rename lib/shared/widgets/{slide_drawer.dart => responsive_drawer_layout.dart} (73%) diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index f70db32..7fe556b 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -8,7 +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 '../../../shared/widgets/slide_drawer.dart'; +import '../../../shared/widgets/responsive_drawer_layout.dart'; import '../../navigation/widgets/chats_drawer.dart'; import 'dart:async'; import '../../../core/providers/app_providers.dart'; @@ -1201,13 +1201,14 @@ class _ChatPageState extends ConsumerState { ? context.colorTokens.overlayMedium : context.colorTokens.overlayStrong; - return SlideDrawer( + return ResponsiveDrawerLayout( maxFraction: maxFraction, edgeFraction: edgeFraction, settleFraction: 0.06, // even gentler settle for instant open feel scrimColor: scrim, contentScaleDelta: 0.0, contentBlurSigma: 0.0, + tabletDrawerWidth: 320.0, onOpenStart: () { // Suppress composer auto-focus once we unfocus for the drawer try { @@ -1245,38 +1246,41 @@ class _ChatPageState extends ConsumerState { ), onPressed: _clearSelection, ) - : Builder( - builder: (ctx) => Padding( - padding: const EdgeInsets.only( - left: Spacing.inputPadding, - ), - child: IconButton( - onPressed: () { - // Suppress auto-focus and dismiss keyboard, then open drawer - try { - ref - .read( - composerAutofocusEnabledProvider - .notifier, - ) - .set(false); - FocusManager.instance.primaryFocus?.unfocus(); - SystemChannels.textInput.invokeMethod( - 'TextInput.hide', - ); - } catch (_) {} - SlideDrawer.of(ctx)?.open(); - }, - icon: Icon( - Platform.isIOS - ? CupertinoIcons.line_horizontal_3 - : Icons.menu, - color: context.conduitTheme.textPrimary, - size: IconSize.appBar, - ), - ), - ), - ), + : (isTablet + ? null // Hide menu button on tablets (drawer is always visible) + : Builder( + builder: (ctx) => Padding( + padding: const EdgeInsets.only( + left: Spacing.inputPadding, + ), + child: IconButton( + onPressed: () { + // Suppress auto-focus and dismiss keyboard, then open drawer + try { + ref + .read( + composerAutofocusEnabledProvider + .notifier, + ) + .set(false); + FocusManager.instance.primaryFocus + ?.unfocus(); + SystemChannels.textInput.invokeMethod( + 'TextInput.hide', + ); + } catch (_) {} + ResponsiveDrawerLayout.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', @@ -1719,7 +1723,7 @@ class _ChatPageState extends ConsumerState { ], ), ), - ), // Scaffold inside SlideDrawer + ), // Scaffold inside ResponsiveDrawerLayout ); }, ), diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index d49b084..6c4ce59 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -22,7 +22,7 @@ import '../../../core/utils/user_avatar_utils.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/widgets/user_avatar.dart'; import '../../../shared/widgets/model_avatar.dart'; -import '../../../shared/widgets/slide_drawer.dart'; +import '../../../shared/widgets/responsive_drawer_layout.dart'; import '../../../core/models/model.dart'; import '../../../core/models/conversation.dart'; import '../../../core/models/folder.dart'; @@ -1388,8 +1388,9 @@ class _ChatsDrawerState extends ConsumerState { container.read(chat.chatMessagesProvider.notifier).clearMessages(); // Close the slide drawer for faster perceived performance + // (only on mobile; on tablet, drawer stays visible) if (mounted) { - SlideDrawer.of(context)?.close(); + ResponsiveDrawerLayout.of(context)?.close(); } // Load the full conversation details in the background diff --git a/lib/shared/widgets/slide_drawer.dart b/lib/shared/widgets/responsive_drawer_layout.dart similarity index 73% rename from lib/shared/widgets/slide_drawer.dart rename to lib/shared/widgets/responsive_drawer_layout.dart index cb02c7a..c74b40a 100644 --- a/lib/shared/widgets/slide_drawer.dart +++ b/lib/shared/widgets/responsive_drawer_layout.dart @@ -3,26 +3,31 @@ import 'package:flutter/services.dart'; import 'dart:ui' as ui; import '../../shared/theme/theme_extensions.dart'; -class SlideDrawer extends StatefulWidget { +/// A responsive layout that shows a persistent drawer on tablets (side-by-side) +/// and an overlay drawer on mobile devices. +/// +/// On tablets (shortestSide >= 600), the drawer is always visible alongside +/// the content. On mobile, it behaves like a standard slide drawer. +class ResponsiveDrawerLayout extends StatefulWidget { final Widget child; final Widget drawer; - final double maxFraction; // 0..1 of screen width + + // Mobile-specific configuration + final double maxFraction; // 0..1 of screen width for mobile drawer 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; - // Optional hook invoked right as opening begins (button or drag). final VoidCallback? onOpenStart; - const SlideDrawer({ + // Tablet-specific configuration + final double tabletDrawerWidth; // Fixed width for tablet drawer + + const ResponsiveDrawerLayout({ super.key, required this.child, required this.drawer, @@ -36,16 +41,17 @@ class SlideDrawer extends StatefulWidget { this.contentScaleDelta = 0.02, this.contentBlurSigma = 2.0, this.onOpenStart, + this.tabletDrawerWidth = 320.0, }); - static SlideDrawerState? of(BuildContext context) => - context.findAncestorStateOfType(); + static ResponsiveDrawerLayoutState? of(BuildContext context) => + context.findAncestorStateOfType(); @override - State createState() => SlideDrawerState(); + State createState() => ResponsiveDrawerLayoutState(); } -class SlideDrawerState extends State +class ResponsiveDrawerLayoutState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, @@ -53,6 +59,11 @@ class SlideDrawerState extends State value: 0.0, ); + bool _isTablet(BuildContext context) { + final size = MediaQuery.of(context).size; + return size.shortestSide >= 600; + } + double get _panelWidth => (MediaQuery.of(context).size.width * widget.maxFraction).clamp( 280.0, @@ -71,10 +82,8 @@ class SlideDrawerState extends State }) 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(); @@ -90,7 +99,9 @@ class SlideDrawerState extends State } void open({double velocity = 0.0}) { - // Notify caller and dismiss keyboard before animating open + // Only animate on mobile; on tablet, drawer is always visible + if (_isTablet(context)) return; + try { widget.onOpenStart?.call(); } catch (_) {} @@ -98,24 +109,32 @@ class SlideDrawerState extends State _animateTo(1.0, velocity: velocity); } - void close({double velocity = 0.0}) => - _animateTo(0.0, velocity: velocity, easeOut: true); - void toggle() => isOpen ? close() : open(); + void close({double velocity = 0.0}) { + // Only animate on mobile; on tablet, drawer is always visible + if (_isTablet(context)) return; + + _animateTo(0.0, velocity: velocity, easeOut: true); + } + + void toggle() { + // Only toggle on mobile; on tablet, drawer is always visible + if (_isTablet(context)) return; + + isOpen ? close() : open(); + } void _dismissKeyboard() { try { FocusManager.instance.primaryFocus?.unfocus(); SystemChannels.textInput.invokeMethod('TextInput.hide'); - } catch (_) { - // Best-effort: ignore platform channel errors. - } + } catch (_) {} } double _startValue = 0.0; void _onDragStart(DragStartDetails d) { - // Let drags from the open state be interactive rather than snapping. - // If starting to open from the edge, dismiss any active keyboard + if (_isTablet(context)) return; + if (_controller.value <= 0.001) { try { widget.onOpenStart?.call(); @@ -126,6 +145,8 @@ class SlideDrawerState extends State } void _onDragUpdate(DragUpdateDetails d) { + if (_isTablet(context)) return; + final delta = d.primaryDelta ?? 0.0; final next = (_startValue + delta / _panelWidth).clamp(0.0, 1.0); _controller.value = next; @@ -133,9 +154,10 @@ class SlideDrawerState extends State } void _onDragEnd(DragEndDetails d) { + if (_isTablet(context)) return; + final vx = d.primaryVelocity ?? 0.0; final vMag = vx.abs(); - // Fling assistance first. if (vMag > 300.0) { if (vx > 0) { open(velocity: vMag); @@ -144,7 +166,6 @@ class SlideDrawerState extends State } return; } - // Gentle settle threshold (less aggressive snap-back). if (_controller.value >= widget.settleFraction) { open(velocity: vMag); } else { @@ -156,7 +177,38 @@ class SlideDrawerState extends State Widget build(BuildContext context) { final theme = context.conduitTheme; final scrim = widget.scrimColor ?? context.colorTokens.overlayStrong; + final isTablet = _isTablet(context); + if (isTablet) { + // Tablet layout: persistent side-by-side + return _buildTabletLayout(theme); + } else { + // Mobile layout: overlay drawer + return _buildMobileLayout(theme, scrim); + } + } + + Widget _buildTabletLayout(ConduitThemeExtension theme) { + return Row( + children: [ + // Persistent drawer + Container( + width: widget.tabletDrawerWidth, + decoration: BoxDecoration( + color: theme.surfaceBackground, + border: Border( + right: BorderSide(color: theme.dividerColor, width: 1), + ), + ), + child: widget.drawer, + ), + // Content + Expanded(child: widget.child), + ], + ); + } + + Widget _buildMobileLayout(ConduitThemeExtension theme, Color scrim) { return Stack( children: [ // Content (optionally pushed by the drawer) @@ -166,7 +218,7 @@ class SlideDrawerState extends State builder: (context, _) { final t = _controller.value; final dx = (widget.pushContent ? _panelWidth * t : 0.0) - .roundToDouble(); // snap to pixel to avoid jitter + .roundToDouble(); final scale = 1.0 - (widget.pushContent @@ -266,4 +318,10 @@ class SlideDrawerState extends State ], ); } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } }