feat: комета в 'поделится
'
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user