feat: комета в 'поделится

'
This commit is contained in:
Jganenokk
2026-08-22 23:18:09 +07:00
parent 0091b5f272
commit 5d115e0f9e
15 changed files with 1972 additions and 9 deletions
+547
View File
@@ -0,0 +1,547 @@
import 'dart:async';
import 'dart:io';
import '../../core/media/gallery_source.dart';
import '../../core/media/share_thumbnail.dart';
import '../../core/media/video_transcoder.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../models/attachment.dart';
import '../../models/chat_preview_media.dart';
import '../../models/shared_payload.dart';
import '../../main.dart';
import 'chats.dart';
import 'messages.dart';
import 'upload_service.dart';
class PreparedShareFile {
final SharedFile source;
final String? thumbDataUri;
final int? width;
final int? height;
final int? durationMs;
const PreparedShareFile({
required this.source,
this.thumbDataUri,
this.width,
this.height,
this.durationMs,
});
SharedFileKind get kind => source.kind;
File get file => source.file;
}
class PreparedShare {
final List<PreparedShareFile> files;
final String? text;
const PreparedShare({required this.files, this.text});
List<PreparedShareFile> get photos =>
files.where((f) => f.kind == SharedFileKind.photo).toList();
List<PreparedShareFile> get videos =>
files.where((f) => f.kind == SharedFileKind.video).toList();
List<PreparedShareFile> get documents =>
files.where((f) => f.kind == SharedFileKind.file).toList();
bool get isTextOnly => files.isEmpty;
static Future<PreparedShare> prepare(SharedPayload payload) async {
final prepared = <PreparedShareFile>[];
for (final source in payload.files) {
prepared.add(await _prepareOne(source));
}
return PreparedShare(files: prepared, text: payload.text);
}
static Future<PreparedShareFile> _prepareOne(SharedFile source) async {
final thumb = await sharedThumbnailDataUri(source);
switch (source.kind) {
case SharedFileKind.photo:
final dim = await imageFileDimensions(source.file);
return PreparedShareFile(
source: source,
thumbDataUri: thumb,
width: dim?.$1,
height: dim?.$2,
);
case SharedFileKind.video:
VideoInfo? info;
try {
info = await VideoTranscoder.probe(source.path);
} catch (e) {
logger.w('Поделиться: probe ${source.path}: $e');
}
return PreparedShareFile(
source: source,
thumbDataUri: thumb,
width: (info?.width ?? 0) > 0 ? info!.width : null,
height: (info?.height ?? 0) > 0 ? info!.height : null,
durationMs: (info?.durationMs ?? 0) > 0 ? info!.durationMs : null,
);
case SharedFileKind.file:
return PreparedShareFile(source: source);
}
}
}
class ShareSendResult {
final int chatCount;
final int messageCount;
const ShareSendResult({required this.chatCount, required this.messageCount});
}
class ShareSender {
ShareSender._();
static final Map<
String,
({int accountId, int chatId, String text, String? preview, int time})
>
_tracked = {};
static StreamSubscription<UploadJobEvent>? _sub;
static void _track(
String tempId, {
required int accountId,
required int chatId,
required String text,
required String? preview,
required int time,
}) {
_sub ??= UploadService.instance.events.listen(_onUploadEvent);
_tracked[tempId] = (
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: time,
);
}
static void _onUploadEvent(UploadJobEvent event) {
final entry = _tracked.remove(event.tempId);
if (entry == null) return;
final status = event is UploadJobDone ? 'sent' : 'error';
final messageId = event is UploadJobDone
? (event.message?.id ?? event.tempId)
: event.tempId;
unawaited(
chats
.applyOutgoing(
entry.accountId,
entry.chatId,
messageId: messageId,
time: entry.time,
text: entry.text,
status: status,
preview: entry.preview,
)
.catchError((Object e) {
logger.w('Поделиться: не обновить превью чата ${entry.chatId}: $e');
}),
);
}
static Future<ShareSendResult> send({
required int accountId,
required List<int> chatIds,
required PreparedShare share,
required String caption,
}) async {
var messages = 0;
for (final chatId in chatIds) {
messages += await _sendToChat(
accountId: accountId,
chatId: chatId,
share: share,
caption: caption,
);
}
logger.i(
'Поделиться: отправлено $messages сообщений в ${chatIds.length} чатов',
);
return ShareSendResult(chatCount: chatIds.length, messageCount: messages);
}
static Future<int> _sendToChat({
required int accountId,
required int chatId,
required PreparedShare share,
required String caption,
}) async {
if (share.isTextOnly) {
final text = caption.trim().isNotEmpty
? caption
: (share.text ?? '').trim();
if (text.isEmpty) return 0;
await _sendText(accountId: accountId, chatId: chatId, text: text);
return 1;
}
final photos = share.photos;
final videos = share.videos;
final documents = share.documents;
var used = false;
String take() {
if (used || caption.isEmpty) return '';
used = true;
return caption;
}
var count = 0;
if (photos.isNotEmpty) {
await _sendPhotos(
accountId: accountId,
chatId: chatId,
photos: photos,
caption: take(),
);
count++;
}
for (final video in videos) {
await _sendVideo(
accountId: accountId,
chatId: chatId,
video: video,
caption: take(),
);
count++;
}
for (final document in documents) {
await _sendDocument(
accountId: accountId,
chatId: chatId,
document: document,
caption: take(),
);
count++;
}
return count;
}
static Future<void> _sendText({
required int accountId,
required int chatId,
required String text,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: text,
time: now,
status: 'sending',
);
await _persist(placeholder);
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: null,
status: 'sending',
);
String realId = tempId;
var status = 'sent';
try {
final sent = await messagesModule.sendMessage(accountId, chatId, text);
if (sent.isNotEmpty) realId = sent;
} catch (e) {
logger.w('Поделиться: текст в $chatId не ушёл: $e');
status = 'error';
}
final settled = CachedMessage(
id: realId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: text,
time: now,
status: status,
);
await _persist(settled, removeId: realId == tempId ? null : tempId);
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: realId,
time: now,
text: text,
preview: null,
status: status,
);
}
static Future<void> _sendPhotos({
required int accountId,
required int chatId,
required List<PreparedShareFile> photos,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final jobs = <({File file, GalleryItem? item})>[];
final attachments = <PhotoAttachment>[];
for (final photo in photos) {
jobs.add((file: photo.file, item: null));
attachments.add(
PhotoAttachment(
localPath: photo.file.path,
previewData: photo.thumbDataUri,
width: photo.width,
height: photo.height,
),
);
}
if (jobs.isEmpty) return;
final label = photos.length > 1 ? 'Изображения' : 'Изображение';
final preview = _preview(
kind: ChatPreviewKind.photo,
files: photos,
label: caption.isEmpty ? label : null,
);
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: attachments,
);
await _persist(placeholder);
final text = caption.isEmpty ? label : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
UploadService.instance.sendPhotos(
accountId: accountId,
chatId: chatId,
tempId: tempId,
jobs: jobs,
caption: caption,
placeholder: placeholder,
),
);
}
static Future<void> _sendVideo({
required int accountId,
required int chatId,
required PreparedShareFile video,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final preview = _preview(
kind: ChatPreviewKind.video,
files: [video],
label: caption.isEmpty ? 'Видео' : null,
);
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: [
VideoAttachment(
localPath: video.file.path,
previewData: video.thumbDataUri,
width: video.width,
height: video.height,
duration: video.durationMs,
),
],
);
await _persist(placeholder);
final text = caption.isEmpty ? 'Видео' : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
UploadService.instance.sendVideo(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: video.file,
caption: caption,
placeholder: placeholder,
),
);
}
static Future<void> _sendDocument({
required int accountId,
required int chatId,
required PreparedShareFile document,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final name = document.source.name;
final size = document.source.size;
final preview = ChatPreviewMedia(
kind: ChatPreviewKind.file,
label: caption.isEmpty ? 'Файл' : null,
detail: caption.isEmpty ? name : null,
).encode();
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: [FileAttachment(name: name, size: size)],
);
await _persist(placeholder);
final text = caption.isEmpty ? 'Файл: $name' : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
UploadService.instance.sendFile(
accountId: accountId,
chatId: chatId,
tempId: tempId,
source: document.file,
filename: name,
size: size,
placeholder: placeholder,
),
);
}
static String? _preview({
required ChatPreviewKind kind,
required List<PreparedShareFile> files,
String? label,
}) {
final thumbs = <ChatPreviewThumb>[];
for (final file in files) {
final data = file.thumbDataUri;
if (data == null) continue;
thumbs.add(
ChatPreviewThumb(
source: data,
video: file.kind == SharedFileKind.video,
),
);
if (thumbs.length >= 3) break;
}
if (thumbs.isEmpty && label == null) return null;
return ChatPreviewMedia(kind: kind, thumbs: thumbs, label: label).encode();
}
static Future<void> _persist(
CachedMessage message, {
String? removeId,
}) async {
try {
if (removeId != null && removeId != message.id) {
await AppDatabase.deleteMessage(
message.accountId,
message.chatId,
removeId,
);
}
await AppDatabase.saveMessages([message.toDbRow()]);
} catch (e) {
logger.w('Поделиться: не сохранить плейсхолдер: $e');
}
}
static Future<void> _bumpChat({
required int accountId,
required int chatId,
required String messageId,
required int time,
required String text,
required String? preview,
required String status,
}) async {
try {
await chats.applyOutgoing(
accountId,
chatId,
messageId: messageId,
time: time,
text: text,
status: status,
preview: preview,
);
} catch (e) {
logger.w('Поделиться: не обновить строку чата $chatId: $e');
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart' show ImageProvider, MemoryImage;
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:image/image.dart' as img;
import '../../models/shared_payload.dart';
import '../utils/logger.dart';
import 'video_transcoder.dart';
const int _thumbMaxDimension = 128;
const int _thumbQuality = 70;
Future<String?> sharedThumbnailDataUri(SharedFile source) async {
switch (source.kind) {
case SharedFileKind.photo:
return _photoThumb(source.file);
case SharedFileKind.video:
return _videoThumb(source.file);
case SharedFileKind.file:
return null;
}
}
Future<String?> _photoThumb(File file) async {
Uint8List bytes;
try {
bytes = await file.readAsBytes();
} catch (e) {
logger.w('Поделиться: не прочитать ${file.path}: $e');
return null;
}
final jpeg = await compute(_encodeThumbIsolate, bytes);
return _asDataUri(jpeg);
}
Future<String?> _videoThumb(File file) async {
try {
final frames = await VideoTranscoder.frames(file.path, const [
0,
], size: _thumbMaxDimension);
if (frames.isEmpty) return null;
return _asDataUri(frames.first);
} on MissingPluginException {
return null;
} catch (e) {
logger.w('Поделиться: не взять кадр из ${file.path}: $e');
return null;
}
}
String? _asDataUri(Uint8List? bytes) {
if (bytes == null || bytes.isEmpty) return null;
return 'data:image/jpeg;base64,${base64Encode(bytes)}';
}
Uint8List? _encodeThumbIsolate(Uint8List bytes) {
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
final oriented = img.bakeOrientation(decoded);
final longest = oriented.width >= oriented.height
? oriented.width
: oriented.height;
final scaled = longest > _thumbMaxDimension
? img.copyResize(
oriented,
width: oriented.width >= oriented.height ? _thumbMaxDimension : null,
height: oriented.height > oriented.width ? _thumbMaxDimension : null,
interpolation: img.Interpolation.average,
)
: oriented;
return img.encodeJpg(scaled, quality: _thumbQuality);
}
final Map<String, ImageProvider> _sharedThumbCache = {};
ImageProvider? decodeSharedThumb(String? dataUri) {
if (dataUri == null || dataUri.isEmpty) return null;
final cached = _sharedThumbCache[dataUri];
if (cached != null) return cached;
final comma = dataUri.indexOf(',');
if (comma < 0) return null;
try {
final provider = MemoryImage(base64Decode(dataUri.substring(comma + 1)));
if (_sharedThumbCache.length >= 32) {
_sharedThumbCache.remove(_sharedThumbCache.keys.first);
}
_sharedThumbCache[dataUri] = provider;
return provider;
} catch (_) {
return null;
}
}
+114
View File
@@ -0,0 +1,114 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import '../../backend/api.dart';
import '../../frontend/screens/chats/chat_list_screen.dart';
import '../../frontend/widgets/swipe_route.dart';
import '../../main.dart';
import '../../models/shared_payload.dart';
import '../utils/logger.dart';
class ShareIntentBridge {
ShareIntentBridge._();
static final ShareIntentBridge instance = ShareIntentBridge._();
static const _method = MethodChannel('ru.komet.app/share');
static const _events = EventChannel('ru.komet.app/share_events');
static const _retryDelay = Duration(milliseconds: 300);
static const _maxRetries = 100;
bool _started = false;
bool _ready = false;
bool _presenting = false;
SharedPayload? _pending;
int _retriesLeft = 0;
Timer? _retry;
bool get _native {
try {
return Platform.isAndroid;
} catch (_) {
return false;
}
}
void init() {
if (_started || !_native) return;
_started = true;
_events.receiveBroadcastStream().listen(
_onEvent,
onError: (e) => logger.w('ShareIntentBridge: events stream error: $e'),
);
api.stateStream.listen((state) {
if (state == SessionState.online) _flushPending();
});
}
void markReady() {
_ready = true;
_flushPending();
}
Future<void> checkInitialShare() async {
if (!_native) return;
try {
_onEvent(await _method.invokeMethod<dynamic>('consumeInitialShare'));
} catch (e) {
logger.w('ShareIntentBridge.checkInitialShare: $e');
}
}
Future<void> clearCache() async {
if (!_native) return;
try {
await _method.invokeMethod<void>('clearCache');
} catch (e) {
logger.w('ShareIntentBridge.clearCache: $e');
}
}
void _onEvent(Object? event) {
final payload = SharedPayload.fromMap(event);
if (payload == null) return;
logger.i(
'Поделиться: получено ${payload.files.length} файлов'
'${payload.text != null ? ' и текст' : ''}',
);
_pending = payload;
_retriesLeft = _maxRetries;
_flushPending();
}
void _flushPending() {
final payload = _pending;
if (payload == null || _presenting) return;
final context = KometApp.navigatorKey.currentContext;
if (!_ready || context == null || api.state != SessionState.online) {
if (_retriesLeft <= 0) {
_pending = null;
return;
}
_retriesLeft--;
_retry ??= Timer(_retryDelay, () {
_retry = null;
_flushPending();
});
return;
}
_pending = null;
_presenting = true;
unawaited(
pushSwipeable<void>(
context,
(_) => ChatListScreen(sharePayload: payload),
).whenComplete(() {
_presenting = false;
unawaited(clearCache());
}),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import '../utils/format.dart';
String shareTitleFor({
required int photos,
required int videos,
required int documents,
bool textOnly = false,
}) {
if (textOnly) return 'Отправить сообщение';
final total = photos + videos + documents;
if (total == 0) return 'Отправить сообщение';
if (photos == total) {
return photos == 1
? 'Отправить фотографию'
: 'Отправить $photos '
'${pluralRu(photos, 'фотографию', 'фотографии', 'фотографий')}';
}
if (videos == total) {
return videos == 1 ? 'Отправить видео' : 'Отправить $videos видео';
}
if (documents == total) {
return documents == 1
? 'Отправить файл'
: 'Отправить $documents '
'${pluralRu(documents, 'файл', 'файла', 'файлов')}';
}
return 'Отправить $total ${pluralRu(total, 'файл', 'файла', 'файлов')}';
}
String shareSubtitleFor(List<String> recipientNames) {
if (recipientNames.isEmpty) return 'Выберите чат';
if (recipientNames.length <= 2) return 'В чат ${recipientNames.join(', ')}';
final count = recipientNames.length;
return 'В $count ${pluralRu(count, 'чат', 'чата', 'чатов')}';
}
@@ -25,7 +25,12 @@ import '../../widgets/swipe_route.dart';
import '../../widgets/sliding_pill_nav.dart';
import '../../widgets/springy_tap.dart';
import '../../widgets/informer_banner_tile.dart';
import '../../../backend/modules/share_sender.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/format.dart';
import '../../../models/shared_payload.dart';
import '../../widgets/rich_message_controller.dart';
import 'share_composer_bar.dart';
import '../../../core/utils/download_history.dart';
import '../../../core/utils/link_opener.dart';
import '../../../core/utils/text_format.dart';
@@ -159,6 +164,7 @@ class ChatListScreen extends StatefulWidget {
final bool forwardMode;
final int forwardMessageCount;
final bool archiveMode;
final SharedPayload? sharePayload;
const ChatListScreen({
super.key,
@@ -166,6 +172,7 @@ class ChatListScreen extends StatefulWidget {
this.forwardMode = false,
this.forwardMessageCount = 1,
this.archiveMode = false,
this.sharePayload,
});
static _ChatListScreenState? _root;
@@ -252,7 +259,8 @@ class _ChatListScreenState extends State<ChatListScreen>
bool _reloadQueued = false;
bool _reloadInFlight = false;
Timer? _settleTimer;
bool get _isSelectionMode => _selectedChats.isNotEmpty;
bool get _shareMode => widget.sharePayload != null;
bool get _isSelectionMode => !_shareMode && _selectedChats.isNotEmpty;
bool? _foldersListKnown;
late AnimationController _navPageAnimController;
@@ -265,6 +273,10 @@ class _ChatListScreenState extends State<ChatListScreen>
final List<VoidCallback> _folderChatScrollListenerFns = [];
final Set<String> _selectedChats = {};
final Set<int> _inflightContactIds = {};
final Map<String, String> _selectedChatNames = {};
RichMessageController? _shareCaption;
PreparedShare? _preparedShare;
bool _shareSending = false;
DateTime _storiesRevealLayoutSettleUntil =
DateTime.fromMillisecondsSinceEpoch(0);
@@ -328,6 +340,107 @@ class _ChatListScreenState extends State<ChatListScreen>
});
}
void _initShare() {
final payload = widget.sharePayload;
if (payload == null) return;
_shareCaption = RichMessageController(
text: payload.isTextOnly ? (payload.text ?? '') : '',
);
unawaited(
PreparedShare.prepare(payload).then((prepared) {
if (mounted) setState(() => _preparedShare = prepared);
}),
);
}
void _toggleShareTarget(String chatId, String name) {
Haptics.selection();
setState(() {
if (_selectedChats.remove(chatId)) {
_selectedChatNames.remove(chatId);
} else {
_selectedChats.add(chatId);
_selectedChatNames[chatId] = name;
}
});
}
List<String> get _shareRecipientNames => [
for (final id in _selectedChats) _selectedChatNames[id] ?? 'Чат',
];
Future<void> _sendShare(String caption) async {
final prepared = _preparedShare;
final myId = _profile?.id ?? 0;
if (prepared == null || myId == 0 || _selectedChats.isEmpty) return;
if (_shareSending) return;
final targets = <int>[];
for (final raw in _selectedChats) {
final id = int.tryParse(raw);
if (id != null) targets.add(id);
}
if (targets.isEmpty) return;
setState(() => _shareSending = true);
Haptics.send();
ShareSendResult? result;
try {
result = await ShareSender.send(
accountId: myId,
chatIds: targets,
share: prepared,
caption: caption,
);
} catch (e) {
logger.w('Поделиться: отправка не удалась: $e');
}
if (!mounted) return;
setState(() => _shareSending = false);
if (result == null) {
Haptics.error();
showCustomNotification(context, 'Не удалось отправить');
return;
}
final navigator = Navigator.of(context);
if (targets.length == 1) {
final chatId = targets.first;
final chat = _chats.where((c) => c.id == chatId).firstOrNull;
navigator.pop();
unawaited(
pushSwipeable(
navigator.context,
(_) => ChatScreen(
chatId: chatId,
name: _selectedChatNames[chatId.toString()] ?? chat?.title ?? 'Чат',
imageUrl: chat?.iconUrl ?? '',
chatType: chat?.type ?? 'DIALOG',
),
),
);
return;
}
navigator.pop();
}
Widget _buildShareComposer(ColorScheme cs) {
final prepared = _preparedShare;
final controller = _shareCaption;
if (prepared == null || controller == null) return const SizedBox.shrink();
if (_selectedChats.isEmpty) return const SizedBox.shrink();
return ShareComposerBar(
share: prepared,
controller: controller,
recipientNames: _shareRecipientNames,
sending: _shareSending,
onSend: _sendShare,
);
}
void _clearSelection() {
setState(() {
_selectedChats.clear();
@@ -600,7 +713,10 @@ class _ChatListScreenState extends State<ChatListScreen>
@override
void initState() {
super.initState();
if (!widget.forwardMode && !widget.archiveMode) ChatListScreen._root = this;
if (!widget.forwardMode && !widget.archiveMode && !_shareMode) {
ChatListScreen._root = this;
}
_initShare();
_fabController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
@@ -1349,6 +1465,7 @@ class _ChatListScreenState extends State<ChatListScreen>
@override
void dispose() {
if (ChatListScreen._root == this) ChatListScreen._root = null;
_shareCaption?.dispose();
appRouteObserver.unsubscribe(this);
_settleTimer?.cancel();
chats.chatsChanged.removeListener(_onChatsChanged);
@@ -1574,7 +1691,27 @@ class _ChatListScreenState extends State<ChatListScreen>
Expanded(
child: Row(
children: [
if (_shareMode)
Padding(
padding: const EdgeInsets.only(
right: 4,
),
child: IconButton(
key: const ValueKey('share-back'),
visualDensity:
VisualDensity.compact,
icon: Icon(
Symbols.arrow_back,
color: cs.onSurface,
weight: 500,
),
onPressed: () => Navigator.of(
context,
).maybePop(),
),
),
if (AppStories.current.value &&
!_shareMode &&
_pullRatio < 0.8 &&
storiesModule.hasAny)
Opacity(
@@ -1612,10 +1749,15 @@ class _ChatListScreenState extends State<ChatListScreen>
),
Flexible(
child: Text(
connectionStatusLabel(
_sessionState,
) ??
(_profile?.firstName ?? 'Чат'),
_shareMode &&
_selectedChats.isNotEmpty
? '${_selectedChats.length} '
'${pluralRu(_selectedChats.length, 'получатель', 'получателя', 'получателей')}'
: connectionStatusLabel(
_sessionState,
) ??
(_profile?.firstName ??
'Чат'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -1634,7 +1776,8 @@ class _ChatListScreenState extends State<ChatListScreen>
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.forwardMode &&
!widget.archiveMode)
!widget.archiveMode &&
!_shareMode)
IconButton(
key: const ValueKey('downloads-button'),
tooltip: AppLocalizations.of(
@@ -1691,7 +1834,9 @@ class _ChatListScreenState extends State<ChatListScreen>
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.forwardMode ? null : _openSearch,
onTap: (widget.forwardMode || _shareMode)
? null
: _openSearch,
child: GlossyPill(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(50),
@@ -2187,6 +2332,14 @@ class _ChatListScreenState extends State<ChatListScreen>
if (widget.forwardMode) {
return _getChatsBody();
}
if (_shareMode) {
return Column(
children: [
Expanded(child: _getChatsBody()),
_buildShareComposer(cs),
],
);
}
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
final pageW = constraints.maxWidth;
final pageH = constraints.maxHeight;
@@ -2981,6 +3134,10 @@ class _ChatListScreenState extends State<ChatListScreen>
);
return;
}
if (_shareMode) {
_toggleShareTarget(id, name);
return;
}
if (_isSelectionMode) {
_toggleSelection(id);
return;
@@ -3018,7 +3175,9 @@ class _ChatListScreenState extends State<ChatListScreen>
);
}
},
onLongPress: widget.forwardMode ? null : () => _toggleSelection(id),
onLongPress: (widget.forwardMode || _shareMode)
? null
: () => _toggleSelection(id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
color: isSelected
@@ -0,0 +1,355 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/share_sender.dart';
import '../../../core/media/share_thumbnail.dart';
import '../../../core/share/share_labels.dart';
import '../../../main.dart';
import '../../../models/animoji.dart';
import '../../../models/shared_payload.dart';
import '../../widgets/emoji_panel.dart';
import '../../widgets/rich_message_controller.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/springy_tap.dart';
class ShareComposerBar extends StatefulWidget {
const ShareComposerBar({
super.key,
required this.share,
required this.controller,
required this.recipientNames,
required this.onSend,
this.sending = false,
});
final PreparedShare share;
final RichMessageController controller;
final List<String> recipientNames;
final Future<void> Function(String caption) onSend;
final bool sending;
@override
State<ShareComposerBar> createState() => _ShareComposerBarState();
}
class _ShareComposerBarState extends State<ShareComposerBar> {
static const double _emojiPanelHeight = 280;
static const Duration _panelDuration = Duration(milliseconds: 220);
final FocusNode _focus = FocusNode();
bool _emojiOpen = false;
RichMessageController get _controller => widget.controller;
@override
void initState() {
super.initState();
_focus.addListener(_onFocus);
}
@override
void dispose() {
_focus.removeListener(_onFocus);
_focus.dispose();
super.dispose();
}
void _onFocus() {
if (_focus.hasFocus && _emojiOpen) setState(() => _emojiOpen = false);
}
void _toggleEmoji() {
if (_emojiOpen) {
setState(() => _emojiOpen = false);
return;
}
_focus.unfocus();
setState(() => _emojiOpen = true);
}
void _insertAnimoji(Animoji animoji) {
_controller.insertAnimoji(animoji);
unawaited(animojiModule.noteUsed(animoji));
}
Future<void> _send() async {
if (widget.sending) return;
await widget.onSend(_controller.buildContent().text.trim());
}
String get _title {
final share = widget.share;
return shareTitleFor(
photos: share.photos.length,
videos: share.videos.length,
documents: share.documents.length,
textOnly: share.isTextOnly,
);
}
String get _subtitle => shareSubtitleFor(widget.recipientNames);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
final safeBottom = MediaQuery.paddingOf(context).bottom;
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(bottom: _emojiOpen ? 0 : bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.share.isTextOnly) _buildPreviewRow(cs),
_buildInputRow(cs),
AnimatedSize(
duration: _panelDuration,
curve: Curves.easeOutCubic,
child: _emojiOpen
? SizedBox(
height: _emojiPanelHeight + safeBottom,
child: Padding(
padding: EdgeInsets.only(bottom: safeBottom),
child: EmojiPanel(onEmojiTap: _insertAnimoji),
),
)
: SizedBox(height: bottomInset > 0 ? 0 : safeBottom),
),
],
),
);
}
Widget _buildPreviewRow(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Icon(Symbols.forward, color: cs.primary, size: 22, weight: 500),
const SizedBox(width: 12),
_ShareThumbStack(files: widget.share.files),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_title,
style: TextStyle(
color: cs.primary,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_subtitle,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget _buildInputRow(ColorScheme cs) {
final count = widget.recipientNames.length;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 12, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
onPressed: _toggleEmoji,
icon: Icon(
Symbols.mood,
color: _emojiOpen ? cs.primary : cs.onSurfaceVariant,
size: 26,
fill: _emojiOpen ? 1 : 0,
),
),
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 120),
child: TextField(
controller: _controller,
focusNode: _focus,
minLines: 1,
maxLines: null,
textCapitalization: TextCapitalization.sentences,
keyboardType: TextInputType.multiline,
style: TextStyle(color: cs.onSurface, fontSize: 16),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: widget.share.isTextOnly
? 'Сообщение'
: 'Добавить подпись...',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
),
),
),
),
const SizedBox(width: 8),
_SendButton(
count: count,
sending: widget.sending,
onTap: count == 0 ? null : _send,
),
],
),
);
}
}
class _ShareThumbStack extends StatelessWidget {
const _ShareThumbStack({required this.files});
final List<PreparedShareFile> files;
static const double _size = 40;
static const double _step = 9;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final visible = files.take(3).toList();
final width = _size + _step * (visible.length - 1).clamp(0, 2);
return SizedBox(
width: width,
height: _size,
child: Stack(
children: [
for (var i = visible.length - 1; i >= 0; i--)
Positioned(
left: i * _step,
child: _ShareThumb(file: visible[i], size: _size, cs: cs),
),
],
),
);
}
}
class _ShareThumb extends StatelessWidget {
const _ShareThumb({required this.file, required this.size, required this.cs});
final PreparedShareFile file;
final double size;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
final provider = decodeSharedThumb(file.thumbDataUri);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.surfaceContainerHigh, width: 2),
),
clipBehavior: Clip.antiAlias,
child: provider != null
? Image(image: provider, fit: BoxFit.cover)
: Icon(
file.kind == SharedFileKind.video
? Symbols.movie
: Symbols.description,
size: 20,
color: cs.onSurfaceVariant,
),
);
}
}
class _SendButton extends StatelessWidget {
const _SendButton({
required this.count,
required this.sending,
required this.onTap,
});
final int count;
final bool sending;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final enabled = onTap != null && !sending;
return SpringyTap(
child: GestureDetector(
onTap: enabled ? onTap : null,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: enabled ? cs.primary : cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: sending
? Padding(
padding: const EdgeInsets.all(14),
child: SmallSpinner(size: 24, color: cs.onPrimary),
)
: Icon(
Symbols.send,
color: enabled ? cs.onPrimary : cs.onSurfaceVariant,
size: 24,
fill: 1,
),
),
if (count > 0 && !sending)
Positioned(
right: -2,
top: -2,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
constraints: const BoxConstraints(minWidth: 20),
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.primary, width: 1.5),
),
child: Text(
'$count',
textAlign: TextAlign.center,
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
}
+5
View File
@@ -77,6 +77,7 @@ import 'core/links/deep_link_service.dart';
import 'frontend/screens/calls/call_screen.dart';
import 'core/push/fkm_controller.dart';
import 'core/push/notification_bridge.dart';
import 'core/share/share_intent_bridge.dart';
import 'core/push/push_service.dart';
import 'core/storage/app_database.dart';
import 'core/transport/tls_config.dart';
@@ -429,6 +430,7 @@ class KometAppState extends State<KometApp>
if (status == LoginStatus.success) {
DeepLinkService.instance.markReady();
NotificationBridge.instance.markReady();
ShareIntentBridge.instance.markReady();
unawaited(_refreshWallpaperSeed());
CallController.instance.init(api);
OutboxService.instance.init(api, messagesModule);
@@ -448,9 +450,11 @@ class KometAppState extends State<KometApp>
CallController.instance.appResumed = true;
CallBridge.instance.init();
NotificationBridge.instance.init();
ShareIntentBridge.instance.init();
WidgetsBinding.instance.addPostFrameCallback((_) {
CallBridge.instance.checkInitialCall();
unawaited(NotificationBridge.instance.checkInitialChat());
unawaited(ShareIntentBridge.instance.checkInitialShare());
});
_sessionExpiredSub = api.sessionExpiredStream.listen((
@@ -622,6 +626,7 @@ class KometAppState extends State<KometApp>
SelfCheckService.instance.resume();
CallBridge.instance.checkInitialCall();
unawaited(NotificationBridge.instance.checkInitialChat());
unawaited(ShareIntentBridge.instance.checkInitialShare());
if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return;
_rescheduleSwitch();
final next = _effectiveThemeMode;
+100
View File
@@ -0,0 +1,100 @@
import 'dart:io';
enum SharedFileKind { photo, video, file }
class SharedFile {
final String path;
final String name;
final String mime;
final int size;
const SharedFile({
required this.path,
required this.name,
required this.mime,
required this.size,
});
static SharedFile? fromMap(Map<dynamic, dynamic> map) {
final path = map['path'];
if (path is! String || path.isEmpty) return null;
final name = map['name'];
final mime = map['mime'];
final size = map['size'];
return SharedFile(
path: path,
name: name is String && name.isNotEmpty ? name : _basename(path),
mime: mime is String && mime.isNotEmpty
? mime
: 'application/octet-stream',
size: size is int ? size : 0,
);
}
File get file => File(path);
SharedFileKind get kind {
if (mime.startsWith('image/') && !mime.contains('svg')) {
return SharedFileKind.photo;
}
if (mime.startsWith('video/')) return SharedFileKind.video;
return SharedFileKind.file;
}
static String _basename(String path) {
final idx = path.lastIndexOf(Platform.pathSeparator);
return idx < 0 ? path : path.substring(idx + 1);
}
}
class SharedPayload {
final List<SharedFile> files;
final String? text;
final String? subject;
const SharedPayload({this.files = const [], this.text, this.subject});
static SharedPayload? fromMap(Object? raw) {
if (raw is! Map) return null;
final rawFiles = raw['files'];
final files = <SharedFile>[];
if (rawFiles is List) {
for (final entry in rawFiles) {
if (entry is! Map) continue;
final file = SharedFile.fromMap(entry);
if (file != null && file.file.existsSync()) files.add(file);
}
}
final text = raw['text'];
final subject = raw['subject'];
final payload = SharedPayload(
files: files,
text: text is String && text.trim().isNotEmpty ? text.trim() : null,
subject: subject is String && subject.trim().isNotEmpty
? subject.trim()
: null,
);
return payload.isEmpty ? null : payload;
}
bool get isEmpty => files.isEmpty && text == null;
bool get isTextOnly => files.isEmpty && text != null;
List<SharedFile> get photos =>
files.where((f) => f.kind == SharedFileKind.photo).toList();
List<SharedFile> get videos =>
files.where((f) => f.kind == SharedFileKind.video).toList();
List<SharedFile> get documents =>
files.where((f) => f.kind == SharedFileKind.file).toList();
SharedFileKind? get dominantKind {
if (files.isEmpty) return null;
if (documents.isNotEmpty) return SharedFileKind.file;
if (videos.isNotEmpty && photos.isEmpty) return SharedFileKind.video;
if (photos.isNotEmpty && videos.isEmpty) return SharedFileKind.photo;
return SharedFileKind.photo;
}
}