import 'dart:async'; import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/core/bridge/groups.dart' as rust_groups; import 'package:twonly/core/bridge/stories.dart' as stories; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/contact_request_badge.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/headline.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/best_friends_selector.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/contact_group_shortcut_row.comp.dart'; import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/story_target_selector.comp.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/background.layer.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart'; bool canSendSharedMedia({ required bool mediaReady, required bool sending, required bool hasChatRecipients, required bool storyPossible, required bool hasStoryTarget, required int? storyAudienceSize, }) { return mediaReady && !sending && (hasChatRecipients || (storyPossible && hasStoryTarget && (storyAudienceSize ?? 0) > 0)); } /// Every chat a send reaches: those picked directly and, with a story, the /// 1:1 chat of everybody the story goes to. Someone picked directly who also /// gets the story counts once. int sharedMediaRecipientCount({ required Set selectedGroupIds, required Set? storyAudienceChats, }) => {...selectedGroupIds, ...?storyAudienceChats}.length; class ShareImageView extends StatefulWidget { const ShareImageView({ required this.selectedGroupIds, required this.updateSelectedGroupIds, required this.mediaStoreFuture, required this.mediaFileService, required this.additionalData, required this.sendToWidget, required this.storyTarget, super.key, }); final HashSet selectedGroupIds; final void Function(String, bool) updateSelectedGroupIds; final Future? mediaStoreFuture; final MediaFileService mediaFileService; final AdditionalMessageData? additionalData; final bool sendToWidget; final StoryTarget storyTarget; @override State createState() => _ShareImageView(); } class _ShareImageView extends State { List _allGroups = []; List _otherUsers = []; List _bestFriends = []; List _pinnedContacts = []; bool sendingImage = false; bool mediaStoreFutureReady = false; ScreenshotImageHelper? _screenshotImage; bool hideArchivedUsers = true; final TextEditingController searchUserName = TextEditingController(); late StreamSubscription> allGroupSub; String lastQuery = ''; int _updateGroupsGeneration = 0; Set? _storyAudienceChats; @override void initState() { super.initState(); final groups = widget.sendToWidget ? twonlyDB.groupsDao.watchGroupsAllowedForWidgetShare() : twonlyDB.groupsDao.watchGroupsForShareImage(); allGroupSub = groups.listen((allGroups) async { if (!mounted) return; if (widget.sendToWidget) { final allowedGroupIds = allGroups.map((group) => group.groupId).toSet(); for (final groupId in widget.selectedGroupIds.toList()) { if (!allowedGroupIds.contains(groupId)) { widget.updateSelectedGroupIds(groupId, false); } } } setState(() { _allGroups = allGroups; }); await updateGroups(_allGroups.where((x) => !x.archived).toList()); }); unawaited(initAsync()); } Future initAsync() async { if (widget.mediaStoreFuture != null) { _screenshotImage = await widget.mediaStoreFuture; } mediaStoreFutureReady = true; if (!mounted) return; setState(() {}); } @override void dispose() { unawaited(allGroupSub.cancel()); searchUserName.dispose(); super.dispose(); } Future updateGroups(List groups) async { final generation = ++_updateGroupsGeneration; // The counters come from Rust in one batch: a sort comparator cannot await, // so every value has to be in hand before sorting starts. final states = await rust_groups.flameStates( groupIds: groups.map((g) => g.groupId).toList(), ); if (!mounted || generation != _updateGroupsGeneration) return; int flameOf(Group group) => states[group.groupId]?.counter ?? 0; // Sort contacts by flameCounter and then by totalMediaCounter groups.sort((a, b) { // First, compare by flameCounter final flameComparison = flameOf(b).compareTo(flameOf(a)); if (flameComparison != 0) { return flameComparison; // Sort by flameCounter in descending order } // If flameCounter is the same, compare by totalMediaCounter return b.totalMediaCounter.compareTo( a.totalMediaCounter, ); // Sort by totalMediaCounter in descending order }); // Separate best friends and other users final bestFriends = []; final otherUsers = []; final pinnedContacts = groups.where((c) => c.pinned).toList(); for (final group in groups) { if (group.pinned) continue; if (!group.archived && flameOf(group) > 0 && bestFriends.length < 6) { bestFriends.add(group); } else { otherUsers.add(group); } } setState(() { _bestFriends = bestFriends; _pinnedContacts = pinnedContacts; _otherUsers = otherUsers; }); } Future _filterUsers(String query) async { lastQuery = query; if (query.isEmpty) { await updateGroups( _allGroups .where( (x) => !x.archived || !hideArchivedUsers || widget.selectedGroupIds.contains(x.groupId), ) .toList(), ); return; } final usersFiltered = _allGroups .where( (user) => user.groupName.toLowerCase().contains(query.toLowerCase()), ) .toList(); await updateGroups(usersFiltered); } void updateSelectedGroupIds(String groupId, bool checked) { widget.updateSelectedGroupIds(groupId, checked); setState(() {}); } /// A story replays for everybody until it expires, so it takes neither a /// twonly-protected send nor anything but an image or video. bool get _storyPossible { final media = widget.mediaFileService.mediaFile; return !media.requiresAuthentication && (media.type == MediaType.image || media.type == MediaType.video); } int get _recipientCount => sharedMediaRecipientCount( selectedGroupIds: widget.selectedGroupIds, storyAudienceChats: _storyPossible && !widget.storyTarget.isEmpty ? _storyAudienceChats : null, ); Future _send() async { setState(() { sendingImage = true; }); final story = _storyPossible ? widget.storyTarget.toAudience() : null; // in case mediaStoreFutureReady is ready, the image is stored in the originalPath final send = RustApi.sendMediaToGroups( mediaId: widget.mediaFileService.mediaFile.mediaId, groupIds: widget.selectedGroupIds.toList(), additionalMessageData: widget.additionalData?.writeToBuffer(), widgetOnly: widget.sendToWidget, storyAudience: story, ); if (story == null) { unawaitedRustCall(send, 'sendMediaToGroups'); } else { // A story can be refused, most likely for reaching more people than one // send may, and the user has to hear about that before leaving here. try { await send; } catch (e) { Log.error('sendMediaToGroups with a story failed: $e'); if (mounted) { setState(() => sendingImage = false); showSnackbar( context, context.lang.shareImageStoryFailed(stories.maxMediaRecipients()), ); } return; } } if (mounted) { Navigator.pop(context, true); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(context.lang.shareImageTitle), actions: const [ ContactRequestBadgeComp(), SizedBox(width: 15), ], ), body: SafeArea( child: Padding( padding: const EdgeInsets.only( bottom: 40, left: 10, right: 10, ), child: ListView( children: [ if (_allGroups.isEmpty) const EmptyChatListComp() else ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: MyInput( controller: searchUserName, dense: true, fontWeight: FontWeight.normal, hintText: context.lang.shareImageSearchAllContacts, prefixIcon: const Icon(Icons.search, size: 20), onChanged: _filterUsers, ), ), const SizedBox(height: 12), if (!widget.sendToWidget) ...[ ContactGroupShortcutRow( selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, ), const SizedBox(height: 10), StoryTargetSelector( target: widget.storyTarget, enabled: _storyPossible, onChanged: () => setState(() {}), onAudienceChanged: (chats) { if (!mounted) return; setState(() => _storyAudienceChats = chats); }, ), // The next section's heading brings its own space. const SizedBox(height: 4), ], BestFriendsSelector( groups: _pinnedContacts, selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, title: context.lang.shareImagePinnedContacts, showSelectAll: !widget.sendToWidget && !widget.mediaFileService.mediaFile.requiresAuthentication, ), if (_pinnedContacts.isNotEmpty) const SizedBox(height: 10), BestFriendsSelector( groups: _bestFriends, selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, title: context.lang.shareImageBestFriends, showSelectAll: !widget.sendToWidget && !widget.mediaFileService.mediaFile.requiresAuthentication, ), if (_bestFriends.isNotEmpty) const SizedBox(height: 10), if (_otherUsers.isNotEmpty) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ HeadLineComp(context.lang.shareImageAllUsers), if (_allGroups.any((x) => x.archived)) Row( children: [ Text( context.lang.shareImageShowArchived, style: const TextStyle(fontSize: 10), ), Transform.scale( scale: 0.75, child: Checkbox.adaptive( value: !hideArchivedUsers, side: WidgetStateBorderSide.resolveWith( (states) { if (states.contains(WidgetState.selected)) { return const BorderSide(width: 0); } return BorderSide( color: Theme.of( context, ).colorScheme.outline, ); }, ), onChanged: (a) async { hideArchivedUsers = !hideArchivedUsers; await _filterUsers(lastQuery); if (mounted) setState(() {}); }, ), ), ], ), ], ), if (_otherUsers.isNotEmpty) UserList( List.from(_otherUsers), selectedGroupIds: widget.selectedGroupIds, updateSelectedGroupIds: updateSelectedGroupIds, ), ], ], ), ), ), floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation, floatingActionButton: _allGroups.isEmpty ? null : SizedBox( height: 168, child: Padding( padding: const EdgeInsets.only(bottom: 20, right: 20), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ if (widget.mediaFileService.mediaFile.type == MediaType.image && _screenshotImage?.image != null && userService.currentUser.showShowImagePreviewWhenSending) SizedBox( height: 100, width: 100 * 9 / 16, child: Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( border: Border.all( color: context.color.primary, width: 2, ), color: context.color.primary, borderRadius: BorderRadius.circular(12), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: CustomPaint( painter: UiImagePainter(_screenshotImage!.image!), ), ), ), ), MyButton( variant: MyButtonVariant.primaryMiddle, onPressed: canSendSharedMedia( mediaReady: mediaStoreFutureReady, sending: sendingImage, hasChatRecipients: widget.selectedGroupIds.isNotEmpty, storyPossible: _storyPossible, hasStoryTarget: !widget.storyTarget.isEmpty, storyAudienceSize: _storyAudienceChats?.length, ) ? _send : null, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (!mediaStoreFutureReady || sendingImage) const SizedBox( height: 14, width: 14, child: CircularProgressIndicator.adaptive( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( Colors.black87, ), ), ) else const FaIcon( FontAwesomeIcons.solidPaperPlane, size: 14, ), const SizedBox(width: 8), Text( '${context.lang.shareImagedEditorSendImage} ($_recipientCount)', ), ], ), ), ], ), ), ), ); } } class UserList extends StatelessWidget { const UserList( this.groups, { required this.selectedGroupIds, required this.updateSelectedGroupIds, super.key, }); final void Function(String, bool) updateSelectedGroupIds; final List groups; final HashSet selectedGroupIds; @override Widget build(BuildContext context) { // Step 1: Sort the users alphabetically groups.sort( (a, b) => b.lastMessageExchange.compareTo(a.lastMessageExchange), ); return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), restorationId: 'new_message_users_list', itemCount: groups.length, itemBuilder: (context, i) { final group = groups[i]; return ListTile( key: ValueKey(group.groupId), title: Row( children: [ Text(substringBy(group.groupName, 12)), FlameCounterWidget( groupId: group.groupId, prefix: true, ), ], ), leading: AvatarIcon( group: group, fontSize: 15, ), trailing: Checkbox.adaptive( value: selectedGroupIds.contains(group.groupId), side: WidgetStateBorderSide.resolveWith( (states) { if (states.contains(WidgetState.selected)) { return const BorderSide(width: 0); } return BorderSide(color: Theme.of(context).colorScheme.outline); }, ), onChanged: (value) { if (value == null) return; updateSelectedGroupIds(group.groupId, value); }, ), onTap: () { updateSelectedGroupIds( group.groupId, !selectedGroupIds.contains(group.groupId), ); }, ); }, ); } }