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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
234
lib/shared/widgets/slide_drawer.dart
Normal file
234
lib/shared/widgets/slide_drawer.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user