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/encryption_lock_badge.dart';
|
||||||
import 'package:komet/frontend/widgets/glossy_pill.dart';
|
import 'package:komet/frontend/widgets/glossy_pill.dart';
|
||||||
import 'package:komet/frontend/widgets/online_dot.dart';
|
import 'package:komet/frontend/widgets/online_dot.dart';
|
||||||
|
import 'package:komet/frontend/widgets/profile_hero.dart';
|
||||||
|
|
||||||
class ChatHeaderRow extends StatelessWidget {
|
class ChatHeaderRow extends StatelessWidget {
|
||||||
final bool glossy;
|
final bool glossy;
|
||||||
@@ -15,6 +16,7 @@ class ChatHeaderRow extends StatelessWidget {
|
|||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
final bool embedded;
|
final bool embedded;
|
||||||
final int chatId;
|
final int chatId;
|
||||||
|
final Object heroTag;
|
||||||
final String name;
|
final String name;
|
||||||
final String imageUrl;
|
final String imageUrl;
|
||||||
final String chatType;
|
final String chatType;
|
||||||
@@ -40,6 +42,7 @@ class ChatHeaderRow extends StatelessWidget {
|
|||||||
required this.cs,
|
required this.cs,
|
||||||
required this.embedded,
|
required this.embedded,
|
||||||
required this.chatId,
|
required this.chatId,
|
||||||
|
required this.heroTag,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.imageUrl,
|
required this.imageUrl,
|
||||||
required this.chatType,
|
required this.chatType,
|
||||||
@@ -66,6 +69,12 @@ class ChatHeaderRow extends StatelessWidget {
|
|||||||
double? get _pillBlur => frosted && !liquid ? AppFrost.sigma : null;
|
double? get _pillBlur => frosted && !liquid ? AppFrost.sigma : null;
|
||||||
|
|
||||||
Widget _glossyRow(BuildContext context) {
|
Widget _glossyRow(BuildContext context) {
|
||||||
|
final nameStyle = TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
);
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
|
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -111,28 +120,32 @@ class ChatHeaderRow extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_withOnlineDot(
|
_withOnlineDot(
|
||||||
cs,
|
cs,
|
||||||
imageUrl.isNotEmpty
|
ProfileHeroAvatar(
|
||||||
? CircleAvatar(
|
tag: heroTag,
|
||||||
radius: 22,
|
size: 44,
|
||||||
backgroundImage: CachedNetworkImageProvider(
|
child: imageUrl.isNotEmpty
|
||||||
imageUrl,
|
? CircleAvatar(
|
||||||
maxWidth: 144,
|
radius: 22,
|
||||||
maxHeight: 144,
|
backgroundImage: CachedNetworkImageProvider(
|
||||||
),
|
imageUrl,
|
||||||
)
|
maxWidth: 144,
|
||||||
: CircleAvatar(
|
maxHeight: 144,
|
||||||
radius: 22,
|
),
|
||||||
backgroundColor: cs.primaryContainer,
|
)
|
||||||
child: Text(
|
: CircleAvatar(
|
||||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
radius: 22,
|
||||||
style: TextStyle(
|
backgroundColor: cs.primaryContainer,
|
||||||
color: cs.onPrimaryContainer,
|
child: Text(
|
||||||
fontSize: 16,
|
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||||
fontWeight: FontWeight.w600,
|
style: TextStyle(
|
||||||
fontFamily: 'Outfit',
|
color: cs.onPrimaryContainer,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -144,16 +157,16 @@ class ChatHeaderRow extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: ProfileHeroName(
|
||||||
name,
|
tag: heroTag,
|
||||||
style: TextStyle(
|
text: name,
|
||||||
color: cs.onSurface,
|
style: nameStyle,
|
||||||
fontSize: 17,
|
child: Text(
|
||||||
fontWeight: FontWeight.w600,
|
name,
|
||||||
fontFamily: 'Outfit',
|
style: nameStyle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isOfficial) ...[
|
if (isOfficial) ...[
|
||||||
@@ -242,6 +255,12 @@ class ChatHeaderRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _materialRow(BuildContext context) {
|
Widget _materialRow(BuildContext context) {
|
||||||
|
final nameStyle = TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
);
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
_backWithBadge(
|
_backWithBadge(
|
||||||
@@ -268,26 +287,30 @@ class ChatHeaderRow extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_withOnlineDot(
|
_withOnlineDot(
|
||||||
cs,
|
cs,
|
||||||
imageUrl.isNotEmpty
|
ProfileHeroAvatar(
|
||||||
? CircleAvatar(
|
tag: heroTag,
|
||||||
radius: 18,
|
size: 36,
|
||||||
backgroundImage: CachedNetworkImageProvider(
|
child: imageUrl.isNotEmpty
|
||||||
imageUrl,
|
? CircleAvatar(
|
||||||
maxWidth: 144,
|
radius: 18,
|
||||||
maxHeight: 144,
|
backgroundImage: CachedNetworkImageProvider(
|
||||||
),
|
imageUrl,
|
||||||
)
|
maxWidth: 144,
|
||||||
: CircleAvatar(
|
maxHeight: 144,
|
||||||
radius: 18,
|
),
|
||||||
backgroundColor: cs.primaryContainer,
|
)
|
||||||
child: Text(
|
: CircleAvatar(
|
||||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
radius: 18,
|
||||||
style: TextStyle(
|
backgroundColor: cs.primaryContainer,
|
||||||
color: cs.onPrimaryContainer,
|
child: Text(
|
||||||
fontSize: 12,
|
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onPrimaryContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
dotSize: 11,
|
dotSize: 11,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -300,16 +323,16 @@ class ChatHeaderRow extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: ProfileHeroName(
|
||||||
name,
|
tag: heroTag,
|
||||||
style: TextStyle(
|
text: name,
|
||||||
color: cs.onSurface,
|
style: nameStyle,
|
||||||
fontSize: 16,
|
child: Text(
|
||||||
fontWeight: FontWeight.w600,
|
name,
|
||||||
fontFamily: 'Outfit',
|
style: nameStyle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isOfficial) ...[
|
if (isOfficial) ...[
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import '../../widgets/formatted_message_text.dart';
|
|||||||
import '../../widgets/reload_on_reconnect.dart';
|
import '../../widgets/reload_on_reconnect.dart';
|
||||||
import '../../widgets/glossy_pill.dart';
|
import '../../widgets/glossy_pill.dart';
|
||||||
import '../../widgets/komet_avatar.dart';
|
import '../../widgets/komet_avatar.dart';
|
||||||
|
import '../../widgets/profile_hero.dart';
|
||||||
import '../../widgets/swipe_route.dart';
|
import '../../widgets/swipe_route.dart';
|
||||||
import '../../../backend/modules/chats.dart';
|
import '../../../backend/modules/chats.dart';
|
||||||
import '../contacts/open_contact_profile.dart';
|
import '../contacts/open_contact_profile.dart';
|
||||||
@@ -67,6 +68,7 @@ class ChatInfoScreen extends StatefulWidget {
|
|||||||
|
|
||||||
final int? dialogPeerId;
|
final int? dialogPeerId;
|
||||||
final ChatInfoTab? initialTab;
|
final ChatInfoTab? initialTab;
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
final void Function(String messageId, int time)? onJumpToMessage;
|
final void Function(String messageId, int time)? onJumpToMessage;
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ class ChatInfoScreen extends StatefulWidget {
|
|||||||
required this.chatType,
|
required this.chatType,
|
||||||
this.dialogPeerId,
|
this.dialogPeerId,
|
||||||
this.initialTab,
|
this.initialTab,
|
||||||
|
this.heroTag,
|
||||||
this.onJumpToMessage,
|
this.onJumpToMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -382,7 +385,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
void _onBodyScroll() {
|
void _onBodyScroll() {
|
||||||
if (!mounted || widget.chatType != 'CHAT') return;
|
if (!mounted || widget.chatType != 'CHAT') return;
|
||||||
if (_membersLoading || _membersEnd) return;
|
if (_membersLoading || _membersEnd) return;
|
||||||
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers) return;
|
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers)
|
||||||
|
return;
|
||||||
final pos = _bodyScrollController.position;
|
final pos = _bodyScrollController.position;
|
||||||
if (pos.pixels >= pos.maxScrollExtent - 400) {
|
if (pos.pixels >= pos.maxScrollExtent - 400) {
|
||||||
_fetchMembersPage();
|
_fetchMembersPage();
|
||||||
@@ -437,9 +441,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
|
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
|
||||||
floatingActionButton: const ConnectionSpinner(),
|
floatingActionButton: const ConnectionSpinner(),
|
||||||
body: SafeArea(
|
body: SafeArea(child: _buildScrollBody(cs)),
|
||||||
child: _isLoading ? _buildShimmer(cs) : _buildScrollBody(cs),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,19 +475,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildNameRow(cs),
|
_buildNameRow(cs),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
if (_isLoading)
|
||||||
_subtitle(),
|
..._loadingBlocks(cs)
|
||||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
else ...[
|
||||||
textAlign: TextAlign.center,
|
Text(
|
||||||
),
|
_subtitle(),
|
||||||
const SizedBox(height: 20),
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||||
_buildActions(cs),
|
textAlign: TextAlign.center,
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_buildPersistentInfo(cs),
|
const SizedBox(height: 20),
|
||||||
_buildTabBar(cs),
|
_buildActions(cs),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
_buildTabContent(cs),
|
_buildPersistentInfo(cs),
|
||||||
const SizedBox(height: 40),
|
_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;
|
bool get _peerDeleted => _contactData?.isDeleted ?? false;
|
||||||
|
|
||||||
String _joinName(String first, String last) => last.trim().isEmpty
|
String _joinName(String first, String last) =>
|
||||||
? first.trim()
|
last.trim().isEmpty ? first.trim() : '${first.trim()} ${last.trim()}';
|
||||||
: '${first.trim()} ${last.trim()}';
|
|
||||||
|
|
||||||
String get _customName {
|
String get _customName {
|
||||||
final c = _localContact;
|
final c = _localContact;
|
||||||
@@ -595,42 +600,53 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
color: cs.onSurface,
|
color: cs.onSurface,
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFamily: 'Outfit',
|
||||||
);
|
);
|
||||||
final custom = _customName;
|
final custom = _customName;
|
||||||
final real = _realName;
|
final real = _realName;
|
||||||
final hasToggle = _isContact && real != null && real != custom;
|
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(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 36),
|
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(
|
SizedBox(
|
||||||
width: 36,
|
width: 36,
|
||||||
child: IconButton(
|
child: hasToggle
|
||||||
padding: EdgeInsets.zero,
|
? IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
padding: EdgeInsets.zero,
|
||||||
iconSize: 20,
|
visualDensity: VisualDensity.compact,
|
||||||
color: _showRealName ? cs.primary : cs.onSurfaceVariant,
|
iconSize: 20,
|
||||||
icon: Icon(
|
color: _showRealName ? cs.primary : cs.onSurfaceVariant,
|
||||||
_showRealName ? Symbols.visibility : Symbols.visibility_off,
|
icon: Icon(
|
||||||
),
|
_showRealName ? Symbols.visibility : Symbols.visibility_off,
|
||||||
tooltip: real,
|
),
|
||||||
onPressed: () => setState(() => _showRealName = !_showRealName),
|
tooltip: real,
|
||||||
),
|
onPressed: () =>
|
||||||
|
setState(() => _showRealName = !_showRealName),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -643,7 +659,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
if (_peerDeleted) return l10n.chatInfoMemberDeleted;
|
if (_peerDeleted) return l10n.chatInfoMemberDeleted;
|
||||||
if (_isBot) return l10n.contactProfileBot;
|
if (_isBot) return l10n.contactProfileBot;
|
||||||
if (_isOnline) return l10n.contactProfileOnline;
|
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) {
|
if (_seenTime != null && _seenTime! > 0) {
|
||||||
return formatLastSeen(_seenTime!);
|
return formatLastSeen(_seenTime!);
|
||||||
}
|
}
|
||||||
@@ -1663,27 +1680,36 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _avatar() {
|
Widget _avatar() {
|
||||||
if (_peerDeleted) return _ghostAvatar(radius: 48, fontSize: 52);
|
const size = 96.0;
|
||||||
final avatar = KometAvatar(
|
|
||||||
name: widget.name,
|
|
||||||
imageUrl: widget.imageUrl,
|
|
||||||
size: 96,
|
|
||||||
fontSize: 36,
|
|
||||||
);
|
|
||||||
final peerId = widget.chatType == 'DIALOG' ? _otherId : null;
|
final peerId = widget.chatType == 'DIALOG' ? _otherId : null;
|
||||||
if (peerId == null || widget.imageUrl.isEmpty) return avatar;
|
final hasHistory =
|
||||||
return GestureDetector(
|
peerId != null && widget.imageUrl.isNotEmpty && !_peerDeleted;
|
||||||
onTap: () => AvatarHistoryScreen.open(
|
return ProfileHeroAvatar(
|
||||||
context,
|
tag: widget.heroTag,
|
||||||
contactId: peerId,
|
size: size,
|
||||||
name: widget.name,
|
child: GestureDetector(
|
||||||
currentAvatarUrl: widget.imageUrl,
|
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(
|
Widget block(double w, double h, {double r = 8}) => Container(
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
@@ -1693,21 +1719,15 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ListView(
|
return [
|
||||||
padding: const EdgeInsets.fromLTRB(16, 60, 16, 0),
|
const SizedBox(height: 4),
|
||||||
children: [
|
block(110, 16, r: 6),
|
||||||
Center(child: block(96, 96, r: 48)),
|
const SizedBox(height: 24),
|
||||||
const SizedBox(height: 14),
|
block(240, 54, r: 14),
|
||||||
Center(child: block(160, 22, r: 8)),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 8),
|
block(double.infinity, 36, r: 20),
|
||||||
Center(child: block(110, 16, r: 6)),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 24),
|
block(double.infinity, 120, r: 14),
|
||||||
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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
int _readMarkTime = 0;
|
int _readMarkTime = 0;
|
||||||
Timer? _readMarkTimer;
|
Timer? _readMarkTimer;
|
||||||
final GlobalKey _listKey = GlobalKey();
|
final GlobalKey _listKey = GlobalKey();
|
||||||
|
final Object _profileHeroTag = UniqueKey();
|
||||||
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _encryptionEnabled = false;
|
bool _encryptionEnabled = false;
|
||||||
@@ -1038,6 +1039,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
name: _headerName(),
|
name: _headerName(),
|
||||||
imageUrl: widget.imageUrl,
|
imageUrl: widget.imageUrl,
|
||||||
chatType: widget.chatType,
|
chatType: widget.chatType,
|
||||||
|
heroTag: _profileHeroTag,
|
||||||
initialTab: initialTab,
|
initialTab: initialTab,
|
||||||
onJumpToMessage: (chatRoute == null || widget.embedded)
|
onJumpToMessage: (chatRoute == null || widget.embedded)
|
||||||
? null
|
? null
|
||||||
@@ -2966,6 +2968,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
cs: cs,
|
cs: cs,
|
||||||
embedded: widget.embedded,
|
embedded: widget.embedded,
|
||||||
chatId: widget.chatId,
|
chatId: widget.chatId,
|
||||||
|
heroTag: _profileHeroTag,
|
||||||
name: _headerName(),
|
name: _headerName(),
|
||||||
imageUrl: widget.imageUrl,
|
imageUrl: widget.imageUrl,
|
||||||
chatType: widget.chatType,
|
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/contact_picker_page.dart';
|
||||||
import 'package:komet/frontend/widgets/attachment/media_preview_screen.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_editor.dart';
|
||||||
|
import 'package:komet/frontend/widgets/attachment/photo_hero.dart';
|
||||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||||
import 'package:komet/frontend/widgets/sheet_helpers.dart';
|
import 'package:komet/frontend/widgets/sheet_helpers.dart';
|
||||||
import 'package:komet/frontend/widgets/sliding_pill_nav.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 GallerySource _source = GallerySource.create();
|
||||||
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
|
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
|
||||||
|
final Map<String, GlobalKey<_ThumbnailState>> _thumbKeys = {};
|
||||||
final Map<String, PhotoEditState> _edits = {};
|
final Map<String, PhotoEditState> _edits = {};
|
||||||
final Set<String> _tempFiles = {};
|
final Set<String> _tempFiles = {};
|
||||||
final Set<String> _sentFiles = {};
|
final Set<String> _sentFiles = {};
|
||||||
@@ -154,11 +156,21 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
_selected.value = next;
|
_selected.value = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GlobalKey<_ThumbnailState> _thumbKey(String id) =>
|
||||||
|
_thumbKeys.putIfAbsent(id, () => GlobalKey<_ThumbnailState>());
|
||||||
|
|
||||||
void _openPreview(GalleryItem item) {
|
void _openPreview(GalleryItem item) {
|
||||||
|
final thumbKey = _thumbKey(item.id);
|
||||||
|
final hero = PhotoHeroController(
|
||||||
|
origin: () => photoHeroRect(thumbKey),
|
||||||
|
image: thumbKey.currentState?.provider,
|
||||||
|
);
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
PhotoHeroRoute<void>(
|
||||||
|
hero: hero,
|
||||||
builder: (_) => MediaPreviewScreen(
|
builder: (_) => MediaPreviewScreen(
|
||||||
item: item,
|
item: item,
|
||||||
|
hero: hero,
|
||||||
title: widget.title,
|
title: widget.title,
|
||||||
selectedIds: _selected,
|
selectedIds: _selected,
|
||||||
onToggleSelection: () => _toggleSelection(item),
|
onToggleSelection: () => _toggleSelection(item),
|
||||||
@@ -478,6 +490,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
final item = gridPhotos[index];
|
final item = gridPhotos[index];
|
||||||
return _GalleryTile(
|
return _GalleryTile(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(item.id),
|
||||||
|
thumbKey: _thumbKey(item.id),
|
||||||
item: item,
|
item: item,
|
||||||
selectedIds: _selected,
|
selectedIds: _selected,
|
||||||
onOpen: () => _openPreview(item),
|
onOpen: () => _openPreview(item),
|
||||||
@@ -501,6 +514,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
final item = photos[i];
|
final item = photos[i];
|
||||||
return _GalleryTile(
|
return _GalleryTile(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(item.id),
|
||||||
|
thumbKey: _thumbKey(item.id),
|
||||||
item: item,
|
item: item,
|
||||||
selectedIds: _selected,
|
selectedIds: _selected,
|
||||||
onOpen: () => _openPreview(item),
|
onOpen: () => _openPreview(item),
|
||||||
@@ -889,6 +903,7 @@ class _CameraTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _GalleryTile extends StatefulWidget {
|
class _GalleryTile extends StatefulWidget {
|
||||||
|
final GlobalKey<_ThumbnailState> thumbKey;
|
||||||
final GalleryItem item;
|
final GalleryItem item;
|
||||||
final ValueListenable<Set<String>> selectedIds;
|
final ValueListenable<Set<String>> selectedIds;
|
||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
@@ -898,6 +913,7 @@ class _GalleryTile extends StatefulWidget {
|
|||||||
|
|
||||||
const _GalleryTile({
|
const _GalleryTile({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.thumbKey,
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.selectedIds,
|
required this.selectedIds,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
@@ -944,6 +960,7 @@ class _GalleryTileState extends State<_GalleryTile> {
|
|||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: _Thumbnail(
|
child: _Thumbnail(
|
||||||
|
key: widget.thumbKey,
|
||||||
item: item,
|
item: item,
|
||||||
editedFile: widget.editedFile,
|
editedFile: widget.editedFile,
|
||||||
cs: widget.cs,
|
cs: widget.cs,
|
||||||
@@ -1039,7 +1056,12 @@ class _Thumbnail extends StatefulWidget {
|
|||||||
final File? editedFile;
|
final File? editedFile;
|
||||||
final ColorScheme cs;
|
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
|
@override
|
||||||
State<_Thumbnail> createState() => _ThumbnailState();
|
State<_Thumbnail> createState() => _ThumbnailState();
|
||||||
@@ -1047,50 +1069,53 @@ class _Thumbnail extends StatefulWidget {
|
|||||||
|
|
||||||
class _ThumbnailState extends State<_Thumbnail> {
|
class _ThumbnailState extends State<_Thumbnail> {
|
||||||
static const int _pixelSize = 320;
|
static const int _pixelSize = 320;
|
||||||
Future<Uint8List?>? _future;
|
ImageProvider? _provider;
|
||||||
|
|
||||||
|
ImageProvider? get provider => _provider;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.item.localFile == null) {
|
_resolveProvider();
|
||||||
_future = widget.item.thumbnail(_pixelSize);
|
}
|
||||||
|
|
||||||
|
@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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final edited = widget.editedFile;
|
final provider = _provider;
|
||||||
if (edited != null) {
|
if (provider == null) return _placeholder();
|
||||||
return Image.file(
|
return Image(
|
||||||
edited,
|
image: provider,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
cacheWidth: _pixelSize,
|
gaplessPlayback: true,
|
||||||
gaplessPlayback: true,
|
errorBuilder: (_, _, _) => _placeholder(),
|
||||||
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(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
|
|
||||||
import '../../../../models/attachment.dart';
|
import '../../../../models/attachment.dart';
|
||||||
import '../../photo_viewer.dart';
|
import '../../photo_viewer.dart';
|
||||||
|
import '../photo_hero.dart';
|
||||||
import 'bubble_context.dart';
|
import 'bubble_context.dart';
|
||||||
|
|
||||||
class PhotoBubble extends StatelessWidget {
|
class PhotoBubble extends StatelessWidget {
|
||||||
@@ -133,13 +134,17 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
? (ctx.isMe ? _smallRadius : _bigRadius)
|
? (ctx.isMe ? _smallRadius : _bigRadius)
|
||||||
: _smallRadius;
|
: _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(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: radius,
|
||||||
topLeft: topR,
|
|
||||||
topRight: topR,
|
|
||||||
bottomLeft: bottomL,
|
|
||||||
bottomRight: bottomR,
|
|
||||||
),
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildPhotoImage(
|
_buildPhotoImage(
|
||||||
@@ -147,16 +152,25 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
photo,
|
photo,
|
||||||
constrainedWidth,
|
constrainedWidth,
|
||||||
constrainedHeight,
|
constrainedHeight,
|
||||||
memWidth: (constrainedWidth * dpr).round(),
|
memWidth: memWidth,
|
||||||
memHeight: (constrainedHeight * dpr).round(),
|
memHeight: memHeight,
|
||||||
),
|
),
|
||||||
if (ctx.uploadProgress != null)
|
if (ctx.uploadProgress != null)
|
||||||
_buildUploadOverlay(ctx.uploadProgress!, 0),
|
_buildUploadOverlay(ctx.uploadProgress!, 0),
|
||||||
if (ctx.uploadProgress == null)
|
if (ctx.uploadProgress == null)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: Builder(
|
||||||
behavior: HitTestBehavior.opaque,
|
builder: (tileContext) => GestureDetector(
|
||||||
onTap: () => _openPhotoViewer(ctx.context, 0),
|
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)
|
if (ctx.uploadProgress != null)
|
||||||
_buildUploadOverlay(ctx.uploadProgress!, index),
|
_buildUploadOverlay(ctx.uploadProgress!, index),
|
||||||
if (ctx.uploadProgress == null)
|
if (ctx.uploadProgress == null)
|
||||||
Positioned.fill(
|
_buildTileTapTarget(ctx, index, cachePx),
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: () => _openPhotoViewer(ctx.context, index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Widget _buildPhotoTileWithOverlay(
|
||||||
BubbleContext ctx,
|
BubbleContext ctx,
|
||||||
PhotoAttachment photo,
|
PhotoAttachment photo,
|
||||||
@@ -402,12 +429,7 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
if (ctx.uploadProgress != null)
|
if (ctx.uploadProgress != null)
|
||||||
_buildUploadOverlay(ctx.uploadProgress!, index),
|
_buildUploadOverlay(ctx.uploadProgress!, index),
|
||||||
if (ctx.uploadProgress == null)
|
if (ctx.uploadProgress == null)
|
||||||
Positioned.fill(
|
_buildTileTapTarget(ctx, index, cachePx),
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: () => _openPhotoViewer(ctx.context, index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
PhotoHeroRoute<void>(
|
||||||
fullscreenDialog: true,
|
hero: hero,
|
||||||
builder: (_) => PhotoViewerScreen(
|
builder: (_) => PhotoViewerScreen(
|
||||||
photos: photos,
|
photos: photos,
|
||||||
initialIndex: index,
|
initialIndex: index,
|
||||||
chatId: ctx.chatId,
|
chatId: ctx.chatId,
|
||||||
message: ctx.message,
|
message: ctx.message,
|
||||||
actions: ctx.photoActions,
|
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/core/media/gallery_source.dart';
|
||||||
import 'package:komet/frontend/widgets/attachment/photo_editor.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/custom_notification.dart';
|
||||||
|
|
||||||
import '../../../core/config/app_colors.dart';
|
import '../../../core/config/app_colors.dart';
|
||||||
@@ -16,6 +17,7 @@ const Color _kBar = Color(0xFF1E1E1E);
|
|||||||
|
|
||||||
class MediaPreviewScreen extends StatefulWidget {
|
class MediaPreviewScreen extends StatefulWidget {
|
||||||
final GalleryItem item;
|
final GalleryItem item;
|
||||||
|
final PhotoHeroController hero;
|
||||||
final String? title;
|
final String? title;
|
||||||
final ValueListenable<Set<String>> selectedIds;
|
final ValueListenable<Set<String>> selectedIds;
|
||||||
final VoidCallback onToggleSelection;
|
final VoidCallback onToggleSelection;
|
||||||
@@ -29,6 +31,7 @@ class MediaPreviewScreen extends StatefulWidget {
|
|||||||
const MediaPreviewScreen({
|
const MediaPreviewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
|
required this.hero,
|
||||||
required this.selectedIds,
|
required this.selectedIds,
|
||||||
required this.onToggleSelection,
|
required this.onToggleSelection,
|
||||||
required this.onSend,
|
required this.onSend,
|
||||||
@@ -48,6 +51,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
late final TextEditingController _caption = TextEditingController(
|
late final TextEditingController _caption = TextEditingController(
|
||||||
text: widget.initialCaption,
|
text: widget.initialCaption,
|
||||||
);
|
);
|
||||||
|
final TransformationController _zoom = TransformationController();
|
||||||
File? _workingFile;
|
File? _workingFile;
|
||||||
File? _cropSource;
|
File? _cropSource;
|
||||||
CropState? _cropState;
|
CropState? _cropState;
|
||||||
@@ -55,6 +59,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_zoom.addListener(_syncHero);
|
||||||
_caption.addListener(() => widget.onCaptionChanged?.call(_caption.text));
|
_caption.addListener(() => widget.onCaptionChanged?.call(_caption.text));
|
||||||
_cropState = widget.editState?.cropState;
|
_cropState = widget.editState?.cropState;
|
||||||
_cropSource = widget.editState?.cropSource;
|
_cropSource = widget.editState?.cropSource;
|
||||||
@@ -64,20 +69,29 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
Future<void> _resolveWorkingFile() async {
|
Future<void> _resolveWorkingFile() async {
|
||||||
final initial = widget.editState?.working ?? widget.item.localFile;
|
final initial = widget.editState?.working ?? widget.item.localFile;
|
||||||
if (initial != null) {
|
if (initial != null) {
|
||||||
_workingFile = initial;
|
_setWorkingFile(initial);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final file = await widget.item.originFile();
|
final file = await widget.item.originFile();
|
||||||
if (!mounted) return;
|
if (!mounted || file == null) return;
|
||||||
setState(() => _workingFile = file);
|
setState(() => _setWorkingFile(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setWorkingFile(File file) {
|
||||||
|
_workingFile = file;
|
||||||
|
widget.hero.image.value = FileImage(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_caption.dispose();
|
_caption.dispose();
|
||||||
|
_zoom.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncHero() =>
|
||||||
|
widget.hero.enabled = _zoom.value.getMaxScaleOnAxis() <= 1.01;
|
||||||
|
|
||||||
void _send() {
|
void _send() {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
widget.onSend();
|
widget.onSend();
|
||||||
@@ -122,7 +136,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
final old = _workingFile;
|
final old = _workingFile;
|
||||||
_cropState = result.state;
|
_cropState = result.state;
|
||||||
widget.tempFiles.add(result.file.path);
|
widget.tempFiles.add(result.file.path);
|
||||||
setState(() => _workingFile = result.file);
|
setState(() => _setWorkingFile(result.file));
|
||||||
_reportEdit();
|
_reportEdit();
|
||||||
_disposeTemp(old, {result.file.path, _cropSource?.path ?? ''});
|
_disposeTemp(old, {result.file.path, _cropSource?.path ?? ''});
|
||||||
}
|
}
|
||||||
@@ -146,7 +160,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
_cropSource = result;
|
_cropSource = result;
|
||||||
_cropState = null;
|
_cropState = null;
|
||||||
widget.tempFiles.add(result.path);
|
widget.tempFiles.add(result.path);
|
||||||
setState(() => _workingFile = result);
|
setState(() => _setWorkingFile(result));
|
||||||
_reportEdit();
|
_reportEdit();
|
||||||
_disposeTemp(oldWorking, {result.path});
|
_disposeTemp(oldWorking, {result.path});
|
||||||
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
|
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
|
||||||
@@ -163,7 +177,7 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
_cropSource = result;
|
_cropSource = result;
|
||||||
_cropState = null;
|
_cropState = null;
|
||||||
widget.tempFiles.add(result.path);
|
widget.tempFiles.add(result.path);
|
||||||
setState(() => _workingFile = result);
|
setState(() => _setWorkingFile(result));
|
||||||
_reportEdit();
|
_reportEdit();
|
||||||
_disposeTemp(oldWorking, {result.path});
|
_disposeTemp(oldWorking, {result.path});
|
||||||
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
|
_disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''});
|
||||||
@@ -202,11 +216,14 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: PhotoHeroTarget(
|
||||||
child: InteractiveViewer(
|
child: Center(
|
||||||
minScale: 1,
|
child: InteractiveViewer(
|
||||||
maxScale: 4,
|
minScale: 1,
|
||||||
child: _buildImage(),
|
maxScale: 4,
|
||||||
|
transformationController: _zoom,
|
||||||
|
child: _buildImage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -217,11 +234,19 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImage() {
|
Widget _buildImage() {
|
||||||
final file = _workingFile;
|
return ValueListenableBuilder<ImageProvider?>(
|
||||||
if (file == null) {
|
valueListenable: widget.hero.image,
|
||||||
return const SmallSpinner(size: 36, color: Colors.white24);
|
builder: (context, provider, _) {
|
||||||
}
|
if (provider == null) {
|
||||||
return Image.file(file, fit: BoxFit.contain, gaplessPlayback: true);
|
return const SmallSpinner(size: 36, color: Colors.white24);
|
||||||
|
}
|
||||||
|
return Image(
|
||||||
|
image: provider,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBottomBar() {
|
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? backgroundColor;
|
||||||
final Color? foregroundColor;
|
final Color? foregroundColor;
|
||||||
final double? fontSize;
|
final double? fontSize;
|
||||||
|
final bool fadeIn;
|
||||||
|
|
||||||
const KometAvatar({
|
const KometAvatar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -19,8 +20,12 @@ class KometAvatar extends StatelessWidget {
|
|||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
this.foregroundColor,
|
this.foregroundColor,
|
||||||
this.fontSize,
|
this.fontSize,
|
||||||
|
this.fadeIn = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const _fadeInDuration = Duration(milliseconds: 500);
|
||||||
|
static const _fadeOutDuration = Duration(milliseconds: 1000);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
@@ -50,6 +55,8 @@ class KometAvatar extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: cache,
|
memCacheWidth: cache,
|
||||||
memCacheHeight: cache,
|
memCacheHeight: cache,
|
||||||
|
fadeInDuration: fadeIn ? _fadeInDuration : Duration.zero,
|
||||||
|
fadeOutDuration: fadeIn ? _fadeOutDuration : Duration.zero,
|
||||||
errorWidget: (_, _, _) => placeholder,
|
errorWidget: (_, _, _) => placeholder,
|
||||||
)
|
)
|
||||||
: placeholder,
|
: placeholder,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import '../../core/utils/media_saver.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../main.dart';
|
import '../../main.dart';
|
||||||
import '../../models/attachment.dart';
|
import '../../models/attachment.dart';
|
||||||
|
import 'attachment/photo_hero.dart';
|
||||||
import 'chat_menu_overlay.dart';
|
import 'chat_menu_overlay.dart';
|
||||||
import 'custom_notification.dart';
|
import 'custom_notification.dart';
|
||||||
import 'small_spinner.dart';
|
import 'small_spinner.dart';
|
||||||
@@ -79,6 +80,7 @@ class PhotoViewerScreen extends StatefulWidget {
|
|||||||
final int? chatId;
|
final int? chatId;
|
||||||
final CachedMessage? message;
|
final CachedMessage? message;
|
||||||
final PhotoViewerActions? actions;
|
final PhotoViewerActions? actions;
|
||||||
|
final PhotoHeroController? hero;
|
||||||
|
|
||||||
/// The opened item is a file attachment rendered as an image, so the counter
|
/// 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.
|
/// 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.chatId,
|
||||||
this.message,
|
this.message,
|
||||||
this.actions,
|
this.actions,
|
||||||
|
this.hero,
|
||||||
this.isFile = false,
|
this.isFile = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,6 +103,7 @@ class PhotoViewerScreen extends StatefulWidget {
|
|||||||
chatId = null,
|
chatId = null,
|
||||||
message = null,
|
message = null,
|
||||||
actions = null,
|
actions = null,
|
||||||
|
hero = null,
|
||||||
isFile = false;
|
isFile = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -123,21 +127,36 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
|||||||
int _total = 0;
|
int _total = 0;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
|
late final String _heroId;
|
||||||
|
final TransformationController _heroTransform = TransformationController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_heroTransform.addListener(_syncHero);
|
||||||
_items = _localItems();
|
_items = _localItems();
|
||||||
_index = (_items.length - 1 - widget.initialIndex).clamp(
|
_index = (_items.length - 1 - widget.initialIndex).clamp(
|
||||||
0,
|
0,
|
||||||
_items.length - 1,
|
_items.length - 1,
|
||||||
);
|
);
|
||||||
|
_heroId = _items[_index].id;
|
||||||
_controller = PageController(initialPage: _index);
|
_controller = PageController(initialPage: _index);
|
||||||
unawaited(_loadFeed());
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
|
_heroTransform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +253,8 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_syncHero();
|
||||||
|
|
||||||
if (movesPage) {
|
if (movesPage) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose());
|
WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose());
|
||||||
}
|
}
|
||||||
@@ -278,6 +299,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
|||||||
|
|
||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
setState(() => _index = index);
|
setState(() => _index = index);
|
||||||
|
_syncHero();
|
||||||
if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore());
|
if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +317,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_quarterTurns[_current.id] = ((_quarterTurns[_current.id] ?? 0) + 1) % 4;
|
_quarterTurns[_current.id] = ((_quarterTurns[_current.id] ?? 0) + 1) % 4;
|
||||||
});
|
});
|
||||||
|
_syncHero();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible);
|
void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible);
|
||||||
@@ -453,20 +476,7 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
|
|||||||
reverse: true,
|
reverse: true,
|
||||||
itemCount: _items.length,
|
itemCount: _items.length,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
itemBuilder: (_, i) => GestureDetector(
|
itemBuilder: (_, i) => _buildPage(i),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
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) {
|
Widget _arrow(IconData icon, VoidCallback onTap) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:komet/frontend/widgets/attachment/photo_hero.dart';
|
||||||
|
|
||||||
|
const Rect _origin = Rect.fromLTWH(50, 500, 100, 100);
|
||||||
|
|
||||||
|
class _TestImageProvider extends ImageProvider<_TestImageProvider> {
|
||||||
|
_TestImageProvider(this.image);
|
||||||
|
|
||||||
|
final ui.Image image;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<_TestImageProvider> obtainKey(ImageConfiguration configuration) =>
|
||||||
|
SynchronousFuture<_TestImageProvider>(this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageStreamCompleter loadImage(
|
||||||
|
_TestImageProvider key,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
) => OneFrameImageStreamCompleter(
|
||||||
|
SynchronousFuture<ImageInfo>(ImageInfo(image: image.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _page() => Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PhotoHeroTarget(
|
||||||
|
child: Center(
|
||||||
|
child: Container(key: const ValueKey('target'), color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Finder get _flying => find.byType(Image);
|
||||||
|
|
||||||
|
double _flyingWidth(WidgetTester tester) => tester.getSize(_flying).width;
|
||||||
|
|
||||||
|
bool _targetHidden(WidgetTester tester) => tester.any(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byKey(const ValueKey('target')),
|
||||||
|
matching: find.byType(Opacity),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
double _pageOpacity(WidgetTester tester) => tester
|
||||||
|
.widget<FadeTransition>(
|
||||||
|
find
|
||||||
|
.ancestor(
|
||||||
|
of: find.byKey(const ValueKey('target')),
|
||||||
|
matching: find.byType(FadeTransition),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
)
|
||||||
|
.opacity
|
||||||
|
.value;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late ui.Image image;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
image = await createTestImage(width: 4, height: 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('photo flies from the origin rect to the contained target', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final hero = PhotoHeroController(
|
||||||
|
origin: () => _origin,
|
||||||
|
image: _TestImageProvider(image),
|
||||||
|
);
|
||||||
|
late BuildContext context;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (ctx) {
|
||||||
|
context = ctx;
|
||||||
|
return const Scaffold();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
).push(PhotoHeroRoute<void>(hero: hero, builder: (_) => _page()));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
|
||||||
|
expect(_flying, findsOneWidget);
|
||||||
|
expect(_targetHidden(tester), isTrue);
|
||||||
|
final early = _flyingWidth(tester);
|
||||||
|
expect(early, greaterThanOrEqualTo(133));
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
final late_ = _flyingWidth(tester);
|
||||||
|
expect(late_, greaterThan(early));
|
||||||
|
expect(late_, lessThan(667));
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_flying, findsNothing);
|
||||||
|
expect(_targetHidden(tester), isFalse);
|
||||||
|
expect(tester.getSize(find.byKey(const ValueKey('target'))).height, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('photo flies back to the origin rect on pop', (tester) async {
|
||||||
|
final hero = PhotoHeroController(
|
||||||
|
origin: () => _origin,
|
||||||
|
image: _TestImageProvider(image),
|
||||||
|
);
|
||||||
|
late BuildContext context;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (ctx) {
|
||||||
|
context = ctx;
|
||||||
|
return const Scaffold();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
navigator.push(PhotoHeroRoute<void>(hero: hero, builder: (_) => _page()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
navigator.pop();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(_flying, findsOneWidget);
|
||||||
|
expect(_targetHidden(tester), isTrue);
|
||||||
|
expect(_flyingWidth(tester), lessThan(667));
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_flying, findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('disabled hero fades the page instead of flying', (tester) async {
|
||||||
|
final hero = PhotoHeroController(
|
||||||
|
origin: () => _origin,
|
||||||
|
image: _TestImageProvider(image),
|
||||||
|
)..enabled = false;
|
||||||
|
late BuildContext context;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (ctx) {
|
||||||
|
context = ctx;
|
||||||
|
return const Scaffold();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
).push(PhotoHeroRoute<void>(hero: hero, builder: (_) => _page()));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(_flying, findsNothing);
|
||||||
|
expect(_targetHidden(tester), isFalse);
|
||||||
|
expect(_pageOpacity(tester), lessThan(1));
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_pageOpacity(tester), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('target renders untouched without a hero scope', (tester) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(home: _page()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(Opacity), findsNothing);
|
||||||
|
expect(tester.getSize(find.byKey(const ValueKey('target'))).height, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:komet/frontend/widgets/profile_hero.dart';
|
||||||
|
|
||||||
|
const _headerStyle = TextStyle(fontSize: 17, fontWeight: FontWeight.w600);
|
||||||
|
const _profileStyle = TextStyle(fontSize: 22, fontWeight: FontWeight.w700);
|
||||||
|
|
||||||
|
final _tag = UniqueKey();
|
||||||
|
|
||||||
|
Widget _header({Object? tag}) => Scaffold(
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
ProfileHeroAvatar(
|
||||||
|
tag: tag,
|
||||||
|
size: 44,
|
||||||
|
child: Container(width: 44, height: 44, color: Colors.red),
|
||||||
|
),
|
||||||
|
ProfileHeroName(
|
||||||
|
tag: tag,
|
||||||
|
text: 'Ann',
|
||||||
|
style: _headerStyle,
|
||||||
|
child: const Text('Ann', style: _headerStyle),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _profile({Object? tag, bool loaded = false}) => Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
ProfileHeroAvatar(
|
||||||
|
tag: tag,
|
||||||
|
size: 96,
|
||||||
|
child: Container(width: 96, height: 96, color: Colors.red),
|
||||||
|
),
|
||||||
|
ProfileHeroName(
|
||||||
|
tag: tag,
|
||||||
|
text: 'Ann',
|
||||||
|
style: _profileStyle,
|
||||||
|
child: const Text('Ann', style: _profileStyle),
|
||||||
|
),
|
||||||
|
if (loaded) const Text('details'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Iterable<double> _fontSizes(WidgetTester tester) => tester
|
||||||
|
.widgetList<Text>(find.byType(Text))
|
||||||
|
.map(
|
||||||
|
(t) =>
|
||||||
|
t.style?.fontSize ??
|
||||||
|
DefaultTextStyle.of(tester.element(find.byWidget(t))).style.fontSize,
|
||||||
|
)
|
||||||
|
.whereType<double>();
|
||||||
|
|
||||||
|
double _flyingAvatarWidth(WidgetTester tester) =>
|
||||||
|
tester.getSize(find.byType(FittedBox).first).width;
|
||||||
|
|
||||||
|
TextStyle _flyingNameStyle(WidgetTester tester) {
|
||||||
|
final transition = find.byType(DefaultTextStyleTransition);
|
||||||
|
expect(transition, findsOneWidget);
|
||||||
|
return DefaultTextStyle.of(
|
||||||
|
tester.element(
|
||||||
|
find.descendant(of: transition, matching: find.byType(Text)),
|
||||||
|
),
|
||||||
|
).style;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('avatar and name fly between header and profile', (tester) async {
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: _header(tag: _tag),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute(builder: (_) => _profile(tag: _tag)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
|
||||||
|
expect(_fontSizes(tester).any((s) => s > 17 && s < 22), isTrue);
|
||||||
|
final pushWidth = _flyingAvatarWidth(tester);
|
||||||
|
expect(pushWidth, greaterThan(44));
|
||||||
|
expect(pushWidth, lessThan(96));
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(FittedBox), findsNothing);
|
||||||
|
|
||||||
|
navigator.currentState!.pop();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
|
||||||
|
expect(_fontSizes(tester).any((s) => s > 17 && s < 22), isTrue);
|
||||||
|
final popWidth = _flyingAvatarWidth(tester);
|
||||||
|
expect(popWidth, greaterThan(44));
|
||||||
|
expect(popWidth, lessThan(96));
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(FittedBox), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'flying name inherits no decoration from the app fallback style',
|
||||||
|
(tester) async {
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: _header(tag: _tag),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute(builder: (_) => _profile(tag: _tag)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
|
||||||
|
final style = _flyingNameStyle(tester);
|
||||||
|
expect(style.decoration, isNull);
|
||||||
|
expect(style.fontFamily, isNull);
|
||||||
|
expect(style.fontSize, greaterThan(17));
|
||||||
|
expect(style.fontSize, lessThan(22));
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('flight stays opaque when the destination finishes loading', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: _header(tag: _tag),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
var loaded = false;
|
||||||
|
late StateSetter setProfileState;
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => StatefulBuilder(
|
||||||
|
builder: (_, setState) {
|
||||||
|
setProfileState = setState;
|
||||||
|
return _profile(tag: _tag, loaded: loaded);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
setProfileState(() => loaded = true);
|
||||||
|
|
||||||
|
var minOpacity = 1.0;
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 10));
|
||||||
|
final flying = find.byType(FittedBox);
|
||||||
|
if (flying.evaluate().isEmpty) break;
|
||||||
|
final fade = tester.widget<FadeTransition>(
|
||||||
|
find.ancestor(of: flying, matching: find.byType(FadeTransition)),
|
||||||
|
);
|
||||||
|
minOpacity = fade.opacity.value < minOpacity
|
||||||
|
? fade.opacity.value
|
||||||
|
: minOpacity;
|
||||||
|
}
|
||||||
|
expect(minOpacity, 1.0);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(FittedBox), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a null tag opts out of the flight entirely', (tester) async {
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: _header(tag: _tag),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute(builder: (_) => _profile(tag: null)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
|
||||||
|
expect(find.byType(FittedBox), findsNothing);
|
||||||
|
expect(find.byType(DefaultTextStyleTransition), findsNothing);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user