feat: комета в 'поделится
'
This commit is contained in:
@@ -85,6 +85,23 @@
|
||||
<data android:scheme="komet"/>
|
||||
<data android:scheme="max"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/share_target_label">
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/*"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/share_target_label">
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity-alias
|
||||
android:name="ru.komet.app.MinimalIcon"
|
||||
|
||||
@@ -77,9 +77,14 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
private var pendingCall: Map<String, Any?>? = null
|
||||
private var pendingChat: Long = 0L
|
||||
private var pendingShare: Map<String, Any?>? = null
|
||||
private var pendingShareTask: java.util.concurrent.Future<Map<String, Any?>?>? = null
|
||||
private val shareExecutor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
private val shareHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private companion object {
|
||||
const val LOG_TAG = "VpnBypass"
|
||||
const val SHARE_TAG = "ShareIntake"
|
||||
const val NFC_TAG = "NfcExchange"
|
||||
const val KEEP_ENGINE_ID = "komet_keep_engine"
|
||||
const val NFC_PHASE_MIN_MS = 350L
|
||||
@@ -439,6 +444,54 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
})
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.app/share",
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"consumeInitialShare" -> {
|
||||
stashShare(intent, emit = false)
|
||||
val ready = pendingShare
|
||||
val task = pendingShareTask
|
||||
if (ready != null) {
|
||||
pendingShare = null
|
||||
result.success(ready)
|
||||
} else if (task != null) {
|
||||
pendingShareTask = null
|
||||
shareExecutor.execute {
|
||||
val payload = try {
|
||||
task.get()
|
||||
} catch (e: Exception) {
|
||||
Log.w(SHARE_TAG, "materialize failed: $e")
|
||||
null
|
||||
}
|
||||
shareHandler.post { result.success(payload) }
|
||||
}
|
||||
} else {
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
"clearCache" -> {
|
||||
ShareIntake.clearCache(applicationContext)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"ru.komet.app/share_events",
|
||||
).setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
ShareIntake.sink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
ShareIntake.sink = null
|
||||
}
|
||||
})
|
||||
|
||||
FkmChannel.attach(flutterEngine, this)
|
||||
}
|
||||
|
||||
@@ -447,6 +500,7 @@ class MainActivity : FlutterActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) }
|
||||
stashChatOpen(intent, emit = false)
|
||||
stashShare(intent, emit = false)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -457,6 +511,7 @@ class MainActivity : FlutterActivity() {
|
||||
stashCall(intent, emit = true)
|
||||
}
|
||||
stashChatOpen(intent, emit = true)
|
||||
stashShare(intent, emit = true)
|
||||
}
|
||||
|
||||
private fun stashChatOpen(source: Intent?, emit: Boolean) {
|
||||
@@ -471,6 +526,34 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stashShare(source: Intent?, emit: Boolean) {
|
||||
if (!ShareIntake.isShare(source)) return
|
||||
val intent = source ?: return
|
||||
val snapshot = ShareIntake.snapshot(intent) ?: return
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(Intent.EXTRA_STREAM)
|
||||
intent.removeExtra(Intent.EXTRA_TEXT)
|
||||
val task = shareExecutor.submit<Map<String, Any?>?> {
|
||||
ShareIntake.materialize(applicationContext, snapshot)
|
||||
}
|
||||
if (!emit) {
|
||||
pendingShareTask = task
|
||||
return
|
||||
}
|
||||
shareExecutor.execute {
|
||||
val payload = try {
|
||||
task.get()
|
||||
} catch (e: Exception) {
|
||||
Log.w(SHARE_TAG, "materialize failed: $e")
|
||||
null
|
||||
} ?: return@execute
|
||||
shareHandler.post {
|
||||
val sink = ShareIntake.sink
|
||||
if (sink != null) sink.success(payload) else pendingShare = payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stashCall(intent: Intent, emit: Boolean) {
|
||||
val json = intent.getStringExtra(CallConst.EXTRA_CALL) ?: return
|
||||
val action = intent.getStringExtra(CallConst.EXTRA_ACTION) ?: CallConst.ACTION_RING
|
||||
@@ -845,6 +928,7 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
shareExecutor.shutdown()
|
||||
if (keepEngineAlive() && isFinishing) {
|
||||
Log.d("KometFcm", "task removed, caching engine (call=${CallState.inCall} fkm=${FkmState.enabled})")
|
||||
flutterEngine?.let { FlutterEngineCache.getInstance().put(KEEP_ENGINE_ID, it) }
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
object ShareIntake {
|
||||
|
||||
private const val TAG = "ShareIntake"
|
||||
private const val CACHE_DIR = "shared_in"
|
||||
private const val MAX_FILES = 30
|
||||
|
||||
private val seq = AtomicLong(0L)
|
||||
|
||||
@Volatile
|
||||
var sink: EventChannel.EventSink? = null
|
||||
|
||||
fun isShare(intent: Intent?): Boolean {
|
||||
val action = intent?.action ?: return false
|
||||
return action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
|
||||
}
|
||||
|
||||
class Snapshot(
|
||||
val uris: List<Uri>,
|
||||
val text: String?,
|
||||
val subject: String?,
|
||||
val intentType: String?,
|
||||
)
|
||||
|
||||
fun snapshot(intent: Intent): Snapshot? {
|
||||
val uris = collectUris(intent).take(MAX_FILES)
|
||||
val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
if (uris.isEmpty() && text.isNullOrBlank()) return null
|
||||
return Snapshot(uris, text, subject, intent.type)
|
||||
}
|
||||
|
||||
fun materialize(context: Context, snapshot: Snapshot): Map<String, Any?>? {
|
||||
val files = ArrayList<Map<String, Any?>>()
|
||||
for (uri in snapshot.uris) {
|
||||
val copied = copyToCache(context, uri, snapshot.intentType)
|
||||
if (copied != null) files.add(copied)
|
||||
}
|
||||
|
||||
if (files.isEmpty() && snapshot.text.isNullOrBlank()) return null
|
||||
|
||||
return mapOf(
|
||||
"files" to files,
|
||||
"text" to snapshot.text,
|
||||
"subject" to snapshot.subject,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCache(context: Context) {
|
||||
try {
|
||||
val dir = File(context.cacheDir, CACHE_DIR)
|
||||
if (!dir.isDirectory) return
|
||||
dir.listFiles()?.forEach { it.delete() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cache cleanup failed: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectUris(intent: Intent): List<Uri> {
|
||||
if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
val list = if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
}
|
||||
return list?.filterNotNull() ?: emptyList()
|
||||
}
|
||||
val single = if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
}
|
||||
return if (single != null) listOf(single) else emptyList()
|
||||
}
|
||||
|
||||
private fun copyToCache(context: Context, uri: Uri, intentType: String?): Map<String, Any?>? {
|
||||
val resolver = context.contentResolver
|
||||
val mime = resolveMime(resolver, uri, intentType)
|
||||
val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime)
|
||||
|
||||
return try {
|
||||
val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }
|
||||
val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}")
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
if (target.length() <= 0L) {
|
||||
target.delete()
|
||||
return null
|
||||
}
|
||||
mapOf(
|
||||
"path" to target.absolutePath,
|
||||
"name" to displayName,
|
||||
"mime" to mime,
|
||||
"size" to target.length(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cannot read $uri: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveMime(resolver: ContentResolver, uri: Uri, intentType: String?): String {
|
||||
val fromResolver = resolver.getType(uri)
|
||||
if (!fromResolver.isNullOrBlank() && fromResolver != "*/*") return fromResolver
|
||||
val ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
|
||||
if (!ext.isNullOrBlank()) {
|
||||
val guessed = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.lowercase())
|
||||
if (!guessed.isNullOrBlank()) return guessed
|
||||
}
|
||||
if (!intentType.isNullOrBlank() && intentType != "*/*") return intentType
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? {
|
||||
if (uri.scheme == ContentResolver.SCHEME_FILE) return uri.lastPathSegment
|
||||
return try {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "display name for $uri: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackName(uri: Uri, mime: String): String {
|
||||
val last = uri.lastPathSegment?.substringAfterLast('/')
|
||||
if (!last.isNullOrBlank() && last.contains('.')) return last
|
||||
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
|
||||
return "shared_${System.currentTimeMillis()}.$ext"
|
||||
}
|
||||
|
||||
private fun sanitize(name: String): String {
|
||||
val cleaned = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
return if (cleaned.length <= 64) cleaned else cleaned.takeLast(64)
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,5 @@
|
||||
<string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string>
|
||||
<string name="fkm_disable">Выключить</string>
|
||||
<string name="notif_deleted_prefix">Удалено:</string>
|
||||
<string name="share_target_label">Отправить в чат</string>
|
||||
</resources>
|
||||
|
||||
@@ -11,4 +11,5 @@
|
||||
<string name="fkm_explain">This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM.</string>
|
||||
<string name="fkm_disable">Turn off</string>
|
||||
<string name="notif_deleted_prefix">Deleted:</string>
|
||||
<string name="share_target_label">Send to a chat</string>
|
||||
</resources>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:komet/backend/modules/share_sender.dart';
|
||||
import 'package:komet/frontend/screens/chats/share_composer_bar.dart';
|
||||
import 'package:komet/frontend/widgets/rich_message_controller.dart';
|
||||
import 'package:komet/models/shared_payload.dart';
|
||||
|
||||
PreparedShareFile _file(String name, String mime) => PreparedShareFile(
|
||||
source: SharedFile(path: '/synthetic/$name', name: name, mime: mime, size: 8),
|
||||
);
|
||||
|
||||
Future<RichMessageController> _pump(
|
||||
WidgetTester tester, {
|
||||
required PreparedShare share,
|
||||
required List<String> recipients,
|
||||
bool sending = false,
|
||||
Future<void> Function(String)? onSend,
|
||||
}) async {
|
||||
final controller = RichMessageController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ShareComposerBar(
|
||||
share: share,
|
||||
controller: controller,
|
||||
recipientNames: recipients,
|
||||
sending: sending,
|
||||
onSend: onSend ?? (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return controller;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a single photo names itself and lists the recipient', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]),
|
||||
recipients: const ['ЛУКА'],
|
||||
);
|
||||
|
||||
expect(find.text('Отправить фотографию'), findsOneWidget);
|
||||
expect(find.text('В чат ЛУКА'), findsOneWidget);
|
||||
expect(find.text('Добавить подпись...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('three chats collapse into a count and a badge', (tester) async {
|
||||
await _pump(
|
||||
tester,
|
||||
share: PreparedShare(
|
||||
files: [
|
||||
_file('a.jpg', 'image/jpeg'),
|
||||
_file('b.jpg', 'image/jpeg'),
|
||||
_file('c.jpg', 'image/jpeg'),
|
||||
],
|
||||
),
|
||||
recipients: const ['a', 'b', 'c'],
|
||||
);
|
||||
|
||||
expect(find.text('Отправить 3 фотографии'), findsOneWidget);
|
||||
expect(find.text('В 3 чата'), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a text share drops the preview row', (tester) async {
|
||||
final controller = await _pump(
|
||||
tester,
|
||||
share: const PreparedShare(files: [], text: 'https://komet.pw'),
|
||||
recipients: const ['ЛУКА'],
|
||||
);
|
||||
|
||||
expect(find.text('Отправить сообщение'), findsNothing);
|
||||
expect(find.text('Сообщение'), findsOneWidget);
|
||||
expect(controller.text, '');
|
||||
});
|
||||
|
||||
testWidgets('a file share still shows a title but no photo wording', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
share: PreparedShare(files: [_file('doc.pdf', 'application/pdf')]),
|
||||
recipients: const ['ЛУКА', 'Zarub'],
|
||||
);
|
||||
|
||||
expect(find.text('Отправить файл'), findsOneWidget);
|
||||
expect(find.text('В чат ЛУКА, Zarub'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the caption reaches onSend', (tester) async {
|
||||
String? captured;
|
||||
final controller = await _pump(
|
||||
tester,
|
||||
share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]),
|
||||
recipients: const ['ЛУКА'],
|
||||
onSend: (caption) async => captured = caption,
|
||||
);
|
||||
|
||||
controller.text = ' привет ';
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Symbols.send));
|
||||
await tester.pump();
|
||||
|
||||
expect(captured, 'привет');
|
||||
});
|
||||
|
||||
testWidgets('with no recipients the send button is inert', (tester) async {
|
||||
var sent = false;
|
||||
await _pump(
|
||||
tester,
|
||||
share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]),
|
||||
recipients: const [],
|
||||
onSend: (_) async => sent = true,
|
||||
);
|
||||
|
||||
expect(find.text('Выберите чат'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Symbols.send));
|
||||
await tester.pump();
|
||||
expect(sent, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/core/share/share_labels.dart';
|
||||
import 'package:komet/models/shared_payload.dart';
|
||||
|
||||
late Directory _dir;
|
||||
|
||||
String _makeFile(String name, {int bytes = 8}) {
|
||||
final file = File('${_dir.path}${Platform.pathSeparator}$name')
|
||||
..writeAsBytesSync(List<int>.filled(bytes, 0x41));
|
||||
return file.path;
|
||||
}
|
||||
|
||||
Map<String, Object?> _entry(String path, String mime, {int size = 8}) => {
|
||||
'path': path,
|
||||
'name': path.split(Platform.pathSeparator).last,
|
||||
'mime': mime,
|
||||
'size': size,
|
||||
};
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
_dir = Directory.systemTemp.createTempSync('synthetic_share_test');
|
||||
addTearDown(() {
|
||||
if (_dir.existsSync()) _dir.deleteSync(recursive: true);
|
||||
});
|
||||
});
|
||||
|
||||
group('SharedPayload.fromMap', () {
|
||||
test('classifies photos, videos and documents by mime', () {
|
||||
final payload = SharedPayload.fromMap({
|
||||
'files': [
|
||||
_entry(_makeFile('a.jpg'), 'image/jpeg'),
|
||||
_entry(_makeFile('b.mp4'), 'video/mp4'),
|
||||
_entry(_makeFile('c.pdf'), 'application/pdf'),
|
||||
],
|
||||
'text': null,
|
||||
});
|
||||
|
||||
expect(payload, isNotNull);
|
||||
expect(payload!.photos.map((f) => f.name), ['a.jpg']);
|
||||
expect(payload.videos.map((f) => f.name), ['b.mp4']);
|
||||
expect(payload.documents.map((f) => f.name), ['c.pdf']);
|
||||
expect(payload.dominantKind, SharedFileKind.file);
|
||||
});
|
||||
|
||||
test('an svg is a document, not a photo', () {
|
||||
final payload = SharedPayload.fromMap({
|
||||
'files': [_entry(_makeFile('d.svg'), 'image/svg+xml')],
|
||||
});
|
||||
|
||||
expect(payload!.files.single.kind, SharedFileKind.file);
|
||||
});
|
||||
|
||||
test('drops entries whose file is gone', () {
|
||||
final payload = SharedPayload.fromMap({
|
||||
'files': [
|
||||
_entry(_makeFile('present.jpg'), 'image/jpeg'),
|
||||
_entry('${_dir.path}/missing.jpg', 'image/jpeg'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload!.files.map((f) => f.name), ['present.jpg']);
|
||||
});
|
||||
|
||||
test('a text-only share survives with no files', () {
|
||||
final payload = SharedPayload.fromMap({
|
||||
'files': const [],
|
||||
'text': ' https://komet.pw ',
|
||||
});
|
||||
|
||||
expect(payload!.isTextOnly, isTrue);
|
||||
expect(payload.text, 'https://komet.pw');
|
||||
});
|
||||
|
||||
test('an empty share is rejected', () {
|
||||
expect(SharedPayload.fromMap({'files': const [], 'text': ' '}), isNull);
|
||||
expect(SharedPayload.fromMap(null), isNull);
|
||||
expect(SharedPayload.fromMap('nonsense'), isNull);
|
||||
});
|
||||
|
||||
test('a missing mime falls back to a document', () {
|
||||
final payload = SharedPayload.fromMap({
|
||||
'files': [
|
||||
{'path': _makeFile('e.bin'), 'name': 'e.bin', 'size': 8},
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload!.files.single.mime, 'application/octet-stream');
|
||||
expect(payload.files.single.kind, SharedFileKind.file);
|
||||
});
|
||||
});
|
||||
|
||||
group('shareTitleFor', () {
|
||||
test('photos use Russian plural forms', () {
|
||||
expect(
|
||||
shareTitleFor(photos: 1, videos: 0, documents: 0),
|
||||
'Отправить фотографию',
|
||||
);
|
||||
expect(
|
||||
shareTitleFor(photos: 3, videos: 0, documents: 0),
|
||||
'Отправить 3 фотографии',
|
||||
);
|
||||
expect(
|
||||
shareTitleFor(photos: 5, videos: 0, documents: 0),
|
||||
'Отправить 5 фотографий',
|
||||
);
|
||||
expect(
|
||||
shareTitleFor(photos: 11, videos: 0, documents: 0),
|
||||
'Отправить 11 фотографий',
|
||||
);
|
||||
});
|
||||
|
||||
test('videos stay uninflected', () {
|
||||
expect(
|
||||
shareTitleFor(photos: 0, videos: 1, documents: 0),
|
||||
'Отправить видео',
|
||||
);
|
||||
expect(
|
||||
shareTitleFor(photos: 0, videos: 2, documents: 0),
|
||||
'Отправить 2 видео',
|
||||
);
|
||||
});
|
||||
|
||||
test('documents and mixed sets fall back to file wording', () {
|
||||
expect(
|
||||
shareTitleFor(photos: 0, videos: 0, documents: 1),
|
||||
'Отправить файл',
|
||||
);
|
||||
expect(
|
||||
shareTitleFor(photos: 0, videos: 0, documents: 4),
|
||||
'Отправить 4 файла',
|
||||
);
|
||||
expect(
|
||||
shareTitleFor(photos: 1, videos: 1, documents: 0),
|
||||
'Отправить 2 файла',
|
||||
);
|
||||
});
|
||||
|
||||
test('a text share has no media wording', () {
|
||||
expect(
|
||||
shareTitleFor(photos: 0, videos: 0, documents: 0, textOnly: true),
|
||||
'Отправить сообщение',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shareSubtitleFor', () {
|
||||
test('names are listed up to two recipients', () {
|
||||
expect(shareSubtitleFor(const ['ЛУКА']), 'В чат ЛУКА');
|
||||
expect(shareSubtitleFor(const ['ЛУКА', 'Zarub']), 'В чат ЛУКА, Zarub');
|
||||
});
|
||||
|
||||
test('three or more recipients collapse to a count', () {
|
||||
expect(shareSubtitleFor(const ['a', 'b', 'c']), 'В 3 чата');
|
||||
expect(shareSubtitleFor(List.filled(5, 'x')), 'В 5 чатов');
|
||||
});
|
||||
|
||||
test('an empty selection asks for one', () {
|
||||
expect(shareSubtitleFor(const []), 'Выберите чат');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user