diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 51d85d4..3453ccb 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -1331,41 +1331,44 @@ class _ChatPageState extends ConsumerState { ), onPressed: _clearSelection, ) - : (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, - ), - ), - ), - )), + : Builder( + builder: (ctx) => Padding( + padding: const EdgeInsets.only( + left: Spacing.inputPadding, + ), + child: IconButton( + onPressed: () { + final layout = ResponsiveDrawerLayout.of(ctx); + if (layout == null) return; + + final isDrawerOpen = layout.isOpen; + if (!isDrawerOpen) { + try { + ref + .read( + composerAutofocusEnabledProvider + .notifier, + ) + .set(false); + FocusManager.instance.primaryFocus + ?.unfocus(); + SystemChannels.textInput.invokeMethod( + 'TextInput.hide', + ); + } catch (_) {} + } + layout.toggle(); + }, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.line_horizontal_3 + : Icons.menu, + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + ), + ), + ), title: _isSelectionMode ? Text( '${_selectedMessageIds.length} selected', @@ -1374,225 +1377,301 @@ class _ChatPageState extends ConsumerState { fontWeight: FontWeight.w500, ), ) - : GestureDetector( - onTap: () async { - final modelsAsync = ref.read(modelsProvider); + : LayoutBuilder( + builder: (context, constraints) { + return 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, + // 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, ); - // 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, + if (conversation == null) return; + showConversationContextMenu( + context: context, + ref: ref, + conversation: conversation, ); - } - } 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, + }, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + AnimatedSwitcher( + duration: const Duration( + milliseconds: 250, ), - mainAxisSize: MainAxisSize.min, - children: [ - StreamingTitleText( - title: displayConversationTitle, - style: AppTypography - .headlineSmallStyle - .copyWith( + 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: () { + const double iconPaddingX = + Spacing.xs; + const double iconPaddingY = + Spacing.xxs; + const double iconWidth = + IconSize.small; + const double iconBoxWidth = + (iconPaddingX * 2) + + (BorderWidth.thin * 2) + + iconWidth; + final double maxLabelWidth = + (constraints.maxWidth - + (iconBoxWidth * 2) - + (Spacing.xs * 2)) + .clamp( + 48.0, + constraints.maxWidth, + ); + + final row = Row( + mainAxisAlignment: + MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: 0.0, + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: + iconPaddingX, + vertical: iconPaddingY, + ), + 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: iconWidth, + ), + ), + ), + const SizedBox(width: Spacing.xs), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxLabelWidth, + ), + child: MiddleEllipsisText( + modelLabel, + style: modelTextStyle, + textAlign: TextAlign.center, + semanticsLabel: modelLabel, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: + const EdgeInsets.symmetric( + horizontal: iconPaddingX, + vertical: iconPaddingY, + ), + 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: 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, + child: Icon( + Platform.isIOS + ? CupertinoIcons + .chevron_down + : Icons + .keyboard_arrow_down, + color: context + .conduitTheme + .iconSecondary, + size: iconWidth, + ), + ), + ], + ); + final constrainedRow = ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth, ), - border: Border.all( + child: row, + ); + return hasConversationTitle + ? SizedBox( + height: 24, + child: constrainedRow, + ) + : constrainedRow; + }(), + ), + 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 - .dividerColor, - width: BorderWidth.thin, + .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, + ), ), ), - 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.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) ...[ diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 60e9fea..18cdebd 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -1438,9 +1438,14 @@ 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) + // (only on mobile; keep tablet drawer unless user toggles it) if (mounted) { - ResponsiveDrawerLayout.of(context)?.close(); + final mediaQuery = MediaQuery.maybeOf(context); + final isTablet = + mediaQuery != null && mediaQuery.size.shortestSide >= 600; + if (!isTablet) { + ResponsiveDrawerLayout.of(context)?.close(); + } } // Load the full conversation details in the background diff --git a/lib/shared/widgets/responsive_drawer_layout.dart b/lib/shared/widgets/responsive_drawer_layout.dart index c74b40a..27b7422 100644 --- a/lib/shared/widgets/responsive_drawer_layout.dart +++ b/lib/shared/widgets/responsive_drawer_layout.dart @@ -8,6 +8,7 @@ import '../../shared/theme/theme_extensions.dart'; /// /// On tablets (shortestSide >= 600), the drawer is always visible alongside /// the content. On mobile, it behaves like a standard slide drawer. +/// Tablets can optionally dismiss the docked drawer to reclaim space. class ResponsiveDrawerLayout extends StatefulWidget { final Widget child; final Widget drawer; @@ -26,6 +27,8 @@ class ResponsiveDrawerLayout extends StatefulWidget { // Tablet-specific configuration final double tabletDrawerWidth; // Fixed width for tablet drawer + final bool tabletDismissible; + final bool tabletInitiallyDocked; const ResponsiveDrawerLayout({ super.key, @@ -42,6 +45,8 @@ class ResponsiveDrawerLayout extends StatefulWidget { this.contentBlurSigma = 2.0, this.onOpenStart, this.tabletDrawerWidth = 320.0, + this.tabletDismissible = true, + this.tabletInitiallyDocked = true, }); static ResponsiveDrawerLayoutState? of(BuildContext context) => @@ -58,6 +63,7 @@ class ResponsiveDrawerLayoutState extends State duration: widget.duration, value: 0.0, ); + late bool _isTabletDocked = widget.tabletInitiallyDocked; bool _isTablet(BuildContext context) { final size = MediaQuery.of(context).size; @@ -73,7 +79,20 @@ class ResponsiveDrawerLayoutState extends State double get _edgeWidth => MediaQuery.of(context).size.width * widget.edgeFraction; - bool get isOpen => _controller.value == 1.0; + bool get isOpen => + _isTablet(context) ? _isTabletDocked : _controller.value == 1.0; + + @override + void didUpdateWidget(covariant ResponsiveDrawerLayout oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.tabletDismissible && !_isTabletDocked) { + setState(() => _isTabletDocked = true); + } else if (widget.tabletInitiallyDocked != + oldWidget.tabletInitiallyDocked && + _isTablet(context)) { + setState(() => _isTabletDocked = widget.tabletInitiallyDocked); + } + } Future _animateTo( double target, { @@ -99,8 +118,12 @@ class ResponsiveDrawerLayoutState extends State } void open({double velocity = 0.0}) { - // Only animate on mobile; on tablet, drawer is always visible - if (_isTablet(context)) return; + if (_isTablet(context)) { + if (!_isTabletDocked) { + setState(() => _isTabletDocked = true); + } + return; + } try { widget.onOpenStart?.call(); @@ -110,15 +133,23 @@ class ResponsiveDrawerLayoutState extends State } void close({double velocity = 0.0}) { - // Only animate on mobile; on tablet, drawer is always visible - if (_isTablet(context)) return; + if (_isTablet(context)) { + if (!widget.tabletDismissible) return; + if (_isTabletDocked) { + setState(() => _isTabletDocked = false); + } + return; + } _animateTo(0.0, velocity: velocity, easeOut: true); } void toggle() { - // Only toggle on mobile; on tablet, drawer is always visible - if (_isTablet(context)) return; + if (_isTablet(context)) { + if (!widget.tabletDismissible) return; + setState(() => _isTabletDocked = !_isTabletDocked); + return; + } isOpen ? close() : open(); } @@ -189,18 +220,28 @@ class ResponsiveDrawerLayoutState extends State } Widget _buildTabletLayout(ConduitThemeExtension theme) { + final targetWidth = widget.tabletDismissible && !_isTabletDocked + ? 0.0 + : widget.tabletDrawerWidth; return Row( children: [ // Persistent drawer - Container( - width: widget.tabletDrawerWidth, + AnimatedContainer( + duration: widget.duration, + curve: widget.curve, + width: targetWidth, decoration: BoxDecoration( color: theme.surfaceBackground, border: Border( right: BorderSide(color: theme.dividerColor, width: 1), ), ), - child: widget.drawer, + child: ClipRect( + child: IgnorePointer( + ignoring: widget.tabletDismissible && !_isTabletDocked, + child: widget.drawer, + ), + ), ), // Content Expanded(child: widget.child),