Files
iiEsaywebUIapp/lib/shared/widgets/slide_drawer.dart
cogwheel0 e73c5ee93a feat: add composer autofocus management for improved chat input experience
- Introduced ComposerAutofocusEnabled provider to manage the auto-focus state of the chat composer, allowing for better control over user interactions.
- Updated ModernChatInput to respect the autofocus setting, ensuring the keyboard behavior aligns with user intent and context.
- Enhanced ChatPage to suppress auto-focus when opening the slide drawer, improving user experience during navigation.
- Refactored SlideDrawer to include an onOpenStart callback for dismissing the keyboard, ensuring a smoother transition when the drawer is opened.
2025-10-10 15:22:54 +05:30

270 lines
8.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.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;
// Optional hook invoked right as opening begins (button or drag).
final VoidCallback? onOpenStart;
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,
this.onOpenStart,
});
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,
bool? easeOut,
}) 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 bool useEaseOut = easeOut ?? (target > current);
final curve = useEaseOut
? (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}) {
// Notify caller and dismiss keyboard before animating open
try {
widget.onOpenStart?.call();
} catch (_) {}
_dismissKeyboard();
_animateTo(1.0, velocity: velocity);
}
void close({double velocity = 0.0}) =>
_animateTo(0.0, velocity: velocity, easeOut: true);
void toggle() => isOpen ? close() : open();
void _dismissKeyboard() {
try {
FocusManager.instance.primaryFocus?.unfocus();
SystemChannels.textInput.invokeMethod('TextInput.hide');
} catch (_) {
// Best-effort: ignore platform channel errors.
}
}
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 (_controller.value <= 0.001) {
try {
widget.onOpenStart?.call();
} catch (_) {}
_dismissKeyboard();
}
_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,
),
),
),
),
],
),
);
},
),
],
);
}
}