twonly-app/lib/src/visual/views/camera/share_image_contact_selection.view.dart
2026-09-27 21:27:52 +02:00

527 lines
19 KiB
Dart

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<String> selectedGroupIds,
required Set<String>? 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<String> selectedGroupIds;
final void Function(String, bool) updateSelectedGroupIds;
final Future<ScreenshotImageHelper?>? mediaStoreFuture;
final MediaFileService mediaFileService;
final AdditionalMessageData? additionalData;
final bool sendToWidget;
final StoryTarget storyTarget;
@override
State<ShareImageView> createState() => _ShareImageView();
}
class _ShareImageView extends State<ShareImageView> {
List<Group> _allGroups = [];
List<Group> _otherUsers = [];
List<Group> _bestFriends = [];
List<Group> _pinnedContacts = [];
bool sendingImage = false;
bool mediaStoreFutureReady = false;
ScreenshotImageHelper? _screenshotImage;
bool hideArchivedUsers = true;
final TextEditingController searchUserName = TextEditingController();
late StreamSubscription<List<Group>> allGroupSub;
String lastQuery = '';
int _updateGroupsGeneration = 0;
Set<String>? _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<void> 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<void> updateGroups(List<Group> 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 = <Group>[];
final otherUsers = <Group>[];
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<void> _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<void> _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<Group> groups;
final HashSet<String> 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),
);
},
);
},
);
}
}