mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-07-18 03:44:08 +00:00
fix performance issues
This commit is contained in:
parent
9a579e818c
commit
aeea5c9c8b
12 changed files with 438 additions and 326 deletions
File diff suppressed because one or more lines are too long
|
|
@ -98,6 +98,7 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
|
||||||
height: 11,
|
height: 11,
|
||||||
child: EmojiAnimationComp(
|
child: EmojiAnimationComp(
|
||||||
emoji: '⌛',
|
emoji: '⌛',
|
||||||
|
repeat: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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 -
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue