initial commit: LeafViewer, LeafZoomable, LeafContextMenu, LeafPopover, LeafImage, misc widgets + tests

This commit is contained in:
foxyz committed 2026-09-02 21:58:54 +03:00
commit 9bb0ff9187
156 files changed
+9887

No files matched your search

+12
View File
@@ -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';
+572
View File
@@ -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,
),
],
),
);
}
}
+407
View File
@@ -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),
],
),
);
}
}
+239
View File
@@ -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,
),
);
},
);
}
}
+203
View File
@@ -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;
}
+176
View File
@@ -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),
),
],
),
);
}
}
+389
View File
@@ -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);
}
}
+415
View File
@@ -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,
),
);
}
}
+53
View File
@@ -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;
}
+387
View File
@@ -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();
}
}
+243
View File
@@ -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),
),
);
},
);
},
);
}
}