fix performance issues

This commit is contained in:
otsmr 2026-07-15 13:25:16 +02:00
parent 9a579e818c
commit aeea5c9c8b
12 changed files with 438 additions and 326 deletions

File diff suppressed because one or more lines are too long

View file

@ -98,6 +98,7 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
height: 11, height: 11,
child: EmojiAnimationComp( child: EmojiAnimationComp(
emoji: '', emoji: '',
repeat: false,
), ),
), ),
], ],

View file

@ -22,15 +22,14 @@ class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var needToDownSizeImage = false; var needToDownSizeImage = false;
var availableHeight = MediaQuery.of(context).size.height;
// Get the screen size and safe area padding // Use narrow MediaQuery selectors to avoid rebuilding on keyboard inset changes
final screenSize = MediaQuery.of(context).size; final screenSize = MediaQuery.sizeOf(context);
final safeAreaPadding = MediaQuery.of(context).padding; final safeAreaPadding = MediaQuery.paddingOf(context);
// Calculate the available width and height // Calculate the available width and height
final availableWidth = screenSize.width; final availableWidth = screenSize.width;
availableHeight = final availableHeight =
screenSize.height - screenSize.height -
safeAreaPadding.top - safeAreaPadding.top -
safeAreaPadding.bottom - safeAreaPadding.bottom -

View file

