initial commit: LeafViewer, LeafZoomable, LeafContextMenu, LeafPopover, LeafImage, misc widgets + tests
This commit is contained in:
commit
9bb0ff9187
156 files changed
+9887
No files matched your search
@@ -0,0 +1,482 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:leaf_widgets/leaf_widgets.dart';
|
||||
|
||||
Widget _zoomablePage(BuildContext context, int i) => LeafZoomable(
|
||||
childSize: const Size(400, 300),
|
||||
originalSize: const Size(1600, 1200),
|
||||
child: Container(color: const Color(0xFF112233)),
|
||||
);
|
||||
|
||||
Widget _harness({
|
||||
Widget Function(BuildContext, int)? pageBuilder,
|
||||
ValueChanged<int>? onPageChanged,
|
||||
VoidCallback? onDismissed,
|
||||
VoidCallback? onTapOutside,
|
||||
ValueChanged<double>? onProgress,
|
||||
bool? enableSwipeGestures,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: LeafViewer(
|
||||
pageCount: 3,
|
||||
pageBuilder: pageBuilder ?? _zoomablePage,
|
||||
onPageChanged: onPageChanged,
|
||||
onDismissed: onDismissed,
|
||||
onTapOutside: onTapOutside,
|
||||
enableSwipeGestures: enableSwipeGestures,
|
||||
backgroundBuilder: onProgress == null
|
||||
? null
|
||||
: (context, p) {
|
||||
onProgress(p);
|
||||
return Container(color: Colors.black);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
LeafZoomController _controller(WidgetTester tester) {
|
||||
return LeafViewer.controllerOf(tester.element(find.byType(LeafZoomable)))!;
|
||||
}
|
||||
|
||||
Future<void> _slowDrag(WidgetTester tester, Offset from, Offset delta, {int steps = 8}) async {
|
||||
final g = await tester.startGesture(from);
|
||||
await tester.pump();
|
||||
final step = delta / steps.toDouble();
|
||||
for (var i = 0; i < steps; i++) {
|
||||
await g.moveBy(step);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
await g.up();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _doubleTapAt(WidgetTester tester, Offset pos) async {
|
||||
await tester.tapAt(pos);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.tapAt(pos);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
Future<void> _wheel(WidgetTester tester, double dy) async {
|
||||
await tester.sendEventToBinding(PointerScrollEvent(
|
||||
position: const Offset(400, 300),
|
||||
scrollDelta: Offset(0, dy),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// Pinch from (300,300)/(500,300) outward so the finger distance reaches [targetDist].
|
||||
Future<void> _pinchOut(WidgetTester tester, double targetDist) async {
|
||||
final g1 = await tester.startGesture(const Offset(300, 300));
|
||||
final g2 = await tester.startGesture(const Offset(500, 300));
|
||||
await tester.pump();
|
||||
final spread = targetDist - 200;
|
||||
await g1.moveTo(Offset(300 - spread / 2, 300));
|
||||
await g2.moveTo(Offset(500 + spread / 2, 300));
|
||||
await tester.pump();
|
||||
await g1.up();
|
||||
await g2.up();
|
||||
await tester.pump(const Duration(milliseconds: 350)); // flush double-tap countdown
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('1× horizontal drag pages', (tester) async {
|
||||
var page = 0;
|
||||
await tester.pumpWidget(_harness(onPageChanged: (i) => page = i));
|
||||
await _slowDrag(tester, const Offset(700, 300), const Offset(-500, 0));
|
||||
expect(page, 1);
|
||||
});
|
||||
|
||||
testWidgets('mouse drag pages (dragDevices fix)', (tester) async {
|
||||
var page = 0;
|
||||
await tester.pumpWidget(_harness(onPageChanged: (i) => page = i));
|
||||
final g = await tester.startGesture(const Offset(700, 300), kind: PointerDeviceKind.mouse);
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await g.moveBy(const Offset(-62.5, 0));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
await g.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(page, 1);
|
||||
});
|
||||
|
||||
testWidgets('zoomed pan does not page', (tester) async {
|
||||
var page = 0;
|
||||
await tester.pumpWidget(_harness(onPageChanged: (i) => page = i));
|
||||
await _pinchOut(tester, 600); // 3×, anchored at (-50,-150)
|
||||
final ctrl = _controller(tester);
|
||||
expect(ctrl.scale, 3.0);
|
||||
await _slowDrag(tester, const Offset(700, 300), const Offset(150, 0)); // pan right
|
||||
expect(ctrl.position.dx, 100);
|
||||
expect(page, 0);
|
||||
});
|
||||
|
||||
testWidgets('zoomed + clamped at edge releases mid-drag and pages', (tester) async {
|
||||
var page = 0;
|
||||
await tester.pumpWidget(_harness(onPageChanged: (i) => page = i));
|
||||
await _pinchOut(tester, 600); // 3× at (-50,-150)
|
||||
final ctrl = _controller(tester);
|
||||
await _slowDrag(tester, const Offset(700, 300), const Offset(-250, 0)); // pan to X clamp
|
||||
expect(ctrl.position.dx, -200);
|
||||
expect(page, 0);
|
||||
await _slowDrag(tester, const Offset(700, 300), const Offset(-500, 0)); // released at edge → pages
|
||||
expect(page, 1);
|
||||
});
|
||||
|
||||
testWidgets('zoomed + clamped vertically: continuing the drag dismisses', (tester) async {
|
||||
var dismissed = false;
|
||||
await tester.pumpWidget(_harness(onDismissed: () => dismissed = true));
|
||||
await _pinchOut(tester, 600); // 3×, Y clamped at -150
|
||||
// Drag further up from the top clamp → edge-release dismiss (offset > 25%).
|
||||
await _slowDrag(tester, const Offset(400, 400), const Offset(0, -300), steps: 6);
|
||||
expect(dismissed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('1× small vertical drag springs back without dismissing', (tester) async {
|
||||
var dismissed = false;
|
||||
var progress = 1.0;
|
||||
await tester.pumpWidget(_harness(onDismissed: () => dismissed = true, onProgress: (p) => progress = p));
|
||||
await tester.timedDrag(find.byType(LeafViewer), const Offset(0, 100), const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissed, isFalse);
|
||||
expect(progress, lessThan(0.001)); // spring settles asymptotically, never exactly 0
|
||||
});
|
||||
|
||||
testWidgets('1× vertical drag past 25% flies off and dismisses', (tester) async {
|
||||
var dismissed = false;
|
||||
var sawProgress = false;
|
||||
await tester.pumpWidget(_harness(onDismissed: () => dismissed = true, onProgress: (p) {
|
||||
if (p > 0.2) sawProgress = true;
|
||||
}));
|
||||
await tester.timedDrag(find.byType(LeafViewer), const Offset(0, 400), const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissed, isTrue);
|
||||
expect(sawProgress, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('1× fast small drag flies off via velocity', (tester) async {
|
||||
var dismissed = false;
|
||||
await tester.pumpWidget(_harness(onDismissed: () => dismissed = true));
|
||||
await tester.timedDrag(find.byType(LeafViewer), const Offset(0, 100), const Duration(milliseconds: 50));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('1× vertical drag anywhere dismisses (no off-image gate)', (tester) async {
|
||||
var dismissed = false;
|
||||
await tester.pumpWidget(_harness(onDismissed: () => dismissed = true));
|
||||
// Image rect is (200,150,400,300) — starting above it still dismisses.
|
||||
await tester.timedDragFrom(const Offset(400, 40), const Offset(0, 400), const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('tap outside the image pops, tap on the image does not', (tester) async {
|
||||
var outside = 0;
|
||||
await tester.pumpWidget(_harness(onTapOutside: () => outside++));
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pump(const Duration(milliseconds: 350)); // flush double-tap countdown
|
||||
expect(outside, 1);
|
||||
await tester.tapAt(const Offset(400, 300));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
expect(outside, 1);
|
||||
});
|
||||
|
||||
testWidgets('wheel zooms from any scale in both directions', (tester) async {
|
||||
await tester.pumpWidget(_harness());
|
||||
await _wheel(tester, -1000);
|
||||
expect(_controller(tester).scale, closeTo(2.0, 0.01)); // zooms from 1×
|
||||
await _wheel(tester, -1000);
|
||||
expect(_controller(tester).scale, closeTo(4.0, 0.01));
|
||||
await _wheel(tester, 3000);
|
||||
expect(_controller(tester).scale, closeTo(1.0, 0.01));
|
||||
await _wheel(tester, 1000); // at 1×, wheel down clamps
|
||||
expect(_controller(tester).scale, 1.0);
|
||||
});
|
||||
|
||||
testWidgets('double-tap cycles 1× → covered → 1:1 → 1× through the viewer', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: LeafViewer(
|
||||
pageCount: 3,
|
||||
pageBuilder: (context, i) => LeafZoomable(
|
||||
childSize: const Size(360, 300),
|
||||
originalSize: const Size(1440, 1200),
|
||||
child: Container(color: const Color(0xFF112233)),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await _doubleTapAt(tester, const Offset(400, 300));
|
||||
expect(_controller(tester).scale, closeTo(2.22, 0.01)); // covered
|
||||
await _doubleTapAt(tester, const Offset(400, 300));
|
||||
expect(_controller(tester).scale, closeTo(2.0, 0.01)); // 1:1
|
||||
await _doubleTapAt(tester, const Offset(400, 300));
|
||||
expect(_controller(tester).scale, closeTo(1.0, 0.01));
|
||||
});
|
||||
|
||||
testWidgets('pinch zooms around the focal point and focal-pans', (tester) async {
|
||||
await tester.pumpWidget(_harness());
|
||||
final g1 = await tester.startGesture(const Offset(300, 300));
|
||||
final g2 = await tester.startGesture(const Offset(500, 300));
|
||||
await tester.pump();
|
||||
await g1.moveTo(const Offset(100, 300)); // dist 400 → 2×, clamped to (0,0)
|
||||
await tester.pump();
|
||||
await g2.moveTo(const Offset(700, 300)); // dist 600 → 3×, anchored at (-50,-150)
|
||||
await tester.pump();
|
||||
final ctrl = _controller(tester);
|
||||
expect(ctrl.scale, closeTo(3.0, 1e-6));
|
||||
expect(ctrl.position, const Offset(-50, -150));
|
||||
await g1.moveBy(const Offset(50, 0)); // focal pan, distance unchanged
|
||||
await g2.moveBy(const Offset(50, 0));
|
||||
await tester.pump();
|
||||
expect(ctrl.position.dx, closeTo(0, 0.01));
|
||||
expect(ctrl.position.dy, closeTo(-150, 0.01));
|
||||
await g1.up();
|
||||
await g2.up();
|
||||
await tester.pump(const Duration(milliseconds: 350)); // flush double-tap countdown
|
||||
});
|
||||
|
||||
testWidgets('per-page zoom state survives page switches', (tester) async {
|
||||
var page = 0;
|
||||
await tester.pumpWidget(_harness(onPageChanged: (i) => page = i));
|
||||
await _pinchOut(tester, 600); // page 0 zoomed to 3×
|
||||
await _slowDrag(tester, const Offset(700, 300), const Offset(-250, 0)); // pan to X clamp
|
||||
await _slowDrag(tester, const Offset(700, 300), const Offset(-500, 0)); // released at edge → page 1
|
||||
expect(page, 1);
|
||||
await _slowDrag(tester, const Offset(100, 300), const Offset(500, 0)); // page 0
|
||||
expect(page, 0);
|
||||
final ctrl = _controller(tester);
|
||||
expect(ctrl.scale, 3.0); // still zoomed
|
||||
expect(ctrl.position, const Offset(-200, -150)); // still clamped
|
||||
});
|
||||
|
||||
testWidgets('pages without LeafZoomable dismiss and page but never pop', (tester) async {
|
||||
var dismissed = false;
|
||||
var outside = 0;
|
||||
var page = 0;
|
||||
await tester.pumpWidget(_harness(
|
||||
pageBuilder: (context, i) => Center(child: Text('page $i')),
|
||||
onDismissed: () => dismissed = true,
|
||||
onTapOutside: () => outside++,
|
||||
onPageChanged: (i) => page = i,
|
||||
));
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
expect(outside, 0);
|
||||
await _slowDrag(tester, const Offset(700, 300), const Offset(-500, 0));
|
||||
expect(page, 1);
|
||||
await tester.timedDrag(find.byType(LeafViewer), const Offset(0, 400), const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissed, isTrue);
|
||||
});
|
||||
|
||||
group('desktop mode', () {
|
||||
testWidgets('mouse drag at 1× neither pages nor dismisses', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
var page = 0;
|
||||
var dismissed = false;
|
||||
await tester.pumpWidget(_harness(onPageChanged: (i) => page = i, onDismissed: () => dismissed = true));
|
||||
final g = await tester.startGesture(const Offset(600, 300), kind: PointerDeviceKind.mouse);
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await g.moveBy(const Offset(-62.5, 0));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
await g.up();
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
expect(page, 0);
|
||||
final g2 = await tester.startGesture(const Offset(400, 200), kind: PointerDeviceKind.mouse);
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await g2.moveBy(const Offset(0, 37.5));
|
||||
await tester.pump(const Duration(milliseconds: 25));
|
||||
}
|
||||
await g2.up();
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
expect(dismissed, isFalse);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('double-tap does nothing on desktop', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
await tester.pumpWidget(_harness());
|
||||
await _doubleTapAt(tester, const Offset(400, 300));
|
||||
expect(_controller(tester).scale, 1.0);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('zoomed mouse drag pans', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
await tester.pumpWidget(_harness());
|
||||
await _pinchOut(tester, 600); // 3× via touch pinch
|
||||
final ctrl = _controller(tester);
|
||||
final g = await tester.startGesture(const Offset(700, 300), kind: PointerDeviceKind.mouse);
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await g.moveBy(const Offset(-18.75, 0));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
await g.up();
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
expect(ctrl.position.dx, lessThan(0));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('default controls show on desktop and page via buttons', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
var page = 0;
|
||||
await tester.pumpWidget(_harness(onPageChanged: (i) => page = i));
|
||||
expect(find.byIcon(Icons.chevron_right), findsOneWidget);
|
||||
expect(find.byIcon(Icons.chevron_left), findsOneWidget);
|
||||
expect(find.byIcon(Icons.close), findsOneWidget);
|
||||
expect(find.text('1 / 3'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.chevron_right));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page, 1);
|
||||
expect(find.text('2 / 3'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.chevron_left));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page, 0);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('custom controlsBuilder replaces the default', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: LeafViewer(
|
||||
pageCount: 3,
|
||||
pageBuilder: _zoomablePage,
|
||||
controlsBuilder: (context, controls) => Text('custom ${controls.currentIndex}'),
|
||||
),
|
||||
),
|
||||
));
|
||||
expect(find.text('custom 0'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.chevron_right), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('close button pops the route', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => Scaffold(body: LeafViewer(pageCount: 3, pageBuilder: _zoomablePage)),
|
||||
)),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byType(LeafViewer), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
expect(find.byType(LeafViewer), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('paging via buttons does not rebuild pages (no black flash)', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
final counts = <int, int>{};
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: LeafViewer(
|
||||
pageCount: 3,
|
||||
pageBuilder: (context, i) {
|
||||
counts[i] = (counts[i] ?? 0) + 1;
|
||||
return _zoomablePage(context, i);
|
||||
},
|
||||
),
|
||||
),
|
||||
));
|
||||
expect(counts[0], 1);
|
||||
await tester.tap(find.byIcon(Icons.chevron_right));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('2 / 3'), findsOneWidget);
|
||||
expect(counts[0], 1); // existing pages are never re-invoked
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('enableSwipeGestures: true emulates touch on desktop', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
var page = 0;
|
||||
var dismissed = false;
|
||||
await tester.pumpWidget(_harness(
|
||||
enableSwipeGestures: true,
|
||||
onPageChanged: (i) => page = i,
|
||||
onDismissed: () => dismissed = true,
|
||||
));
|
||||
final g = await tester.startGesture(const Offset(600, 300), kind: PointerDeviceKind.mouse);
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await g.moveBy(const Offset(-62.5, 0));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
await g.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(page, 1); // mouse swipe pages
|
||||
await tester.timedDrag(find.byType(LeafViewer), const Offset(0, 400), const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissed, isTrue); // mouse drag dismisses
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('enableSwipeGestures: true enables double-tap on desktop', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
await tester.pumpWidget(_harness(enableSwipeGestures: true));
|
||||
await _doubleTapAt(tester, const Offset(400, 300));
|
||||
expect(_controller(tester).scale, greaterThan(1.0));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('enableSwipeGestures: false disables touch gestures on touch platforms', (tester) async {
|
||||
var dismissed = false;
|
||||
await tester.pumpWidget(_harness(enableSwipeGestures: false, onDismissed: () => dismissed = true));
|
||||
await tester.timedDrag(find.byType(LeafViewer), const Offset(0, 400), const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissed, isFalse);
|
||||
await _doubleTapAt(tester, const Offset(400, 300));
|
||||
expect(_controller(tester).scale, 1.0);
|
||||
});
|
||||
|
||||
testWidgets('dismiss without onDismissed pops the route', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
body: LeafViewer(pageCount: 3, pageBuilder: _zoomablePage, enableSwipeGestures: true),
|
||||
),
|
||||
)),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.timedDrag(find.byType(LeafViewer), const Offset(0, 400), const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(LeafViewer), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:leaf_widgets/leaf_widgets.dart';
|
||||
|
||||
const viewport = Size(800, 600);
|
||||
|
||||
LeafZoomController _make({Size child = const Size(400, 300), Size? original, double maxScale = 5.0}) {
|
||||
final ctrl = LeafZoomController(maxScale: maxScale, vsync: TestVSync());
|
||||
ctrl.updateLayout(viewport: viewport, childSize: child, originalSize: original);
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
// photo_view reference implementations (verified against 0.15.0 source)
|
||||
(double, double) _pvCorners(Size outer, Size child, double scale, bool vertical) {
|
||||
final dim = vertical ? child.height : child.width;
|
||||
final outerDim = vertical ? outer.height : outer.width;
|
||||
final diff = dim * scale - outerDim;
|
||||
return (-diff / 2, diff / 2);
|
||||
}
|
||||
|
||||
Offset _pvClamp(Offset pos, double scale, Size outer, Size child) {
|
||||
double fx = 0;
|
||||
if (outer.width < child.width * scale) {
|
||||
final (min, max) = _pvCorners(outer, child, scale, false);
|
||||
fx = pos.dx.clamp(min, max);
|
||||
}
|
||||
double fy = 0;
|
||||
if (outer.height < child.height * scale) {
|
||||
final (min, max) = _pvCorners(outer, child, scale, true);
|
||||
fy = pos.dy.clamp(min, max);
|
||||
}
|
||||
return Offset(fx, fy);
|
||||
}
|
||||
|
||||
// First pump starts the ticker (elapsed 0), the second advances past the
|
||||
// 200ms zoom animation.
|
||||
Future<void> _settle(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('clamp & corners (property test vs photo_view formulas)', () {
|
||||
for (final child in const [Size(400, 300), Size(300, 200), Size(200, 300), Size(1000, 500)]) {
|
||||
for (final scale in const [1.0, 2.0, 3.5, 5.0]) {
|
||||
test('viewport $viewport child $child scale $scale', () {
|
||||
final ctrl = _make(child: child);
|
||||
ctrl.wheelZoom(-(scale - 1) * 1000, viewport.center(Offset.zero));
|
||||
ctrl.panBy(const Offset(100000, -100000));
|
||||
final expected = _pvClamp(const Offset(100000, -100000), scale.clamp(1.0, 5.0), viewport, child);
|
||||
expect(ctrl.scale, scale.clamp(1.0, 5.0));
|
||||
expect(ctrl.position.dx, closeTo(expected.dx, 1e-6));
|
||||
expect(ctrl.position.dy, closeTo(expected.dy, 1e-6));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
group('edge-release (HitCorners port)', () {
|
||||
// child 400x300, scale 3 → X corners ±200, Y corners ±150
|
||||
LeafZoomController zoomed() {
|
||||
final ctrl = _make(child: const Size(400, 300));
|
||||
ctrl.wheelZoom(-2000, const Offset(400, 300));
|
||||
expect(ctrl.scale, 3.0);
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
test('at 1× never claims', () {
|
||||
final ctrl = _make();
|
||||
expect(ctrl.shouldClaim(const Offset(10, 10)), isFalse);
|
||||
expect(ctrl.shouldClaim(const Offset(-10, 0)), isFalse);
|
||||
});
|
||||
|
||||
test('free position claims any direction', () {
|
||||
final ctrl = zoomed();
|
||||
expect(ctrl.shouldClaim(const Offset(10, 10)), isTrue);
|
||||
expect(ctrl.shouldClaim(const Offset(-10, 0)), isTrue);
|
||||
expect(ctrl.shouldClaim(const Offset(0, -10)), isTrue);
|
||||
});
|
||||
|
||||
test('panned right: release on rightward drag, claim on leftward', () {
|
||||
final ctrl = zoomed();
|
||||
ctrl.panBy(const Offset(10000, 0));
|
||||
expect(ctrl.position.dx, 200);
|
||||
expect(ctrl.shouldClaim(const Offset(10, 0)), isFalse);
|
||||
expect(ctrl.shouldClaim(const Offset(-10, 0)), isTrue);
|
||||
});
|
||||
|
||||
test('panned down: release on downward drag, claim on upward', () {
|
||||
final ctrl = zoomed();
|
||||
ctrl.panBy(const Offset(0, 10000));
|
||||
expect(ctrl.position.dy, 150);
|
||||
expect(ctrl.shouldClaim(const Offset(0, 10)), isFalse);
|
||||
expect(ctrl.shouldClaim(const Offset(0, -10)), isTrue);
|
||||
});
|
||||
|
||||
test('fully clamped: release; one free axis: claim (Axis.values)', () {
|
||||
final ctrl = zoomed();
|
||||
ctrl.panBy(const Offset(10000, 10000));
|
||||
expect(ctrl.shouldClaim(const Offset(10, 10)), isFalse);
|
||||
ctrl.panBy(const Offset(-10000, 0));
|
||||
expect(ctrl.shouldClaim(const Offset(10, 10)), isTrue);
|
||||
});
|
||||
|
||||
test('cross-axis drag at edge is a claim when the other axis is free', () {
|
||||
final ctrl = zoomed();
|
||||
ctrl.panBy(const Offset(10000, 0)); // X max, Y free
|
||||
expect(ctrl.shouldClaim(const Offset(0, 10)), isTrue);
|
||||
expect(ctrl.shouldClaim(const Offset(10, 10)), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('double-tap cycle', () {
|
||||
testWidgets('1× → covered → 1:1 → 1×', (tester) async {
|
||||
final ctrl = _make(child: const Size(300, 200), original: const Size(1200, 800));
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester);
|
||||
expect(ctrl.scale, closeTo(3.0, 1e-6)); // covered
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester);
|
||||
expect(ctrl.scale, closeTo(1.5, 1e-6)); // 1:1 = 1/contained(0.667)
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester);
|
||||
expect(ctrl.scale, closeTo(1.0, 1e-6));
|
||||
});
|
||||
|
||||
testWidgets('skips states that change nothing (no originalSize)', (tester) async {
|
||||
final ctrl = _make(child: const Size(300, 200));
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester);
|
||||
expect(ctrl.scale, closeTo(3.0, 1e-6));
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester);
|
||||
expect(ctrl.scale, closeTo(1.0, 1e-6));
|
||||
});
|
||||
|
||||
testWidgets('small image: 1:1 clamps to 1× and is skipped', (tester) async {
|
||||
final ctrl = _make(child: const Size(400, 300), original: const Size(400, 300));
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester);
|
||||
expect(ctrl.scale, closeTo(2.0, 1e-6)); // covered
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester);
|
||||
expect(ctrl.scale, closeTo(1.0, 1e-6)); // 1:1=0.5 clamped → skipped
|
||||
});
|
||||
|
||||
testWidgets('panned/pinched state resets to 1× on double-tap', (tester) async {
|
||||
final ctrl = _make(child: const Size(400, 300));
|
||||
ctrl.wheelZoom(-2000, const Offset(400, 300)); // zoomedIn via gesture
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester);
|
||||
expect(ctrl.scale, closeTo(1.0, 1e-6));
|
||||
expect(ctrl.position, Offset.zero);
|
||||
});
|
||||
|
||||
testWidgets('zoom-in anchors the tap point', (tester) async {
|
||||
final ctrl = _make(child: const Size(300, 200), original: const Size(1200, 800));
|
||||
// At covered (3×) the Y axis exactly fits (200·3 == 600) → clamped to 0,
|
||||
// so tap along X only: target (−20, 0) is inside the ±50 X corners.
|
||||
final tap = const Offset(410, 300);
|
||||
ctrl.doubleTap(tap);
|
||||
await _settle(tester);
|
||||
final vpc = viewport.center(Offset.zero);
|
||||
final expected = (tap - vpc) * (1 - 3.0);
|
||||
expect(ctrl.position.dx, closeTo(expected.dx, 1e-6));
|
||||
expect(ctrl.position.dy, closeTo(expected.dy, 1e-6));
|
||||
final screen = vpc + ctrl.position + (tap - vpc) * ctrl.scale;
|
||||
expect(screen.dx, closeTo(tap.dx, 1e-4));
|
||||
expect(screen.dy, closeTo(tap.dy, 1e-4));
|
||||
});
|
||||
});
|
||||
|
||||
group('gestures', () {
|
||||
test('scaleUpdate scales around the focal point and focal-pans', () {
|
||||
final ctrl = _make(child: const Size(500, 400));
|
||||
ctrl.scaleUpdate(2.0, const Offset(400, 300), const Offset(400, 300));
|
||||
expect(ctrl.scale, 2.0);
|
||||
expect(ctrl.position, const Offset(-100, -100)); // clamped anchor
|
||||
ctrl.scaleUpdate(2.0, const Offset(400, 300), const Offset(430, 320));
|
||||
expect(ctrl.position, const Offset(-70, -80)); // focal delta (30, 20)
|
||||
expect(ctrl.scale, 2.0);
|
||||
});
|
||||
|
||||
test('scaleUpdate with unchanged scale is a pure pan', () {
|
||||
final ctrl = _make(child: const Size(500, 400));
|
||||
ctrl.scaleUpdate(2.0, const Offset(400, 300), const Offset(400, 300));
|
||||
ctrl.scaleUpdate(2.0, const Offset(400, 300), const Offset(430, 320));
|
||||
expect(ctrl.position, const Offset(-70, -80));
|
||||
});
|
||||
|
||||
test('scaleUpdate clamps to max and fires zoom limit', () {
|
||||
var limits = 0;
|
||||
final ctrl = _make(child: const Size(400, 300), maxScale: 5.0)..onZoomLimit = () => limits++;
|
||||
ctrl.scaleUpdate(100, const Offset(400, 300), const Offset(400, 300));
|
||||
expect(ctrl.scale, 5.0);
|
||||
expect(limits, 1);
|
||||
});
|
||||
|
||||
test('wheel zooms cursor-anchored and clamps', () {
|
||||
final ctrl = _make(child: const Size(400, 300));
|
||||
ctrl.wheelZoom(-2000, const Offset(200, 150));
|
||||
expect(ctrl.scale, 3.0);
|
||||
expect(ctrl.position, const Offset(200, 150)); // clamped corner anchor
|
||||
ctrl.wheelZoom(2000, const Offset(200, 150));
|
||||
expect(ctrl.scale, 1.0);
|
||||
expect(ctrl.position, Offset.zero);
|
||||
});
|
||||
|
||||
test('wheel fires zoom limit at 1.0 and max', () {
|
||||
var limits = 0;
|
||||
final ctrl = _make(child: const Size(400, 300))..onZoomLimit = () => limits++;
|
||||
ctrl.wheelZoom(-4000, const Offset(400, 300)); // → 5.0
|
||||
expect(limits, 1);
|
||||
ctrl.wheelZoom(4000, const Offset(400, 300)); // → 1.0
|
||||
expect(limits, 2);
|
||||
});
|
||||
|
||||
test('pan clamps to corners per axis', () {
|
||||
final ctrl = _make(child: const Size(400, 300));
|
||||
ctrl.wheelZoom(-2000, const Offset(400, 300)); // 3× → X corners ±200, Y ±150
|
||||
ctrl.panBy(const Offset(1000, 1000));
|
||||
expect(ctrl.position, const Offset(200, 150));
|
||||
ctrl.panBy(const Offset(-3000, -3000));
|
||||
expect(ctrl.position, const Offset(-200, -150));
|
||||
});
|
||||
|
||||
test('fits axis stays zeroed while the other pans', () {
|
||||
final ctrl = _make(child: const Size(300, 200));
|
||||
ctrl.wheelZoom(-2000, const Offset(400, 300)); // 3× → X overhang 100 (±50), Y exactly fits → zeroed
|
||||
ctrl.panBy(const Offset(1000, 1000));
|
||||
expect(ctrl.position.dx, 50);
|
||||
expect(ctrl.position.dy, 0);
|
||||
});
|
||||
|
||||
test('imageRect is the centered contained rect', () {
|
||||
final ctrl = _make(child: const Size(400, 300));
|
||||
expect(ctrl.imageRect, const Rect.fromLTWH(200, 150, 400, 300));
|
||||
ctrl.wheelZoom(-2000, const Offset(400, 300)); // 3× → 1200×900, corners ±200/±150
|
||||
ctrl.panBy(const Offset(100, 50));
|
||||
expect(ctrl.imageRect, const Rect.fromLTWH(-100, -100, 1200, 900));
|
||||
});
|
||||
});
|
||||
|
||||
group('fling', () {
|
||||
// Scale 3× with child 400×300 → X overhang 400 (corners ±200), Y overhang 300 (±150).
|
||||
LeafZoomController flingZoomed() {
|
||||
final ctrl = _make(child: const Size(400, 300));
|
||||
ctrl.wheelZoom(-2000, const Offset(400, 300));
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
testWidgets('decays with friction and stops', (tester) async {
|
||||
final ctrl = flingZoomed();
|
||||
ctrl.startFling(const Offset(2000, 0));
|
||||
expect(ctrl.isFlinging, isTrue);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(ctrl.position.dx, greaterThan(0));
|
||||
for (var i = 0; i < 300; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
if (!ctrl.isFlinging) break;
|
||||
}
|
||||
expect(ctrl.isFlinging, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('clamps while flinging', (tester) async {
|
||||
final ctrl = flingZoomed();
|
||||
ctrl.startFling(const Offset(100000, 0));
|
||||
await tester.pump();
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
expect(ctrl.position.dx, 200);
|
||||
ctrl.cancelGesture();
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
testWidgets('tap-to-stop via cancelGesture', (tester) async {
|
||||
final ctrl = flingZoomed();
|
||||
ctrl.startFling(const Offset(2000, 0));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
ctrl.cancelGesture();
|
||||
expect(ctrl.isFlinging, isFalse);
|
||||
await tester.pump();
|
||||
});
|
||||
});
|
||||
|
||||
group('animation interrupt', () {
|
||||
testWidgets('cancel mid-zoom-in keeps the current frame', (tester) async {
|
||||
final ctrl = _make(child: const Size(300, 200));
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
ctrl.cancelGesture();
|
||||
expect(ctrl.scale, greaterThan(1.0));
|
||||
expect(ctrl.scale, lessThan(3.0));
|
||||
});
|
||||
|
||||
testWidgets('cancel mid-zoom-out snaps to 1×', (tester) async {
|
||||
final ctrl = _make(child: const Size(300, 200));
|
||||
ctrl.doubleTap(const Offset(400, 300));
|
||||
await _settle(tester); // covered
|
||||
ctrl.doubleTap(const Offset(400, 300)); // → 1×
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
ctrl.cancelGesture();
|
||||
expect(ctrl.scale, 1.0);
|
||||
expect(ctrl.position, Offset.zero);
|
||||
});
|
||||
});
|
||||
|
||||
group('layout changes', () {
|
||||
test('cycle states recompute on layout change', () {
|
||||
final ctrl = _make(child: const Size(300, 200));
|
||||
// covered = 3 at 800×600
|
||||
// force state via wheel? states only change via doubleTap/gestures; use doubleTap in testWidgets? plain test: wheelZoom sets zoomedIn (kept).
|
||||
ctrl.updateLayout(viewport: const Size(400, 300), childSize: const Size(300, 200));
|
||||
expect(ctrl.scale, 1.0);
|
||||
// zoomedIn state keeps its scale
|
||||
ctrl.wheelZoom(-1000, const Offset(200, 150));
|
||||
expect(ctrl.scale, 2.0);
|
||||
ctrl.updateLayout(viewport: const Size(400, 300), childSize: const Size(300, 200));
|
||||
expect(ctrl.scale, 2.0);
|
||||
expect(ctrl.position, Offset.zero); // reclamped
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user