feat: херо анимейшон фром реанимейшон
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user