@ -38,7 +38,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
List<Group> _groupsPinned = []; List<Group> _groupsPinned = [];
List<Group> _groupsArchived = []; List<Group> _groupsArchived = [];
bool _hasContacts = false; final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
bool _loading = true; bool _loading = true;
bool get _hasOpenGroup => bool get _hasOpenGroup =>
_groupsNotPinned.isNotEmpty || _groupsNotPinned.isNotEmpty ||
@ -50,6 +50,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
int _countContactRequest = 0; int _countContactRequest = 0;
int _countAnnouncedUsers = 0; int _countAnnouncedUsers = 0;
final ValueNotifier<int> _badgeCount = ValueNotifier(0);
late StreamSubscription<int?> _countContactRequestStream; late StreamSubscription<int?> _countContactRequestStream;
late StreamSubscription<int?> _countAnnouncedStream; late StreamSubscription<int?> _countAnnouncedStream;
@ -80,9 +81,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
contacts, contacts,
) { ) {
if (!mounted) return; if (!mounted) return;
setState(() { _hasContacts.value = contacts.isNotEmpty;
_hasContacts = contacts.isNotEmpty;
});
}); });
_countContactRequestStream = twonlyDB.contactsDao _countContactRequestStream = twonlyDB.contactsDao
@ -90,9 +89,8 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
.listen((update) { .listen((update) {
if (update != null) { if (update != null) {
if (!mounted) return; if (!mounted) return;
setState(() { _countContactRequest = update;
_countContactRequest = update; _badgeCount.value = _countAnnouncedUsers + _countContactRequest;
});
} }
}); });
@ -100,9 +98,8 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
.watchNewAnnouncementsWithDataCount() .watchNewAnnouncementsWithDataCount()
.listen((update) { .listen((update) {
if (!mounted) return; if (!mounted) return;
setState(() { _countAnnouncedUsers = update;
_countAnnouncedUsers = update; _badgeCount.value = _countAnnouncedUsers + _countContactRequest;
});
}); });
_precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((mediaFiles) { _precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((mediaFiles) {
@ -141,7 +138,10 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
final plan = context.watch<PurchasesProvider>().plan; final plan = context.select<PurchasesProvider, SubscriptionPlan>(
(p) => p.plan,
);
final dark = isDarkMode(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
@ -175,7 +175,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isDarkMode(context) ? Colors.black : Colors.white, color: dark ? Colors.black : Colors.white,
), ),
), ),
), ),
@ -184,40 +184,40 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
), ),
actions: [ actions: [
const FeedbackIconButtonComp(), const FeedbackIconButtonComp(),
Stack( ValueListenableBuilder<int>(
children: [ valueListenable: _badgeCount,
if (_countAnnouncedUsers + _countContactRequest > 0) builder: (context, badgeCount, child) {
Positioned.fill( return Stack(
child: Center( children: [
child: Container( if (badgeCount > 0)
width: 40, Positioned.fill(
height: 40, child: Center(
decoration: const BoxDecoration( child: Container(
color: primaryColor, width: 40,
shape: BoxShape.circle, height: 40,
decoration: const BoxDecoration(
color: primaryColor,
shape: BoxShape.circle,
),
),
),
),
Center(
child: NotificationBadgeComp(
backgroundColor: dark ? Colors.white : Colors.black,
textColor: dark ? Colors.black : Colors.white,
count: badgeCount.toString(),
child: IconButton(
color: badgeCount > 0 ? Colors.black : null,
key: searchForOtherUsers,
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
onPressed: () => context.push(Routes.chatsAddNewUser),
), ),
), ),
), ),
), ],
Center( );
child: NotificationBadgeComp( },
backgroundColor: isDarkMode(context)
? Colors.white
: Colors.black,
textColor: isDarkMode(context) ? Colors.black : Colors.white,
count: (_countAnnouncedUsers + _countContactRequest)
.toString(),
child: IconButton(
color: (_countAnnouncedUsers + _countContactRequest > 0)
? Colors.black
: null,
key: searchForOtherUsers,
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
onPressed: () => context.push(Routes.chatsAddNewUser),
),
),
),
],
), ),
IconButton( IconButton(
@ -303,45 +303,43 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
), ),
), ),
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation, floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
floatingActionButton: !_hasContacts floatingActionButton: ValueListenableBuilder<bool>(
? null valueListenable: _hasContacts,
: Padding( builder: (context, hasContacts, child) {
padding: const EdgeInsets.only(bottom: 30), if (!hasContacts) return const SizedBox.shrink();
child: Column( return Padding(
mainAxisAlignment: MainAxisAlignment.end, padding: const EdgeInsets.only(bottom: 30),
children: [ child: Column(
FloatingActionButton( mainAxisAlignment: MainAxisAlignment.end,
heroTag: 'qrcode_fab', children: [
elevation: 2, FloatingActionButton(
backgroundColor: isDarkMode(context) heroTag: 'qrcode_fab',
? Colors.grey[800] elevation: 2,
: Colors.grey[200], backgroundColor: dark ? Colors.grey[800] : Colors.grey[200],
foregroundColor: isDarkMode(context) foregroundColor: dark ? Colors.white : Colors.black87,
? Colors.white onPressed: () => context.push(Routes.settingsPublicProfile),
: Colors.black87, child: FaIcon(
onPressed: () => context.push(Routes.settingsPublicProfile), FontAwesomeIcons.qrcode,
child: FaIcon( color: dark ? Colors.white : Colors.black87,
FontAwesomeIcons.qrcode,
color: isDarkMode(context)
? Colors.white
: Colors.black87,
),
), ),
const SizedBox(height: 12), ),
FloatingActionButton( const SizedBox(height: 12),
heroTag: 'new_chat_fab', FloatingActionButton(
elevation: 2, heroTag: 'new_chat_fab',
backgroundColor: primaryColor, elevation: 2,
foregroundColor: Colors.black87, backgroundColor: primaryColor,
onPressed: () => context.push(Routes.chatsStartNewChat), foregroundColor: Colors.black87,
child: const FaIcon( onPressed: () => context.push(Routes.chatsStartNewChat),
FontAwesomeIcons.penToSquare, child: const FaIcon(
color: Colors.black87, FontAwesomeIcons.penToSquare,
), color: Colors.black87,
), ),
], ),
), ],
), ),
);
},
),
); );
} }
} }

View file

