mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
527 lines
19 KiB
Dart
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),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|