initial commit: LeafViewer, LeafZoomable, LeafContextMenu, LeafPopover, LeafImage, misc widgets + tests
This commit is contained in:
commit
9bb0ff9187
156 files changed
+9887
No files matched your search
@@ -0,0 +1,12 @@
|
||||
/// Leaf UI widgets — zero external dependencies.
|
||||
library;
|
||||
|
||||
export 'src/context_menu/leaf_context_menu.dart';
|
||||
export 'src/image/leaf_image.dart';
|
||||
export 'src/misc/animated_favorite_button.dart';
|
||||
export 'src/misc/animated_star.dart';
|
||||
export 'src/misc/leaf_async_chip.dart';
|
||||
export 'src/popover/leaf_popover.dart';
|
||||
export 'src/viewer/leaf_viewer.dart';
|
||||
export 'src/viewer/leaf_zoom_controller.dart';
|
||||
export 'src/viewer/leaf_zoomable.dart';
|
||||
@@ -0,0 +1,572 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Base class for entries in a [LeafContextMenu].
|
||||
sealed class ContextMenuEntry {
|
||||
const ContextMenuEntry();
|
||||
}
|
||||
|
||||
/// A tappable menu action.
|
||||
///
|
||||
/// Layout: `[icon] label [shortcut]` with an optional subtitle below the label.
|
||||
class ContextMenuAction extends ContextMenuEntry {
|
||||
/// Label text. Required.
|
||||
final String label;
|
||||
|
||||
/// Optional leading icon.
|
||||
final IconData? icon;
|
||||
|
||||
/// Optional subtitle shown below the label.
|
||||
final String? subtitle;
|
||||
|
||||
/// Optional shortcut hint shown right-aligned (e.g. `Ctrl+C`).
|
||||
final String? shortcut;
|
||||
|
||||
/// Called when the action is tapped.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Whether the action is interactive. Defaults to true.
|
||||
final bool enabled;
|
||||
|
||||
/// When true the label and icon are rendered in the error colour.
|
||||
final bool destructive;
|
||||
|
||||
const ContextMenuAction({
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.subtitle,
|
||||
this.shortcut,
|
||||
this.onTap,
|
||||
this.enabled = true,
|
||||
this.destructive = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// A visual separator between groups of actions.
|
||||
class ContextMenuDivider extends ContextMenuEntry {
|
||||
const ContextMenuDivider();
|
||||
}
|
||||
|
||||
/// A menu action that runs an async [task] and reports its progress and
|
||||
/// result in place, inside the menu item.
|
||||
///
|
||||
/// While the task runs the item's icon becomes a spinner and its label
|
||||
/// switches to [runningLabel] (with a thin determinate progress bar when the
|
||||
/// task reports progress via [task]'s callback); the rest of the menu is
|
||||
/// locked (other items disabled, outside taps and Escape ignored) until the
|
||||
/// task completes. On success the item shows a check + [doneLabel] for
|
||||
/// [doneDuration], on failure an error icon + [errorLabel] for
|
||||
/// [errorDuration]; then the menu dismisses itself.
|
||||
class ContextMenuAsyncAction extends ContextMenuAction {
|
||||
/// The async task. [onProgress] reports 0..1 when the total is known;
|
||||
/// never calling it keeps the spinner indeterminate.
|
||||
final Future<void> Function(ValueChanged<double>? onProgress) task;
|
||||
|
||||
/// Label shown while [task] runs.
|
||||
final String runningLabel;
|
||||
|
||||
/// Label shown briefly on success.
|
||||
final String doneLabel;
|
||||
|
||||
/// Label shown briefly on failure.
|
||||
final String errorLabel;
|
||||
|
||||
/// How long the success state stays visible before the menu dismisses.
|
||||
final Duration doneDuration;
|
||||
|
||||
/// How long the error state stays visible before the menu dismisses.
|
||||
final Duration errorDuration;
|
||||
|
||||
/// Called with the task's error + stack trace when it throws. The error is
|
||||
/// always debugPrinted regardless.
|
||||
final void Function(Object error, StackTrace stackTrace)? onError;
|
||||
|
||||
const ContextMenuAsyncAction({
|
||||
required super.label,
|
||||
required this.task,
|
||||
super.icon,
|
||||
super.subtitle,
|
||||
super.shortcut,
|
||||
super.enabled,
|
||||
super.destructive,
|
||||
this.runningLabel = 'Copying…',
|
||||
this.doneLabel = 'Copied',
|
||||
this.errorLabel = 'Failed',
|
||||
this.doneDuration = const Duration(milliseconds: 700),
|
||||
this.errorDuration = const Duration(milliseconds: 1400),
|
||||
this.onError,
|
||||
}) : super(onTap: null);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LeafContextMenu
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A Material-styled context menu that opens on right-click (desktop) or
|
||||
/// long-press (touch).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// LeafContextMenu(
|
||||
/// entries: [
|
||||
/// const ContextMenuAction(label: 'Copy', icon: Icons.copy, onTap: _onCopy),
|
||||
/// const ContextMenuDivider(),
|
||||
/// const ContextMenuAction(label: 'Delete', icon: Icons.delete, destructive: true, onTap: _onDelete),
|
||||
/// ],
|
||||
/// child: MyWidget(...),
|
||||
/// )
|
||||
/// ```
|
||||
class LeafContextMenu extends StatefulWidget {
|
||||
/// The menu entries ([ContextMenuAction]s and [ContextMenuDivider]s).
|
||||
final List<ContextMenuEntry> entries;
|
||||
|
||||
/// The widget that acts as the trigger area.
|
||||
final Widget child;
|
||||
|
||||
/// Optional callback fired when the menu is dismissed (by action, outside
|
||||
/// tap, or Escape).
|
||||
final VoidCallback? onDismissed;
|
||||
|
||||
const LeafContextMenu({required this.entries, required this.child, this.onDismissed, super.key});
|
||||
|
||||
@override
|
||||
State<LeafContextMenu> createState() => _LeafContextMenuState();
|
||||
}
|
||||
|
||||
class _LeafContextMenuState extends State<LeafContextMenu> {
|
||||
final _overlayController = OverlayPortalController();
|
||||
Offset? _globalPosition;
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
static bool get _isTouchDevice =>
|
||||
defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.fuchsia;
|
||||
|
||||
void _showAtGlobal(Offset globalPosition) {
|
||||
_globalPosition = globalPosition;
|
||||
setState(() {});
|
||||
_overlayController.show();
|
||||
}
|
||||
|
||||
void _hide() {
|
||||
_globalPosition = null;
|
||||
_overlayController.hide();
|
||||
widget.onDismissed?.call();
|
||||
}
|
||||
|
||||
void _onPrimaryTap(PointerEvent e) {
|
||||
// Hide only when the menu is already visible and a primary click happens
|
||||
// on the trigger (acts as a toggle-off).
|
||||
if (_overlayController.isShowing) {
|
||||
_hide();
|
||||
}
|
||||
}
|
||||
|
||||
// ── build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = widget.child;
|
||||
|
||||
// Right-click (desktop) / secondary button.
|
||||
child = Listener(
|
||||
onPointerDown: (e) {
|
||||
if (e.buttons & kSecondaryButton != 0) {
|
||||
_showAtGlobal(e.position);
|
||||
} else if (e.buttons & kPrimaryButton != 0) {
|
||||
_onPrimaryTap(e);
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
|
||||
// Long-press (touch).
|
||||
if (_isTouchDevice) {
|
||||
child = GestureDetector(
|
||||
onLongPressStart: (details) => _showAtGlobal(details.globalPosition),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return OverlayPortal(
|
||||
controller: _overlayController,
|
||||
overlayChildBuilder: (_) {
|
||||
if (_globalPosition == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _ContextMenuOverlay(globalPosition: _globalPosition!, entries: widget.entries, onDismiss: _hide);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// _ContextMenuOverlay
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _ContextMenuOverlay extends StatefulWidget {
|
||||
final Offset globalPosition;
|
||||
final List<ContextMenuEntry> entries;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const _ContextMenuOverlay({required this.globalPosition, required this.entries, required this.onDismiss});
|
||||
|
||||
@override
|
||||
State<_ContextMenuOverlay> createState() => _ContextMenuOverlayState();
|
||||
}
|
||||
|
||||
class _ContextMenuOverlayState extends State<_ContextMenuOverlay> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _animCtrl;
|
||||
late final Animation<double> _fade;
|
||||
late final Animation<double> _scale;
|
||||
|
||||
/// Key attached to the menu Card so we can measure its actual size.
|
||||
final _menuKey = GlobalKey();
|
||||
|
||||
/// Adjusted position after overflow detection.
|
||||
Offset? _adjustedPos;
|
||||
|
||||
/// Whether the exit animation is running.
|
||||
bool _dismissing = false;
|
||||
|
||||
/// Whether an async action is currently running. While true, outside taps
|
||||
/// and Escape are ignored and all other items are disabled.
|
||||
bool _busy = false;
|
||||
|
||||
// ── lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animCtrl = AnimationController(duration: const Duration(milliseconds: 100), vsync: this);
|
||||
|
||||
_fade = CurvedAnimation(parent: _animCtrl, curve: Curves.linear);
|
||||
_scale = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOutCubic);
|
||||
|
||||
_animCtrl.addStatusListener(_onStatusChanged);
|
||||
// Start entrance.
|
||||
_animCtrl.forward();
|
||||
|
||||
// Measure and adjust position after first frame.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _adjustPosition());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animCtrl.removeStatusListener(_onStatusChanged);
|
||||
_animCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onStatusChanged(AnimationStatus status) {
|
||||
if (status == AnimationStatus.dismissed && _dismissing) {
|
||||
widget.onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// ── positioning ───────────────────────────────────────────────────────────
|
||||
|
||||
void _adjustPosition() {
|
||||
final menuBox = _menuKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (menuBox == null || !menuBox.hasSize) return;
|
||||
|
||||
final menuSize = menuBox.size;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final screen = mediaQuery.size;
|
||||
final padding = mediaQuery.padding;
|
||||
|
||||
var x = widget.globalPosition.dx;
|
||||
var y = widget.globalPosition.dy;
|
||||
|
||||
// Horizontal flip.
|
||||
if (x + menuSize.width > screen.width) {
|
||||
x = screen.width - menuSize.width - padding.right;
|
||||
}
|
||||
// Vertical flip.
|
||||
if (y + menuSize.height > screen.height) {
|
||||
y = screen.height - menuSize.height - padding.bottom;
|
||||
}
|
||||
|
||||
// Ensure minimum edge padding.
|
||||
x = x.clamp(padding.left, screen.width - menuSize.width - padding.right);
|
||||
y = y.clamp(padding.top, screen.height - menuSize.height - padding.bottom);
|
||||
|
||||
if (_adjustedPos == null || _adjustedPos!.dx != x || _adjustedPos!.dy != y) {
|
||||
setState(() => _adjustedPos = Offset(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
void _setBusy(bool busy) {
|
||||
if (_busy != busy) setState(() => _busy = busy);
|
||||
}
|
||||
|
||||
// ── dismiss ───────────────────────────────────────────────────────────────
|
||||
|
||||
void _dismiss() {
|
||||
if (_dismissing) return;
|
||||
_dismissing = true;
|
||||
_animCtrl.reverse();
|
||||
}
|
||||
|
||||
// ── build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pos = _adjustedPos ?? widget.globalPosition;
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Invisible barrier. Listener catches ALL pointer events (left,
|
||||
// right, middle click). Menu items (above in the stack) take
|
||||
// priority via InkWell gesture absorption. Ignored while an async
|
||||
// action is running so its feedback isn't cut short.
|
||||
Positioned.fill(
|
||||
child: Listener(
|
||||
onPointerDown: (_) {
|
||||
if (!_busy) _dismiss();
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// The menu itself.
|
||||
Positioned(
|
||||
left: pos.dx,
|
||||
top: pos.dy,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||||
if (!_busy) _dismiss();
|
||||
},
|
||||
},
|
||||
child: FocusScope(
|
||||
child: FadeTransition(
|
||||
opacity: Tween<double>(begin: 0, end: 1).animate(_fade),
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.93, end: 1).animate(_scale),
|
||||
child: Card(
|
||||
key: _menuKey,
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
color: cs.surfaceContainerHighest,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _buildEntries(theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildEntries(ThemeData theme) {
|
||||
final children = <Widget>[];
|
||||
for (final entry in widget.entries) {
|
||||
switch (entry) {
|
||||
case ContextMenuAction action:
|
||||
children.add(_ContextMenuActionWidget(
|
||||
action: action,
|
||||
busy: _busy,
|
||||
onBusyChanged: _setBusy,
|
||||
onDismiss: _dismiss,
|
||||
));
|
||||
case ContextMenuDivider _:
|
||||
children.add(Divider(height: 1, thickness: 1, color: theme.dividerColor));
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// _ContextMenuActionWidget
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum _AsyncActionState { idle, running, done, error }
|
||||
|
||||
class _ContextMenuActionWidget extends StatefulWidget {
|
||||
final ContextMenuAction action;
|
||||
|
||||
/// Whether some async action is currently running (this one or another).
|
||||
/// While true, this item is disabled.
|
||||
final bool busy;
|
||||
|
||||
final ValueChanged<bool> onBusyChanged;
|
||||
|
||||
/// Dismisses the menu (with its exit animation).
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const _ContextMenuActionWidget({
|
||||
required this.action,
|
||||
required this.busy,
|
||||
required this.onBusyChanged,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ContextMenuActionWidget> createState() => _ContextMenuActionWidgetState();
|
||||
}
|
||||
|
||||
class _ContextMenuActionWidgetState extends State<_ContextMenuActionWidget> {
|
||||
_AsyncActionState _state = _AsyncActionState.idle;
|
||||
double? _progress;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
ContextMenuAsyncAction? get _async =>
|
||||
widget.action is ContextMenuAsyncAction ? widget.action as ContextMenuAsyncAction : null;
|
||||
|
||||
Future<void> _runAsync() async {
|
||||
final async = _async!;
|
||||
if (_state == _AsyncActionState.running) return;
|
||||
_timer?.cancel();
|
||||
setState(() {
|
||||
_state = _AsyncActionState.running;
|
||||
_progress = null;
|
||||
});
|
||||
widget.onBusyChanged(true);
|
||||
try {
|
||||
await async.task((p) {
|
||||
if (mounted) setState(() => _progress = p.clamp(0.0, 1.0));
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => _state = _AsyncActionState.done);
|
||||
widget.onBusyChanged(false);
|
||||
_timer = Timer(async.doneDuration, widget.onDismiss);
|
||||
} catch (e, st) {
|
||||
debugPrint('ContextMenuAsyncAction "${async.label}" failed: $e\n$st');
|
||||
if (!mounted) return;
|
||||
setState(() => _state = _AsyncActionState.error);
|
||||
widget.onBusyChanged(false);
|
||||
_timer = Timer(async.errorDuration, widget.onDismiss);
|
||||
async.onError?.call(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final text = theme.textTheme;
|
||||
final action = widget.action;
|
||||
final async = _async;
|
||||
final running = async != null && _state == _AsyncActionState.running;
|
||||
final done = async != null && _state == _AsyncActionState.done;
|
||||
final failed = async != null && _state == _AsyncActionState.error;
|
||||
final interactive = action.enabled && !widget.busy;
|
||||
|
||||
final foreground = failed
|
||||
? cs.error
|
||||
: running
|
||||
? cs.onSurface
|
||||
: done
|
||||
? cs.onSurface
|
||||
: action.destructive
|
||||
? cs.error
|
||||
: interactive
|
||||
? cs.onSurface
|
||||
: cs.onSurface.withValues(alpha: 0.38);
|
||||
|
||||
final label = running
|
||||
? async.runningLabel
|
||||
: done
|
||||
? async.doneLabel
|
||||
: failed
|
||||
? async.errorLabel
|
||||
: action.label;
|
||||
|
||||
final VoidCallback? onTap = async != null
|
||||
? (_state == _AsyncActionState.running ? null : interactive ? _runAsync : null)
|
||||
: (interactive
|
||||
? () {
|
||||
action.onTap?.call();
|
||||
widget.onDismiss();
|
||||
}
|
||||
: null);
|
||||
|
||||
final leading = switch ((running, done, failed)) {
|
||||
(true, _, _) => const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
(_, true, _) => const Icon(Icons.check, size: 18, color: Color(0xFF7CF29C)),
|
||||
(_, _, true) => Icon(Icons.error_outline, size: 18, color: cs.error),
|
||||
_ => action.icon == null ? null : Icon(action.icon, size: 18, color: foreground),
|
||||
};
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (leading != null) ...[leading, const SizedBox(width: 12)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: text.bodyLarge?.copyWith(color: foreground)),
|
||||
if (action.subtitle != null)
|
||||
Text(action.subtitle!, style: text.bodySmall?.copyWith(color: cs.onSurface.withValues(alpha: 0.6))),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (action.shortcut != null) ...[
|
||||
const SizedBox(width: 24),
|
||||
Text(action.shortcut!, style: text.bodySmall?.copyWith(color: cs.onSurface.withValues(alpha: 0.5))),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (running && _progress != null)
|
||||
LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 2,
|
||||
color: cs.primary,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Reveal Animation Interface
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Pluggable reveal effect that plays when an image finishes loading.
|
||||
///
|
||||
/// Implementors own an [AnimationController] with the given [duration] and
|
||||
/// return the wrapped [child] from [build].
|
||||
abstract class LeafRevealAnimation {
|
||||
const LeafRevealAnimation();
|
||||
|
||||
/// How long the reveal animation takes.
|
||||
Duration get duration;
|
||||
|
||||
/// Wrap [child] (the loaded image) with the reveal effect.
|
||||
///
|
||||
/// [controller] is pre-configured with [duration] and will be
|
||||
/// `.forward()`-ed by [LeafImage] when the first frame arrives.
|
||||
Widget build(BuildContext context, Widget child, AnimationController controller);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Built-in Animations
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Sweeps a gradient mask across the image from left-to-right (or top-to-bottom).
|
||||
class GradientRevealAnimation extends LeafRevealAnimation {
|
||||
/// Direction of the sweep.
|
||||
final Axis direction;
|
||||
|
||||
/// Width of the gradient transition zone as a fraction of the image width.
|
||||
final double window;
|
||||
|
||||
/// Final position of the trailing gradient stop (1.0 = fully revealed).
|
||||
final double finalStop2;
|
||||
|
||||
@override
|
||||
final Duration duration;
|
||||
|
||||
/// Animation curve.
|
||||
final Curve curve;
|
||||
|
||||
const GradientRevealAnimation({
|
||||
this.direction = Axis.horizontal,
|
||||
this.window = 0.3,
|
||||
this.finalStop2 = 1.3,
|
||||
this.duration = const Duration(milliseconds: 400),
|
||||
this.curve = Curves.fastLinearToSlowEaseIn,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, Widget child, AnimationController controller) {
|
||||
return AnimatedBuilder(
|
||||
animation: CurvedAnimation(parent: controller, curve: curve),
|
||||
builder: (context, child) {
|
||||
final t = controller.value;
|
||||
final startStop1 = -window;
|
||||
final startStop2 = 0.0;
|
||||
final stop1 = lerpDouble(startStop1, finalStop2 - window, t)!;
|
||||
final stop2 = lerpDouble(startStop2, finalStop2, t)!;
|
||||
return ShaderMask(
|
||||
shaderCallback: (bounds) => LinearGradient(
|
||||
begin: direction == Axis.horizontal ? Alignment.centerLeft : Alignment.topCenter,
|
||||
end: direction == Axis.horizontal ? Alignment.centerRight : Alignment.bottomCenter,
|
||||
colors: const [Colors.white, Colors.transparent],
|
||||
stops: [stop1, stop2],
|
||||
).createShader(bounds),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple opacity fade-in.
|
||||
class FadeRevealAnimation extends LeafRevealAnimation {
|
||||
@override
|
||||
final Duration duration;
|
||||
final Curve curve;
|
||||
|
||||
const FadeRevealAnimation({this.duration = const Duration(milliseconds: 300), this.curve = Curves.easeIn});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, Widget child, AnimationController controller) {
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(parent: controller, curve: curve),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// LeafImage
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Configurable image widget with pluggable reveal animations, built-in
|
||||
/// interactivity, loading skeleton support, and one-tap retry on error.
|
||||
///
|
||||
/// Wraps Flutter's [Image] and manages its three builders internally so you
|
||||
/// never have to write `Stack` + `Material` + `InkWell` + `loadingBuilder` +
|
||||
/// `errorBuilder` yourself.
|
||||
///
|
||||
/// ```dart
|
||||
/// LeafImage(
|
||||
/// image: KemonoImageProvider(url, creatorId: id, postId: pid),
|
||||
/// fit: BoxFit.cover,
|
||||
/// loadingBuilder: (ctx, progress) => SkeletonTile(progress: progress),
|
||||
/// onTap: () => FullscreenImageViewerScreen.open(...),
|
||||
/// revealAnimation: const GradientRevealAnimation(),
|
||||
/// )
|
||||
/// ```
|
||||
class LeafImage extends StatefulWidget {
|
||||
// ── Required ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// The source image.
|
||||
final ImageProvider image;
|
||||
|
||||
// ── Standard Image passthrough ────────────────────────────────────────────
|
||||
|
||||
final BoxFit? fit;
|
||||
final AlignmentGeometry alignment;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final int? cacheWidth;
|
||||
final int? cacheHeight;
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Called with a [progress] value in `[0.0, 1.0]` while the image downloads.
|
||||
///
|
||||
/// This is the right place for loading skeletons — the skeleton fills the
|
||||
/// same layout space the final image will occupy.
|
||||
///
|
||||
/// When `null`, no custom loading state is shown and Flutter's default
|
||||
/// behaviour applies.
|
||||
final Widget Function(BuildContext context, double progress)? loadingBuilder;
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Called when the image fails to load.
|
||||
///
|
||||
/// The [retry] callback bumps an internal key so the image reloads from
|
||||
/// scratch.
|
||||
///
|
||||
/// When `null`, a default grey box with a refresh icon is shown.
|
||||
final Widget Function(BuildContext context, Object error, VoidCallback retry)? errorBuilder;
|
||||
|
||||
// ── Reveal ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Animation that plays once when the first image frame is decoded.
|
||||
///
|
||||
/// Defaults to [GradientRevealAnimation] when `null`.
|
||||
final LeafRevealAnimation? revealAnimation;
|
||||
|
||||
// ── Decoration ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Wraps the loaded image (e.g. with [ClipRRect] or [DecoratedBox]).
|
||||
///
|
||||
/// This runs **before** the reveal animation, so the clip applies to both
|
||||
/// the image and its reveal effect.
|
||||
final Widget Function(BuildContext context, Widget child)? imageBuilder;
|
||||
|
||||
// ── Interactivity ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Tapping the loaded image area.
|
||||
///
|
||||
/// Applying a handler wraps the image in a transparent [Material] +
|
||||
/// [InkWell] so you get the standard ripple/splash behaviour without
|
||||
/// needing an external `Stack`.
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final VoidCallback? onSecondaryTap;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Called when the image fails to load.
|
||||
final void Function(Object error, StackTrace? stack)? onError;
|
||||
|
||||
// ── Semantics ─────────────────────────────────────────────────────────────
|
||||
|
||||
final String? semanticLabel;
|
||||
final bool excludeFromSemantics;
|
||||
|
||||
// ── Gapless playback ──────────────────────────────────────────────────────
|
||||
|
||||
/// If `true`, keeps showing the old image while a new [image] provider loads.
|
||||
final bool gaplessPlayback;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const LeafImage({
|
||||
super.key,
|
||||
required this.image,
|
||||
this.fit,
|
||||
this.alignment = Alignment.center,
|
||||
this.width,
|
||||
this.height,
|
||||
this.cacheWidth,
|
||||
this.cacheHeight,
|
||||
this.filterQuality = FilterQuality.low,
|
||||
this.loadingBuilder,
|
||||
this.errorBuilder,
|
||||
this.revealAnimation = const GradientRevealAnimation(),
|
||||
this.imageBuilder,
|
||||
this.onTap,
|
||||
this.onDoubleTap,
|
||||
this.onLongPress,
|
||||
this.onSecondaryTap,
|
||||
this.onError,
|
||||
this.semanticLabel,
|
||||
this.excludeFromSemantics = false,
|
||||
this.gaplessPlayback = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LeafImage> createState() => _LeafImageState();
|
||||
}
|
||||
|
||||
class _LeafImageState extends State<LeafImage> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _revealController;
|
||||
int _retryKey = 0;
|
||||
bool _imageReady = false;
|
||||
bool _wasSync = false;
|
||||
bool _revealStarted = false;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_revealController = AnimationController(duration: widget.revealAnimation?.duration ?? Duration.zero, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_revealController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LeafImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.image != widget.image ||
|
||||
oldWidget.cacheWidth != widget.cacheWidth ||
|
||||
oldWidget.cacheHeight != widget.cacheHeight) {
|
||||
_retryKey = 0;
|
||||
_imageReady = false;
|
||||
_wasSync = false;
|
||||
_revealStarted = false;
|
||||
_revealController
|
||||
..reset()
|
||||
..duration = widget.revealAnimation?.duration ?? Duration.zero;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retry ────────────────────────────────────────────────────────────────
|
||||
|
||||
void _handleRetry() {
|
||||
setState(() {
|
||||
_retryKey++;
|
||||
_imageReady = false;
|
||||
_wasSync = false;
|
||||
_revealStarted = false;
|
||||
_revealController.reset();
|
||||
});
|
||||
}
|
||||
|
||||
void _startReveal(bool wasSync) {
|
||||
if (_revealStarted) return;
|
||||
_revealStarted = true;
|
||||
_wasSync = wasSync;
|
||||
|
||||
if (wasSync) {
|
||||
_revealController.value = 1.0;
|
||||
} else {
|
||||
_revealController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
bool get _hasTapHandler =>
|
||||
widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null || widget.onSecondaryTap != null;
|
||||
|
||||
/// Wraps the rendered image with decoration → reveal animation.
|
||||
/// Interactivity is applied at the outermost layer in [build] so it's
|
||||
/// present even during loading and error states.
|
||||
Widget _wrapLoaded(Widget child) {
|
||||
// 1. Custom decoration (ClipRRect, border, etc.)
|
||||
if (widget.imageBuilder != null) {
|
||||
child = widget.imageBuilder!(context, child);
|
||||
}
|
||||
|
||||
// 2. Reveal animation (null = disabled)
|
||||
if (widget.revealAnimation != null && !_wasSync) {
|
||||
child = widget.revealAnimation!.build(context, child, _revealController);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
// ── Build ────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageInkwell = _hasTapHandler
|
||||
? Positioned.fill(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
onDoubleTap: widget.onDoubleTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
onSecondaryTap: widget.onSecondaryTap,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
final effectiveImage = (widget.cacheWidth != null || widget.cacheHeight != null)
|
||||
? ResizeImage.resizeIfNeeded(widget.cacheWidth, widget.cacheHeight, widget.image)
|
||||
: widget.image;
|
||||
Widget imageWidget = Image(
|
||||
key: ValueKey('leaf_$_retryKey'),
|
||||
image: effectiveImage,
|
||||
fit: widget.fit,
|
||||
alignment: widget.alignment,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
filterQuality: widget.filterQuality,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
excludeFromSemantics: widget.excludeFromSemantics,
|
||||
loadingBuilder: imageInkwell != null
|
||||
? (context, child, loadingProgress) {
|
||||
return Stack(children: [_loadingBuilder(context, child, loadingProgress), imageInkwell]);
|
||||
}
|
||||
: (widget.loadingBuilder != null ? _loadingBuilder : null),
|
||||
frameBuilder: _frameBuilder,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
final errorContent = _errorBuilder(context, error, stackTrace);
|
||||
if (imageInkwell == null) return errorContent;
|
||||
return SizedBox.expand(child: Stack(children: [imageInkwell, errorContent]));
|
||||
},
|
||||
);
|
||||
|
||||
return SizedBox(width: widget.width, height: widget.height, child: imageWidget);
|
||||
}
|
||||
|
||||
Widget _loadingBuilder(BuildContext context, Widget child, ImageChunkEvent? progress) {
|
||||
if (_imageReady || progress == null) {
|
||||
// Already decoded or download complete — pass through to frameBuilder
|
||||
return child;
|
||||
}
|
||||
|
||||
if (widget.loadingBuilder == null) {
|
||||
// No custom skeleton — show the default placeholder
|
||||
return child;
|
||||
}
|
||||
|
||||
final p = progress.expectedTotalBytes != null ? progress.cumulativeBytesLoaded / progress.expectedTotalBytes! : 0.0;
|
||||
|
||||
return widget.loadingBuilder!(context, p);
|
||||
}
|
||||
|
||||
Widget _frameBuilder(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
||||
if (frame != null) {
|
||||
// First frame decoded
|
||||
_imageReady = true;
|
||||
_startReveal(wasSynchronouslyLoaded);
|
||||
return _wrapLoaded(child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _errorBuilder(BuildContext context, Object error, StackTrace? stackTrace) {
|
||||
widget.onError?.call(error, stackTrace);
|
||||
|
||||
if (widget.errorBuilder != null) {
|
||||
return widget.errorBuilder!(context, error, _handleRetry);
|
||||
}
|
||||
|
||||
return _defaultError();
|
||||
}
|
||||
|
||||
Widget _defaultError() {
|
||||
return Container(
|
||||
color: Colors.grey.shade800,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(Icons.image_not_supported, color: Colors.white38),
|
||||
IconButton.outlined(icon: const Icon(Icons.refresh), onPressed: _handleRetry),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The kind of animation currently playing.
|
||||
enum _AnimKind { pop, shake }
|
||||
|
||||
/// Heart toggle button with async-aware animation.
|
||||
///
|
||||
/// All motion uses Flutter's built-in [Curves] for smooth 120 Hz output.
|
||||
///
|
||||
/// - **idle** — shows the current heart (outlined / filled)
|
||||
/// - **pending** — a network call is in flight; icon pulses gently
|
||||
/// - **animating (pop)** — server accepted → `easeOut` up + `elasticOut` settle
|
||||
/// - **animating (shake)** — server rejected → horizontal wobble via `easeOutBack`
|
||||
///
|
||||
/// If [onChanged] is omitted the toggle is instant (no pending state).
|
||||
class AnimatedFavoriteButton extends StatefulWidget {
|
||||
final bool isFavorited;
|
||||
final double size;
|
||||
final Future<bool> Function(bool newIsFavorited)? onChanged;
|
||||
|
||||
const AnimatedFavoriteButton({
|
||||
super.key,
|
||||
this.isFavorited = false,
|
||||
this.size = 24,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedFavoriteButton> createState() => _AnimatedFavoriteButtonState();
|
||||
}
|
||||
|
||||
class _AnimatedFavoriteButtonState extends State<AnimatedFavoriteButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
|
||||
late bool _current;
|
||||
bool? _target;
|
||||
_AnimKind? _animKind;
|
||||
|
||||
// ── Pre-built animation tweens (reusable across frames) ────────────────────
|
||||
|
||||
late final Animatable<double> _favPop = TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
weight: 35,
|
||||
tween: Tween(begin: 1.0, end: 1.40).chain(CurveTween(curve: Curves.easeOut)),
|
||||
),
|
||||
TweenSequenceItem(
|
||||
weight: 65,
|
||||
tween: Tween(begin: 1.40, end: 1.0).chain(CurveTween(curve: Curves.elasticOut)),
|
||||
),
|
||||
]);
|
||||
|
||||
late final Animatable<double> _unfavPop = TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
weight: 30,
|
||||
tween: Tween(begin: 1.0, end: 0.82).chain(CurveTween(curve: Curves.easeIn)),
|
||||
),
|
||||
TweenSequenceItem(
|
||||
weight: 70,
|
||||
tween: Tween(begin: 0.82, end: 1.0).chain(CurveTween(curve: Curves.elasticOut)),
|
||||
),
|
||||
]);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_current = widget.isFavorited;
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 450));
|
||||
_ctrl.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedFavoriteButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Sync internal state with external prop when not mid-animation.
|
||||
if (_animKind == null && _target == null) {
|
||||
if (widget.isFavorited != _current) {
|
||||
_current = widget.isFavorited;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleTap() async {
|
||||
if (_ctrl.isAnimating || _target != null) return;
|
||||
|
||||
final desired = !_current;
|
||||
_target = desired;
|
||||
|
||||
if (widget.onChanged == null) {
|
||||
_current = desired;
|
||||
_animKind = _AnimKind.pop;
|
||||
await _ctrl.forward(from: 0.0);
|
||||
if (mounted) _cleanUp();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 1. Pending ──────────────────────────────────────────────────────────
|
||||
setState(() {});
|
||||
|
||||
try {
|
||||
final success = await widget.onChanged!(desired);
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
_current = desired;
|
||||
_animKind = _AnimKind.pop;
|
||||
} else {
|
||||
_animKind = _AnimKind.shake;
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
_animKind = _AnimKind.shake;
|
||||
}
|
||||
|
||||
// ── 2. Animate ──────────────────────────────────────────────────────────
|
||||
await _ctrl.forward(from: 0.0);
|
||||
if (mounted) _cleanUp();
|
||||
}
|
||||
|
||||
void _cleanUp() {
|
||||
_animKind = null;
|
||||
_target = null;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final t = _ctrl.value;
|
||||
final pending = widget.onChanged != null && _target != null && _animKind == null;
|
||||
|
||||
// Icon & colour
|
||||
bool showFilled;
|
||||
Color color;
|
||||
|
||||
if (_animKind == _AnimKind.pop) {
|
||||
final filling = _target ?? true;
|
||||
showFilled = filling ? t > 0.35 : t < 0.30;
|
||||
color = showFilled ? Colors.red : cs.onSurfaceVariant;
|
||||
} else if (_animKind == _AnimKind.shake) {
|
||||
showFilled = _current;
|
||||
color = Colors.red.shade200;
|
||||
} else {
|
||||
showFilled = _current;
|
||||
color = _current ? Colors.red : cs.onSurfaceVariant;
|
||||
}
|
||||
|
||||
Widget icon = Icon(
|
||||
showFilled ? Icons.favorite : Icons.favorite_border,
|
||||
size: widget.size,
|
||||
color: color,
|
||||
);
|
||||
|
||||
// Transforms
|
||||
if (pending) {
|
||||
icon = _Pulse(child: icon);
|
||||
} else if (_animKind == _AnimKind.pop) {
|
||||
final scale = (_target ?? true) ? _favPop.evaluate(_ctrl) : _unfavPop.evaluate(_ctrl);
|
||||
icon = Transform.scale(scale: scale, child: icon);
|
||||
} else if (_animKind == _AnimKind.shake) {
|
||||
// Decaying oscillation: easeOut envelope × sine
|
||||
final amplitude = Curves.easeOut.transform(t) * 5.0;
|
||||
final offset = amplitude * sin(t * pi * 6); // 3 full wobbles
|
||||
icon = Transform.translate(offset: Offset(offset, 0), child: icon);
|
||||
}
|
||||
|
||||
// Material tap target: 40 dp circle, red-tinted hover, ink splash.
|
||||
const tapSize = 40.0;
|
||||
return SizedBox(
|
||||
width: tapSize,
|
||||
height: tapSize,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(tapSize / 2),
|
||||
hoverColor: Colors.red.withValues(alpha: 0.10),
|
||||
splashColor: Colors.red.withValues(alpha: 0.20),
|
||||
highlightColor: Colors.red.withValues(alpha: 0.08),
|
||||
onTap: _handleTap,
|
||||
child: Center(child: icon),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtle opacity + scale pulse for the pending (in-flight) state.
|
||||
class _Pulse extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _Pulse({required this.child});
|
||||
|
||||
@override
|
||||
State<_Pulse> createState() => _PulseState();
|
||||
}
|
||||
|
||||
class _PulseState extends State<_Pulse> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))
|
||||
..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (context, _) {
|
||||
final v = Curves.easeInOutSine.transform(_ctrl.value);
|
||||
return Opacity(
|
||||
opacity: 0.45 + 0.55 * v,
|
||||
child: Transform.scale(
|
||||
scale: 0.92 + 0.08 * v,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A 4-pointed sparkle star that continuously rotates and pulses in size.
|
||||
/// Use as a "New posts" indicator icon.
|
||||
///
|
||||
/// The silhouette is a concave-edged sparkle (sharp tips, curved bellies)
|
||||
/// with an optional white-core shine gradient and a soft glow.
|
||||
class AnimatedStar extends StatefulWidget {
|
||||
/// Base size of the canvas in logical pixels.
|
||||
final double size;
|
||||
|
||||
/// Base color of the star (tips, glow, and the outer edge of the shine).
|
||||
final Color color;
|
||||
|
||||
/// Inner (elbow) radius as a fraction of the outer radius.
|
||||
/// Smaller = deeper notches = sharper points.
|
||||
final double innerRatio;
|
||||
|
||||
/// Concave curvature of the edges, 0..1.
|
||||
/// 0 = straight edges (classic star), ~0.3 = sparkle silhouette.
|
||||
final double edgeCurve;
|
||||
|
||||
/// Distance of the star tips from the center, as a fraction of the half-size.
|
||||
/// Keep enough margin for [glowRatio] so the glow isn't clipped.
|
||||
final double outerRatio;
|
||||
|
||||
/// Glow blur radius as a fraction of [size]. 0 = no glow.
|
||||
final double glowRatio;
|
||||
|
||||
/// Opacity of the glow pass.
|
||||
final double glowOpacity;
|
||||
|
||||
/// Whether to fill with a white-core shine gradient instead of flat color.
|
||||
final bool shine;
|
||||
|
||||
const AnimatedStar({
|
||||
super.key,
|
||||
this.size = 24,
|
||||
this.color = Colors.amberAccent,
|
||||
this.innerRatio = 0.25,
|
||||
this.edgeCurve = 0.3,
|
||||
this.outerRatio = 0.8,
|
||||
this.glowRatio = 0.2,
|
||||
this.glowOpacity = 0.3,
|
||||
this.shine = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedStar> createState() => _AnimatedStarState();
|
||||
}
|
||||
|
||||
class _AnimatedStarState extends State<AnimatedStar> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))
|
||||
..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (context, _) {
|
||||
final v = _ctrl.value;
|
||||
|
||||
// Smooth ease-in-out twinkle: 2 pulses per full rotation.
|
||||
final triangle = 1.0 - 2.0 * ((v * 2) % 1.0 - 0.5).abs();
|
||||
final scale = 0.70 + 0.30 * Curves.easeInOutSine.transform(triangle);
|
||||
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: Transform.rotate(
|
||||
angle: v * 2 * pi, // linear rotation — constant speed
|
||||
child: CustomPaint(
|
||||
size: Size.square(widget.size),
|
||||
painter: _FourPointStarPainter(
|
||||
color: widget.color,
|
||||
innerRatio: widget.innerRatio,
|
||||
edgeCurve: widget.edgeCurve,
|
||||
outerRatio: widget.outerRatio,
|
||||
glowRatio: widget.glowRatio,
|
||||
glowOpacity: widget.glowOpacity,
|
||||
shine: widget.shine,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FourPointStarPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double innerRatio;
|
||||
final double edgeCurve;
|
||||
final double outerRatio;
|
||||
final double glowRatio;
|
||||
final double glowOpacity;
|
||||
final bool shine;
|
||||
|
||||
const _FourPointStarPainter({
|
||||
required this.color,
|
||||
required this.innerRatio,
|
||||
required this.edgeCurve,
|
||||
required this.outerRatio,
|
||||
required this.glowRatio,
|
||||
required this.glowOpacity,
|
||||
required this.shine,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final half = size.width / 2;
|
||||
final center = Offset(half, half);
|
||||
final outer = half * outerRatio;
|
||||
final inner = outer * innerRatio;
|
||||
final curve = edgeCurve.clamp(0.0, 1.0);
|
||||
|
||||
final path = Path();
|
||||
for (var i = 0; i < 4; i++) {
|
||||
final tipAngle = i * pi / 2 - pi / 2;
|
||||
final tip = center + Offset(cos(tipAngle), sin(tipAngle)) * outer;
|
||||
final nextTip = center + Offset(cos(tipAngle + pi / 2), sin(tipAngle + pi / 2)) * outer;
|
||||
final elbowAngle = tipAngle + pi / 4;
|
||||
final elbow = center + Offset(cos(elbowAngle), sin(elbowAngle)) * inner;
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(tip.dx, tip.dy);
|
||||
} else {
|
||||
path.lineTo(tip.dx, tip.dy);
|
||||
}
|
||||
|
||||
if (curve > 0) {
|
||||
// Each edge bows toward the center: control point pulled from the
|
||||
// edge midpoint toward the star's center.
|
||||
final mid1 = Offset((tip.dx + elbow.dx) / 2, (tip.dy + elbow.dy) / 2);
|
||||
final mid2 = Offset((elbow.dx + nextTip.dx) / 2, (elbow.dy + nextTip.dy) / 2);
|
||||
path.quadraticBezierTo(
|
||||
mid1.dx + (center.dx - mid1.dx) * curve,
|
||||
mid1.dy + (center.dy - mid1.dy) * curve,
|
||||
elbow.dx,
|
||||
elbow.dy,
|
||||
);
|
||||
path.quadraticBezierTo(
|
||||
mid2.dx + (center.dx - mid2.dx) * curve,
|
||||
mid2.dy + (center.dy - mid2.dy) * curve,
|
||||
nextTip.dx,
|
||||
nextTip.dy,
|
||||
);
|
||||
} else {
|
||||
path.lineTo(elbow.dx, elbow.dy);
|
||||
path.lineTo(nextTip.dx, nextTip.dy);
|
||||
}
|
||||
}
|
||||
path.close();
|
||||
|
||||
// Glow pass
|
||||
if (glowRatio > 0 && glowOpacity > 0) {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = color.withValues(alpha: glowOpacity)
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, glowRatio * size.width),
|
||||
);
|
||||
}
|
||||
|
||||
// Fill pass — white-core radial gradient, light from the top-left
|
||||
final fill = Paint()..style = PaintingStyle.fill;
|
||||
if (shine) {
|
||||
fill.shader = RadialGradient(
|
||||
center: const Alignment(-0.25, -0.25),
|
||||
radius: 1.2,
|
||||
colors: [Color.lerp(color, Colors.white, 0.85)!, color],
|
||||
stops: const [0.0, 0.45],
|
||||
).createShader(Rect.fromCircle(center: center, radius: outer));
|
||||
} else {
|
||||
fill.color = color;
|
||||
}
|
||||
canvas.drawPath(path, fill);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_FourPointStarPainter old) =>
|
||||
old.color != color ||
|
||||
old.innerRatio != innerRatio ||
|
||||
old.edgeCurve != edgeCurve ||
|
||||
old.outerRatio != outerRatio ||
|
||||
old.glowRatio != glowRatio ||
|
||||
old.glowOpacity != glowOpacity ||
|
||||
old.shine != shine;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Status of the async task shown by [LeafAsyncChip].
|
||||
enum LeafAsyncStatus { idle, running, done, error }
|
||||
|
||||
/// Overlay chip that reports the progress and result of an async task.
|
||||
///
|
||||
/// Trigger the task imperatively via `GlobalKey<LeafAsyncChipState>` and
|
||||
/// `LeafAsyncChipState.run()`. While the task runs a spinner pill is shown
|
||||
/// over [child] (determinate when the task reports progress, indeterminate
|
||||
/// otherwise); on success it pops into a check + [doneLabel] pill that
|
||||
/// auto-resets after [doneDuration]; on failure it shows an error pill for
|
||||
/// [errorDuration], logs the error, and forwards it to [onError].
|
||||
class LeafAsyncChip extends StatefulWidget {
|
||||
/// The widget the overlay renders on top of.
|
||||
final Widget child;
|
||||
|
||||
/// Label shown next to the spinner while the task runs.
|
||||
final String runningLabel;
|
||||
|
||||
/// Label shown next to the check when the task completes. Can be overridden
|
||||
/// per-run via [LeafAsyncChipState.run].
|
||||
final String doneLabel;
|
||||
|
||||
/// Label shown when the task throws.
|
||||
final String errorLabel;
|
||||
|
||||
/// How long the success pill stays visible before resetting to idle.
|
||||
final Duration doneDuration;
|
||||
|
||||
/// How long the error pill stays visible before resetting to idle.
|
||||
final Duration errorDuration;
|
||||
|
||||
/// Called with the task's error + stack trace when it throws. The error is
|
||||
/// always debugPrinted regardless.
|
||||
final void Function(Object error, StackTrace stackTrace)? onError;
|
||||
|
||||
const LeafAsyncChip({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.runningLabel = 'Working…',
|
||||
this.doneLabel = 'Done',
|
||||
this.errorLabel = 'Failed',
|
||||
this.doneDuration = const Duration(milliseconds: 1200),
|
||||
this.errorDuration = const Duration(milliseconds: 3000),
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LeafAsyncChip> createState() => LeafAsyncChipState();
|
||||
}
|
||||
|
||||
class LeafAsyncChipState extends State<LeafAsyncChip> {
|
||||
LeafAsyncStatus _status = LeafAsyncStatus.idle;
|
||||
double? _progress;
|
||||
String _doneLabel = '';
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Runs [task], showing the running → done/error overlay. Ignores the call
|
||||
/// while a task is already running. [doneLabel] overrides the widget's
|
||||
/// default label for this run.
|
||||
Future<void> run({
|
||||
required Future<void> Function(ValueChanged<double>? onProgress) task,
|
||||
String? doneLabel,
|
||||
}) async {
|
||||
if (_status == LeafAsyncStatus.running) return;
|
||||
_timer?.cancel();
|
||||
setState(() {
|
||||
_status = LeafAsyncStatus.running;
|
||||
_progress = null;
|
||||
_doneLabel = doneLabel ?? widget.doneLabel;
|
||||
});
|
||||
try {
|
||||
await task((p) {
|
||||
if (mounted) setState(() => _progress = p.clamp(0.0, 1.0));
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => _status = LeafAsyncStatus.done);
|
||||
_timer = Timer(widget.doneDuration, _reset);
|
||||
} catch (e, st) {
|
||||
debugPrint('LeafAsyncChip: task failed: $e\n$st');
|
||||
if (!mounted) return;
|
||||
setState(() => _status = LeafAsyncStatus.error);
|
||||
_timer = Timer(widget.errorDuration, _reset);
|
||||
widget.onError?.call(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
if (mounted) setState(() => _status = LeafAsyncStatus.idle);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
widget.child,
|
||||
if (_status != LeafAsyncStatus.idle)
|
||||
IgnorePointer(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.6, end: 1.0).animate(
|
||||
CurvedAnimation(parent: anim, curve: Curves.easeOutBack),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _Pill(key: ValueKey(_status), status: _status, progress: _progress, label: _label),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String get _label => switch (_status) {
|
||||
LeafAsyncStatus.running => widget.runningLabel,
|
||||
LeafAsyncStatus.done => _doneLabel,
|
||||
LeafAsyncStatus.error => widget.errorLabel,
|
||||
LeafAsyncStatus.idle => '',
|
||||
};
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
final LeafAsyncStatus status;
|
||||
final double? progress;
|
||||
final String label;
|
||||
|
||||
const _Pill({super.key, required this.status, required this.progress, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.75),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
switch (status) {
|
||||
LeafAsyncStatus.running => SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: progress,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
LeafAsyncStatus.done => const Icon(Icons.check, size: 16, color: Color(0xFF7CF29C)),
|
||||
LeafAsyncStatus.error => const Icon(Icons.error_outline, size: 16, color: Color(0xFFFF6B6B)),
|
||||
LeafAsyncStatus.idle => const SizedBox.shrink(),
|
||||
},
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LeafPopoverController
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Controls the visibility of an [LeafPopover] programmatically.
|
||||
///
|
||||
/// Pass an instance to [LeafPopover.controller]. The controller is also passed to
|
||||
/// the `popoverBuilder` so the popover content can dismiss itself.
|
||||
class LeafPopoverController {
|
||||
bool _isShowing = false;
|
||||
|
||||
/// Whether the popover is currently showing.
|
||||
bool get isShowing => _isShowing;
|
||||
|
||||
/// Callbacks wired by the owning [LeafPopover] state.
|
||||
void Function()? _onShow;
|
||||
void Function()? _onHide;
|
||||
|
||||
/// Show the popover.
|
||||
void show() {
|
||||
if (!_isShowing) {
|
||||
_isShowing = true;
|
||||
_onShow?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the popover.
|
||||
void hide() {
|
||||
if (_isShowing) {
|
||||
_isShowing = false;
|
||||
_onHide?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle visibility.
|
||||
void toggle() {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LeafPopover
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A Material-styled popover that shows arbitrary content anchored to a child.
|
||||
///
|
||||
/// On tap of [child], the content built by [popoverBuilder] is displayed in an
|
||||
/// anchored overlay. Tap outside or press Escape to dismiss.
|
||||
///
|
||||
/// The [popoverBuilder] receives the [LeafPopoverController] so the popover
|
||||
/// content can dismiss itself (e.g. a close button inside the popover).
|
||||
///
|
||||
/// Pass an optional [controller] for external show/hide control. If omitted,
|
||||
/// the popover manages visibility internally.
|
||||
///
|
||||
/// ```dart
|
||||
/// LeafPopover(
|
||||
/// popoverAnchor: Alignment.topCenter,
|
||||
/// childAnchor: Alignment.bottomCenter,
|
||||
/// spacing: 4,
|
||||
/// popoverBuilder: (context, controller) => Card(
|
||||
/// child: Padding(
|
||||
/// padding: EdgeInsets.all(16),
|
||||
/// child: Text('Popover content'),
|
||||
/// ),
|
||||
/// ),
|
||||
/// child: TextButton(onPressed: null, child: Text('Open')),
|
||||
/// )
|
||||
/// ```
|
||||
class LeafPopover extends StatefulWidget {
|
||||
/// Builder for the popover overlay content.
|
||||
///
|
||||
/// Receives the [LeafPopoverController] so the popover content can call
|
||||
/// `controller.hide()` to dismiss itself.
|
||||
final Widget Function(BuildContext context, LeafPopoverController controller) popoverBuilder;
|
||||
|
||||
/// The trigger widget. Tapping it toggles the popover.
|
||||
final Widget child;
|
||||
|
||||
/// Anchor point on the popover itself. Defaults to [Alignment.topCenter].
|
||||
///
|
||||
/// Together with [childAnchor] this controls where the popover appears
|
||||
/// relative to the child. For example with the default values the popover
|
||||
/// appears centered above the child.
|
||||
final AlignmentGeometry popoverAnchor;
|
||||
|
||||
/// Anchor point on the child. Defaults to [Alignment.bottomCenter].
|
||||
///
|
||||
/// Together with [popoverAnchor] this controls where the popover appears
|
||||
/// relative to the child. For example with the default values the popover
|
||||
/// appears centered above the child, positioned just below the child.
|
||||
final AlignmentGeometry childAnchor;
|
||||
|
||||
/// Gap between the child edge and the popover edge. Defaults to 4.
|
||||
final double spacing;
|
||||
|
||||
/// Optional external controller for programmatic show/hide.
|
||||
///
|
||||
/// If omitted, the popover creates its own internal controller. Either way
|
||||
/// the controller is passed to [popoverBuilder] for self-dismiss.
|
||||
final LeafPopoverController? controller;
|
||||
|
||||
/// Called when the popover is dismissed.
|
||||
final VoidCallback? onDismissed;
|
||||
|
||||
const LeafPopover({
|
||||
required this.popoverBuilder,
|
||||
required this.child,
|
||||
this.popoverAnchor = Alignment.topCenter,
|
||||
this.childAnchor = Alignment.bottomCenter,
|
||||
this.spacing = 4,
|
||||
this.controller,
|
||||
this.onDismissed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LeafPopover> createState() => _LeafPopoverState();
|
||||
}
|
||||
|
||||
class _LeafPopoverState extends State<LeafPopover> {
|
||||
final _overlayController = OverlayPortalController();
|
||||
final _childKey = GlobalKey();
|
||||
final _fallbackController = LeafPopoverController();
|
||||
|
||||
late final LeafPopoverController _controller;
|
||||
Offset? _childGlobalPosition;
|
||||
Size? _childSize;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = widget.controller ?? _fallbackController;
|
||||
_controller._onShow = _show;
|
||||
_controller._onHide = _hide;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Unwire callbacks so the controller doesn't call into disposed state.
|
||||
if (!identical(_controller, _fallbackController)) {
|
||||
_controller._onShow = null;
|
||||
_controller._onHide = null;
|
||||
}
|
||||
_fallbackController._onShow = null;
|
||||
_fallbackController._onHide = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _show() {
|
||||
if (_overlayController.isShowing) return;
|
||||
final renderBox = _childKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null || !renderBox.attached) return;
|
||||
|
||||
_childGlobalPosition = renderBox.localToGlobal(Offset.zero);
|
||||
_childSize = renderBox.size;
|
||||
setState(() {});
|
||||
_overlayController.show();
|
||||
}
|
||||
|
||||
void _hide() {
|
||||
_overlayController.hide();
|
||||
widget.onDismissed?.call();
|
||||
}
|
||||
|
||||
void _onChildTap() {
|
||||
_controller.toggle();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OverlayPortal(
|
||||
controller: _overlayController,
|
||||
overlayChildBuilder: (_) {
|
||||
if (_childGlobalPosition == null || _childSize == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _PopoverOverlay(
|
||||
childGlobalPosition: _childGlobalPosition!,
|
||||
childSize: _childSize!,
|
||||
popoverAnchor: widget.popoverAnchor,
|
||||
childAnchor: widget.childAnchor,
|
||||
spacing: widget.spacing,
|
||||
popoverBuilder: widget.popoverBuilder,
|
||||
controller: _controller,
|
||||
onDismiss: () => _controller.hide(),
|
||||
);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: _onChildTap,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: KeyedSubtree(key: _childKey, child: widget.child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// _PopoverOverlay
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PopoverOverlay extends StatefulWidget {
|
||||
final Offset childGlobalPosition;
|
||||
final Size childSize;
|
||||
final AlignmentGeometry popoverAnchor;
|
||||
final AlignmentGeometry childAnchor;
|
||||
final double spacing;
|
||||
final Widget Function(BuildContext context, LeafPopoverController controller) popoverBuilder;
|
||||
final LeafPopoverController controller;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const _PopoverOverlay({
|
||||
required this.childGlobalPosition,
|
||||
required this.childSize,
|
||||
required this.popoverAnchor,
|
||||
required this.childAnchor,
|
||||
required this.spacing,
|
||||
required this.popoverBuilder,
|
||||
required this.controller,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PopoverOverlay> createState() => _PopoverOverlayState();
|
||||
}
|
||||
|
||||
class _PopoverOverlayState extends State<_PopoverOverlay> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _animCtrl;
|
||||
late final Animation<double> _fade;
|
||||
late final Animation<double> _scale;
|
||||
|
||||
/// Key on the popover content so we can measure its actual size for
|
||||
/// positioning.
|
||||
final _menuKey = GlobalKey();
|
||||
|
||||
/// Adjusted position after overflow detection.
|
||||
Offset? _adjustedPos;
|
||||
|
||||
/// Whether the exit animation is running.
|
||||
bool _dismissing = false;
|
||||
|
||||
// ── lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animCtrl = AnimationController(duration: const Duration(milliseconds: 100), vsync: this);
|
||||
|
||||
_fade = CurvedAnimation(parent: _animCtrl, curve: Curves.linear);
|
||||
_scale = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOutCubic);
|
||||
|
||||
_animCtrl.addStatusListener(_onStatusChanged);
|
||||
_animCtrl.forward();
|
||||
|
||||
// Measure and adjust position after first layout.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _adjustPosition());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animCtrl.removeStatusListener(_onStatusChanged);
|
||||
_animCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onStatusChanged(AnimationStatus status) {
|
||||
if (status == AnimationStatus.dismissed && _dismissing) {
|
||||
widget.onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// ── positioning ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the desired popover position given its size.
|
||||
///
|
||||
/// Pass [Size.zero] for a rough estimate before measurement.
|
||||
Offset _computeDesiredPosition(Size popoverSize) {
|
||||
final dir = Directionality.of(context);
|
||||
final childAnchor = widget.childAnchor.resolve(dir);
|
||||
final popoverAnchor = widget.popoverAnchor.resolve(dir);
|
||||
|
||||
// Offset from top-left of each rect to the anchor point.
|
||||
final childOffset = Offset(widget.childSize.width / 2 * (childAnchor.x + 1), widget.childSize.height / 2 * (childAnchor.y + 1));
|
||||
final popoverOffset = Offset(popoverSize.width / 2 * (popoverAnchor.x + 1), popoverSize.height / 2 * (popoverAnchor.y + 1));
|
||||
|
||||
// Base position: the childAnchor point on the child aligns with the
|
||||
// popoverAnchor point on the popover.
|
||||
final baseX = widget.childGlobalPosition.dx + childOffset.dx - popoverOffset.dx;
|
||||
final baseY = widget.childGlobalPosition.dy + childOffset.dy - popoverOffset.dy;
|
||||
|
||||
// Spacing pushes the popover further away from the child, in the direction
|
||||
// normal to the anchored edge.
|
||||
final spacingDirX = (childAnchor.x - popoverAnchor.x).sign;
|
||||
final spacingDirY = (childAnchor.y - popoverAnchor.y).sign;
|
||||
|
||||
return Offset(baseX + spacingDirX * widget.spacing, baseY + spacingDirY * widget.spacing);
|
||||
}
|
||||
|
||||
void _adjustPosition() {
|
||||
final menuBox = _menuKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (menuBox == null || !menuBox.hasSize) return;
|
||||
|
||||
final menuSize = menuBox.size;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final screen = mediaQuery.size;
|
||||
final padding = mediaQuery.padding;
|
||||
|
||||
var pos = _computeDesiredPosition(menuSize);
|
||||
|
||||
// Horizontal overflow → slide left.
|
||||
if (pos.dx + menuSize.width > screen.width - padding.right) {
|
||||
pos = Offset(screen.width - menuSize.width - padding.right, pos.dy);
|
||||
}
|
||||
// Vertical overflow → slide up.
|
||||
if (pos.dy + menuSize.height > screen.height - padding.bottom) {
|
||||
pos = Offset(pos.dx, screen.height - menuSize.height - padding.bottom);
|
||||
}
|
||||
|
||||
// Edge clamping (positive padding only).
|
||||
pos = Offset(
|
||||
pos.dx.clamp(padding.left, screen.width - menuSize.width - padding.right),
|
||||
pos.dy.clamp(padding.top, screen.height - menuSize.height - padding.bottom),
|
||||
);
|
||||
|
||||
if (_adjustedPos == null || _adjustedPos! != pos) {
|
||||
setState(() => _adjustedPos = pos);
|
||||
}
|
||||
}
|
||||
|
||||
// ── dismiss ───────────────────────────────────────────────────────────────
|
||||
|
||||
void _dismiss() {
|
||||
if (_dismissing) return;
|
||||
_dismissing = true;
|
||||
_animCtrl.reverse();
|
||||
}
|
||||
|
||||
// ── build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use a rough estimate (popover at Size.zero) for the initial frame before
|
||||
// _adjustPosition runs. The entrance animation starts at opacity 0.0 /
|
||||
// scale 0.93, so the flash is imperceptible.
|
||||
final pos = _adjustedPos ?? _computeDesiredPosition(Size.zero);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Invisible barrier catching all pointer events. Menu items (above in
|
||||
// the stack) take priority via their own gesture absorption.
|
||||
Positioned.fill(
|
||||
child: Listener(
|
||||
onPointerDown: (_) => _dismiss(),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// The popover content itself.
|
||||
Positioned(
|
||||
left: pos.dx,
|
||||
top: pos.dy,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: CallbackShortcuts(
|
||||
bindings: {const SingleActivator(LogicalKeyboardKey.escape): _dismiss},
|
||||
child: FocusScope(
|
||||
child: FadeTransition(
|
||||
opacity: Tween<double>(begin: 0, end: 1).animate(_fade),
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.93, end: 1).animate(_scale),
|
||||
child: KeyedSubtree(key: _menuKey, child: widget.popoverBuilder(context, widget.controller)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
|
||||
/// Multi-touch pan/pinch recognizer for [LeafViewer] pages.
|
||||
///
|
||||
/// Ported from photo_view's `PhotoViewGestureRecognizer` (MIT): a
|
||||
/// [ScaleGestureRecognizer] that decides per move event whether to claim the
|
||||
/// gesture via [shouldClaim].
|
||||
///
|
||||
/// Unlike photo_view, the decision is made once on the first meaningful move
|
||||
/// (beyond finger-jitter tolerance) and resolved immediately (accept or
|
||||
/// reject) — this prevents the recognizer's own slop-based auto-accept
|
||||
/// (18-36px, see `_advanceStateMachine`) from outracing the PageView's drag
|
||||
/// (18px) on large per-event deltas.
|
||||
///
|
||||
/// Rejecting leaves the arena to the parent (the PageView's horizontal drag
|
||||
/// or the viewer's dismiss drag) — this is what makes edge-release work: a
|
||||
/// drag starting at a fully clamped position pages/dismisses instead of
|
||||
/// panning.
|
||||
///
|
||||
/// This recognizer must NOT touch the base's private bookkeeping
|
||||
/// (`_pointerLocations`, `_initialFocalPoint`, `_currentFocalPoint`):
|
||||
/// [ScaleGestureRecognizer.handleEvent] recomputes its own focal/span state
|
||||
/// from those fields on every event, and re-stamping them here corrupts the
|
||||
/// pinch anchor and the double-tap arena.
|
||||
class LeafPanZoomRecognizer extends ScaleGestureRecognizer {
|
||||
LeafPanZoomRecognizer({required this.shouldClaim, super.debugOwner});
|
||||
|
||||
/// Whether a pan with the given finger delta should be claimed.
|
||||
final bool Function(Offset fingerDelta) shouldClaim;
|
||||
|
||||
/// Movement under this distance is treated as finger jitter — it never
|
||||
/// decides the claim, letting a double-tap run its course on a noisy tap.
|
||||
static const double _jitterSlop = 4.0;
|
||||
|
||||
int _activePointers = 0;
|
||||
Offset _moveDelta = Offset.zero;
|
||||
int? _movingPointer;
|
||||
bool _claimDecided = false;
|
||||
bool _claimed = false;
|
||||
|
||||
@override
|
||||
void addAllowedPointer(PointerDownEvent event) {
|
||||
_activePointers++;
|
||||
super.addAllowedPointer(event);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStopTrackingLastPointer(int pointer) {
|
||||
_activePointers = 0;
|
||||
_moveDelta = Offset.zero;
|
||||
_movingPointer = null;
|
||||
_claimDecided = false;
|
||||
_claimed = false;
|
||||
super.didStopTrackingLastPointer(pointer);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (event is PointerMoveEvent && !event.synthesized) {
|
||||
if (event.pointer == _movingPointer) {
|
||||
_moveDelta += event.localDelta;
|
||||
} else {
|
||||
_movingPointer = event.pointer;
|
||||
_moveDelta = event.localDelta;
|
||||
}
|
||||
|
||||
if (!_claimDecided && !_claimed && _moveDelta.distance > _jitterSlop) {
|
||||
_claimDecided = true;
|
||||
if (_activePointers > 1 || shouldClaim(_moveDelta)) {
|
||||
_claimed = true;
|
||||
// Full arena claim (NOT acceptGesture alone — that only sets the
|
||||
// local state, leaving the arena open so another member can later
|
||||
// win and reject us while `started`). The base handler then runs
|
||||
// safely: the arena is already resolved in our favor.
|
||||
resolve(GestureDisposition.accepted);
|
||||
super.handleEvent(event);
|
||||
return;
|
||||
}
|
||||
// Reject BEFORE the base's own auto-accept (pan slop 18-36px): the
|
||||
// arena removes us and stops tracking (state becomes `ready`), so
|
||||
// `super.handleEvent` must not run — it asserts on `ready` state.
|
||||
resolve(GestureDisposition.rejected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
super.handleEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/rendering.dart' show RenderViewportBase;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'leaf_viewer_internal.dart';
|
||||
import 'leaf_zoom_controller.dart';
|
||||
|
||||
/// Fullscreen pager with per-page zoom, boundary-aware edge release and a
|
||||
/// viewer-level dismiss gesture.
|
||||
///
|
||||
/// Pages are built by [pageBuilder]. Wrap a page's content in [LeafZoomable]
|
||||
/// to give it zoom/pan/double-tap; pages without one get paging + dismiss
|
||||
/// only.
|
||||
///
|
||||
/// Gesture arbitration happens in the gesture arena (no raw pointer routing):
|
||||
/// - [LeafZoomable]'s pan/zoom recognizer claims drags only when the child
|
||||
/// can actually move (zoomed + a free axis); otherwise the PageView's drag
|
||||
/// (horizontal) or the dismiss drag (perpendicular) wins — edge-release
|
||||
/// - the mouse wheel zooms cursor-anchored on all platforms; a plain wheel
|
||||
/// over a scrollable under the viewer scrolls that scrollable instead
|
||||
/// - a quick tap outside the active image fires [onTapOutside]
|
||||
/// - keyboard (when the viewer has focus): the arrows page prev/next,
|
||||
/// Esc dismisses (via [onDismissed] or pop); disabled with
|
||||
/// [enableKeyboard]
|
||||
///
|
||||
/// Desktop (Windows/Linux/macOS) differs from touch: no mouse drag paging, no
|
||||
/// drag dismiss and no double-tap ([enableSwipeGestures] forces the touch
|
||||
/// behaviour anywhere — the close button / wheel / keyboard replace them).
|
||||
/// When [controlsBuilder] is null on desktop a default control set
|
||||
/// (previous/next, close, page counter) is shown.
|
||||
class LeafViewer extends StatefulWidget {
|
||||
const LeafViewer({
|
||||
super.key,
|
||||
required this.pageCount,
|
||||
required this.pageBuilder,
|
||||
this.pageController,
|
||||
this.initialPage = 0,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.onPageChanged,
|
||||
this.dismissEnabled = true,
|
||||
this.onDismissed,
|
||||
this.onTapOutside,
|
||||
this.backgroundBuilder,
|
||||
this.controlsBuilder,
|
||||
this.enableSwipeGestures,
|
||||
this.enableKeyboard = true,
|
||||
});
|
||||
|
||||
final int pageCount;
|
||||
final Widget Function(BuildContext context, int index) pageBuilder;
|
||||
final PageController? pageController;
|
||||
final int initialPage;
|
||||
final Axis scrollDirection;
|
||||
final ValueChanged<int>? onPageChanged;
|
||||
final bool dismissEnabled;
|
||||
final VoidCallback? onDismissed;
|
||||
final VoidCallback? onTapOutside;
|
||||
|
||||
/// Builds the layer behind the pager. Receives the dismiss progress in
|
||||
/// `[0, 1]` for backdrop fading.
|
||||
final Widget Function(BuildContext context, double progress)? backgroundBuilder;
|
||||
|
||||
/// Builds the chrome overlay on top of the pager (media_kit `controlsBuilder`
|
||||
/// style). When null, a default control set is shown on desktop and nothing
|
||||
/// on touch platforms.
|
||||
final Widget Function(BuildContext context, LeafViewerControls controls)? controlsBuilder;
|
||||
|
||||
/// Force the touch gesture set (swipe paging, drag dismiss, double-tap) on
|
||||
/// any platform. Null = platform default (off on desktop, on elsewhere).
|
||||
final bool? enableSwipeGestures;
|
||||
|
||||
/// Whether the viewer grabs keyboard focus and handles the arrows for
|
||||
/// paging and Esc for dismissal (default true).
|
||||
final bool enableKeyboard;
|
||||
|
||||
/// The zoom controller of the currently active page, if any.
|
||||
static LeafZoomController? controllerOf(BuildContext context) {
|
||||
return LeafViewerScope.maybeOf(context)?.registry.activeController;
|
||||
}
|
||||
|
||||
@override
|
||||
State<LeafViewer> createState() => _LeafViewerState();
|
||||
}
|
||||
|
||||
/// State passed to [LeafViewer.controlsBuilder].
|
||||
class LeafViewerControls {
|
||||
const LeafViewerControls({required this.pageCount, required this.currentIndex, required this.goToPage});
|
||||
|
||||
final int pageCount;
|
||||
final int currentIndex;
|
||||
|
||||
/// Animates to the given page index (clamped by [pageCount]).
|
||||
final void Function(int index) goToPage;
|
||||
|
||||
bool get canGoPrevious => currentIndex > 0;
|
||||
bool get canGoNext => currentIndex < pageCount - 1;
|
||||
}
|
||||
|
||||
class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin implements LeafViewerRegistry {
|
||||
late final PageController _pageController;
|
||||
late final AnimationController _dismissCtrl;
|
||||
final Map<int, LeafZoomController> _controllers = {};
|
||||
final ValueNotifier<int> _indexNotifier = ValueNotifier(0);
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
int _currentIndex = 0;
|
||||
bool _dismissing = false;
|
||||
Size _viewerSize = Size.zero;
|
||||
|
||||
LeafZoomController? get _activeController => _controllers[_currentIndex];
|
||||
|
||||
bool get _swipeMode => widget.enableSwipeGestures ?? !leafIsDesktopPlatform();
|
||||
|
||||
@override
|
||||
bool get swipeGesturesEnabled => _swipeMode;
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
final horizontal = widget.scrollDirection == Axis.horizontal;
|
||||
final prev = horizontal ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowUp;
|
||||
final next = horizontal ? LogicalKeyboardKey.arrowRight : LogicalKeyboardKey.arrowDown;
|
||||
final key = event.logicalKey;
|
||||
if (key == prev) {
|
||||
_goToPage(_currentIndex - 1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == next) {
|
||||
_goToPage(_currentIndex + 1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
if (widget.onDismissed != null) {
|
||||
widget.onDismissed!();
|
||||
} else {
|
||||
Navigator.maybePop(context);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.pageController?.initialPage ?? widget.initialPage;
|
||||
_indexNotifier.value = _currentIndex;
|
||||
_pageController = widget.pageController ?? PageController(initialPage: _currentIndex);
|
||||
_dismissCtrl = AnimationController.unbounded(vsync: this)
|
||||
..addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
_dismissCtrl.dispose();
|
||||
_indexNotifier.dispose();
|
||||
if (widget.pageController == null) _pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── Controller registry ─────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void register(int index, LeafZoomController ctrl) {
|
||||
_controllers[index] = ctrl;
|
||||
}
|
||||
|
||||
@override
|
||||
void unregister(int index, LeafZoomController ctrl) {
|
||||
if (_controllers[index] == ctrl) _controllers.remove(index);
|
||||
}
|
||||
|
||||
@override
|
||||
LeafZoomController? get activeController => _controllers[_currentIndex];
|
||||
|
||||
void _onPageIndexChanged(int index) {
|
||||
_currentIndex = index;
|
||||
_indexNotifier.value = index;
|
||||
}
|
||||
|
||||
void _goToPage(int index) {
|
||||
if (index < 0 || index >= widget.pageCount) return;
|
||||
_pageController.animateToPage(index, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
// ── Dismiss ─────────────────────────────────────────────────────────────
|
||||
|
||||
double get _dismissOffset => _dismissCtrl.value;
|
||||
double get _dismissExtent => widget.scrollDirection == Axis.horizontal ? _viewerSize.height : _viewerSize.width;
|
||||
double get _dismissProgress => _dismissExtent > 0 ? (_dismissOffset.abs() / (_dismissExtent * 0.5)).clamp(0.0, 1.0) : 0.0;
|
||||
|
||||
Offset get _dismissOffsetVector => widget.scrollDirection == Axis.horizontal ? Offset(0, _dismissOffset) : Offset(_dismissOffset, 0);
|
||||
|
||||
void _onDismissStart(DragStartDetails details) {
|
||||
_activeController?.cancelGesture();
|
||||
}
|
||||
|
||||
void _onDismissUpdate(DragUpdateDetails details) {
|
||||
_dismissCtrl.value += widget.scrollDirection == Axis.horizontal ? details.delta.dy : details.delta.dx;
|
||||
}
|
||||
|
||||
void _onDismissEnd(DragEndDetails details) {
|
||||
_endDismissDrag(details.primaryVelocity ?? 0);
|
||||
}
|
||||
|
||||
void _endDismissDrag(double velocity) {
|
||||
if (_dismissing || !widget.dismissEnabled) return;
|
||||
final offset = _dismissCtrl.value;
|
||||
final extent = _dismissExtent;
|
||||
if (velocity.abs() > 800 || offset.abs() > extent * 0.25) {
|
||||
_dismissing = true;
|
||||
final dir = velocity.abs() > 50 ? velocity.sign : (offset.sign == 0 ? 1.0 : offset.sign);
|
||||
_dismissCtrl.animateTo(dir * extent * 1.5, duration: const Duration(milliseconds: 200), curve: Curves.easeIn).then((_) {
|
||||
if (!mounted) return;
|
||||
final onDismissed = widget.onDismissed;
|
||||
if (onDismissed != null) {
|
||||
onDismissed();
|
||||
} else {
|
||||
Navigator.maybePop(context);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_dismissCtrl.animateWith(SpringSimulation(const SpringDescription(mass: 1, stiffness: 600, damping: 50), offset, 0, velocity));
|
||||
}
|
||||
}
|
||||
|
||||
void _onTapUp(TapUpDetails details) {
|
||||
final ctrl = _activeController;
|
||||
if (ctrl != null && !ctrl.zoomed && !ctrl.imageRect.contains(details.localPosition)) {
|
||||
widget.onTapOutside?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void _onPointerSignal(PointerSignalEvent e) {
|
||||
if (e is! PointerScrollEvent) return;
|
||||
final ctrl = _activeController;
|
||||
if (ctrl == null || _viewerSize == Size.zero) return;
|
||||
// Plain wheel over a scrollable under the viewer scrolls that scrollable
|
||||
// (it registers deeper, so the resolver picks it first); everywhere else,
|
||||
// and on Ctrl+wheel or once zoomed, the wheel zooms cursor-anchored.
|
||||
if (!HardwareKeyboard.instance.isControlPressed && !ctrl.zoomed) {
|
||||
final hit = HitTestResult();
|
||||
GestureBinding.instance.hitTestInView(hit, e.position, e.viewId);
|
||||
final overScrollable = hit.path.any((entry) =>
|
||||
entry.target is RenderViewportBase &&
|
||||
(entry.target as RenderViewportBase).axis == Axis.vertical);
|
||||
if (overScrollable) return;
|
||||
}
|
||||
GestureBinding.instance.pointerSignalResolver.register(e, (event) {
|
||||
final scroll = event as PointerScrollEvent;
|
||||
ctrl.wheelZoom(scroll.scrollDelta.dy, scroll.localPosition);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Build ───────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = _dismissProgress;
|
||||
final behavior = ScrollConfiguration.of(context);
|
||||
final horizontal = widget.scrollDirection == Axis.horizontal;
|
||||
final desktop = leafIsDesktopPlatform();
|
||||
final swipeMode = _swipeMode;
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: widget.enableKeyboard,
|
||||
onKeyEvent: widget.enableKeyboard ? _onKeyEvent : null,
|
||||
child: LeafViewerScope(
|
||||
registry: this,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_viewerSize = constraints.biggest;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.backgroundBuilder != null) widget.backgroundBuilder!(context, progress),
|
||||
Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerSignal: _onPointerSignal,
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: horizontal && widget.dismissEnabled && swipeMode ? _onDismissStart : null,
|
||||
onVerticalDragUpdate: horizontal && widget.dismissEnabled && swipeMode ? _onDismissUpdate : null,
|
||||
onVerticalDragEnd: horizontal && widget.dismissEnabled && swipeMode ? _onDismissEnd : null,
|
||||
onHorizontalDragStart: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissStart : null,
|
||||
onHorizontalDragUpdate: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissUpdate : null,
|
||||
onHorizontalDragEnd: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissEnd : null,
|
||||
onTapUp: _onTapUp,
|
||||
child: Transform.translate(
|
||||
offset: _dismissOffsetVector,
|
||||
child: Transform.scale(
|
||||
scale: 1.0 - _dismissProgress * 0.15,
|
||||
child: ScrollConfiguration(
|
||||
behavior: behavior.copyWith(
|
||||
dragDevices: swipeMode ? {...behavior.dragDevices, PointerDeviceKind.mouse} : behavior.dragDevices,
|
||||
),
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const PageScrollPhysics(),
|
||||
scrollDirection: widget.scrollDirection,
|
||||
// Keep adjacent pages alive so their zoom state
|
||||
// survives swipes (and preloads neighbors).
|
||||
allowImplicitScrolling: true,
|
||||
itemCount: widget.pageCount,
|
||||
onPageChanged: (index) {
|
||||
widget.onPageChanged?.call(index);
|
||||
_onPageIndexChanged(index);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return LeafPageIndexScope(index: index, child: widget.pageBuilder(context, index));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.controlsBuilder != null)
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: _indexNotifier,
|
||||
builder: (context, index, _) => widget.controlsBuilder!(
|
||||
context,
|
||||
LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage),
|
||||
),
|
||||
)
|
||||
else if (desktop)
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: _indexNotifier,
|
||||
builder: (context, index, _) =>
|
||||
_LeafViewerDefaultControls(controls: LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LeafViewerDefaultControls extends StatelessWidget {
|
||||
const _LeafViewerDefaultControls({required this.controls});
|
||||
|
||||
final LeafViewerControls controls;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: _ControlButton(
|
||||
icon: Icons.chevron_left,
|
||||
onTap: controls.canGoPrevious ? () => controls.goToPage(controls.currentIndex - 1) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: _ControlButton(
|
||||
icon: Icons.chevron_right,
|
||||
onTap: controls.canGoNext ? () => controls.goToPage(controls.currentIndex + 1) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: _ControlButton(icon: Icons.close, onTap: () => Navigator.maybePop(context)),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 16,
|
||||
child: IgnorePointer(
|
||||
child: Text(
|
||||
'${controls.currentIndex + 1} / ${controls.pageCount}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ControlButton extends StatelessWidget {
|
||||
const _ControlButton({required this.icon, required this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon),
|
||||
tooltip: null,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.45),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'leaf_zoom_controller.dart';
|
||||
|
||||
/// Whether the current platform is a desktop OS. Desktop viewers swap swipe
|
||||
/// gestures for controls: no mouse paging, no drag dismiss, no double-tap.
|
||||
bool leafIsDesktopPlatform() {
|
||||
if (kIsWeb) return false;
|
||||
return defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.linux ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS;
|
||||
}
|
||||
|
||||
/// Internal: registry contract between [LeafViewer] and [LeafZoomable].
|
||||
/// Not exported from the package barrel.
|
||||
abstract class LeafViewerRegistry {
|
||||
void register(int index, LeafZoomController ctrl);
|
||||
void unregister(int index, LeafZoomController ctrl);
|
||||
LeafZoomController? get activeController;
|
||||
|
||||
/// Whether the touch gesture set (double-tap etc.) is active.
|
||||
bool get swipeGesturesEnabled;
|
||||
}
|
||||
|
||||
/// Internal: registry scope shared between [LeafViewer] and [LeafZoomable].
|
||||
class LeafViewerScope extends InheritedWidget {
|
||||
const LeafViewerScope({required this.registry, super.key, required super.child});
|
||||
|
||||
final LeafViewerRegistry registry;
|
||||
|
||||
static LeafViewerScope? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<LeafViewerScope>();
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(LeafViewerScope oldWidget) => false;
|
||||
}
|
||||
|
||||
/// Internal: provides a page's index to [LeafZoomable] so it can register its
|
||||
/// controller with the viewer.
|
||||
class LeafPageIndexScope extends InheritedWidget {
|
||||
const LeafPageIndexScope({required this.index, super.key, required super.child});
|
||||
|
||||
final int index;
|
||||
|
||||
static int? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<LeafPageIndexScope>()?.index;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(LeafPageIndexScope oldWidget) => oldWidget.index != index;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Double-tap cycle position (ported from photo_view's scale-state cycle,
|
||||
/// adapted to our scale convention where 1.0 = contained).
|
||||
enum _LeafZoomState { initial, covering, originalSize, zoomedIn, zoomedOut }
|
||||
|
||||
class _CornersRange {
|
||||
const _CornersRange(this.min, this.max);
|
||||
final double min;
|
||||
final double max;
|
||||
}
|
||||
|
||||
class _HitCorners {
|
||||
const _HitCorners(this.hasHitMin, this.hasHitMax);
|
||||
final bool hasHitMin;
|
||||
final bool hasHitMax;
|
||||
bool get hasHitAny => hasHitMin || hasHitMax;
|
||||
bool get hasHitBoth => hasHitMin && hasHitMax;
|
||||
}
|
||||
|
||||
/// Per-page zoom state for [LeafViewer].
|
||||
///
|
||||
/// Scale is relative to the contained (fit-to-screen) size, so 1.0 is the
|
||||
/// resting scale. Position is the translation in screen pixels.
|
||||
///
|
||||
/// Boundary math (pan corners, clamping) and the double-tap cycle are ported
|
||||
/// from photo_view (MIT) and verified by property tests; input handling,
|
||||
/// fling physics and animation curves are our own.
|
||||
class LeafZoomController extends ChangeNotifier {
|
||||
LeafZoomController({this.maxScale = 5.0, required TickerProvider vsync})
|
||||
: _vsync = vsync {
|
||||
_animCtrl = AnimationController(vsync: vsync, duration: _animDuration)
|
||||
..addListener(_onAnimTick);
|
||||
}
|
||||
|
||||
static const double _minScale = 1.0;
|
||||
static const Duration _animDuration = Duration(milliseconds: 200);
|
||||
|
||||
/// Upper zoom limit (relative to contained).
|
||||
final double maxScale;
|
||||
final TickerProvider _vsync;
|
||||
late final AnimationController _animCtrl;
|
||||
|
||||
/// Viewport (page) size in pixels. Set by [LeafZoomable] on layout.
|
||||
Size viewport = Size.zero;
|
||||
|
||||
/// Contained (fit-to-screen) render size of the child. Set by
|
||||
/// [LeafZoomable] on layout.
|
||||
Size childSize = Size.zero;
|
||||
|
||||
/// Full-resolution child size (for the 1:1 double-tap step).
|
||||
/// Null disables the 1:1 step.
|
||||
Size? originalSize;
|
||||
|
||||
double _scale = 1.0;
|
||||
Offset _position = Offset.zero;
|
||||
|
||||
_LeafZoomState _state = _LeafZoomState.initial;
|
||||
bool _snapOnCancel = false;
|
||||
Animation<Matrix4>? _matrixAnim;
|
||||
|
||||
Ticker? _flingTicker;
|
||||
double _flingVx = 0, _flingVy = 0;
|
||||
int _flingId = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Fired when the scale reaches the 1.0 or max limit (haptics hook).
|
||||
VoidCallback? onZoomLimit;
|
||||
|
||||
double get scale => _scale;
|
||||
Offset get position => _position;
|
||||
|
||||
/// Whether the child is zoomed beyond the resting scale.
|
||||
bool get zoomed => _scale > 1.001;
|
||||
bool get isAnimating => _animCtrl.isAnimating;
|
||||
bool get isFlinging => _flingTicker?.isActive ?? false;
|
||||
bool get isInteracting => isAnimating || isFlinging;
|
||||
|
||||
/// The on-screen rect of the child at the current scale/position.
|
||||
Rect get imageRect {
|
||||
final w = childSize.width * _scale;
|
||||
final h = childSize.height * _scale;
|
||||
return Rect.fromLTWH(
|
||||
(viewport.width - w) / 2 + _position.dx,
|
||||
(viewport.height - h) / 2 + _position.dy,
|
||||
w,
|
||||
h,
|
||||
);
|
||||
}
|
||||
|
||||
/// Notify layout changes. Recomputes cycle-state scales and reclamps.
|
||||
void updateLayout({required Size viewport, required Size childSize, Size? originalSize}) {
|
||||
final changed = viewport != this.viewport || childSize != this.childSize || originalSize != this.originalSize;
|
||||
this.viewport = viewport;
|
||||
this.childSize = childSize;
|
||||
this.originalSize = originalSize;
|
||||
if (!changed || isInteracting) return;
|
||||
if (_state != _LeafZoomState.zoomedIn && _state != _LeafZoomState.zoomedOut) {
|
||||
_scale = _scaleForState(_state);
|
||||
}
|
||||
_position = _clampPosition(_position, _scale);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Gestures ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cancel any running fling/animation. If the animation was heading back to
|
||||
/// 1×, snap there instantly so a following swipe pages instead of panning.
|
||||
void cancelGesture() {
|
||||
_cancelFling();
|
||||
if (_animCtrl.isAnimating) {
|
||||
_animCtrl.stop();
|
||||
if (_snapOnCancel) {
|
||||
_snapToInitial();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-finger pan while zoomed.
|
||||
void panBy(Offset delta) {
|
||||
_setZoom(_scale, _position + delta);
|
||||
}
|
||||
|
||||
/// Unified pan/pinch update: scale to [newScale] while keeping the child
|
||||
/// point under [prevFocal] anchored to [curFocal]. For a pure pan the scale
|
||||
/// is unchanged and this reduces to `position += curFocal - prevFocal`.
|
||||
void scaleUpdate(double newScale, Offset prevFocal, Offset curFocal) {
|
||||
final oldScale = _scale;
|
||||
final clamped = newScale.clamp(_minScale, maxScale);
|
||||
final anchored = curFocal + (_position - prevFocal) * (clamped / oldScale);
|
||||
_setZoom(clamped, anchored);
|
||||
_checkHaptic(oldScale, _scale);
|
||||
}
|
||||
|
||||
/// Cursor-anchored wheel zoom.
|
||||
void wheelZoom(double scrollDy, Offset cursor) {
|
||||
_cancelFling();
|
||||
final oldScale = _scale;
|
||||
final newScale = (_scale * (1 - scrollDy * 0.001)).clamp(_minScale, maxScale);
|
||||
if (newScale == oldScale) return;
|
||||
final vpc = Offset(viewport.width / 2, viewport.height / 2);
|
||||
final anchored = (cursor - vpc) + (_position - (cursor - vpc)) * (newScale / oldScale);
|
||||
_setZoom(newScale, anchored);
|
||||
_checkHaptic(oldScale, _scale);
|
||||
}
|
||||
|
||||
/// Double-tap: advance the 1× → covered → 1:1 → 1× cycle, zooming in
|
||||
/// around [tapPoint].
|
||||
void doubleTap(Offset tapPoint) {
|
||||
cancelGesture();
|
||||
final state = _state;
|
||||
if (state == _LeafZoomState.zoomedIn || state == _LeafZoomState.zoomedOut) {
|
||||
_animateToState(_LeafZoomState.initial, tapPoint);
|
||||
return;
|
||||
}
|
||||
var prevScale = _scaleForState(state);
|
||||
var prevState = state;
|
||||
var nextScale = prevScale;
|
||||
var nextState = state;
|
||||
do {
|
||||
prevScale = nextScale;
|
||||
prevState = nextState;
|
||||
nextState = _cycle(prevState);
|
||||
nextScale = _scaleForState(nextState);
|
||||
} while (prevScale == nextScale && state != nextState);
|
||||
if (prevScale == nextScale) return;
|
||||
_animateToState(nextState, tapPoint);
|
||||
}
|
||||
|
||||
/// Velocity-proportional pan fling (friction 0.92/frame, tap-to-stop).
|
||||
void startFling(Offset velocity) {
|
||||
_cancelFling();
|
||||
final speed = velocity.distance;
|
||||
if (speed < 50) return;
|
||||
_flingVx = velocity.dx;
|
||||
_flingVy = velocity.dy;
|
||||
_flingId++;
|
||||
final id = _flingId;
|
||||
const friction = 0.92;
|
||||
_flingTicker = _vsync.createTicker((elapsed) {
|
||||
if (_disposed || _flingId != id) {
|
||||
_cancelFling();
|
||||
return;
|
||||
}
|
||||
if (Offset(_flingVx, _flingVy).distance < 5) {
|
||||
_cancelFling();
|
||||
return;
|
||||
}
|
||||
_setZoom(_scale, _position + Offset(_flingVx / 60, _flingVy / 60));
|
||||
_flingVx *= friction;
|
||||
_flingVy *= friction;
|
||||
})..start();
|
||||
}
|
||||
|
||||
// ── Edge-release (ported from photo_view's HitCornersDetector) ──────────
|
||||
|
||||
/// Whether a drag with per-event [fingerDelta] should be claimed as a pan.
|
||||
/// Claims when either axis can still move the child; releases only when
|
||||
/// fully clamped (the LoliSnatcher `Axis.values` behaviour).
|
||||
///
|
||||
/// [fingerDelta] is the raw finger movement. The ported corner rules use
|
||||
/// photo_view's sign convention (negated movement), so the delta is
|
||||
/// inverted internally.
|
||||
bool shouldClaim(Offset fingerDelta) => shouldMoveX(fingerDelta) || shouldMoveY(fingerDelta);
|
||||
|
||||
bool shouldMoveX(Offset fingerDelta) => _shouldMoveAxis(_hitCornersX(), -fingerDelta.dx, -fingerDelta.dy);
|
||||
bool shouldMoveY(Offset fingerDelta) => _shouldMoveAxis(_hitCornersY(), -fingerDelta.dy, -fingerDelta.dx);
|
||||
|
||||
bool _shouldMoveAxis(_HitCorners corners, double mainAxisMove, double crossAxisMove) {
|
||||
if (mainAxisMove == 0) return false;
|
||||
if (!corners.hasHitAny) return true;
|
||||
final axisBlocked = corners.hasHitBoth || (corners.hasHitMax ? mainAxisMove > 0 : mainAxisMove < 0);
|
||||
return !axisBlocked;
|
||||
}
|
||||
|
||||
_HitCorners _hitCornersX() {
|
||||
final childWidth = childSize.width * _scale;
|
||||
if (viewport.width >= childWidth) return const _HitCorners(true, true);
|
||||
final x = -_position.dx;
|
||||
final corners = _cornersX();
|
||||
return _HitCorners(x <= corners.min, x >= corners.max);
|
||||
}
|
||||
|
||||
_HitCorners _hitCornersY() {
|
||||
final childHeight = childSize.height * _scale;
|
||||
if (viewport.height >= childHeight) return const _HitCorners(true, true);
|
||||
final y = -_position.dy;
|
||||
final corners = _cornersY();
|
||||
return _HitCorners(y <= corners.min, y >= corners.max);
|
||||
}
|
||||
|
||||
_CornersRange _cornersX({double? scale}) {
|
||||
final widthDiff = childSize.width * (scale ?? _scale) - viewport.width;
|
||||
return _CornersRange(-widthDiff / 2, widthDiff / 2);
|
||||
}
|
||||
|
||||
_CornersRange _cornersY({double? scale}) {
|
||||
final heightDiff = childSize.height * (scale ?? _scale) - viewport.height;
|
||||
return _CornersRange(-heightDiff / 2, heightDiff / 2);
|
||||
}
|
||||
|
||||
Offset _clampPosition(Offset position, double scale) {
|
||||
final computedWidth = childSize.width * scale;
|
||||
final computedHeight = childSize.height * scale;
|
||||
double finalX = 0;
|
||||
if (viewport.width < computedWidth) {
|
||||
final c = _cornersX(scale: scale);
|
||||
finalX = position.dx.clamp(c.min, c.max);
|
||||
}
|
||||
double finalY = 0;
|
||||
if (viewport.height < computedHeight) {
|
||||
final c = _cornersY(scale: scale);
|
||||
finalY = position.dy.clamp(c.min, c.max);
|
||||
}
|
||||
return Offset(finalX, finalY);
|
||||
}
|
||||
|
||||
// ── Double-tap cycle (ported from photo_view's nextScaleState) ──────────
|
||||
|
||||
double _scaleForState(_LeafZoomState state) {
|
||||
switch (state) {
|
||||
case _LeafZoomState.initial:
|
||||
case _LeafZoomState.zoomedIn:
|
||||
case _LeafZoomState.zoomedOut:
|
||||
return _minScale;
|
||||
case _LeafZoomState.covering:
|
||||
return _clampScale(math.max(viewport.width / childSize.width, viewport.height / childSize.height));
|
||||
case _LeafZoomState.originalSize:
|
||||
final o = originalSize;
|
||||
if (o == null || o.width <= 0 || o.height <= 0) return _minScale;
|
||||
final contained = math.min(viewport.width / o.width, viewport.height / o.height);
|
||||
return _clampScale(1 / contained);
|
||||
}
|
||||
}
|
||||
|
||||
double _clampScale(double s) => s.clamp(_minScale, maxScale);
|
||||
|
||||
_LeafZoomState _cycle(_LeafZoomState s) {
|
||||
switch (s) {
|
||||
case _LeafZoomState.initial:
|
||||
return _LeafZoomState.covering;
|
||||
case _LeafZoomState.covering:
|
||||
return _LeafZoomState.originalSize;
|
||||
case _LeafZoomState.originalSize:
|
||||
return _LeafZoomState.initial;
|
||||
case _LeafZoomState.zoomedIn:
|
||||
case _LeafZoomState.zoomedOut:
|
||||
return _LeafZoomState.initial;
|
||||
}
|
||||
}
|
||||
|
||||
void _animateToState(_LeafZoomState target, Offset tapPoint) {
|
||||
final targetScale = _scaleForState(target);
|
||||
final Offset targetPosition;
|
||||
if (targetScale > _scale) {
|
||||
final vpc = Offset(viewport.width / 2, viewport.height / 2);
|
||||
targetPosition = _clampPosition(
|
||||
(tapPoint - vpc) + (_position - (tapPoint - vpc)) * (targetScale / _scale),
|
||||
targetScale,
|
||||
);
|
||||
} else {
|
||||
targetPosition = Offset.zero;
|
||||
}
|
||||
_state = target;
|
||||
_animateZoomTo(targetScale, targetPosition);
|
||||
}
|
||||
|
||||
void _animateZoomTo(double targetScale, Offset targetPosition) {
|
||||
_cancelFling();
|
||||
final begin = Matrix4.identity()
|
||||
..setEntry(0, 3, _position.dx)
|
||||
..setEntry(1, 3, _position.dy)
|
||||
..setEntry(0, 0, _scale)
|
||||
..setEntry(1, 1, _scale);
|
||||
final end = Matrix4.identity()
|
||||
..setEntry(0, 3, targetPosition.dx)
|
||||
..setEntry(1, 3, targetPosition.dy)
|
||||
..setEntry(0, 0, targetScale)
|
||||
..setEntry(1, 1, targetScale);
|
||||
_snapOnCancel = targetScale == _minScale;
|
||||
_matrixAnim = Matrix4Tween(begin: begin, end: end)
|
||||
.animate(CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut));
|
||||
_animCtrl.forward(from: 0).whenCompleteOrCancel(() {
|
||||
if (_disposed) return;
|
||||
_scale = targetScale;
|
||||
_position = _clampPosition(targetPosition, targetScale);
|
||||
_snapOnCancel = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _onAnimTick() {
|
||||
final anim = _matrixAnim;
|
||||
if (anim == null) return;
|
||||
_scale = anim.value.getMaxScaleOnAxis();
|
||||
final t = anim.value.getTranslation();
|
||||
_position = Offset(t.x, t.y);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setZoom(double newScale, Offset newPosition) {
|
||||
if (newScale != _minScale) {
|
||||
_state = newScale > _minScale ? _LeafZoomState.zoomedIn : _LeafZoomState.zoomedOut;
|
||||
} else {
|
||||
_state = _LeafZoomState.initial;
|
||||
}
|
||||
final clamped = _clampPosition(newPosition, newScale);
|
||||
if (_scale == newScale && _position == clamped) return;
|
||||
_scale = newScale;
|
||||
_position = clamped;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _checkHaptic(double oldScale, double newScale) {
|
||||
if (oldScale == newScale) return;
|
||||
if ((newScale == _minScale && oldScale != _minScale) || (newScale == maxScale && oldScale != maxScale)) {
|
||||
onZoomLimit?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelFling() {
|
||||
final t = _flingTicker;
|
||||
if (t != null && t.isActive) t.stop();
|
||||
_flingTicker = null;
|
||||
_flingId++;
|
||||
}
|
||||
|
||||
void _snapToInitial() {
|
||||
_scale = _minScale;
|
||||
_position = Offset.zero;
|
||||
_state = _LeafZoomState.initial;
|
||||
_snapOnCancel = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_cancelFling();
|
||||
_animCtrl.removeListener(_onAnimTick);
|
||||
_animCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'leaf_pan_zoom_recognizer.dart';
|
||||
import 'leaf_viewer_internal.dart';
|
||||
import 'leaf_zoom_controller.dart';
|
||||
|
||||
/// Opt-in per-page zoom wrapper for [LeafViewer].
|
||||
///
|
||||
/// Wrap a page's content to give it zoom/pan/double-tap behaviour. Pages
|
||||
/// without a [LeafZoomable] only get paging and dismiss.
|
||||
///
|
||||
/// [childSize] is the contained (fit-to-screen) render size of the child.
|
||||
/// Optional: when null the zoom clamps against the viewport (always works,
|
||||
/// slightly loose for letterboxed images). [originalSize] is the
|
||||
/// full-resolution size of the child and enables the 1:1 double-tap step;
|
||||
/// when null that step is skipped.
|
||||
///
|
||||
/// Use [LeafZoomable.image] to render a plain image: its pixel dimensions
|
||||
/// are extracted from the image stream at load time, so both the contained
|
||||
/// size and the 1:1 step work without passing anything.
|
||||
///
|
||||
/// Set [enabled] to false to render content without zoom (e.g. until the
|
||||
/// image has loaded).
|
||||
class LeafZoomable extends StatefulWidget {
|
||||
const LeafZoomable({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.childSize,
|
||||
this.originalSize,
|
||||
this.maxScale = 5.0,
|
||||
this.enabled = true,
|
||||
}) : image = null;
|
||||
|
||||
const LeafZoomable.image({
|
||||
super.key,
|
||||
required Image image,
|
||||
this.maxScale = 5.0,
|
||||
this.enabled = true,
|
||||
}) : child = image,
|
||||
childSize = null,
|
||||
originalSize = null,
|
||||
image = image;
|
||||
|
||||
final Widget child;
|
||||
final Size? childSize;
|
||||
final Size? originalSize;
|
||||
|
||||
/// Non-null when created via [LeafZoomable.image]: the image's pixel
|
||||
/// dimensions are resolved from its stream to size the zoom correctly.
|
||||
final Image? image;
|
||||
|
||||
final double maxScale;
|
||||
final bool enabled;
|
||||
|
||||
/// The contained (letterboxed) size of [source] inside [viewport].
|
||||
static Size containSize(Size viewport, Size source) {
|
||||
final ratio = source.width / source.height;
|
||||
if (viewport.width / viewport.height > ratio) {
|
||||
return Size(viewport.height * ratio, viewport.height);
|
||||
}
|
||||
return Size(viewport.width, viewport.width / ratio);
|
||||
}
|
||||
|
||||
@override
|
||||
State<LeafZoomable> createState() => _LeafZoomableState();
|
||||
}
|
||||
|
||||
class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMixin {
|
||||
late final LeafZoomController _controller;
|
||||
int? _index;
|
||||
LeafViewerRegistry? _registry;
|
||||
double _scaleBefore = 1.0;
|
||||
Offset _prevFocal = Offset.zero;
|
||||
|
||||
ImageStream? _imageStream;
|
||||
ImageStreamListener? _imageListener;
|
||||
Size? _decodedSize;
|
||||
bool _imageResolved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = LeafZoomController(maxScale: widget.maxScale, vsync: this)
|
||||
..onZoomLimit = () => HapticFeedback.mediumImpact();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final index = LeafPageIndexScope.maybeOf(context);
|
||||
final registry = LeafViewerScope.maybeOf(context)?.registry;
|
||||
if (index != _index || registry != _registry) {
|
||||
_unregister();
|
||||
_index = index;
|
||||
_registry = registry;
|
||||
_register();
|
||||
}
|
||||
if (widget.image != null && !_imageResolved) {
|
||||
_imageResolved = true;
|
||||
_resolveImageDimensions();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LeafZoomable oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.maxScale != widget.maxScale) {
|
||||
_controller.updateLayout(viewport: _controller.viewport, childSize: _controller.childSize, originalSize: _controller.originalSize);
|
||||
}
|
||||
if (oldWidget.enabled != widget.enabled) {
|
||||
if (widget.enabled) {
|
||||
_register();
|
||||
} else {
|
||||
_unregister();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _register() {
|
||||
final index = _index;
|
||||
if (index == null || !widget.enabled) return;
|
||||
_registry?.register(index, _controller);
|
||||
}
|
||||
|
||||
void _unregister() {
|
||||
final index = _index;
|
||||
if (index == null) return;
|
||||
_registry?.unregister(index, _controller);
|
||||
}
|
||||
|
||||
void _resolveImageDimensions() {
|
||||
final image = widget.image!;
|
||||
final stream = image.image.resolve(createLocalImageConfiguration(context));
|
||||
final listener = ImageStreamListener((info, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_decodedSize = Size(info.image.width.toDouble(), info.image.height.toDouble());
|
||||
});
|
||||
});
|
||||
stream.addListener(listener);
|
||||
_imageStream = stream;
|
||||
_imageListener = listener;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_imageStream != null && _imageListener != null) {
|
||||
_imageStream!.removeListener(_imageListener!);
|
||||
}
|
||||
_unregister();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScaleStart(ScaleStartDetails details) {
|
||||
_controller.cancelGesture();
|
||||
_scaleBefore = _controller.scale;
|
||||
_prevFocal = details.localFocalPoint;
|
||||
}
|
||||
|
||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final newScale = _scaleBefore * details.scale;
|
||||
_controller.scaleUpdate(newScale, _prevFocal, details.localFocalPoint);
|
||||
_prevFocal = details.localFocalPoint;
|
||||
}
|
||||
|
||||
void _onScaleEnd(ScaleEndDetails details) {
|
||||
if ((_controller.scale - _scaleBefore).abs() < 0.001) {
|
||||
final v = details.velocity.pixelsPerSecond;
|
||||
if (v.distance > 300) _controller.startFling(v);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPointerSignal(PointerSignalEvent event) {
|
||||
if (event is! PointerScrollEvent) return;
|
||||
if (!widget.enabled) return;
|
||||
// Ctrl+scroll always zooms; plain scroll also zooms once zoomed (so the
|
||||
// wheel can zoom back out). Otherwise the event passes through to the
|
||||
// enclosing scrollable.
|
||||
if (!HardwareKeyboard.instance.isControlPressed && !_controller.zoomed) return;
|
||||
GestureBinding.instance.pointerSignalResolver.register(event, (event) {
|
||||
final scroll = event as PointerScrollEvent;
|
||||
_controller.wheelZoom(scroll.scrollDelta.dy, scroll.localPosition);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewport = constraints.biggest;
|
||||
final decoded = _decodedSize;
|
||||
final contained = widget.childSize ??
|
||||
(decoded != null ? LeafZoomable.containSize(viewport, decoded) : viewport);
|
||||
final original = widget.originalSize ?? decoded;
|
||||
final content = Center(
|
||||
child: SizedBox(
|
||||
width: contained.width,
|
||||
height: contained.height,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
if (!widget.enabled) return content;
|
||||
_controller.updateLayout(viewport: viewport, childSize: contained, originalSize: original);
|
||||
return ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
final transform = Matrix4.identity()
|
||||
..setEntry(0, 3, _controller.position.dx)
|
||||
..setEntry(1, 3, _controller.position.dy)
|
||||
..setEntry(0, 0, _controller.scale)
|
||||
..setEntry(1, 1, _controller.scale);
|
||||
final gestures = <Type, GestureRecognizerFactory>{
|
||||
LeafPanZoomRecognizer: GestureRecognizerFactoryWithHandlers<LeafPanZoomRecognizer>(
|
||||
() => LeafPanZoomRecognizer(shouldClaim: _controller.shouldClaim, debugOwner: this),
|
||||
(instance) => instance
|
||||
..onStart = _onScaleStart
|
||||
..onUpdate = _onScaleUpdate
|
||||
..onEnd = _onScaleEnd,
|
||||
),
|
||||
};
|
||||
if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? false) {
|
||||
gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(debugOwner: this),
|
||||
(instance) => instance.onDoubleTapDown = (details) => _controller.doubleTap(details.localPosition),
|
||||
);
|
||||
}
|
||||
return Listener(
|
||||
onPointerSignal: _onPointerSignal,
|
||||
child: RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
gestures: gestures,
|
||||
child: Transform(transform: transform, alignment: Alignment.center, child: content),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user