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.
This commit is contained in:
cogwheel0
2025-10-10 14:47:54 +05:30
parent fa32630868
commit fe1e03c198
2 changed files with 715 additions and 441 deletions

View File

@@ -8,6 +8,8 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import '../../../shared/widgets/slide_drawer.dart';
import '../../navigation/widgets/chats_drawer.dart';
import 'dart:async'; import 'dart:async';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../providers/chat_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/assistant_message_widget.dart' as assistant;
import '../widgets/streaming_title_text.dart'; import '../widgets/streaming_title_text.dart';
import '../widgets/file_attachment_widget.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/voice_input_service.dart';
import '../services/file_attachment_service.dart'; import '../services/file_attachment_service.dart';
import 'voice_call_page.dart'; import 'voice_call_page.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import '../../../shared/services/tasks/task_queue.dart'; import '../../../shared/services/tasks/task_queue.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
import '../../navigation/widgets/chats_drawer.dart';
import '../../../core/models/chat_message.dart'; import '../../../core/models/chat_message.dart';
import '../../../core/models/model.dart'; import '../../../core/models/model.dart';
import '../../../shared/widgets/loading_states.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 '../../../core/services/settings_service.dart';
import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/utils/conversation_context_menu.dart';
import '../../../shared/widgets/model_avatar.dart'; import '../../../shared/widgets/model_avatar.dart';
// Removed unused PlatformUtils import
import '../../../core/services/platform_service.dart' as ps; import '../../../core/services/platform_service.dart' as ps;
import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/gestures.dart' show DragStartBehavior;
@@ -1164,475 +1163,516 @@ class _ChatPageState extends ConsumerState<ChatPage> {
} }
} }
}, },
child: Scaffold( child: Builder(
backgroundColor: context.conduitTheme.surfaceBackground, builder: (outerCtx) {
// Left navigation drawer with draggable edge open (native, finger-following) final size = MediaQuery.of(outerCtx).size;
drawerEnableOpenDragGesture: true, final isTablet = size.shortestSide >= 600;
drawerDragStartBehavior: DragStartBehavior.down, final maxFraction = isTablet ? 0.42 : 0.84;
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5, final edgeFraction = isTablet ? 0.36 : 0.50; // large phone edge
drawerScrimColor: context.colorTokens.overlayStrong, final scrim = Platform.isIOS
drawer: Drawer( ? context.colorTokens.overlayMedium
width: (MediaQuery.of(context).size.width * 0.80).clamp( : context.colorTokens.overlayStrong;
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);
// Handle all async states properly return SlideDrawer(
if (modelsAsync.isLoading) { maxFraction: maxFraction,
// If still loading, wait for it to complete edgeFraction: edgeFraction,
try { settleFraction: 0.06, // even gentler settle for instant open feel
final models = await ref.read(modelsProvider.future); scrimColor: scrim,
// Check mounted and use context immediately together drawer: SafeArea(
if (!mounted) return; top: true,
// ignore: use_build_context_synchronously bottom: true,
_showModelDropdown(context, ref, models); left: false,
} catch (e) { right: false,
DebugLogger.error( child: const ChatsDrawer(),
'model-load-failed', ),
scope: 'chat/model-selector', child: Scaffold(
error: e, backgroundColor: context.conduitTheme.surfaceBackground,
); // Replace Scaffold drawer with a tunable slide drawer for gentler snap behavior.
} drawerEnableOpenDragGesture: false,
} else if (modelsAsync.hasValue) { drawerDragStartBehavior: DragStartBehavior.down,
// If we have data, show immediately (no async gap) appBar: AppBar(
_showModelDropdown(context, ref, modelsAsync.value!); backgroundColor: context.conduitTheme.surfaceBackground,
} else if (modelsAsync.hasError) { elevation: Elevation.none,
// If there's an error, try to refresh and load surfaceTintColor: Colors.transparent,
try { shadowColor: Colors.transparent,
ref.invalidate(modelsProvider); toolbarHeight: kToolbarHeight + 8,
final models = await ref.read(modelsProvider.future); centerTitle: true,
// Check mounted and use context immediately together titleSpacing: 0.0,
if (!mounted) return; leading: _isSelectionMode
// ignore: use_build_context_synchronously ? IconButton(
_showModelDropdown(context, ref, models); icon: Icon(
} catch (e) { Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
DebugLogger.error( color: context.conduitTheme.textPrimary,
'model-refresh-failed', size: IconSize.appBar,
scope: 'chat/model-selector', ),
error: e, onPressed: _clearSelection,
); )
} : Builder(
} builder: (ctx) => Padding(
}, padding: const EdgeInsets.only(
onLongPress: () { left: Spacing.inputPadding,
final conversation = ref.read(activeConversationProvider); ),
if (conversation == null) return; child: IconButton(
showConversationContextMenu( onPressed: () {
context: context, // Open slide drawer
ref: ref, SlideDrawer.of(ctx)?.open();
conversation: conversation, },
); icon: Icon(
}, Platform.isIOS
child: Column( ? CupertinoIcons.line_horizontal_3
mainAxisSize: MainAxisSize.min, : Icons.menu,
crossAxisAlignment: CrossAxisAlignment.center, color: context.conduitTheme.textPrimary,
children: [ size: IconSize.appBar,
AnimatedSwitcher( ),
duration: const Duration(milliseconds: 250), ),
switchInCurve: Curves.easeOutCubic, ),
switchOutCurve: Curves.easeInCubic, ),
child: displayConversationTitle != null title: _isSelectionMode
? Column( ? Text(
key: ValueKey<String>( '${_selectedMessageIds.length} selected',
displayConversationTitle, style: AppTypography.headlineSmallStyle.copyWith(
), color: context.conduitTheme.textPrimary,
mainAxisSize: MainAxisSize.min, fontWeight: FontWeight.w500,
children: [ ),
StreamingTitleText( )
title: displayConversationTitle, : GestureDetector(
style: AppTypography.headlineSmallStyle onTap: () async {
.copyWith( 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<String>(
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<String>('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 color: context
.conduitTheme .conduitTheme
.textPrimary, .surfaceBackground
fontWeight: FontWeight.w600, .withValues(alpha: 0.3),
fontSize: 18, borderRadius: BorderRadius.circular(
height: 1.3, AppBorderRadius.badge,
),
border: Border.all(
color: context
.conduitTheme
.dividerColor,
width: BorderWidth.thin,
),
), ),
cursorColor: context child: Icon(
.conduitTheme Platform.isIOS
.textPrimary ? CupertinoIcons.chevron_down
.withValues(alpha: 0.8), : Icons.keyboard_arrow_down,
), color: context
const SizedBox(height: Spacing.xs), .conduitTheme
], .iconSecondary,
) size: IconSize.small,
: const SizedBox.shrink( ),
key: ValueKey<String>('empty-title'), ),
), ),
), const SizedBox(width: Spacing.xs),
Transform.translate( Flexible(
offset: const Offset(0, 0), child: MiddleEllipsisText(
child: () { modelLabel,
final row = Row( style: modelTextStyle,
mainAxisAlignment: MainAxisAlignment.center, textAlign: TextAlign.center,
mainAxisSize: MainAxisSize.min, semanticsLabel: modelLabel,
children: [ ),
Opacity( ),
opacity: 0.0, 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( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: Spacing.xs, horizontal: Spacing.sm,
vertical: Spacing.xxs, vertical: 1.0,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context color: context.conduitTheme.success
.conduitTheme .withValues(alpha: 0.1),
.surfaceBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppBorderRadius.badge, AppBorderRadius.badge,
), ),
border: Border.all( border: Border.all(
color: color: context.conduitTheme.success
context.conduitTheme.dividerColor, .withValues(alpha: 0.3),
width: BorderWidth.thin, width: BorderWidth.thin,
), ),
), ),
child: Icon( child: Text(
Platform.isIOS 'REVIEWER MODE',
? CupertinoIcons.chevron_down style: AppTypography.captionStyle
: Icons.keyboard_arrow_down, .copyWith(
color: context.conduitTheme.iconSecondary, color: context.conduitTheme.success,
size: IconSize.small, 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),
), ),
), ),
), actions: [
), if (!_isSelectionMode) ...[
Padding(
// File attachments padding: const EdgeInsets.only(
const FileAttachmentWidget(), right: Spacing.inputPadding,
),
// Modern Input (root matches input background including safe area) child: IconButton(
RepaintBoundary( icon: Icon(
child: MeasureSize( Platform.isIOS
onChange: (size) { ? CupertinoIcons.create
if (mounted) { : Icons.add_comment,
setState(() { color: context.conduitTheme.textPrimary,
_inputHeight = size.height; size: IconSize.appBar,
}); ),
} onPressed: _handleNewChat,
}, tooltip: AppLocalizations.of(context)!.newChat,
child: ModernChatInput(
onSendMessage: (text) =>
_handleMessageSend(text, selectedModel),
onVoiceInput: null,
onVoiceCall: _handleVoiceCall,
onFileAttachment: _handleFileAttachment,
onImageAttachment: _handleImageAttachment,
onCameraCapture: () =>
_handleImageAttachment(fromCamera: true),
), ),
), ),
), ] 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 // Also refresh the conversations list to reconcile missed events
Positioned( // and keep timestamps/order in sync with the server.
bottom: try {
((_inputHeight > 0) refreshConversationsCache(ref);
? _inputHeight // Best-effort await to stabilize UI; ignore errors.
: (Spacing.xxl + Spacing.xxxl)) + await ref.read(conversationsProvider.future);
Spacing.sm, } catch (_) {}
left: 0,
right: 0, // Add small delay for better UX feedback
child: AnimatedSwitcher( await Future.delayed(
duration: AnimationDuration.microInteraction, const Duration(milliseconds: 300),
switchInCurve: AnimationCurves.microInteraction, );
switchOutCurve: AnimationCurves.microInteraction, },
transitionBuilder: (child, animation) { child: GestureDetector(
final slideAnimation = Tween<Offset>( behavior: HitTestBehavior.opaque,
begin: const Offset(0, 0.15), onTap: () {
end: Offset.zero, FocusManager.instance.primaryFocus?.unfocus();
).animate(animation); try {
return FadeTransition( SystemChannels.textInput.invokeMethod(
opacity: animation, 'TextInput.hide',
child: SlideTransition( );
position: slideAnimation, } catch (_) {}
child: child, },
), child: RepaintBoundary(
); child: _buildMessagesList(theme),
},
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'),
), ),
// 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<Offset>(
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 inside SlideDrawer
], );
), },
), ),
), // Scaffold
), // PopScope ), // PopScope
); // ErrorBoundary ); // ErrorBoundary
} }

View File

@@ -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<SlideDrawerState>();
@override
State<SlideDrawer> createState() => SlideDrawerState();
}
class SlideDrawerState extends State<SlideDrawer>
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<void> _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,
),
),
),
),
],
),
);
},
),
],
);
}
}