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
@@ -6,6 +6,7 @@ import 'package:komet/core/config/app_frost.dart';
import 'package:komet/frontend/widgets/encryption_lock_badge.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/online_dot.dart';
import 'package:komet/frontend/widgets/profile_hero.dart';
class ChatHeaderRow extends StatelessWidget {
final bool glossy;
@@ -15,6 +16,7 @@ class ChatHeaderRow extends StatelessWidget {
final ColorScheme cs;
final bool embedded;
final int chatId;
final Object heroTag;
final String name;
final String imageUrl;
final String chatType;
@@ -40,6 +42,7 @@ class ChatHeaderRow extends StatelessWidget {
required this.cs,
required this.embedded,
required this.chatId,
required this.heroTag,
required this.name,
required this.imageUrl,
required this.chatType,
@@ -66,6 +69,12 @@ class ChatHeaderRow extends StatelessWidget {
double? get _pillBlur => frosted && !liquid ? AppFrost.sigma : null;
Widget _glossyRow(BuildContext context) {
final nameStyle = TextStyle(
color: cs.onSurface,
fontSize: 17,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
);
return Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
child: Row(
@@ -111,28 +120,32 @@ class ChatHeaderRow extends StatelessWidget {
children: [
_withOnlineDot(
cs,
imageUrl.isNotEmpty
? CircleAvatar(
radius: 22,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
maxWidth: 144,
maxHeight: 144,
),
)
: CircleAvatar(
radius: 22,
backgroundColor: cs.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
ProfileHeroAvatar(
tag: heroTag,
size: 44,
child: imageUrl.isNotEmpty
? CircleAvatar(
radius: 22,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
maxWidth: 144,
maxHeight: 144,
),
)
: CircleAvatar(
radius: 22,
backgroundColor: cs.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
@@ -144,16 +157,16 @@ class ChatHeaderRow extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
name,
style: TextStyle(
color: cs.onSurface,
fontSize: 17,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
child: ProfileHeroName(
tag: heroTag,
text: name,
style: nameStyle,
child: Text(
name,
style: nameStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isOfficial) ...[
@@ -242,6 +255,12 @@ class ChatHeaderRow extends StatelessWidget {
}
Widget _materialRow(BuildContext context) {
final nameStyle = TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
);
return Row(
children: [
_backWithBadge(
@@ -268,26 +287,30 @@ class ChatHeaderRow extends StatelessWidget {
children: [
_withOnlineDot(
cs,
imageUrl.isNotEmpty
? CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
maxWidth: 144,
maxHeight: 144,
),
)
: CircleAvatar(
radius: 18,
backgroundColor: cs.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 12,
ProfileHeroAvatar(
tag: heroTag,
size: 36,
child: imageUrl.isNotEmpty
? CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
maxWidth: 144,
maxHeight: 144,
),
)
: CircleAvatar(
radius: 18,
backgroundColor: cs.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 12,
),
),
),
),
),
dotSize: 11,
),
const SizedBox(width: 12),
@@ -300,16 +323,16 @@ class ChatHeaderRow extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
name,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
child: ProfileHeroName(
tag: heroTag,
text: name,
style: nameStyle,
child: Text(
name,
style: nameStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isOfficial) ...[
@@ -21,6 +21,7 @@ import '../../widgets/formatted_message_text.dart';
import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/profile_hero.dart';
import '../../widgets/swipe_route.dart';
import '../../../backend/modules/chats.dart';
import '../contacts/open_contact_profile.dart';
@@ -67,6 +68,7 @@ class ChatInfoScreen extends StatefulWidget {
final int? dialogPeerId;
final ChatInfoTab? initialTab;
final Object? heroTag;
final void Function(String messageId, int time)? onJumpToMessage;
@@ -78,6 +80,7 @@ class ChatInfoScreen extends StatefulWidget {
required this.chatType,
this.dialogPeerId,
this.initialTab,
this.heroTag,
this.onJumpToMessage,
});
@@ -382,7 +385,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
void _onBodyScroll() {
if (!mounted || widget.chatType != 'CHAT') return;
if (_membersLoading || _membersEnd) return;
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers) return;
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers)
return;
final pos = _bodyScrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 400) {
_fetchMembersPage();
@@ -437,9 +441,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
backgroundColor: cs.surface,
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
floatingActionButton: const ConnectionSpinner(),
body: SafeArea(
child: _isLoading ? _buildShimmer(cs) : _buildScrollBody(cs),
),
body: SafeArea(child: _buildScrollBody(cs)),
);
}
@@ -473,19 +475,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
const SizedBox(height: 14),
_buildNameRow(cs),
const SizedBox(height: 4),
Text(
_subtitle(),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
_buildActions(cs),
const SizedBox(height: 16),
_buildPersistentInfo(cs),
_buildTabBar(cs),
const SizedBox(height: 12),
_buildTabContent(cs),
const SizedBox(height: 40),
if (_isLoading)
..._loadingBlocks(cs)
else ...[
Text(
_subtitle(),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
_buildActions(cs),
const SizedBox(height: 16),
_buildPersistentInfo(cs),
_buildTabBar(cs),
const SizedBox(height: 12),
_buildTabContent(cs),
const SizedBox(height: 40),
],
],
),
);
@@ -504,9 +510,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
bool get _peerDeleted => _contactData?.isDeleted ?? false;
String _joinName(String first, String last) => last.trim().isEmpty
? first.trim()
: '${first.trim()} ${last.trim()}';
String _joinName(String first, String last) =>
last.trim().isEmpty ? first.trim() : '${first.trim()} ${last.trim()}';
String get _customName {
final c = _localContact;
@@ -595,42 +600,53 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
color: cs.onSurface,
fontSize: 22,
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
);
final custom = _customName;
final real = _realName;
final hasToggle = _isContact && real != null && real != custom;
final nameSwap = AnimatedTextSwap(
showAlternate: _showRealName,
alignment: Alignment.center,
alternate: Text(
real ?? custom,
style: nameStyle,
textAlign: TextAlign.center,
),
child: Text(custom, style: nameStyle, textAlign: TextAlign.center),
);
if (!hasToggle) return nameSwap;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(width: 36),
Flexible(child: nameSwap),
Flexible(
child: ProfileHeroName(
tag: widget.heroTag,
text: custom,
style: nameStyle,
child: AnimatedTextSwap(
showAlternate: _showRealName,
alignment: Alignment.center,
alternate: Text(
real ?? custom,
style: nameStyle,
textAlign: TextAlign.center,
),
child: Text(
custom,
style: nameStyle,
textAlign: TextAlign.center,
),
),
),
),
SizedBox(
width: 36,
child: IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
iconSize: 20,
color: _showRealName ? cs.primary : cs.onSurfaceVariant,
icon: Icon(
_showRealName ? Symbols.visibility : Symbols.visibility_off,
),
tooltip: real,
onPressed: () => setState(() => _showRealName = !_showRealName),
),
child: hasToggle
? IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
iconSize: 20,
color: _showRealName ? cs.primary : cs.onSurfaceVariant,
icon: Icon(
_showRealName ? Symbols.visibility : Symbols.visibility_off,
),
tooltip: real,
onPressed: () =>
setState(() => _showRealName = !_showRealName),
)
: null,
),
],
);
@@ -643,7 +659,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
if (_peerDeleted) return l10n.chatInfoMemberDeleted;
if (_isBot) return l10n.contactProfileBot;
if (_isOnline) return l10n.contactProfileOnline;
if (_presenceStatus == 2 || _presenceStatus == 3) return l10n.contactProfileRecentlyActive;
if (_presenceStatus == 2 || _presenceStatus == 3)
return l10n.contactProfileRecentlyActive;
if (_seenTime != null && _seenTime! > 0) {
return formatLastSeen(_seenTime!);
}
@@ -1663,27 +1680,36 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
}
Widget _avatar() {
if (_peerDeleted) return _ghostAvatar(radius: 48, fontSize: 52);
final avatar = KometAvatar(
name: widget.name,
imageUrl: widget.imageUrl,
size: 96,
fontSize: 36,
);
const size = 96.0;
final peerId = widget.chatType == 'DIALOG' ? _otherId : null;
if (peerId == null || widget.imageUrl.isEmpty) return avatar;
return GestureDetector(
onTap: () => AvatarHistoryScreen.open(
context,
contactId: peerId,
name: widget.name,
currentAvatarUrl: widget.imageUrl,
final hasHistory =
peerId != null && widget.imageUrl.isNotEmpty && !_peerDeleted;
return ProfileHeroAvatar(
tag: widget.heroTag,
size: size,
child: GestureDetector(
onTap: hasHistory
? () => AvatarHistoryScreen.open(
context,
contactId: peerId,
name: widget.name,
currentAvatarUrl: widget.imageUrl,
)
: null,
child: _peerDeleted
? _ghostAvatar(radius: size / 2, fontSize: 52)
: KometAvatar(
name: widget.name,
imageUrl: widget.imageUrl,
size: size,
fontSize: 36,
fadeIn: false,
),
),
child: avatar,
);
}
Widget _buildShimmer(ColorScheme cs) {
List<Widget> _loadingBlocks(ColorScheme cs) {
Widget block(double w, double h, {double r = 8}) => Container(
width: w,
height: h,
@@ -1693,21 +1719,15 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
),
);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 60, 16, 0),
children: [
Center(child: block(96, 96, r: 48)),
const SizedBox(height: 14),
Center(child: block(160, 22, r: 8)),
const SizedBox(height: 8),
Center(child: block(110, 16, r: 6)),
const SizedBox(height: 24),
Center(child: block(240, 54, r: 14)),
const SizedBox(height: 16),
block(double.infinity, 36, r: 20),
const SizedBox(height: 12),
block(double.infinity, 120, r: 14),
],
);
return [
const SizedBox(height: 4),
block(110, 16, r: 6),
const SizedBox(height: 24),
block(240, 54, r: 14),
const SizedBox(height: 16),
block(double.infinity, 36, r: 20),
const SizedBox(height: 12),
block(double.infinity, 120, r: 14),
];
}
}
@@ -266,6 +266,7 @@ class _ChatScreenState extends State<ChatScreen>
int _readMarkTime = 0;
Timer? _readMarkTimer;
final GlobalKey _listKey = GlobalKey();
final Object _profileHeroTag = UniqueKey();
final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true;
bool _encryptionEnabled = false;
@@ -1038,6 +1039,7 @@ class _ChatScreenState extends State<ChatScreen>
name: _headerName(),
imageUrl: widget.imageUrl,
chatType: widget.chatType,
heroTag: _profileHeroTag,
initialTab: initialTab,
onJumpToMessage: (chatRoute == null || widget.embedded)
? null
@@ -2966,6 +2968,7 @@ class _ChatScreenState extends State<ChatScreen>
cs: cs,
embedded: widget.embedded,
chatId: widget.chatId,
heroTag: _profileHeroTag,
name: _headerName(),
imageUrl: widget.imageUrl,
chatType: widget.chatType,
@@ -13,6 +13,7 @@ import 'package:komet/core/utils/format.dart';
import 'package:komet/frontend/widgets/attachment/contact_picker_page.dart';
import 'package:komet/frontend/widgets/attachment/media_preview_screen.dart';
import 'package:komet/frontend/widgets/attachment/photo_editor.dart';
import 'package:komet/frontend/widgets/attachment/photo_hero.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/sheet_helpers.dart';
import 'package:komet/frontend/widgets/sliding_pill_nav.dart';
@@ -84,6 +85,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
final GallerySource _source = GallerySource.create();
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
final Map<String, GlobalKey<_ThumbnailState>> _thumbKeys = {};
final Map<String, PhotoEditState> _edits = {};
final Set<String> _tempFiles = {};
final Set<String> _sentFiles = {};
@@ -154,11 +156,21 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
_selected.value = next;
}
GlobalKey<_ThumbnailState> _thumbKey(String id) =>
_thumbKeys.putIfAbsent(id, () => GlobalKey<_ThumbnailState>());
void _openPreview(GalleryItem item) {
final thumbKey = _thumbKey(item.id);
final hero = PhotoHeroController(
origin: () => photoHeroRect(thumbKey),
image: thumbKey.currentState?.provider,
);
Navigator.of(context).push(
MaterialPageRoute(
PhotoHeroRoute<void>(
hero: hero,
builder: (_) => MediaPreviewScreen(
item: item,
hero: hero,
title: widget.title,
selectedIds: _selected,
onToggleSelection: () => _toggleSelection(item),
@@ -478,6 +490,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
final item = gridPhotos[index];
return _GalleryTile(
key: ValueKey(item.id),
thumbKey: _thumbKey(item.id),
item: item,
selectedIds: _selected,
onOpen: () => _openPreview(item),
@@ -501,6 +514,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
final item = photos[i];
return _GalleryTile(
key: ValueKey(item.id),
thumbKey: _thumbKey(item.id),
item: item,
selectedIds: _selected,
onOpen: () => _openPreview(item),
@@ -889,6 +903,7 @@ class _CameraTile extends StatelessWidget {
}
class _GalleryTile extends StatefulWidget {
final GlobalKey<_ThumbnailState> thumbKey;
final GalleryItem item;
final ValueListenable<Set<String>> selectedIds;
final VoidCallback onOpen;
@@ -898,6 +913,7 @@ class _GalleryTile extends StatefulWidget {
const _GalleryTile({
super.key,
required this.thumbKey,
required this.item,
required this.selectedIds,
required this.onOpen,
@@ -944,6 +960,7 @@ class _GalleryTileState extends State<_GalleryTile> {
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: _Thumbnail(
key: widget.thumbKey,
item: item,
editedFile: widget.editedFile,
cs: widget.cs,
@@ -1039,7 +1056,12 @@ class _Thumbnail extends StatefulWidget {
final File? editedFile;
final ColorScheme cs;
const _Thumbnail({required this.item, this.editedFile, required this.cs});
const _Thumbnail({
super.key,
required this.item,
this.editedFile,
required this.cs,
});
@override
State<_Thumbnail> createState() => _ThumbnailState();
@@ -1047,50 +1069,53 @@ class _Thumbnail extends StatefulWidget {
class _ThumbnailState extends State<_Thumbnail> {
static const int _pixelSize = 320;
Future<Uint8List?>? _future;
ImageProvider? _provider;
ImageProvider? get provider => _provider;
@override
void initState() {
super.initState();
if (widget.item.localFile == null) {
_future = widget.item.thumbnail(_pixelSize);
_resolveProvider();
}
@override
void didUpdateWidget(covariant _Thumbnail oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.editedFile?.path != oldWidget.editedFile?.path ||
widget.item.id != oldWidget.item.id) {
setState(_resolveProvider);
}
}
void _resolveProvider() {
final file = widget.editedFile ?? widget.item.localFile;
if (file != null) {
_provider = ResizeImage(
FileImage(file),
width: _pixelSize,
allowUpscaling: false,
);
return;
}
_provider = null;
final id = widget.item.id;
widget.item.thumbnail(_pixelSize).then((data) {
if (!mounted || data == null || widget.item.id != id) return;
if (widget.editedFile != null || widget.item.localFile != null) return;
setState(() => _provider = MemoryImage(data));
});
}
@override
Widget build(BuildContext context) {
final edited = widget.editedFile;
if (edited != null) {
return Image.file(
edited,
fit: BoxFit.cover,
cacheWidth: _pixelSize,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _placeholder(),
);
}
final file = widget.item.localFile;
if (file != null) {
return Image.file(
file,
fit: BoxFit.cover,
cacheWidth: _pixelSize,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _placeholder(),
);
}
return FutureBuilder<Uint8List?>(
future: _future,
builder: (context, snapshot) {
final data = snapshot.data;
if (data == null) return _placeholder();
return Image.memory(
data,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _placeholder(),
);
},
final provider = _provider;
if (provider == null) return _placeholder();
return Image(
image: provider,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => _placeholder(),
);
}
@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../../models/attachment.dart';
import '../../photo_viewer.dart';
import '../photo_hero.dart';
import 'bubble_context.dart';
class PhotoBubble extends StatelessWidget {
@@ -133,13 +134,17 @@ class PhotoBubble extends StatelessWidget {
? (ctx.isMe ? _smallRadius : _bigRadius)
: _smallRadius;
final radius = BorderRadius.only(
topLeft: topR,
topRight: topR,
bottomLeft: bottomL,
bottomRight: bottomR,
);
final memWidth = (constrainedWidth * dpr).round();
final memHeight = (constrainedHeight * dpr).round();
return ClipRRect(
borderRadius: BorderRadius.only(
topLeft: topR,
topRight: topR,
bottomLeft: bottomL,
bottomRight: bottomR,
),
borderRadius: radius,
child: Stack(
children: [
_buildPhotoImage(
@@ -147,16 +152,25 @@ class PhotoBubble extends StatelessWidget {
photo,
constrainedWidth,
constrainedHeight,
memWidth: (constrainedWidth * dpr).round(),
memHeight: (constrainedHeight * dpr).round(),
memWidth: memWidth,
memHeight: memHeight,
),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, 0),
if (ctx.uploadProgress == null)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(ctx.context, 0),
child: Builder(
builder: (tileContext) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(
ctx.context,
0,
tileContext: tileContext,
radius: radius,
memWidth: memWidth,
memHeight: memHeight,
),
),
),
),
],
@@ -350,17 +364,30 @@ class PhotoBubble extends StatelessWidget {
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, index),
if (ctx.uploadProgress == null)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(ctx.context, index),
),
),
_buildTileTapTarget(ctx, index, cachePx),
],
),
);
}
Widget _buildTileTapTarget(BubbleContext ctx, int index, int cachePx) {
return Positioned.fill(
child: Builder(
builder: (tileContext) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(
ctx.context,
index,
tileContext: tileContext,
radius: BorderRadius.zero,
memWidth: cachePx,
memHeight: cachePx,
),
),
),
);
}
Widget _buildPhotoTileWithOverlay(
BubbleContext ctx,
PhotoAttachment photo,
@@ -402,12 +429,7 @@ class PhotoBubble extends StatelessWidget {
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, index),
if (ctx.uploadProgress == null)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(ctx.context, index),
),
),
_buildTileTapTarget(ctx, index, cachePx),
],
),
);
@@ -437,16 +459,60 @@ class PhotoBubble extends StatelessWidget {
);
}
void _openPhotoViewer(BuildContext context, int index) {
static ImageProvider? _photoProvider(
PhotoAttachment photo, {
required int memWidth,
required int memHeight,
}) {
final localPath = photo.localPath;
if (localPath != null) {
return ResizeImage.resizeIfNeeded(
memWidth,
null,
FileImage(File(localPath)),
);
}
final url = photo.baseUrl ?? '';
if (url.isEmpty) return null;
return ResizeImage.resizeIfNeeded(
memWidth,
memHeight,
CachedNetworkImageProvider(url),
);
}
static Size? _photoSize(PhotoAttachment photo) {
final width = photo.width ?? 0;
final height = photo.height ?? 0;
if (width <= 0 || height <= 0) return null;
return Size(width.toDouble(), height.toDouble());
}
void _openPhotoViewer(
BuildContext context,
int index, {
required BuildContext tileContext,
required BorderRadius radius,
required int memWidth,
required int memHeight,
}) {
final photo = photos[index];
final hero = PhotoHeroController(
origin: () => photoHeroRectOf(tileContext),
image: _photoProvider(photo, memWidth: memWidth, memHeight: memHeight),
size: _photoSize(photo),
radius: radius,
);
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
PhotoHeroRoute<void>(
hero: hero,
builder: (_) => PhotoViewerScreen(
photos: photos,
initialIndex: index,
chatId: ctx.chatId,
message: ctx.message,
actions: ctx.photoActions,
hero: hero,
),
),
);
@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/frontend/widgets/attachment/photo_editor.dart';
import 'package:komet/frontend/widgets/attachment/photo_hero.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import '../../../core/config/app_colors.dart';
@@ -16,6 +17,7 @@ const Color _kBar = Color(0xFF1E1E1E);
class MediaPreviewScreen extends StatefulWidget {
final GalleryItem item;
final PhotoHeroController hero;
final String? title;
final ValueListenable<Set<String>> selectedIds;
final VoidCallback onToggleSelection;
@@ -29,6 +31,7 @@ class MediaPreviewScreen extends StatefulWidget {
const MediaPreviewScreen({
super.key,
required this.item,
required this.hero,
required this.selectedIds,
required this.onToggleSelection,
required this.onSend,
@@ -48,6 +51,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
late final TextEditingController _caption = TextEditingController(
text: widget.initialCaption,
);
final TransformationController _zoom = TransformationController();
File? _workingFile;
File? _cropSource;
CropState? _cropState;
@@ -55,6 +59,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
@override
void initState() {
super.initState();
_zoom.addListener(_syncHero);
_caption.addListener(() => widget.onCaptionChanged?.call(_caption.text));
_cropState = widget.editState?.cropState;
_cropSource = widget.editState?.cropSource;
@@ -64,20 +69,29 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
Future<void> _resolveWorkingFile() async {
final initial = widget.editState?.working ?? widget.item.localFile;
if (initial != null) {
_workingFile = initial;
_setWorkingFile(initial);
return;
}
final file = await widget.item.originFile();
if (!mounted) return;
setState(() => _workingFile = file);
if (!mounted || file == null) return;
setState(() => _setWorkingFile(file));
}
void _setWorkingFile(File file) {
_workingFile = file;
widget.hero.image.value = FileImage(file);
}
@override
void dispose() {
_caption.dispose();
_zoom.dispose();
super.dispose();
}
void _syncHero() =>
widget.hero.enabled = _zoom.value.getMaxScaleOnAxis() <= 1.01;
void _send() {
Navigator.of(context).pop();
widget.onSend();
@@ -122,7 +136,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
final old = _workingFile;
_cropState = result.state;
widget.tempFiles.add(result.file.path);
setState(() => _workingFile = result.file);
setState(() => _setWorkingFile(result.file));
_reportEdit();
_disposeTemp(old, {result.file.path, _cropSource?.path ?? ''});
}
@@ -146,7 +160,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
_cropSource = result;
_cropState = null;
widget.tempFiles.add(result.path);
setState(() => _workingFile = result);
setState(() => _setWorkingFile(result));
_reportEdit();
_disposeTemp(oldWorking, {result.path});
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
@@ -163,7 +177,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
_cropSource = result;
_cropState = null;
widget.tempFiles.add(result.path);
setState(() => _workingFile = result);
setState(() => _setWorkingFile(result));
_reportEdit();
_disposeTemp(oldWorking, {result.path});
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
@@ -202,11 +216,14 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
body: Column(
children: [
Expanded(
child: Center(
child: InteractiveViewer(
minScale: 1,
maxScale: 4,
child: _buildImage(),
child: PhotoHeroTarget(
child: Center(
child: InteractiveViewer(
minScale: 1,
maxScale: 4,
transformationController: _zoom,
child: _buildImage(),
),
),
),
),
@@ -217,11 +234,19 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
}
Widget _buildImage() {
final file = _workingFile;
if (file == null) {
return const SmallSpinner(size: 36, color: Colors.white24);
}
return Image.file(file, fit: BoxFit.contain, gaplessPlayback: true);
return ValueListenableBuilder<ImageProvider?>(
valueListenable: widget.hero.image,
builder: (context, provider, _) {
if (provider == null) {
return const SmallSpinner(size: 36, color: Colors.white24);
}
return Image(
image: provider,
fit: BoxFit.contain,
gaplessPlayback: true,
);
},
);
}
Widget _buildBottomBar() {
@@ -0,0 +1,314 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
typedef PhotoHeroOrigin = Rect? Function();
Rect? photoHeroRect(GlobalKey? key) =>
_globalRect(key?.currentContext?.findRenderObject());
Rect? photoHeroRectOf(BuildContext context) =>
context.mounted ? _globalRect(context.findRenderObject()) : null;
Rect? _globalRect(RenderObject? object) {
if (object is! RenderBox || !object.attached || !object.hasSize) return null;
final size = object.size;
if (size.isEmpty) return null;
return MatrixUtils.transformRect(
object.getTransformTo(null),
Offset.zero & size,
);
}
Rect _inscribe(Size source, Rect box, {required bool cover}) {
if (source.isEmpty || box.isEmpty) return box;
final scaleX = box.width / source.width;
final scaleY = box.height / source.height;
final scale = cover ? math.max(scaleX, scaleY) : math.min(scaleX, scaleY);
return Alignment.center.inscribe(
Size(source.width * scale, source.height * scale),
box,
);
}
class PhotoHeroController {
PhotoHeroController({
required this.origin,
ImageProvider? image,
this.size,
this.radius = BorderRadius.zero,
}) : image = ValueNotifier(image);
final PhotoHeroOrigin origin;
final ValueNotifier<ImageProvider?> image;
final ValueNotifier<bool> flying = ValueNotifier(false);
final Size? size;
final BorderRadius radius;
final GlobalKey areaKey = GlobalKey();
bool enabled = true;
Rect? get areaRect => photoHeroRect(areaKey);
Rect? get originRect => enabled ? origin() : null;
bool get canFly => image.value != null && originRect != null;
void dispose() {
image.dispose();
flying.dispose();
}
}
class PhotoHeroRoute<T> extends PageRouteBuilder<T> {
PhotoHeroRoute({required this.hero, required WidgetBuilder builder})
: super(
transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 280),
pageBuilder: (context, animation, secondaryAnimation) =>
PhotoHeroScope(controller: hero, child: builder(context)),
transitionsBuilder:
(context, animation, secondaryAnimation, child) =>
_PhotoHeroTransition(
controller: hero,
animation: animation,
child: child,
),
);
final PhotoHeroController hero;
@override
void dispose() {
hero.dispose();
super.dispose();
}
}
class PhotoHeroScope extends InheritedWidget {
const PhotoHeroScope({
super.key,
required this.controller,
required super.child,
});
final PhotoHeroController controller;
static PhotoHeroController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<PhotoHeroScope>()?.controller;
@override
bool updateShouldNotify(PhotoHeroScope oldWidget) =>
controller != oldWidget.controller;
}
class PhotoHeroTarget extends StatelessWidget {
const PhotoHeroTarget({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final controller = PhotoHeroScope.maybeOf(context);
if (controller == null) return child;
return KeyedSubtree(
key: controller.areaKey,
child: ValueListenableBuilder<bool>(
valueListenable: controller.flying,
child: child,
builder: (context, flying, child) =>
flying ? Opacity(opacity: 0, child: child) : child!,
),
);
}
}
class _PhotoHeroTransition extends StatefulWidget {
const _PhotoHeroTransition({
required this.controller,
required this.animation,
required this.child,
});
final PhotoHeroController controller;
final Animation<double> animation;
final Widget child;
@override
State<_PhotoHeroTransition> createState() => _PhotoHeroTransitionState();
}
class _PhotoHeroTransitionState extends State<_PhotoHeroTransition> {
ImageStream? _stream;
ImageStreamListener? _listener;
Size? _resolvedSize;
Rect? _from;
Rect? _area;
ImageProvider? _flightProvider;
Widget? _flightImage;
Size? get _imageSize => widget.controller.size ?? _resolvedSize;
bool get _flying => widget.controller.flying.value;
@override
void initState() {
super.initState();
widget.controller.image.addListener(_resolveImage);
widget.animation.addStatusListener(_onStatusChanged);
if (widget.animation.status != AnimationStatus.completed) {
widget.controller.flying.value = widget.controller.canFly;
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_resolveImage();
}
@override
void dispose() {
widget.animation.removeStatusListener(_onStatusChanged);
widget.controller.image.removeListener(_resolveImage);
_detachStream();
super.dispose();
}
void _onStatusChanged(AnimationStatus status) {
switch (status) {
case AnimationStatus.forward:
case AnimationStatus.reverse:
_startFlight();
case AnimationStatus.completed:
case AnimationStatus.dismissed:
_stopFlight();
}
}
void _startFlight() {
_from = null;
_area = null;
_setFlying(widget.controller.canFly);
}
void _stopFlight() => _setFlying(false);
void _setFlying(bool value) {
if (_flying == value) return;
widget.controller.flying.value = value;
if (mounted) setState(() {});
}
void _detachStream() {
final listener = _listener;
if (listener != null) _stream?.removeListener(listener);
_stream = null;
_listener = null;
}
void _resolveImage() {
if (!mounted) return;
final provider = widget.controller.image.value;
if (provider == null) return;
final stream = provider.resolve(createLocalImageConfiguration(context));
if (stream.key == _stream?.key) return;
_detachStream();
final listener = ImageStreamListener((info, synchronous) {
final size = Size(
info.image.width.toDouble(),
info.image.height.toDouble(),
);
info.dispose();
if (_resolvedSize == size) return;
if (_resolvedSize != null && _flying) return;
if (synchronous) {
_resolvedSize = size;
} else if (mounted) {
setState(() => _resolvedSize = size);
}
});
_listener = listener;
_stream = stream..addListener(listener);
}
Widget _imageWidget(ImageProvider provider) {
if (!identical(_flightProvider, provider)) {
_flightProvider = provider;
_flightImage = Image(
image: provider,
fit: BoxFit.fill,
gaplessPlayback: true,
);
}
return _flightImage!;
}
@override
Widget build(BuildContext context) {
final provider = widget.controller.image.value;
if (!_flying || provider == null) {
return FadeTransition(opacity: widget.animation, child: widget.child);
}
return Stack(
children: [
widget.child,
Positioned.fill(
child: IgnorePointer(
child: AnimatedBuilder(
animation: widget.animation,
child: _imageWidget(provider),
builder: (context, child) => _layoutFlight(child!),
),
),
),
],
);
}
Widget _layoutFlight(Widget image) {
final from = _from ??= widget.controller.originRect;
if (from == null) return const SizedBox.shrink();
final area = _area ??= widget.controller.areaRect;
final imageSize = _imageSize;
if (area == null || imageSize == null) {
return _position(image, from, from, 1);
}
final t = Curves.fastOutSlowIn.transform(
widget.animation.value.clamp(0.0, 1.0),
);
final target = _inscribe(imageSize, area, cover: false);
return _position(
image,
Rect.lerp(from, target, t)!,
Rect.lerp(_inscribe(imageSize, from, cover: true), target, t)!,
1 - t,
);
}
Widget _position(Widget image, Rect clip, Rect rect, double radiusT) {
final radius = widget.controller.radius * radiusT;
final content = Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: rect.left - clip.left,
top: rect.top - clip.top,
width: rect.width,
height: rect.height,
child: image,
),
],
);
return Stack(
children: [
Positioned.fromRect(
rect: clip,
child: radius == BorderRadius.zero
? ClipRect(child: content)
: ClipRRect(borderRadius: radius, child: content),
),
],
);
}
}
+7
View File
@@ -10,6 +10,7 @@ class KometAvatar extends StatelessWidget {
final Color? backgroundColor;
final Color? foregroundColor;
final double? fontSize;
final bool fadeIn;
const KometAvatar({
super.key,
@@ -19,8 +20,12 @@ class KometAvatar extends StatelessWidget {
this.backgroundColor,
this.foregroundColor,
this.fontSize,
this.fadeIn = true,
});
static const _fadeInDuration = Duration(milliseconds: 500);
static const _fadeOutDuration = Duration(milliseconds: 1000);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -50,6 +55,8 @@ class KometAvatar extends StatelessWidget {
fit: BoxFit.cover,
memCacheWidth: cache,
memCacheHeight: cache,
fadeInDuration: fadeIn ? _fadeInDuration : Duration.zero,
fadeOutDuration: fadeIn ? _fadeOutDuration : Duration.zero,
errorWidget: (_, _, _) => placeholder,
)
: placeholder,
+44 -14
View File
@@ -19,6 +19,7 @@ import '../../core/utils/media_saver.dart';
import '../../l10n/app_localizations.dart';
import '../../main.dart';
import '../../models/attachment.dart';
import 'attachment/photo_hero.dart';
import 'chat_menu_overlay.dart';
import 'custom_notification.dart';
import 'small_spinner.dart';
@@ -79,6 +80,7 @@ class PhotoViewerScreen extends StatefulWidget {
final int? chatId;
final CachedMessage? message;
final PhotoViewerActions? actions;
final PhotoHeroController? hero;
/// The opened item is a file attachment rendered as an image, so the counter
/// reads "FILE of N" — it has no position within the chat's photo feed.
@@ -91,6 +93,7 @@ class PhotoViewerScreen extends StatefulWidget {
this.chatId,
this.message,
this.actions,
this.hero,
this.isFile = false,
});
@@ -100,6 +103,7 @@ class PhotoViewerScreen extends StatefulWidget {
chatId = null,
message = null,
actions = null,
hero = null,
isFile = false;
@override
@@ -123,21 +127,36 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
int _total = 0;
bool _saving = false;
late final String _heroId;
final TransformationController _heroTransform = TransformationController();
@override
void initState() {
super.initState();
_heroTransform.addListener(_syncHero);
_items = _localItems();
_index = (_items.length - 1 - widget.initialIndex).clamp(
0,
_items.length - 1,
);
_heroId = _items[_index].id;
_controller = PageController(initialPage: _index);
unawaited(_loadFeed());
}
void _syncHero() {
final hero = widget.hero;
if (hero == null) return;
hero.enabled =
_current.id == _heroId &&
(_quarterTurns[_heroId] ?? 0) == 0 &&
_heroTransform.value.getMaxScaleOnAxis() <= 1.01;
}
@override
void dispose() {
_controller.dispose();
_heroTransform.dispose();
super.dispose();
}
@@ -234,6 +253,8 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
}
});
_syncHero();
if (movesPage) {
WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose());
}
@@ -278,6 +299,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
void _onPageChanged(int index) {
setState(() => _index = index);
_syncHero();
if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore());
}
@@ -295,6 +317,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
setState(() {
_quarterTurns[_current.id] = ((_quarterTurns[_current.id] ?? 0) + 1) % 4;
});
_syncHero();
}
void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible);
@@ -453,20 +476,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
reverse: true,
itemCount: _items.length,
onPageChanged: _onPageChanged,
itemBuilder: (_, i) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleChrome,
child: InteractiveViewer(
minScale: 1,
maxScale: 5,
child: Center(
child: RotatedBox(
quarterTurns: _quarterTurns[_items[i].id] ?? 0,
child: _buildImage(_items[i].photo),
),
),
),
),
itemBuilder: (_, i) => _buildPage(i),
),
),
Positioned.fill(
@@ -536,6 +546,26 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
);
}
Widget _buildPage(int i) {
final isHero = widget.hero != null && _items[i].id == _heroId;
final page = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleChrome,
child: InteractiveViewer(
minScale: 1,
maxScale: 5,
transformationController: isHero ? _heroTransform : null,
child: Center(
child: RotatedBox(
quarterTurns: _quarterTurns[_items[i].id] ?? 0,
child: _buildImage(_items[i].photo),
),
),
),
);
return isHero ? PhotoHeroTarget(child: page) : page;
}
Widget _arrow(IconData icon, VoidCallback onTap) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
+127
View File
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
class ProfileHeroAvatar extends StatelessWidget {
const ProfileHeroAvatar({
super.key,
required this.tag,
required this.size,
required this.child,
});
final Object? tag;
final double size;
final Widget child;
@override
Widget build(BuildContext context) {
final tag = this.tag;
if (tag == null) return child;
return Hero(
tag: ('profile-avatar', tag),
flightShuttleBuilder: _buildFlyingAvatar,
child: _AvatarHeroChild(size: size, child: child),
);
}
static Widget _buildFlyingAvatar(
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection direction,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
final from = _AvatarHeroChild.of(fromHeroContext);
final to = _AvatarHeroChild.of(toHeroContext);
final sharpest = from.size >= to.size ? from : to;
return FittedBox(
fit: BoxFit.fill,
child: SizedBox.square(dimension: sharpest.size, child: sharpest.child),
);
}
}
class ProfileHeroName extends StatelessWidget {
const ProfileHeroName({
super.key,
required this.tag,
required this.text,
required this.style,
required this.child,
});
final Object? tag;
final String text;
final TextStyle style;
final Widget child;
@override
Widget build(BuildContext context) {
final tag = this.tag;
if (tag == null) return child;
return Hero(
tag: ('profile-name', tag),
flightShuttleBuilder: _buildFlyingName,
child: _NameHeroChild(text: text, style: style, child: child),
);
}
static Widget _buildFlyingName(
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection direction,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
final from = _NameHeroChild.of(fromHeroContext);
final to = _NameHeroChild.of(toHeroContext);
final push = direction == HeroFlightDirection.push;
final style = TextStyleTween(
begin: push ? from.style : to.style,
end: push ? to.style : from.style,
).animate(animation);
return OverflowBox(
alignment: Alignment.centerLeft,
minWidth: 0,
maxWidth: double.infinity,
minHeight: 0,
maxHeight: double.infinity,
child: DefaultTextStyleTransition(
style: style,
softWrap: false,
maxLines: 1,
child: Text(to.text),
),
);
}
}
class _AvatarHeroChild extends StatelessWidget {
const _AvatarHeroChild({required this.size, required this.child});
final double size;
final Widget child;
static _AvatarHeroChild of(BuildContext heroContext) =>
(heroContext.widget as Hero).child as _AvatarHeroChild;
@override
Widget build(BuildContext context) => child;
}
class _NameHeroChild extends StatelessWidget {
const _NameHeroChild({
required this.text,
required this.style,
required this.child,
});
final String text;
final TextStyle style;
final Widget child;
static _NameHeroChild of(BuildContext heroContext) =>
(heroContext.widget as Hero).child as _NameHeroChild;
@override
Widget build(BuildContext context) => child;
}
+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();
});
}