@ -174,19 +174,23 @@ class _UserListItem extends State<GroupListItemComp> {
/// Fetches any media files referenced by preview messages but not yet in the /// Fetches any media files referenced by preview messages but not yet in the
/// local cache. Fire-and-forget; updates state when results arrive. /// local cache. Fire-and-forget; updates state when results arrive.
Future<void> _fetchMissingMediaFiles() async { Future<void> _fetchMissingMediaFiles() async {
final missing = <MediaFile>[];
for (final message in _previewMessages) { for (final message in _previewMessages) {
if (message.mediaId != null && if (message.mediaId != null &&
!_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) { !_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) {
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
message.mediaId!, message.mediaId!,
); );
if (mediaFile != null && mounted) { if (mediaFile != null) {
setState(() { missing.add(mediaFile);
_previewMediaFiles.add(mediaFile);
});
} }
} }
} }
if (missing.isNotEmpty && mounted) {
setState(() {
_previewMediaFiles.addAll(missing);
});
}
} }
Future<void> onTap() async { Future<void> onTap() async {

View file

@ -26,11 +26,6 @@ class _LastMessageTimeCompState extends State<LastMessageTimeComp> {
void initState() { void initState() {
super.initState(); super.initState();
_loadTargetTime(); _loadTargetTime();
updateTime = Timer.periodic(
const Duration(milliseconds: 500),
(_) => _updateSeconds(),
);
} }
@override @override
@ -66,6 +61,16 @@ class _LastMessageTimeCompState extends State<LastMessageTimeComp> {
setState(() { setState(() {
lastMessageInSeconds = seconds < 0 ? 0 : seconds; lastMessageInSeconds = seconds < 0 ? 0 : seconds;
}); });
var nextTickMs = 1000;
if (lastMessageInSeconds >= 120 && lastMessageInSeconds < 3600) {
nextTickMs = 30000;
} else if (lastMessageInSeconds >= 3600) {
nextTickMs = 60000;
}
updateTime?.cancel();
updateTime = Timer(Duration(milliseconds: nextTickMs), _updateSeconds);
} }
@override @override

View file

@ -28,10 +28,6 @@ class _TypingIndicatorSubtitleCompState
void initState() { void initState() {
super.initState(); super.initState();
_periodicUpdate = Timer.periodic(const Duration(seconds: 1), (_) {
filterOpenUsers(_groupMembers);
});
final membersStream = twonlyDB.groupsDao.watchGroupMembers( final membersStream = twonlyDB.groupsDao.watchGroupMembers(
widget.groupId, widget.groupId,
); );
@ -41,11 +37,22 @@ class _TypingIndicatorSubtitleCompState
} }
void filterOpenUsers(List<GroupMember> input) { void filterOpenUsers(List<GroupMember> input) {
if (mounted) { if (!mounted) return;
setState(() {
_groupMembers = input.where(isTyping).toList(); final typingMembers = input.where(isTyping).toList();
if (typingMembers.isEmpty) {
_periodicUpdate?.cancel();
_periodicUpdate = null;
} else {
_periodicUpdate ??= Timer.periodic(const Duration(seconds: 1), (_) {
filterOpenUsers(_groupMembers);
}); });
} }
setState(() {
_groupMembers = typingMembers;
});
} }
@override @override

View file

@ -4,7 +4,6 @@ import 'dart:collection';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:screen_protector/screen_protector.dart'; import 'package:screen_protector/screen_protector.dart';
@ -24,13 +23,13 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/keyboard_dismiss_observer.comp.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_bottom_navigation.comp.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart'; import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart';
@ -63,7 +62,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
DateTime? canBeSeenUntil; DateTime? canBeSeenUntil;
final ValueNotifier<double> progress = ValueNotifier(0); final ValueNotifier<double> progress = ValueNotifier(0);
bool showSendTextMessageInput = false; bool showSendTextMessageInput = false;
double maxBottomInset = 0;
DateTime? _lastTimeInputClosed; DateTime? _lastTimeInputClosed;
final GlobalKey mediaWidgetKey = GlobalKey(); final GlobalKey mediaWidgetKey = GlobalKey();
@ -505,7 +503,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
advanceToNextMediaOrExit(); advanceToNextMediaOrExit();
} }
}); });
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) { progressTimer = Timer.periodic(const Duration(milliseconds: 16), (timer) {
final mediaFile = currentMedia?.mediaFile; final mediaFile = currentMedia?.mediaFile;
if (mediaFile == null) return; if (mediaFile == null) return;
if (mediaFile.displayLimitInMilliseconds == null || if (mediaFile.displayLimitInMilliseconds == null ||
@ -562,107 +560,49 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
Widget bottomNavigation() { Widget bottomNavigation() {
return Row( return MediaViewerBottomNavigationBar(
key: mediaWidgetKey, key: mediaWidgetKey,
mainAxisAlignment: MainAxisAlignment.center, currentMedia: currentMedia,
children: [ currentMessage: currentMessage,
if (currentMedia != null && imageSaving: imageSaving,
currentMessage != null && imageSaved: imageSaved,
!currentMedia!.mediaFile.requiresAuthentication && showShortReactions: showShortReactions,
currentMedia!.mediaFile.displayLimitInMilliseconds == null) onSaveToGallery: onPressedSaveToGallery,
MyIconButton( onToggleReactions: () {
variant: MyIconButtonVariant.secondary, if (!showShortReactions) {
onPressed: (currentMedia == null || currentMessage == null) displayShortReactions();
? null } else {
: onPressedSaveToGallery, setState(() {
icon: imageSaving showShortReactions = false;
? const SizedBox( });
width: 16, }
height: 16, },
child: CircularProgressIndicator.adaptive(strokeWidth: 2), onMessagePressed: () {
) displayShortReactions();
: imageSaved setState(() {
? const Icon(Icons.check) showSendTextMessageInput = true;
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 20), });
},
onCameraPressed: () async {
nextMediaTimer?.cancel();
progressTimer?.cancel();
await videoController?.pause();
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return CameraSendToView(widget.group);
},
), ),
const SizedBox(width: 10), );
IconButton( if (mounted &&
icon: SizedBox( currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
width: 30, await advanceToNextMediaOrExit();
height: 30, } else {
child: GridView.count( await videoController?.play();
crossAxisCount: 2, }
children: List.generate( },
4,
(index) {
return SizedBox(
width: 8,
height: 8,
child: Center(
child: EmojiAnimationComp(
emoji: EmojiAnimationComp.animatedIcons.keys
.toList()[index],
),
),
);
},
),
),
),
onPressed: () {
if (!showShortReactions) {
displayShortReactions();
} else {
setState(() {
showShortReactions = false;
});
}
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
),
),
),
const SizedBox(width: 10),
MyIconButton(
variant: MyIconButtonVariant.secondary,
onPressed: () async {
displayShortReactions();
setState(() {
showSendTextMessageInput = true;
});
},
icon: const FaIcon(
FontAwesomeIcons.message,
size: 20,
),
),
const SizedBox(width: 10),
MyIconButton(
onPressed: () async {
nextMediaTimer?.cancel();
progressTimer?.cancel();
await videoController?.pause();
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return CameraSendToView(widget.group);
},
),
);
if (mounted &&
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
await advanceToNextMediaOrExit();
} else {
await videoController?.play();
}
},
icon: const FaIcon(FontAwesomeIcons.camera, size: 24),
),
],
); );
} }
@ -717,124 +657,118 @@ class _MediaViewerViewState extends State<MediaViewerView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom; return KeyboardDismissObserver(
if (bottomInset > maxBottomInset) { showSendTextMessageInput: showSendTextMessageInput,
maxBottomInset = bottomInset; onKeyboardDismissed: () {
} else if (bottomInset == 0 && maxBottomInset > 0) { setState(() {
maxBottomInset = 0; showSendTextMessageInput = false;
if (showSendTextMessageInput) { showShortReactions = false;
WidgetsBinding.instance.addPostFrameCallback((_) { _lastTimeInputClosed = clock.now();
if (mounted) {
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
_lastTimeInputClosed = clock.now();
});
}
}); });
} },
} child: Scaffold(
body: SafeArea(
return Scaffold( child: Stack(
body: SafeArea( fit: StackFit.expand,
child: Stack( children: [
fit: StackFit.expand, if (_showDownloadingLoader) _loader(),
children: [ if ((currentMedia != null || videoController != null) &&
if (_showDownloadingLoader) _loader(), (canBeSeenUntil == null || progress.value >= 0))
if ((currentMedia != null || videoController != null) && GestureDetector(
(canBeSeenUntil == null || progress.value >= 0)) onTap: onScreenTapped,
GestureDetector( onDoubleTap: (videoController == null)
onTap: onScreenTapped, ? null
onDoubleTap: (videoController == null) ? null : onScreenTapped, : onScreenTapped,
child: MediaViewSizingHelper( child: MediaViewSizingHelper(
bottomNavigation: bottomNavigation(), bottomNavigation: bottomNavigation(),
requiredHeight: 55, requiredHeight: 55,
child: MediaContentRenderer( child: MediaContentRenderer(
currentMedia: currentMedia, currentMedia: currentMedia,
videoController: videoController, videoController: videoController,
loader: _loader(), loader: _loader(),
),
),
),
if (displayTwonlyPresent)
TwonlyPresentOverlay(
onTap: () => loadAndDownloadCurrentMedia(showTwonly: true),
),
if (currentMedia != null &&
currentMedia?.mediaFile.downloadState != DownloadState.ready)
Positioned.fill(child: _loader()),
if (canBeSeenUntil != null || progress.value >= 0)
Positioned(
right: 20,
top: 27,
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: ValueListenableBuilder<double>(
valueListenable: progress,
builder: (context, value, child) {
return CircularProgressIndicator(
value: value,
strokeWidth: 2,
);
},
),
),
],
),
),
Positioned(
top: 10,
left: showSendTextMessageInput ? 0 : null,
right: showSendTextMessageInput ? 0 : 15,
child: Text(
_currentMediaSender,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: showSendTextMessageInput ? 24 : 14,
fontWeight: FontWeight.bold,
color: showSendTextMessageInput
? null
: const Color.fromARGB(255, 126, 126, 126),
shadows: const [
Shadow(
color: Color.fromARGB(122, 0, 0, 0),
blurRadius: 5,
),
],
), ),
), ),
), ),
if (displayTwonlyPresent) if (showSendTextMessageInput)
TwonlyPresentOverlay( MediaViewerMessageInput(
onTap: () => loadAndDownloadCurrentMedia(showTwonly: true), controller: textMessageController,
), onSubmitted: (value) => _sendTextMessage(),
if (currentMedia != null && onSendPressed: _sendTextMessage,
currentMedia?.mediaFile.downloadState != DownloadState.ready)
Positioned.fill(child: _loader()),
if (canBeSeenUntil != null || progress.value >= 0)
Positioned(
right: 20,
top: 27,
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: ValueListenableBuilder<double>(
valueListenable: progress,
builder: (context, value, child) {
return CircularProgressIndicator(
value: value,
strokeWidth: 2,
);
},
),
),
],
), ),
), if (currentMessage != null)
Positioned( AdditionalMessageContent(currentMessage!),
top: 10, if (currentMedia != null)
left: showSendTextMessageInput ? 0 : null, ReactionButtons(
right: showSendTextMessageInput ? 0 : 15, show: showShortReactions,
child: Text( textInputFocused: showSendTextMessageInput,
_currentMediaSender, mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
textAlign: TextAlign.center, groupId: widget.group.groupId,
style: TextStyle( messageId: currentMessage!.messageId,
fontSize: showSendTextMessageInput ? 24 : 14, emojiKey: emojiKey,
fontWeight: FontWeight.bold, hide: () {
color: showSendTextMessageInput setState(() {
? null showShortReactions = false;
: const Color.fromARGB(255, 126, 126, 126), showSendTextMessageInput = false;
shadows: const [ _lastTimeInputClosed = clock.now();
Shadow( });
color: Color.fromARGB(122, 0, 0, 0), },
blurRadius: 5,
),
],
), ),
Positioned.fill(
child: EmojiFloatWidget(key: emojiKey),
), ),
), ],
if (showSendTextMessageInput) ),
MediaViewerMessageInput(
controller: textMessageController,
onSubmitted: (value) => _sendTextMessage(),
onSendPressed: _sendTextMessage,
),
if (currentMessage != null)
AdditionalMessageContent(currentMessage!),
if (currentMedia != null)
ReactionButtons(
show: showShortReactions,
textInputFocused: showSendTextMessageInput,
mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
groupId: widget.group.groupId,
messageId: currentMessage!.messageId,
emojiKey: emojiKey,
hide: () {
setState(() {
showShortReactions = false;
showSendTextMessageInput = false;
_lastTimeInputClosed = clock.now();
});
},
),
Positioned.fill(
child: EmojiFloatWidget(key: emojiKey),
),
],
), ),
), ),
); );

