From 3b34ef0535bcbe6bb457ebdc13add8e7e2c9d0ae Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 26 Jul 2026 22:37:48 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=85=D0=B5=D1=80=D0=BE=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=B9=D1=88=D0=BE=D0=BD=20=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=20=D1=80=D0=B5=D0=B0=D0=BD=D0=B8=D0=BC=D0=B5=D0=B9=D1=88?= =?UTF-8?q?=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat/view/chat_header.dart | 135 ++++---- .../screens/chats/chat_info_screen.dart | 176 +++++----- lib/frontend/screens/chats/chat_screen.dart | 3 + .../widgets/attachment/attachment_sheet.dart | 99 +++--- .../attachment/bubbles/photo_bubble.dart | 118 +++++-- .../attachment/media_preview_screen.dart | 57 +++- .../widgets/attachment/photo_hero.dart | 314 ++++++++++++++++++ lib/frontend/widgets/komet_avatar.dart | 7 + lib/frontend/widgets/photo_viewer.dart | 58 +++- lib/frontend/widgets/profile_hero.dart | 127 +++++++ test/photo_hero_test.dart | 188 +++++++++++ test/profile_hero_test.dart | 199 +++++++++++ 12 files changed, 1254 insertions(+), 227 deletions(-) create mode 100644 lib/frontend/widgets/attachment/photo_hero.dart create mode 100644 lib/frontend/widgets/profile_hero.dart create mode 100644 test/photo_hero_test.dart create mode 100644 test/profile_hero_test.dart diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart index 9703dc5..6a93a36 100644 --- a/lib/frontend/screens/chats/chat/view/chat_header.dart +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -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) ...[ diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 44b075e..eb198f6 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -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 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 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 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 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 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 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 } 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 _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 ), ); - 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), + ]; } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index f2bdf75..1e8fbe0 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -266,6 +266,7 @@ class _ChatScreenState extends State int _readMarkTime = 0; Timer? _readMarkTimer; final GlobalKey _listKey = GlobalKey(); + final Object _profileHeroTag = UniqueKey(); final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; bool _encryptionEnabled = false; @@ -1038,6 +1039,7 @@ class _ChatScreenState extends State 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 cs: cs, embedded: widget.embedded, chatId: widget.chatId, + heroTag: _profileHeroTag, name: _headerName(), imageUrl: widget.imageUrl, chatType: widget.chatType, diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index fbb71c5..65a76e9 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -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 { final GallerySource _source = GallerySource.create(); final ValueNotifier> _selected = ValueNotifier({}); + final Map> _thumbKeys = {}; final Map _edits = {}; final Set _tempFiles = {}; final Set _sentFiles = {}; @@ -154,11 +156,21 @@ class _AttachmentSheetState extends State { _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( + hero: hero, builder: (_) => MediaPreviewScreen( item: item, + hero: hero, title: widget.title, selectedIds: _selected, onToggleSelection: () => _toggleSelection(item), @@ -478,6 +490,7 @@ class _AttachmentSheetState extends State { 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 { 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> 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? _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( - 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(), ); } diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart index 1e86714..aefc2c1 100644 --- a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -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( + hero: hero, builder: (_) => PhotoViewerScreen( photos: photos, initialIndex: index, chatId: ctx.chatId, message: ctx.message, actions: ctx.photoActions, + hero: hero, ), ), ); diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index b471628..ad69d83 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -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> 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 { 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 { @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 { Future _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 { 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 { _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 { _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 { 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 { } 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( + 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() { diff --git a/lib/frontend/widgets/attachment/photo_hero.dart b/lib/frontend/widgets/attachment/photo_hero.dart new file mode 100644 index 0000000..5e8733f --- /dev/null +++ b/lib/frontend/widgets/attachment/photo_hero.dart @@ -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 image; + final ValueNotifier 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 extends PageRouteBuilder { + 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()?.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( + 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 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), + ), + ], + ); + } +} diff --git a/lib/frontend/widgets/komet_avatar.dart b/lib/frontend/widgets/komet_avatar.dart index 000eea7..404f671 100644 --- a/lib/frontend/widgets/komet_avatar.dart +++ b/lib/frontend/widgets/komet_avatar.dart @@ -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, diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 15a9ad8..2e3e712 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -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 { 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 { } }); + _syncHero(); + if (movesPage) { WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose()); } @@ -278,6 +299,7 @@ class _PhotoViewerScreenState extends State { void _onPageChanged(int index) { setState(() => _index = index); + _syncHero(); if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore()); } @@ -295,6 +317,7 @@ class _PhotoViewerScreenState extends State { setState(() { _quarterTurns[_current.id] = ((_quarterTurns[_current.id] ?? 0) + 1) % 4; }); + _syncHero(); } void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible); @@ -453,20 +476,7 @@ class _PhotoViewerScreenState extends State { 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 { ); } + 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), diff --git a/lib/frontend/widgets/profile_hero.dart b/lib/frontend/widgets/profile_hero.dart new file mode 100644 index 0000000..edc4d9f --- /dev/null +++ b/lib/frontend/widgets/profile_hero.dart @@ -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 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 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; +} diff --git a/test/photo_hero_test.dart b/test/photo_hero_test.dart new file mode 100644 index 0000000..68032d8 --- /dev/null +++ b/test/photo_hero_test.dart @@ -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(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( + 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(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(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(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); + }); +} diff --git a/test/profile_hero_test.dart b/test/profile_hero_test.dart new file mode 100644 index 0000000..d2a53e3 --- /dev/null +++ b/test/profile_hero_test.dart @@ -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 _fontSizes(WidgetTester tester) => tester + .widgetList(find.byType(Text)) + .map( + (t) => + t.style?.fontSize ?? + DefaultTextStyle.of(tester.element(find.byWidget(t))).style.fontSize, + ) + .whereType(); + +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(); + 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(); + 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(); + 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( + 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(); + 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(); + }); +}