feat: херо анимейшон фром реанимейшон

This commit is contained in:
Jganenokk
2026-07-26 22:39:09 +07:00
parent 90ca80431d
commit 3b34ef0535
12 changed files with 1254 additions and 227 deletions
+188
View File
@@ -0,0 +1,188 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/attachment/photo_hero.dart';
const Rect _origin = Rect.fromLTWH(50, 500, 100, 100);
class _TestImageProvider extends ImageProvider<_TestImageProvider> {
_TestImageProvider(this.image);
final ui.Image image;
@override
Future<_TestImageProvider> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<_TestImageProvider>(this);
@override
ImageStreamCompleter loadImage(
_TestImageProvider key,
ImageDecoderCallback decode,
) => OneFrameImageStreamCompleter(
SynchronousFuture<ImageInfo>(ImageInfo(image: image.clone())),
);
}
Widget _page() => Scaffold(
body: Column(
children: [
Expanded(
child: PhotoHeroTarget(
child: Center(
child: Container(key: const ValueKey('target'), color: Colors.red),
),
),
),
const SizedBox(height: 100),
],
),
);
Finder get _flying => find.byType(Image);
double _flyingWidth(WidgetTester tester) => tester.getSize(_flying).width;
bool _targetHidden(WidgetTester tester) => tester.any(
find.ancestor(
of: find.byKey(const ValueKey('target')),
matching: find.byType(Opacity),
),
);
double _pageOpacity(WidgetTester tester) => tester
.widget<FadeTransition>(
find
.ancestor(
of: find.byKey(const ValueKey('target')),
matching: find.byType(FadeTransition),
)
.first,
)
.opacity
.value;
void main() {
late ui.Image image;
setUpAll(() async {
image = await createTestImage(width: 4, height: 3);
});
testWidgets('photo flies from the origin rect to the contained target', (
tester,
) async {
final hero = PhotoHeroController(
origin: () => _origin,
image: _TestImageProvider(image),
);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (ctx) {
context = ctx;
return const Scaffold();
},
),
),
);
Navigator.of(
context,
).push(PhotoHeroRoute<void>(hero: hero, builder: (_) => _page()));
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(_flying, findsOneWidget);
expect(_targetHidden(tester), isTrue);
final early = _flyingWidth(tester);
expect(early, greaterThanOrEqualTo(133));
await tester.pump(const Duration(milliseconds: 150));
final late_ = _flyingWidth(tester);
expect(late_, greaterThan(early));
expect(late_, lessThan(667));
await tester.pumpAndSettle();
expect(_flying, findsNothing);
expect(_targetHidden(tester), isFalse);
expect(tester.getSize(find.byKey(const ValueKey('target'))).height, 500);
});
testWidgets('photo flies back to the origin rect on pop', (tester) async {
final hero = PhotoHeroController(
origin: () => _origin,
image: _TestImageProvider(image),
);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (ctx) {
context = ctx;
return const Scaffold();
},
),
),
);
final navigator = Navigator.of(context);
navigator.push(PhotoHeroRoute<void>(hero: hero, builder: (_) => _page()));
await tester.pumpAndSettle();
navigator.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(_flying, findsOneWidget);
expect(_targetHidden(tester), isTrue);
expect(_flyingWidth(tester), lessThan(667));
await tester.pumpAndSettle();
expect(_flying, findsNothing);
});
testWidgets('disabled hero fades the page instead of flying', (tester) async {
final hero = PhotoHeroController(
origin: () => _origin,
image: _TestImageProvider(image),
)..enabled = false;
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (ctx) {
context = ctx;
return const Scaffold();
},
),
),
);
Navigator.of(
context,
).push(PhotoHeroRoute<void>(hero: hero, builder: (_) => _page()));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(_flying, findsNothing);
expect(_targetHidden(tester), isFalse);
expect(_pageOpacity(tester), lessThan(1));
await tester.pumpAndSettle();
expect(_pageOpacity(tester), 1);
});
testWidgets('target renders untouched without a hero scope', (tester) async {
await tester.pumpWidget(MaterialApp(home: _page()));
await tester.pumpAndSettle();
expect(find.byType(Opacity), findsNothing);
expect(tester.getSize(find.byKey(const ValueKey('target'))).height, 500);
});
}
+199
View File
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/profile_hero.dart';
const _headerStyle = TextStyle(fontSize: 17, fontWeight: FontWeight.w600);
const _profileStyle = TextStyle(fontSize: 22, fontWeight: FontWeight.w700);
final _tag = UniqueKey();
Widget _header({Object? tag}) => Scaffold(
body: Row(
children: [
ProfileHeroAvatar(
tag: tag,
size: 44,
child: Container(width: 44, height: 44, color: Colors.red),
),
ProfileHeroName(
tag: tag,
text: 'Ann',
style: _headerStyle,
child: const Text('Ann', style: _headerStyle),
),
],
),
);
Widget _profile({Object? tag, bool loaded = false}) => Scaffold(
body: Column(
children: [
ProfileHeroAvatar(
tag: tag,
size: 96,
child: Container(width: 96, height: 96, color: Colors.red),
),
ProfileHeroName(
tag: tag,
text: 'Ann',
style: _profileStyle,
child: const Text('Ann', style: _profileStyle),
),
if (loaded) const Text('details'),
],
),
);
Iterable<double> _fontSizes(WidgetTester tester) => tester
.widgetList<Text>(find.byType(Text))
.map(
(t) =>
t.style?.fontSize ??
DefaultTextStyle.of(tester.element(find.byWidget(t))).style.fontSize,
)
.whereType<double>();
double _flyingAvatarWidth(WidgetTester tester) =>
tester.getSize(find.byType(FittedBox).first).width;
TextStyle _flyingNameStyle(WidgetTester tester) {
final transition = find.byType(DefaultTextStyleTransition);
expect(transition, findsOneWidget);
return DefaultTextStyle.of(
tester.element(
find.descendant(of: transition, matching: find.byType(Text)),
),
).style;
}
void main() {
testWidgets('avatar and name fly between header and profile', (tester) async {
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigator,
home: _header(tag: _tag),
),
);
navigator.currentState!.push(
MaterialPageRoute(builder: (_) => _profile(tag: _tag)),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(_fontSizes(tester).any((s) => s > 17 && s < 22), isTrue);
final pushWidth = _flyingAvatarWidth(tester);
expect(pushWidth, greaterThan(44));
expect(pushWidth, lessThan(96));
await tester.pumpAndSettle();
expect(find.byType(FittedBox), findsNothing);
navigator.currentState!.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(_fontSizes(tester).any((s) => s > 17 && s < 22), isTrue);
final popWidth = _flyingAvatarWidth(tester);
expect(popWidth, greaterThan(44));
expect(popWidth, lessThan(96));
await tester.pumpAndSettle();
expect(find.byType(FittedBox), findsNothing);
});
testWidgets(
'flying name inherits no decoration from the app fallback style',
(tester) async {
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigator,
home: _header(tag: _tag),
),
);
navigator.currentState!.push(
MaterialPageRoute(builder: (_) => _profile(tag: _tag)),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
final style = _flyingNameStyle(tester);
expect(style.decoration, isNull);
expect(style.fontFamily, isNull);
expect(style.fontSize, greaterThan(17));
expect(style.fontSize, lessThan(22));
await tester.pumpAndSettle();
},
);
testWidgets('flight stays opaque when the destination finishes loading', (
tester,
) async {
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigator,
home: _header(tag: _tag),
),
);
var loaded = false;
late StateSetter setProfileState;
navigator.currentState!.push(
MaterialPageRoute(
builder: (_) => StatefulBuilder(
builder: (_, setState) {
setProfileState = setState;
return _profile(tag: _tag, loaded: loaded);
},
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
setProfileState(() => loaded = true);
var minOpacity = 1.0;
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 10));
final flying = find.byType(FittedBox);
if (flying.evaluate().isEmpty) break;
final fade = tester.widget<FadeTransition>(
find.ancestor(of: flying, matching: find.byType(FadeTransition)),
);
minOpacity = fade.opacity.value < minOpacity
? fade.opacity.value
: minOpacity;
}
expect(minOpacity, 1.0);
await tester.pumpAndSettle();
expect(find.byType(FittedBox), findsNothing);
});
testWidgets('a null tag opts out of the flight entirely', (tester) async {
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigator,
home: _header(tag: _tag),
),
);
navigator.currentState!.push(
MaterialPageRoute(builder: (_) => _profile(tag: null)),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(find.byType(FittedBox), findsNothing);
expect(find.byType(DefaultTextStyleTransition), findsNothing);
await tester.pumpAndSettle();
});
}