View file

@ -80,11 +80,18 @@ class _EmojiReactionWidgetState extends State<EmojiReactionWidget> {
child: SizedBox( child: SizedBox(
width: 40, width: 40,
child: Center( child: Center(
child: widget.show child: AnimatedOpacity(
? EmojiAnimationComp( opacity: widget.show ? 1.0 : 0.0,
emoji: widget.emoji, duration: widget.show
) ? const Duration(milliseconds: 150)
: const SizedBox.shrink(), : Duration.zero,
child: Offstage(
offstage: !widget.show,
child: EmojiAnimationComp(
emoji: widget.emoji,
),
),
),
), ),
), ),
); );

View file

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
/// Observes keyboard dismiss via viewInsets without causing the parent to
/// rebuild on every keyboard animation frame.
class KeyboardDismissObserver extends StatefulWidget {
const KeyboardDismissObserver({
required this.showSendTextMessageInput,
required this.onKeyboardDismissed,
required this.child,
super.key,
});
final bool showSendTextMessageInput;
final VoidCallback onKeyboardDismissed;
final Widget child;
@override
State<KeyboardDismissObserver> createState() =>
_KeyboardDismissObserverState();
}
class _KeyboardDismissObserverState extends State<KeyboardDismissObserver> {
double _maxBottomInset = 0;
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
if (bottomInset > _maxBottomInset) {
_maxBottomInset = bottomInset;
} else if (bottomInset == 0 && _maxBottomInset > 0) {
_maxBottomInset = 0;
if (widget.showSendTextMessageInput) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
widget.onKeyboardDismissed();
}
});
}
}
return widget.child;
}
}

View file

@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
/// Extracted bottom navigation bar widget to provide subtree isolation.
/// Flutter can skip rebuilding this widget when only the parent's unrelated
/// state changes (e.g. progress timer, keyboard insets).
class MediaViewerBottomNavigationBar extends StatelessWidget {
const MediaViewerBottomNavigationBar({
required this.currentMedia,
required this.currentMessage,
required this.imageSaving,
required this.imageSaved,
required this.showShortReactions,
required this.onSaveToGallery,
required this.onToggleReactions,
required this.onMessagePressed,
required this.onCameraPressed,
super.key,
});
final MediaFileService? currentMedia;
final Message? currentMessage;
final bool imageSaving;
final bool imageSaved;
final bool showShortReactions;
final VoidCallback onSaveToGallery;
final VoidCallback onToggleReactions;
final VoidCallback onMessagePressed;
final VoidCallback onCameraPressed;
/// Pre-built static icon for the emoji grid button so the 4 Lottie
/// animations are created only once.
static final Widget _emojiGridIcon = SizedBox(
width: 30,
height: 30,
child: GridView.count(
crossAxisCount: 2,
children: List.generate(
4,
(index) {
return SizedBox(
width: 8,
height: 8,
child: Center(
child: EmojiAnimationComp(
emoji: EmojiAnimationComp.animatedIconKeys[index],
),
),
);
},
),
),
);
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (currentMedia != null &&
currentMessage != null &&
!currentMedia!.mediaFile.requiresAuthentication &&
currentMedia!.mediaFile.displayLimitInMilliseconds == null)
MyIconButton(
variant: MyIconButtonVariant.secondary,
onPressed: (currentMedia == null || currentMessage == null)
? null
: onSaveToGallery,
icon: imageSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: imageSaved
? const Icon(Icons.check)
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 20),
),
const SizedBox(width: 10),
IconButton(
icon: _emojiGridIcon,
onPressed: onToggleReactions,
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
),
),
),
const SizedBox(width: 10),
MyIconButton(
variant: MyIconButtonVariant.secondary,
onPressed: onMessagePressed,
icon: const FaIcon(
FontAwesomeIcons.message,
size: 20,
),
),
const SizedBox(width: 10),
MyIconButton(
onPressed: onCameraPressed,
icon: const FaIcon(FontAwesomeIcons.camera, size: 24),
),
],
);
}
}

View file

@ -335,9 +335,10 @@ class _ParticlePainter extends CustomPainter {
_ParticlePainter(this.particles); _ParticlePainter(this.particles);
final List<_Particle> particles; final List<_Particle> particles;
static final _textPainter = TextPainter(textDirection: TextDirection.ltr);
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final textPainter = TextPainter(textDirection: TextDirection.ltr);
for (final p in particles) { for (final p in particles) {
final tp = TextSpan( final tp = TextSpan(
text: p.emoji, text: p.emoji,
@ -350,14 +351,17 @@ class _ParticlePainter extends CustomPainter {
: null, : null,
), ),
); );
textPainter _textPainter
..text = tp ..text = tp
..layout(); ..layout();
canvas canvas
..save() ..save()
..translate(p.x - textPainter.width / 2, p.y - textPainter.height / 2) ..translate(
p.x - _textPainter.width / 2,
p.y - _textPainter.height / 2,
)
..rotate(p.rotation); ..rotate(p.rotation);
textPainter.paint(canvas, Offset.zero); _textPainter.paint(canvas, Offset.zero);
canvas.restore(); canvas.restore();
} }
} }