feat: анимации с реанимации
This commit is contained in:
@@ -4,3 +4,4 @@
|
||||
Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно.
|
||||
ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст')
|
||||
Never leave real data in test files, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead.
|
||||
Не пытайся собирать APK/AAB (flutter build apk, flutter build appbundle, gradle assemble) — сборку запускает пользователь, тебе достаточно flutter analyze
|
||||
|
||||
@@ -29,6 +29,8 @@ flutter build windows --release
|
||||
|
||||
Android builds require **Java 17**. Gradle memory is configured to `-Xmx4096m`.
|
||||
|
||||
**Never run APK/AAB builds yourself** (`flutter build apk`, `flutter build appbundle`, gradle assemble tasks) — they are slow and the user builds them. Verify changes with `flutter analyze`; the build commands above are documentation only.
|
||||
|
||||
## Build Flavors
|
||||
|
||||
| Flavor | App ID | Notes |
|
||||
|
||||
@@ -27,6 +27,7 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterEngineCache
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import android.media.MediaCodecInfo
|
||||
import android.net.Uri
|
||||
@@ -189,37 +190,46 @@ class MainActivity : FlutterActivity() {
|
||||
"ru.komet.app/upload_service",
|
||||
).setMethodCallHandler { call, result ->
|
||||
val ctx = this
|
||||
when (call.method) {
|
||||
"start" -> {
|
||||
val filename = call.argument<String>("filename") ?: "Файл"
|
||||
val intent = Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_START
|
||||
putExtra(UploadForegroundService.EXTRA_FILENAME, filename)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
fun uploadIntent(call: MethodCall, action: String) =
|
||||
Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(UploadForegroundService.EXTRA_TITLE, call.argument<String>("title"))
|
||||
putExtra(UploadForegroundService.EXTRA_BODY, call.argument<String>("body") ?: "")
|
||||
putExtra(UploadForegroundService.EXTRA_PROGRESS, call.argument<Int>("progress") ?: 0)
|
||||
putExtra(
|
||||
UploadForegroundService.EXTRA_INDETERMINATE,
|
||||
call.argument<Boolean>("indeterminate") ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
fun launch(intent: Intent, asForeground: Boolean) {
|
||||
try {
|
||||
if (asForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("UploadService", "${intent.action} failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
when (call.method) {
|
||||
"start" -> {
|
||||
launch(uploadIntent(call, UploadForegroundService.ACTION_START), true)
|
||||
result.success(null)
|
||||
}
|
||||
"update" -> {
|
||||
val filename = call.argument<String>("filename") ?: "Файл"
|
||||
val progress = call.argument<Int>("progress") ?: 0
|
||||
val speed = call.argument<Long>("speed") ?: 0L
|
||||
val intent = Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_UPDATE
|
||||
putExtra(UploadForegroundService.EXTRA_FILENAME, filename)
|
||||
putExtra(UploadForegroundService.EXTRA_PROGRESS, progress)
|
||||
putExtra(UploadForegroundService.EXTRA_SPEED, speed)
|
||||
}
|
||||
startService(intent)
|
||||
launch(uploadIntent(call, UploadForegroundService.ACTION_UPDATE), false)
|
||||
result.success(null)
|
||||
}
|
||||
"stop" -> {
|
||||
startService(Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_STOP
|
||||
})
|
||||
launch(
|
||||
Intent(ctx, UploadForegroundService::class.java).apply {
|
||||
action = UploadForegroundService.ACTION_STOP
|
||||
},
|
||||
false,
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class UploadForegroundService : Service() {
|
||||
@@ -15,11 +16,14 @@ class UploadForegroundService : Service() {
|
||||
const val ACTION_START = "ru.komet.app.UPLOAD_START"
|
||||
const val ACTION_UPDATE = "ru.komet.app.UPLOAD_UPDATE"
|
||||
const val ACTION_STOP = "ru.komet.app.UPLOAD_STOP"
|
||||
const val EXTRA_FILENAME = "filename"
|
||||
const val EXTRA_PROGRESS = "progress" // 0-100
|
||||
const val EXTRA_SPEED = "speed" // bytes/sec (Long)
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_BODY = "body"
|
||||
const val EXTRA_PROGRESS = "progress"
|
||||
const val EXTRA_INDETERMINATE = "indeterminate"
|
||||
}
|
||||
|
||||
private var inForeground = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -29,67 +33,96 @@ class UploadForegroundService : Service() {
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
val filename = intent.getStringExtra(EXTRA_FILENAME) ?: "Файл"
|
||||
val notification = buildNotification(filename, 0, 0, indeterminate = true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
ACTION_START, ACTION_UPDATE -> {
|
||||
val notification = buildNotification(
|
||||
title = intent.getStringExtra(EXTRA_TITLE)
|
||||
?: applicationInfo.loadLabel(packageManager).toString(),
|
||||
body = intent.getStringExtra(EXTRA_BODY) ?: "",
|
||||
progress = intent.getIntExtra(EXTRA_PROGRESS, 0),
|
||||
indeterminate = intent.getBooleanExtra(EXTRA_INDETERMINATE, true),
|
||||
)
|
||||
if (inForeground) {
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.notify(NOTIFICATION_ID, notification)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
goForeground(notification)
|
||||
}
|
||||
}
|
||||
ACTION_UPDATE -> {
|
||||
val filename = intent.getStringExtra(EXTRA_FILENAME) ?: "Файл"
|
||||
val progress = intent.getIntExtra(EXTRA_PROGRESS, 0)
|
||||
val speed = intent.getLongExtra(EXTRA_SPEED, 0L)
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, buildNotification(filename, progress, speed))
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
ACTION_STOP -> stopEverything()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Загрузка файлов",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply { setShowBadge(false) }
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
stopEverything()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
private fun goForeground(notification: Notification) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
inForeground = true
|
||||
} catch (e: Exception) {
|
||||
Log.w("UploadService", "startForeground failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopEverything() {
|
||||
if (inForeground) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
inForeground = false
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.upload_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
enableLights(false)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(
|
||||
filename: String,
|
||||
title: String,
|
||||
body: String,
|
||||
progress: Int,
|
||||
speedBps: Long,
|
||||
indeterminate: Boolean = false
|
||||
indeterminate: Boolean,
|
||||
): Notification {
|
||||
val body = when {
|
||||
indeterminate -> "Подготовка..."
|
||||
speedBps > 0 -> "$progress% · ${formatSpeed(speedBps)}"
|
||||
else -> "$progress%"
|
||||
}
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
val open = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||
.setContentTitle(filename)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(open)
|
||||
.setProgress(100, progress, indeterminate)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun formatSpeed(bps: Long): String = when {
|
||||
bps < 1_024L -> "$bps Б/с"
|
||||
bps < 1_048_576L -> "${bps / 1024} КБ/с"
|
||||
else -> "${"%.1f".format(bps / 1_048_576.0)} МБ/с"
|
||||
.setOnlyAlertOnce(true)
|
||||
.setDefaults(0)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="upload_channel_name">Отправка медиа</string>
|
||||
</resources>
|
||||
@@ -1,4 +1,5 @@
|
||||
<resources>
|
||||
<string name="nfc_service_description">Komet contact exchange</string>
|
||||
<string name="nfc_aid_group_description">Komet contact exchange</string>
|
||||
<string name="upload_channel_name">Sending media</string>
|
||||
</resources>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ import '../../core/cache/info_cache.dart';
|
||||
import '../../core/cache/message_session_cache.dart';
|
||||
import 'shared_content.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/chat_members_store.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
@@ -559,6 +560,41 @@ class ChatsModule {
|
||||
final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
||||
void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||
|
||||
static const Set<String> _membershipEvents = {
|
||||
'add',
|
||||
'joinByLink',
|
||||
'leave',
|
||||
'remove',
|
||||
};
|
||||
|
||||
void _applyMembershipControl(
|
||||
int accountId,
|
||||
int chatId,
|
||||
CachedMessage message,
|
||||
) {
|
||||
final control = message.controlAttachment;
|
||||
final event = control?.event;
|
||||
if (control == null || event == null) return;
|
||||
if (!_membershipEvents.contains(event)) return;
|
||||
|
||||
if (message.senderId != accountId) {
|
||||
final affected = control.userIds?.length ?? 1;
|
||||
ChatMembersStore.instance.adjust(chatId, switch (event) {
|
||||
'add' => affected,
|
||||
'joinByLink' => 1,
|
||||
'leave' => -1,
|
||||
'remove' => -affected,
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
_refreshChatInfo(chatId);
|
||||
}
|
||||
|
||||
void _refreshChatInfo(int chatId) {
|
||||
ChatInfoFetch.invalidate(chatId);
|
||||
unawaited(ChatInfoFetch.get(chatId));
|
||||
}
|
||||
|
||||
Future<bool> _updateChat(
|
||||
int accountId,
|
||||
int chatId,
|
||||
@@ -819,6 +855,7 @@ class ChatsModule {
|
||||
final cached = CachedMessage.fromPushPayload(accountId, chatId, msg);
|
||||
await AppDatabase.saveMessages([cached.toDbRow()]);
|
||||
emittedMessage = cached;
|
||||
_applyMembershipControl(accountId, chatId, cached);
|
||||
_messageEventsController.add(MessageAddedEvent(chatId, cached));
|
||||
}
|
||||
}
|
||||
@@ -1160,6 +1197,7 @@ class ChatsModule {
|
||||
}) async {
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
final id = chat['id'];
|
||||
ChatMembersStore.instance.applyChatPayload(chat);
|
||||
Map<int, CachedChat> existing = const {};
|
||||
Map<String, dynamic>? existingRow;
|
||||
if (preloadedExisting != null) {
|
||||
@@ -1260,6 +1298,7 @@ class ChatsModule {
|
||||
final rows = <Map<String, dynamic>>[];
|
||||
for (final c in chats.whereType<Map>()) {
|
||||
final map = c.cast<dynamic, dynamic>();
|
||||
ChatMembersStore.instance.applyChatPayload(map);
|
||||
final parsed = parseChatRow(
|
||||
map,
|
||||
accountId,
|
||||
@@ -1360,7 +1399,9 @@ class ChatsModule {
|
||||
final payload = packet.payload as Map?;
|
||||
final chats = payload?['chats'] as List?;
|
||||
if (chats == null || chats.isEmpty) return null;
|
||||
return Map<String, dynamic>.from(chats.first as Map);
|
||||
final info = Map<String, dynamic>.from(chats.first as Map);
|
||||
ChatMembersStore.instance.applyChatPayload(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getReadMarks(Api api, int accountId, int chatId) async {
|
||||
@@ -1461,6 +1502,8 @@ class ChatsModule {
|
||||
throw const PacketError('Не удалось подписаться');
|
||||
}
|
||||
final count = chatMap['participantsCount'];
|
||||
if (count is! int) ChatMembersStore.instance.adjust(cached.id, 1);
|
||||
_refreshChatInfo(cached.id);
|
||||
return (chat: cached, subscribersCount: count is int ? count : null);
|
||||
}
|
||||
|
||||
@@ -1850,6 +1893,10 @@ class ChatsModule {
|
||||
await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
|
||||
}
|
||||
}
|
||||
if (chat is! Map || chat['participantsCount'] is! int) {
|
||||
ChatMembersStore.instance.adjust(chatId, userIds.length);
|
||||
}
|
||||
_refreshChatInfo(chatId);
|
||||
return true;
|
||||
} on PacketError catch (e) {
|
||||
logger.w('addMembers $chatId: ${e.message}');
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/cache/message_session_cache.dart';
|
||||
import '../../core/media/gallery_source.dart';
|
||||
import '../../core/media/image_optimizer.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../main.dart' show fileUploader, messagesModule;
|
||||
import 'messages.dart';
|
||||
|
||||
sealed class MediaSendEvent {
|
||||
final int chatId;
|
||||
final String tempId;
|
||||
final bool scheduled;
|
||||
|
||||
const MediaSendEvent({
|
||||
required this.chatId,
|
||||
required this.tempId,
|
||||
required this.scheduled,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendDone extends MediaSendEvent {
|
||||
final CachedMessage? message;
|
||||
final int? scheduledTime;
|
||||
|
||||
const MediaSendDone({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.scheduled,
|
||||
this.message,
|
||||
this.scheduledTime,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendFailed extends MediaSendEvent {
|
||||
const MediaSendFailed({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.scheduled,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendService {
|
||||
MediaSendService._();
|
||||
|
||||
static final MediaSendService instance = MediaSendService._();
|
||||
|
||||
final StreamController<MediaSendEvent> _events =
|
||||
StreamController<MediaSendEvent>.broadcast();
|
||||
|
||||
static const int _historyLimit = 60;
|
||||
static const int _photoConcurrency = 3;
|
||||
static const int _photoAttempts = 3;
|
||||
|
||||
final Map<String, ValueNotifier<List<double>>> _progress = {};
|
||||
final Map<int, List<CachedMessage>> _pending = {};
|
||||
final Map<String, CachedMessage> _completed = {};
|
||||
final Set<String> _failed = {};
|
||||
|
||||
Stream<MediaSendEvent> get events => _events.stream;
|
||||
|
||||
ValueListenable<List<double>>? progressFor(String tempId) =>
|
||||
_progress[tempId];
|
||||
|
||||
List<CachedMessage> pendingFor(int chatId) =>
|
||||
List<CachedMessage>.unmodifiable(_pending[chatId] ?? const []);
|
||||
|
||||
CachedMessage? completedFor(String tempId) => _completed[tempId];
|
||||
|
||||
bool didFail(String tempId) => _failed.contains(tempId);
|
||||
|
||||
Future<void> sendPhotos({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required List<({File file, GalleryItem? item})> jobs,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
slots: jobs.length,
|
||||
upload: (progress) async {
|
||||
final tokens = await _uploadPhotos(jobs, progress);
|
||||
if (tokens.any((t) => t == null)) return null;
|
||||
return messagesModule.sendPhotoMessage(
|
||||
chatId,
|
||||
tokens.cast<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendVideo({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File file,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
slots: 1,
|
||||
upload: (progress) async {
|
||||
final info = await messagesModule.requestVideoUploadUrl();
|
||||
if (info == null || info.url.isEmpty) return null;
|
||||
final ok = await fileUploader.uploadVideoFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: (sent, total) {
|
||||
if (total > 0) {
|
||||
progress.value = [(sent / total).clamp(0.0, 1.0)];
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!ok) return null;
|
||||
progress.value = const [1];
|
||||
return messagesModule.sendVideoMessage(
|
||||
chatId,
|
||||
info.token,
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _run({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required int slots,
|
||||
required Future<Map<String, dynamic>?> Function(
|
||||
ValueNotifier<List<double>> progress,
|
||||
)
|
||||
upload,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) async {
|
||||
final scheduled = scheduledTime != null;
|
||||
final progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(slots < 1 ? 1 : slots, 0),
|
||||
);
|
||||
_progress[tempId] = progress;
|
||||
if (placeholder != null) {
|
||||
(_pending[chatId] ??= <CachedMessage>[]).add(placeholder);
|
||||
}
|
||||
|
||||
try {
|
||||
final serverMsg = await upload(progress);
|
||||
if (serverMsg == null) throw Exception('send_failed');
|
||||
|
||||
if (scheduled) {
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendDone(
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
scheduled: true,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final real = CachedMessage.fromPushPayload(accountId, chatId, serverMsg);
|
||||
try {
|
||||
await AppDatabase.saveMessages([real.toDbRow()]);
|
||||
if (real.id != tempId) {
|
||||
await AppDatabase.deleteMessage(accountId, chatId, tempId);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('MediaSendService: не удалось сохранить сообщение: $e');
|
||||
}
|
||||
_replaceInSessionCache(accountId, chatId, tempId, real);
|
||||
_remember(tempId, real);
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendDone(
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
scheduled: false,
|
||||
message: real,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('MediaSendService: $e');
|
||||
if (!scheduled) {
|
||||
_replaceInSessionCache(accountId, chatId, tempId, null);
|
||||
_remember(tempId, null);
|
||||
}
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendFailed(chatId: chatId, tempId: tempId, scheduled: scheduled),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _remember(String tempId, CachedMessage? real) {
|
||||
if (_completed.length + _failed.length > _historyLimit) {
|
||||
_completed.clear();
|
||||
_failed.clear();
|
||||
}
|
||||
if (real == null) {
|
||||
_failed.add(tempId);
|
||||
} else {
|
||||
_completed[tempId] = real;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String?>> _uploadPhotos(
|
||||
List<({File file, GalleryItem? item})> jobs,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
final tokens = List<String?>.filled(jobs.length, null);
|
||||
var nextIndex = 0;
|
||||
var failed = false;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final i = nextIndex++;
|
||||
if (i >= jobs.length) return;
|
||||
final token = await _uploadOnePhoto(jobs[i], i, progress);
|
||||
if (token == null) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
tokens[i] = token;
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = jobs.length < _photoConcurrency
|
||||
? jobs.length
|
||||
: _photoConcurrency;
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<String?> _uploadOnePhoto(
|
||||
({File file, GalleryItem? item}) job,
|
||||
int index,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
File file;
|
||||
try {
|
||||
file = await optimizePhotoForUpload(job.file, item: job.item);
|
||||
} catch (e) {
|
||||
logger.w('optimize photo: $e');
|
||||
file = job.file;
|
||||
}
|
||||
for (var attempt = 0; attempt < _photoAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future.delayed(Duration(seconds: attempt));
|
||||
_setSlot(progress, index, 0);
|
||||
}
|
||||
try {
|
||||
final url = await messagesModule.requestPhotoUploadUrl();
|
||||
if (url == null || url.isEmpty) continue;
|
||||
final token = await fileUploader.uploadPhoto(
|
||||
Uri.parse(url),
|
||||
file,
|
||||
filename: _photoFilename(file),
|
||||
onProgress: (sent, total) {
|
||||
if (total <= 0) return;
|
||||
_setSlot(progress, index, (sent / total).clamp(0.0, 1.0));
|
||||
},
|
||||
);
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setSlot(
|
||||
ValueNotifier<List<double>> progress,
|
||||
int index,
|
||||
double value,
|
||||
) {
|
||||
final next = List<double>.from(progress.value);
|
||||
if (index < next.length) {
|
||||
next[index] = value;
|
||||
progress.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
String _photoFilename(File file) {
|
||||
final segments = file.uri.pathSegments;
|
||||
final name = segments.isNotEmpty ? segments.last : '';
|
||||
return name.isNotEmpty ? name : 'photo.jpg';
|
||||
}
|
||||
|
||||
void _finish(int chatId, String tempId) {
|
||||
_progress.remove(tempId);
|
||||
final list = _pending[chatId];
|
||||
if (list == null) return;
|
||||
list.removeWhere((m) => m.id == tempId);
|
||||
if (list.isEmpty) _pending.remove(chatId);
|
||||
}
|
||||
|
||||
void _replaceInSessionCache(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String tempId,
|
||||
CachedMessage? real,
|
||||
) {
|
||||
final cached = MessageSessionCache.get(accountId, chatId);
|
||||
if (cached == null) return;
|
||||
final list = List<CachedMessage>.of(cached.messages);
|
||||
final idx = list.indexWhere((m) => m.id == tempId);
|
||||
if (idx == -1) return;
|
||||
list[idx] = real ?? list[idx].copyWith(status: 'error');
|
||||
MessageSessionCache.save(
|
||||
accountId,
|
||||
chatId,
|
||||
list,
|
||||
reachedStart: cached.reachedStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../main.dart';
|
||||
import 'cloud_storage.dart';
|
||||
import 'file_uploader.dart';
|
||||
import 'upload_notification_service.dart';
|
||||
|
||||
class UploadManager {
|
||||
UploadManager._();
|
||||
static final instance = UploadManager._();
|
||||
|
||||
StreamSubscription<UploadEvent>? _sub;
|
||||
bool get isActive => _sub != null;
|
||||
|
||||
// UI callbacks — registered by the screen while it is mounted
|
||||
void Function(double progress, int speedBps)? onProgress;
|
||||
void Function(CloudFile file)? onDone;
|
||||
void Function(String error)? onError;
|
||||
|
||||
Future<void> start({
|
||||
required int chatId,
|
||||
required int accountId,
|
||||
required File file,
|
||||
required String filename,
|
||||
required int totalSize,
|
||||
}) async {
|
||||
await cancel(); // cancel any previous upload
|
||||
|
||||
await UploadNotificationService.start(filename);
|
||||
|
||||
var lastSentBytes = 0;
|
||||
var lastSpeedMs = DateTime.now().millisecondsSinceEpoch;
|
||||
var speedBps = 0;
|
||||
var lastNotifPercent = -1;
|
||||
|
||||
_sub = fileUploader
|
||||
.upload(
|
||||
chatId: chatId,
|
||||
file: file,
|
||||
filename: filename,
|
||||
totalSize: totalSize,
|
||||
)
|
||||
.listen(
|
||||
(event) async {
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
final progress = total > 0 ? sent / total : 0.0;
|
||||
|
||||
// Speed: recompute every 500 ms
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = nowMs - lastSpeedMs;
|
||||
if (elapsed >= 500) {
|
||||
speedBps = ((sent - lastSentBytes) * 1000 / elapsed).round();
|
||||
lastSentBytes = sent;
|
||||
lastSpeedMs = nowMs;
|
||||
}
|
||||
|
||||
onProgress?.call(progress, speedBps);
|
||||
|
||||
// Throttle notification to once per 1% change
|
||||
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
|
||||
if (percent != lastNotifPercent) {
|
||||
lastNotifPercent = percent;
|
||||
UploadNotificationService.update(
|
||||
filename: filename,
|
||||
progressPercent: percent,
|
||||
speedBps: speedBps,
|
||||
);
|
||||
}
|
||||
|
||||
case UploadDone(:final fileId):
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
final newest = await CloudStorageModule.fetchLatestFile(
|
||||
messagesModule,
|
||||
accountId,
|
||||
chatId,
|
||||
expectedFileId: fileId,
|
||||
);
|
||||
if (newest != null) {
|
||||
onDone?.call(newest);
|
||||
}
|
||||
|
||||
case UploadError(:final message):
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
onError?.call(message);
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
_sub = null;
|
||||
UploadNotificationService.stop();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await UploadNotificationService.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,192 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class UploadNotificationService {
|
||||
static const _ch = MethodChannel('ru.komet.app/upload_service');
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../main.dart' show KometApp;
|
||||
|
||||
static Future<void> start(String filename) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try { await _ch.invokeMethod('start', {'filename': filename}); } catch (_) {}
|
||||
enum UploadKind { photo, video, file }
|
||||
|
||||
class _NotificationJob {
|
||||
_NotificationJob({required this.kind, required this.count, this.filename});
|
||||
|
||||
final UploadKind kind;
|
||||
final int count;
|
||||
final String? filename;
|
||||
|
||||
int sent = 0;
|
||||
int total = 0;
|
||||
double fraction = 0;
|
||||
int speedBps = 0;
|
||||
|
||||
int _windowSent = 0;
|
||||
int _windowAt = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
void report(int sentBytes, int totalBytes, double jobFraction) {
|
||||
sent = sentBytes;
|
||||
total = totalBytes;
|
||||
fraction = jobFraction.clamp(0.0, 1.0);
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = now - _windowAt;
|
||||
if (elapsed < 500) return;
|
||||
final delta = sent - _windowSent;
|
||||
speedBps = delta <= 0 ? 0 : (delta * 1000 / elapsed).round();
|
||||
_windowSent = sent;
|
||||
_windowAt = now;
|
||||
}
|
||||
|
||||
static Future<void> update({
|
||||
required String filename,
|
||||
required int progressPercent,
|
||||
required int speedBps,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
String label(AppLocalizations l10n) {
|
||||
final name = filename;
|
||||
return switch (kind) {
|
||||
UploadKind.photo => l10n.uploadNotificationPhotos(count),
|
||||
UploadKind.video => l10n.uploadNotificationVideo,
|
||||
UploadKind.file =>
|
||||
name == null || name.isEmpty ? l10n.uploadNotificationFile : name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class UploadNotificationService {
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
'ru.komet.app/upload_service',
|
||||
);
|
||||
static const int _minIntervalMs = 350;
|
||||
|
||||
static final Map<String, _NotificationJob> _jobs = {};
|
||||
static bool _running = false;
|
||||
static String? _lastTitle;
|
||||
static String? _lastBody;
|
||||
static int _lastPercent = -1;
|
||||
static int _lastPushAt = 0;
|
||||
|
||||
static bool get _enabled =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
static void begin(
|
||||
String id, {
|
||||
required UploadKind kind,
|
||||
int count = 1,
|
||||
String? filename,
|
||||
}) {
|
||||
if (!_enabled) return;
|
||||
_jobs[id] = _NotificationJob(
|
||||
kind: kind,
|
||||
count: count < 1 ? 1 : count,
|
||||
filename: filename,
|
||||
);
|
||||
_push(force: true);
|
||||
}
|
||||
|
||||
static void report(
|
||||
String id, {
|
||||
required int sent,
|
||||
required int total,
|
||||
required double fraction,
|
||||
}) {
|
||||
if (!_enabled) return;
|
||||
final job = _jobs[id];
|
||||
if (job == null) return;
|
||||
job.report(sent, total, fraction);
|
||||
_push();
|
||||
}
|
||||
|
||||
static void end(String id) {
|
||||
if (!_enabled) return;
|
||||
if (_jobs.remove(id) == null) return;
|
||||
if (_jobs.isEmpty) {
|
||||
_stop();
|
||||
return;
|
||||
}
|
||||
_push(force: true);
|
||||
}
|
||||
|
||||
static void _stop() {
|
||||
_running = false;
|
||||
_lastTitle = null;
|
||||
_lastBody = null;
|
||||
_lastPercent = -1;
|
||||
_lastPushAt = 0;
|
||||
_invoke('stop', const <String, dynamic>{});
|
||||
}
|
||||
|
||||
static void _push({bool force = false}) {
|
||||
if (_jobs.isEmpty) return;
|
||||
|
||||
var sumSent = 0;
|
||||
var sumTotal = 0;
|
||||
var sumSpeed = 0;
|
||||
var fractionSum = 0.0;
|
||||
var sizesKnown = true;
|
||||
for (final job in _jobs.values) {
|
||||
sumSent += job.sent;
|
||||
sumTotal += job.total;
|
||||
sumSpeed += job.speedBps;
|
||||
fractionSum += job.fraction;
|
||||
if (job.total <= 0) sizesKnown = false;
|
||||
}
|
||||
|
||||
final fraction = sizesKnown && sumTotal > 0
|
||||
? sumSent / sumTotal
|
||||
: fractionSum / _jobs.length;
|
||||
final percent = (fraction * 100).round().clamp(0, 100);
|
||||
|
||||
final l10n = _localizations();
|
||||
final title = _jobs.length == 1
|
||||
? _jobs.values.first.label(l10n)
|
||||
: l10n.uploadNotificationMultiple(_jobs.length);
|
||||
final body = percent <= 0 && sumSpeed <= 0
|
||||
? l10n.uploadNotificationPreparing
|
||||
: sumSpeed > 0
|
||||
? '$percent% · ${_formatSpeed(l10n, sumSpeed)}'
|
||||
: '$percent%';
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final changed =
|
||||
title != _lastTitle || body != _lastBody || percent != _lastPercent;
|
||||
if (!force && (!changed || now - _lastPushAt < _minIntervalMs)) return;
|
||||
|
||||
_lastTitle = title;
|
||||
_lastBody = body;
|
||||
_lastPercent = percent;
|
||||
_lastPushAt = now;
|
||||
|
||||
final args = <String, dynamic>{
|
||||
'title': title,
|
||||
'body': body,
|
||||
'progress': percent,
|
||||
'indeterminate': percent <= 0 && sumSpeed <= 0,
|
||||
};
|
||||
if (_running) {
|
||||
_invoke('update', args);
|
||||
return;
|
||||
}
|
||||
_running = true;
|
||||
_invoke('start', args);
|
||||
}
|
||||
|
||||
static String _formatSpeed(AppLocalizations l10n, int bps) {
|
||||
if (bps < 1024) return l10n.uploadSpeedBytes('$bps');
|
||||
if (bps < 1024 * 1024) return l10n.uploadSpeedKb('${(bps / 1024).round()}');
|
||||
return l10n.uploadSpeedMb((bps / (1024 * 1024)).toStringAsFixed(1));
|
||||
}
|
||||
|
||||
static AppLocalizations _localizations() {
|
||||
final context = KometApp.navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
final scoped = Localizations.of<AppLocalizations>(
|
||||
context,
|
||||
AppLocalizations,
|
||||
);
|
||||
if (scoped != null) return scoped;
|
||||
}
|
||||
final code = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
|
||||
return lookupAppLocalizations(Locale(code == 'ru' ? 'ru' : 'en'));
|
||||
}
|
||||
|
||||
static Future<void> _invoke(String method, Map<String, dynamic> args) async {
|
||||
try {
|
||||
await _ch.invokeMethod('update', {
|
||||
'filename': filename,
|
||||
'progress': progressPercent,
|
||||
'speed': speedBps,
|
||||
});
|
||||
await _channel.invokeMethod(method, args);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> stop() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try { await _ch.invokeMethod('stop'); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/cache/message_session_cache.dart';
|
||||
import '../../core/media/gallery_source.dart';
|
||||
import '../../core/media/image_optimizer.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../main.dart' show fileUploader, messagesModule;
|
||||
import '../../models/attachment.dart';
|
||||
import 'file_uploader.dart';
|
||||
import 'messages.dart';
|
||||
import 'upload_notification_service.dart';
|
||||
|
||||
export 'upload_notification_service.dart' show UploadKind;
|
||||
|
||||
sealed class UploadJobEvent {
|
||||
const UploadJobEvent({
|
||||
required this.chatId,
|
||||
required this.tempId,
|
||||
required this.kind,
|
||||
required this.scheduled,
|
||||
});
|
||||
|
||||
final int chatId;
|
||||
final String tempId;
|
||||
final UploadKind kind;
|
||||
final bool scheduled;
|
||||
}
|
||||
|
||||
class UploadJobDone extends UploadJobEvent {
|
||||
const UploadJobDone({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.kind,
|
||||
required super.scheduled,
|
||||
this.message,
|
||||
this.scheduledTime,
|
||||
this.fileId,
|
||||
this.fileToken,
|
||||
});
|
||||
|
||||
final CachedMessage? message;
|
||||
final int? scheduledTime;
|
||||
final int? fileId;
|
||||
final String? fileToken;
|
||||
}
|
||||
|
||||
class UploadJobFailed extends UploadJobEvent {
|
||||
const UploadJobFailed({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.kind,
|
||||
required super.scheduled,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
final String reason;
|
||||
}
|
||||
|
||||
class UploadFailure implements Exception {
|
||||
const UploadFailure(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'UploadFailure($message)';
|
||||
}
|
||||
|
||||
class UploadBytes {
|
||||
const UploadBytes(this.sent, this.total);
|
||||
|
||||
final int sent;
|
||||
final int total;
|
||||
}
|
||||
|
||||
class UploadJob {
|
||||
UploadJob._({
|
||||
required this.id,
|
||||
required this.accountId,
|
||||
required this.chatId,
|
||||
required this.kind,
|
||||
required int slots,
|
||||
this.filename,
|
||||
this.totalBytes = 0,
|
||||
this.placeholder,
|
||||
this.scheduledTime,
|
||||
}) : _slots = slots < 1 ? 1 : slots,
|
||||
_sent = List<int>.filled(slots < 1 ? 1 : slots, 0),
|
||||
_total = List<int>.filled(slots < 1 ? 1 : slots, 0),
|
||||
progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(slots < 1 ? 1 : slots, 0),
|
||||
),
|
||||
bytes = ValueNotifier<UploadBytes>(UploadBytes(0, totalBytes)) {
|
||||
if (_slots == 1 && totalBytes > 0) _total[0] = totalBytes;
|
||||
}
|
||||
|
||||
final String id;
|
||||
final int accountId;
|
||||
final int chatId;
|
||||
final UploadKind kind;
|
||||
final String? filename;
|
||||
final int totalBytes;
|
||||
final CachedMessage? placeholder;
|
||||
final int? scheduledTime;
|
||||
|
||||
final ValueNotifier<List<double>> progress;
|
||||
final ValueNotifier<UploadBytes> bytes;
|
||||
|
||||
int? resultFileId;
|
||||
String? resultFileToken;
|
||||
|
||||
final int _slots;
|
||||
final List<int> _sent;
|
||||
final List<int> _total;
|
||||
|
||||
bool get scheduled => scheduledTime != null;
|
||||
int get slots => _slots;
|
||||
|
||||
void report(int slot, int sent, int total) {
|
||||
if (slot < 0 || slot >= _slots) return;
|
||||
_sent[slot] = sent;
|
||||
if (total > 0) _total[slot] = total;
|
||||
_publish();
|
||||
}
|
||||
|
||||
void resetSlot(int slot) {
|
||||
if (slot < 0 || slot >= _slots) return;
|
||||
_sent[slot] = 0;
|
||||
_publish();
|
||||
}
|
||||
|
||||
void markUploaded() {
|
||||
for (var i = 0; i < _slots; i++) {
|
||||
if (_total[i] <= 0) _total[i] = _sent[i] > 0 ? _sent[i] : 1;
|
||||
_sent[i] = _total[i];
|
||||
}
|
||||
_publish();
|
||||
}
|
||||
|
||||
void _publish() {
|
||||
final fractions = List<double>.generate(_slots, (i) {
|
||||
if (_total[i] <= 0) return 0.0;
|
||||
return (_sent[i] / _total[i]).clamp(0.0, 1.0);
|
||||
});
|
||||
var sumSent = 0;
|
||||
var sumTotal = 0;
|
||||
var fractionSum = 0.0;
|
||||
for (var i = 0; i < _slots; i++) {
|
||||
sumSent += _sent[i];
|
||||
sumTotal += _total[i];
|
||||
fractionSum += fractions[i];
|
||||
}
|
||||
progress.value = fractions;
|
||||
bytes.value = UploadBytes(sumSent, sumTotal);
|
||||
UploadNotificationService.report(
|
||||
id,
|
||||
sent: sumSent,
|
||||
total: sumTotal,
|
||||
fraction: fractionSum / _slots,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UploadService {
|
||||
UploadService._();
|
||||
|
||||
static final UploadService instance = UploadService._();
|
||||
|
||||
static const int _historyLimit = 60;
|
||||
static const int _photoConcurrency = 3;
|
||||
static const int _photoAttempts = 3;
|
||||
|
||||
final StreamController<UploadJobEvent> _events =
|
||||
StreamController<UploadJobEvent>.broadcast();
|
||||
|
||||
final Map<String, UploadJob> _jobs = {};
|
||||
final Map<String, CachedMessage> _completed = {};
|
||||
final Set<String> _failed = {};
|
||||
|
||||
int _tempIdCounter = 0;
|
||||
|
||||
Stream<UploadJobEvent> get events => _events.stream;
|
||||
|
||||
String newTempId() =>
|
||||
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
|
||||
UploadJob? job(String tempId) => _jobs[tempId];
|
||||
|
||||
ValueListenable<List<double>>? progressFor(String tempId) =>
|
||||
_jobs[tempId]?.progress;
|
||||
|
||||
UploadJob? activeFileJob(int chatId) {
|
||||
for (final job in _jobs.values) {
|
||||
if (job.chatId == chatId && job.kind == UploadKind.file) return job;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<CachedMessage> pendingFor(int chatId) {
|
||||
final pending = <CachedMessage>[];
|
||||
for (final job in _jobs.values) {
|
||||
final placeholder = job.placeholder;
|
||||
if (job.chatId == chatId && placeholder != null) pending.add(placeholder);
|
||||
}
|
||||
return List<CachedMessage>.unmodifiable(pending);
|
||||
}
|
||||
|
||||
CachedMessage? completedFor(String tempId) => _completed[tempId];
|
||||
|
||||
bool didFail(String tempId) => _failed.contains(tempId);
|
||||
|
||||
Future<void> sendPhotos({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required List<({File file, GalleryItem? item})> jobs,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
UploadJob._(
|
||||
id: tempId,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
kind: UploadKind.photo,
|
||||
slots: jobs.length,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
(job) async {
|
||||
final tokens = await _uploadPhotos(jobs, job);
|
||||
if (tokens.any((token) => token == null)) {
|
||||
throw const UploadFailure('upload_failed');
|
||||
}
|
||||
final sent = await messagesModule.sendPhotoMessage(
|
||||
chatId,
|
||||
tokens.cast<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
if (sent == null) return null;
|
||||
return CachedMessage.fromPushPayload(accountId, chatId, sent);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendVideo({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File file,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
UploadJob._(
|
||||
id: tempId,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
kind: UploadKind.video,
|
||||
slots: 1,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
(job) async {
|
||||
final info = await messagesModule.requestVideoUploadUrl();
|
||||
if (info == null || info.url.isEmpty) {
|
||||
throw const UploadFailure('no_upload_url');
|
||||
}
|
||||
final ok = await fileUploader.uploadVideoFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: (sent, total) => job.report(0, sent, total),
|
||||
);
|
||||
if (!ok) throw const UploadFailure('upload_failed');
|
||||
job.markUploaded();
|
||||
final sent = await messagesModule.sendVideoMessage(
|
||||
chatId,
|
||||
info.token,
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
if (sent == null) return null;
|
||||
return CachedMessage.fromPushPayload(accountId, chatId, sent);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendFile({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File source,
|
||||
required String filename,
|
||||
required int size,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
UploadJob._(
|
||||
id: tempId,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
kind: UploadKind.file,
|
||||
slots: 1,
|
||||
filename: filename,
|
||||
totalBytes: size,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
(job) async {
|
||||
final done = await _uploadFile(
|
||||
chatId: chatId,
|
||||
job: job,
|
||||
source: source,
|
||||
filename: filename,
|
||||
size: size,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
FileHistoryCache.add(
|
||||
FileHistoryEntry(
|
||||
fileId: done.fileId,
|
||||
url: done.url,
|
||||
token: done.token,
|
||||
filename: done.filename,
|
||||
size: done.size,
|
||||
sentAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
job.resultFileId = done.fileId;
|
||||
job.resultFileToken = done.token;
|
||||
if (scheduledTime != null) return null;
|
||||
|
||||
final base = placeholder;
|
||||
return CachedMessage(
|
||||
id: done.messageId == null || done.messageId!.isEmpty
|
||||
? tempId
|
||||
: done.messageId!,
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
senderId: accountId,
|
||||
text: base?.text,
|
||||
time: base?.time ?? DateTime.now().millisecondsSinceEpoch,
|
||||
status: 'sent',
|
||||
attachments: [
|
||||
FileAttachment(
|
||||
fileId: done.fileId,
|
||||
fileToken: done.token,
|
||||
name: filename,
|
||||
size: size,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<UploadDone> _uploadFile({
|
||||
required int chatId,
|
||||
required UploadJob job,
|
||||
required File source,
|
||||
required String filename,
|
||||
required int size,
|
||||
int? scheduledTime,
|
||||
}) async {
|
||||
final result = Completer<UploadDone>();
|
||||
late final StreamSubscription<UploadEvent> sub;
|
||||
sub = fileUploader
|
||||
.upload(
|
||||
chatId: chatId,
|
||||
file: source,
|
||||
filename: filename,
|
||||
totalSize: size,
|
||||
scheduledTime: scheduledTime,
|
||||
)
|
||||
.listen(
|
||||
(event) {
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
job.report(0, sent, total);
|
||||
case UploadDone():
|
||||
if (!result.isCompleted) result.complete(event);
|
||||
case UploadError(:final message):
|
||||
if (!result.isCompleted) {
|
||||
result.completeError(UploadFailure(message));
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (Object e) {
|
||||
if (!result.isCompleted) result.completeError(e);
|
||||
},
|
||||
onDone: () {
|
||||
if (!result.isCompleted) {
|
||||
result.completeError(const UploadFailure('upload_failed'));
|
||||
}
|
||||
},
|
||||
);
|
||||
try {
|
||||
return await result.future;
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run(
|
||||
UploadJob job,
|
||||
Future<CachedMessage?> Function(UploadJob job) upload,
|
||||
) async {
|
||||
_jobs[job.id] = job;
|
||||
UploadNotificationService.begin(
|
||||
job.id,
|
||||
kind: job.kind,
|
||||
count: job.slots,
|
||||
filename: job.filename,
|
||||
);
|
||||
|
||||
CachedMessage? real;
|
||||
String? failure;
|
||||
try {
|
||||
real = await upload(job);
|
||||
if (real == null && !job.scheduled) failure = 'send_failed';
|
||||
} catch (e) {
|
||||
failure = e is UploadFailure ? e.message : e.toString();
|
||||
}
|
||||
|
||||
UploadNotificationService.end(job.id);
|
||||
_jobs.remove(job.id);
|
||||
|
||||
if (failure != null) {
|
||||
logger.w('UploadService: ${job.id} — $failure');
|
||||
if (!job.scheduled) {
|
||||
_replaceInSessionCache(job.accountId, job.chatId, job.id, null);
|
||||
_remember(job.id, null);
|
||||
}
|
||||
_events.add(
|
||||
UploadJobFailed(
|
||||
chatId: job.chatId,
|
||||
tempId: job.id,
|
||||
kind: job.kind,
|
||||
scheduled: job.scheduled,
|
||||
reason: failure,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.scheduled) {
|
||||
_events.add(
|
||||
UploadJobDone(
|
||||
chatId: job.chatId,
|
||||
tempId: job.id,
|
||||
kind: job.kind,
|
||||
scheduled: true,
|
||||
scheduledTime: job.scheduledTime,
|
||||
fileId: job.resultFileId,
|
||||
fileToken: job.resultFileToken,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final message = real!;
|
||||
try {
|
||||
await AppDatabase.saveMessages([message.toDbRow()]);
|
||||
if (message.id != job.id) {
|
||||
await AppDatabase.deleteMessage(job.accountId, job.chatId, job.id);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('UploadService: не удалось сохранить сообщение: $e');
|
||||
}
|
||||
_replaceInSessionCache(job.accountId, job.chatId, job.id, message);
|
||||
_remember(job.id, message);
|
||||
_events.add(
|
||||
UploadJobDone(
|
||||
chatId: job.chatId,
|
||||
tempId: job.id,
|
||||
kind: job.kind,
|
||||
scheduled: false,
|
||||
message: message,
|
||||
fileId: job.resultFileId,
|
||||
fileToken: job.resultFileToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _remember(String tempId, CachedMessage? real) {
|
||||
if (_completed.length + _failed.length > _historyLimit) {
|
||||
_completed.clear();
|
||||
_failed.clear();
|
||||
}
|
||||
if (real == null) {
|
||||
_failed.add(tempId);
|
||||
} else {
|
||||
_completed[tempId] = real;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String?>> _uploadPhotos(
|
||||
List<({File file, GalleryItem? item})> jobs,
|
||||
UploadJob job,
|
||||
) async {
|
||||
final tokens = List<String?>.filled(jobs.length, null);
|
||||
var nextIndex = 0;
|
||||
var failed = false;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final i = nextIndex++;
|
||||
if (i >= jobs.length) return;
|
||||
final token = await _uploadOnePhoto(jobs[i], i, job);
|
||||
if (token == null) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
tokens[i] = token;
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = jobs.length < _photoConcurrency
|
||||
? jobs.length
|
||||
: _photoConcurrency;
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<String?> _uploadOnePhoto(
|
||||
({File file, GalleryItem? item}) photo,
|
||||
int index,
|
||||
UploadJob job,
|
||||
) async {
|
||||
File file;
|
||||
try {
|
||||
file = await optimizePhotoForUpload(photo.file, item: photo.item);
|
||||
} catch (e) {
|
||||
logger.w('optimize photo: $e');
|
||||
file = photo.file;
|
||||
}
|
||||
for (var attempt = 0; attempt < _photoAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future.delayed(Duration(seconds: attempt));
|
||||
job.resetSlot(index);
|
||||
}
|
||||
try {
|
||||
final url = await messagesModule.requestPhotoUploadUrl();
|
||||
if (url == null || url.isEmpty) continue;
|
||||
final token = await fileUploader.uploadPhoto(
|
||||
Uri.parse(url),
|
||||
file,
|
||||
filename: _photoFilename(file),
|
||||
onProgress: (sent, total) => job.report(index, sent, total),
|
||||
);
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _photoFilename(File file) {
|
||||
final segments = file.uri.pathSegments;
|
||||
final name = segments.isNotEmpty ? segments.last : '';
|
||||
return name.isNotEmpty ? name : 'photo.jpg';
|
||||
}
|
||||
|
||||
void _replaceInSessionCache(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String tempId,
|
||||
CachedMessage? real,
|
||||
) {
|
||||
final cached = MessageSessionCache.get(accountId, chatId);
|
||||
if (cached == null) return;
|
||||
final list = List<CachedMessage>.of(cached.messages);
|
||||
final idx = list.indexWhere((m) => m.id == tempId);
|
||||
if (idx == -1) return;
|
||||
list[idx] = real ?? list[idx].copyWith(status: 'error');
|
||||
MessageSessionCache.save(
|
||||
accountId,
|
||||
chatId,
|
||||
list,
|
||||
reachedStart: cached.reachedStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -7,6 +7,7 @@ import '../../models/bot_info.dart';
|
||||
import '../../models/chat_info.dart';
|
||||
import '../../models/contact_info.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
import '../storage/chat_members_store.dart';
|
||||
|
||||
Api? _api;
|
||||
|
||||
@@ -336,6 +337,7 @@ class ChatInfoFetch {
|
||||
if (chats is! List || chats.isEmpty) return null;
|
||||
final first = chats.first;
|
||||
if (first is! Map) return null;
|
||||
ChatMembersStore.instance.applyChatPayload(first);
|
||||
return ChatInfo.fromMap(Map<String, dynamic>.from(first));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,24 @@ extension ChatActivityLabel on ChatActivity {
|
||||
ChatActivity chatActivityFromType(dynamic type) =>
|
||||
type == 'STICKER' ? ChatActivity.sticker : ChatActivity.typing;
|
||||
|
||||
class ChatActivitySnapshot {
|
||||
const ChatActivitySnapshot({required this.activity, required this.userIds});
|
||||
|
||||
final ChatActivity activity;
|
||||
final List<int> userIds;
|
||||
|
||||
String get label => activity.label;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ChatActivitySnapshot &&
|
||||
other.activity == activity &&
|
||||
listEquals(other.userIds, userIds);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(activity, Object.hashAll(userIds));
|
||||
}
|
||||
|
||||
class ChatActivityStore {
|
||||
ChatActivityStore._();
|
||||
|
||||
@@ -23,15 +41,17 @@ class ChatActivityStore {
|
||||
|
||||
final Map<int, Map<int, ChatActivity>> _users = {};
|
||||
final Map<int, Map<int, Timer>> _timers = {};
|
||||
final Map<int, ValueNotifier<ChatActivity?>> _notifiers = {};
|
||||
final Map<int, ValueNotifier<ChatActivitySnapshot?>> _notifiers = {};
|
||||
|
||||
ValueListenable<ChatActivity?> listenable(int chatId) =>
|
||||
ValueListenable<ChatActivitySnapshot?> listenable(int chatId) =>
|
||||
_notifiers.putIfAbsent(
|
||||
chatId,
|
||||
() => ValueNotifier<ChatActivity?>(_current(chatId)),
|
||||
() => ValueNotifier<ChatActivitySnapshot?>(_current(chatId)),
|
||||
);
|
||||
|
||||
ChatActivity? activity(int chatId) => _current(chatId);
|
||||
ChatActivitySnapshot? snapshot(int chatId) => _current(chatId);
|
||||
|
||||
ChatActivity? activity(int chatId) => _current(chatId)?.activity;
|
||||
|
||||
void mark(int chatId, int userId, ChatActivity activity) {
|
||||
final timers = _timers.putIfAbsent(chatId, () => <int, Timer>{});
|
||||
@@ -64,13 +84,17 @@ class ChatActivityStore {
|
||||
_sync(chatId);
|
||||
}
|
||||
|
||||
ChatActivity? _current(int chatId) {
|
||||
ChatActivitySnapshot? _current(int chatId) {
|
||||
final users = _users[chatId];
|
||||
if (users == null || users.isEmpty) return null;
|
||||
for (final activity in users.values) {
|
||||
if (activity == ChatActivity.typing) return ChatActivity.typing;
|
||||
}
|
||||
return ChatActivity.sticker;
|
||||
final leading = users.values.contains(ChatActivity.typing)
|
||||
? ChatActivity.typing
|
||||
: ChatActivity.sticker;
|
||||
final ids = <int>[];
|
||||
users.forEach((userId, activity) {
|
||||
if (activity == leading) ids.add(userId);
|
||||
});
|
||||
return ChatActivitySnapshot(activity: leading, userIds: ids);
|
||||
}
|
||||
|
||||
void _sync(int chatId) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ChatMembersStore {
|
||||
ChatMembersStore._();
|
||||
|
||||
static final ChatMembersStore instance = ChatMembersStore._();
|
||||
|
||||
final Map<int, int> _counts = {};
|
||||
final Map<int, ValueNotifier<int?>> _notifiers = {};
|
||||
|
||||
ValueListenable<int?> listenable(int chatId) => _notifiers.putIfAbsent(
|
||||
chatId,
|
||||
() => ValueNotifier<int?>(_counts[chatId]),
|
||||
);
|
||||
|
||||
int? count(int chatId) => _counts[chatId];
|
||||
|
||||
void setCount(int chatId, int? count) {
|
||||
if (count == null || count < 0) return;
|
||||
if (_counts[chatId] == count) return;
|
||||
_counts[chatId] = count;
|
||||
_notifiers[chatId]?.value = count;
|
||||
}
|
||||
|
||||
void adjust(int chatId, int delta) {
|
||||
final current = _counts[chatId];
|
||||
if (current == null || delta == 0) return;
|
||||
final next = current + delta;
|
||||
setCount(chatId, next < 0 ? 0 : next);
|
||||
}
|
||||
|
||||
void applyChatPayload(Object? chat) {
|
||||
if (chat is! Map) return;
|
||||
final id = chat['id'];
|
||||
final count = chat['participantsCount'];
|
||||
if (id is int && count is int) setCount(id, count);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_counts.clear();
|
||||
for (final notifier in _notifiers.values) {
|
||||
notifier.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/animated_slash_icon.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
@@ -171,10 +172,10 @@ class _Password2FAScreenState extends State<Password2FAScreen>
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordVisible
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
icon: AnimatedSlashIcon(
|
||||
icon: Icons.visibility,
|
||||
slashedIcon: Icons.visibility_off,
|
||||
slashed: _isPasswordVisible,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/calls/call_admin.dart';
|
||||
import '../../../core/calls/call_session.dart';
|
||||
import '../../widgets/animated_slash_icon.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/prompt_dialog.dart';
|
||||
@@ -497,8 +498,10 @@ class _ParticipantsSheetState extends State<_ParticipantsSheet> {
|
||||
Icon(Symbols.screen_share, size: 18, color: cs.primary),
|
||||
if (p.videoEnabled)
|
||||
Icon(Symbols.videocam, size: 18, color: cs.onSurfaceVariant),
|
||||
Icon(
|
||||
p.audioEnabled ? Symbols.mic : Symbols.mic_off,
|
||||
AnimatedSlashIcon(
|
||||
icon: Symbols.mic,
|
||||
slashedIcon: Symbols.mic_off,
|
||||
slashed: !p.audioEnabled,
|
||||
size: 18,
|
||||
color: p.audioEnabled ? cs.onSurfaceVariant : cs.error,
|
||||
),
|
||||
|
||||
@@ -25,6 +25,7 @@ import '../../../core/utils/logger.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/animated_slash_icon.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import 'call_participants_sheet.dart';
|
||||
@@ -1260,7 +1261,9 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
onTap: _toggleSpeaker,
|
||||
),
|
||||
_CallButton(
|
||||
icon: video ? Symbols.videocam : Symbols.videocam_off,
|
||||
icon: Symbols.videocam,
|
||||
slashedIcon: Symbols.videocam_off,
|
||||
slashed: !video,
|
||||
label: l10n.callVideoLabel,
|
||||
background: video ? cs.primary : cs.surfaceContainerHighest,
|
||||
foreground: video ? cs.onPrimary : cs.onSurface,
|
||||
@@ -1276,7 +1279,9 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
onTap: _toggleScreen,
|
||||
),
|
||||
_CallButton(
|
||||
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
|
||||
icon: Symbols.mic,
|
||||
slashedIcon: Symbols.mic_off,
|
||||
slashed: _isMuted,
|
||||
label: _isMuted ? l10n.callUnmute : l10n.callMute,
|
||||
background: _isMuted ? cs.primary : cs.surfaceContainerHighest,
|
||||
foreground: _isMuted ? cs.onPrimary : cs.onSurface,
|
||||
@@ -1340,6 +1345,8 @@ class _CallingDots extends StatelessWidget {
|
||||
|
||||
class _CallButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final IconData? slashedIcon;
|
||||
final bool slashed;
|
||||
final String label;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
@@ -1352,9 +1359,26 @@ class _CallButton extends StatelessWidget {
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.onTap,
|
||||
this.slashedIcon,
|
||||
this.slashed = false,
|
||||
this.busy = false,
|
||||
});
|
||||
|
||||
Widget _buildIcon() {
|
||||
final crossed = slashedIcon;
|
||||
if (crossed == null) {
|
||||
return Icon(icon, color: foreground, size: 26, fill: 1);
|
||||
}
|
||||
return AnimatedSlashIcon(
|
||||
icon: icon,
|
||||
slashedIcon: crossed,
|
||||
slashed: slashed,
|
||||
color: foreground,
|
||||
size: 26,
|
||||
fill: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -1372,7 +1396,7 @@ class _CallButton extends StatelessWidget {
|
||||
child: Center(
|
||||
child: busy
|
||||
? SmallSpinner(size: 22, color: foreground)
|
||||
: Icon(icon, color: foreground, size: 26, fill: 1),
|
||||
: _buildIcon(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import '../../../../backend/modules/messages.dart';
|
||||
import '../../../../core/storage/chat_activity_store.dart';
|
||||
|
||||
String chatActivityLabel(
|
||||
ChatActivitySnapshot snapshot, {
|
||||
bool withNames = false,
|
||||
}) {
|
||||
if (!withNames) return snapshot.activity.label;
|
||||
|
||||
final names = <String>[];
|
||||
for (final id in snapshot.userIds) {
|
||||
final name = ContactCache.get(id);
|
||||
if (name == null || name.trim().isEmpty) continue;
|
||||
names.add(_shortName(name));
|
||||
}
|
||||
if (names.isEmpty) return snapshot.activity.label;
|
||||
|
||||
final many = names.length > 1;
|
||||
final verb = switch (snapshot.activity) {
|
||||
ChatActivity.typing => many ? 'печатают' : 'печатает',
|
||||
ChatActivity.sticker => many ? 'выбирают стикеры' : 'выбирает стикер',
|
||||
};
|
||||
if (!many) return '${names.first} $verb...';
|
||||
if (names.length == 2) return '${names[0]} и ${names[1]} $verb...';
|
||||
return '${names[0]} и ещё ${names.length - 1} $verb...';
|
||||
}
|
||||
|
||||
String _shortName(String name) {
|
||||
final trimmed = name.trim();
|
||||
final space = trimmed.indexOf(' ');
|
||||
return space > 0 ? trimmed.substring(0, space) : trimmed;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
import 'package:komet/core/storage/chat_activity_store.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat/typing_label.dart';
|
||||
import 'package:komet/frontend/widgets/animated_text_swap.dart';
|
||||
|
||||
class AnimatedChatTile extends StatefulWidget {
|
||||
@@ -126,30 +127,37 @@ class ActivitySubtitle extends StatefulWidget {
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.child,
|
||||
this.group = false,
|
||||
});
|
||||
|
||||
final int chatId;
|
||||
final Widget child;
|
||||
final bool group;
|
||||
|
||||
@override
|
||||
State<ActivitySubtitle> createState() => _ActivitySubtitleState();
|
||||
}
|
||||
|
||||
class _ActivitySubtitleState extends State<ActivitySubtitle> {
|
||||
ChatActivity _lastActivity = ChatActivity.typing;
|
||||
String _lastLabel = ChatActivity.typing.label.toLowerCase();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder<ChatActivity?>(
|
||||
return ValueListenableBuilder<ChatActivitySnapshot?>(
|
||||
valueListenable: ChatActivityStore.instance.listenable(widget.chatId),
|
||||
child: widget.child,
|
||||
builder: (context, activity, base) {
|
||||
if (activity != null) _lastActivity = activity;
|
||||
if (activity != null) {
|
||||
final named = chatActivityLabel(activity, withNames: widget.group);
|
||||
_lastLabel = widget.group && named != activity.label
|
||||
? named
|
||||
: named.toLowerCase();
|
||||
}
|
||||
return AnimatedTextSwap(
|
||||
showAlternate: activity != null,
|
||||
alternate: Text(
|
||||
_lastActivity.label.toLowerCase(),
|
||||
_lastLabel,
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 14,
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:komet/core/config/app_frost.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat/upload_status.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart';
|
||||
import 'package:komet/frontend/widgets/composer_morph_icon.dart';
|
||||
import 'package:komet/frontend/widgets/glossy_pill.dart';
|
||||
import 'package:komet/frontend/widgets/liquid_glass.dart';
|
||||
import 'package:komet/frontend/widgets/rich_message_controller.dart';
|
||||
@@ -387,113 +388,103 @@ class ComposerInputBar extends StatelessWidget {
|
||||
},
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: hasText,
|
||||
builder: (context, hasText, _) =>
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: voiceRec.locked,
|
||||
builder: (context, locked, _) =>
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: voiceRec.isRecording,
|
||||
builder: (context, recording, _) =>
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: note.videoNoteMode,
|
||||
builder: (context, videoMode, _) {
|
||||
final sendMode =
|
||||
hasText ||
|
||||
hasForward ||
|
||||
locked ||
|
||||
forceSend;
|
||||
final pill = _actionSurface(
|
||||
color: _flat
|
||||
? Colors.transparent
|
||||
: sendMode
|
||||
? cs.primary
|
||||
: recording
|
||||
? cs.error
|
||||
: _frost
|
||||
? AppFrost.inputTint(cs)
|
||||
: cs.surfaceContainerHighest,
|
||||
onTap:
|
||||
(hasText ||
|
||||
hasForward ||
|
||||
forceSend)
|
||||
? onSendText
|
||||
: locked
|
||||
? () => voiceRec.stop(
|
||||
cancel: false,
|
||||
)
|
||||
: null,
|
||||
onLongPress:
|
||||
(hasText &&
|
||||
!forceSend &&
|
||||
!hasForward)
|
||||
? onScheduleMessage
|
||||
: null,
|
||||
child: SizedBox(
|
||||
width: 54,
|
||||
height: 54,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
sendMode
|
||||
? Symbols.send
|
||||
: videoMode
|
||||
? Symbols.videocam
|
||||
: Symbols.mic,
|
||||
color: _flat
|
||||
? (sendMode
|
||||
? cs.primary
|
||||
: recording
|
||||
? cs.error
|
||||
: cs.onSurfaceVariant)
|
||||
: sendMode
|
||||
? cs.onPrimary
|
||||
: recording
|
||||
? cs.onError
|
||||
: cs.onSurface,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
),
|
||||
builder: (context, hasText, _) => ValueListenableBuilder<bool>(
|
||||
valueListenable: voiceRec.locked,
|
||||
builder: (context, locked, _) =>
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: voiceRec.isRecording,
|
||||
builder: (context, recording, _) =>
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: note.videoNoteMode,
|
||||
builder: (context, videoMode, _) {
|
||||
final sendMode =
|
||||
hasText ||
|
||||
hasForward ||
|
||||
locked ||
|
||||
forceSend;
|
||||
final pill = _actionSurface(
|
||||
color: _flat
|
||||
? Colors.transparent
|
||||
: recording
|
||||
? cs.error
|
||||
: _frost
|
||||
? AppFrost.inputTint(cs)
|
||||
: cs.surfaceContainerHighest,
|
||||
onTap:
|
||||
(hasText ||
|
||||
hasForward ||
|
||||
forceSend)
|
||||
? onSendText
|
||||
: locked
|
||||
? () => voiceRec.stop(
|
||||
cancel: false,
|
||||
)
|
||||
: null,
|
||||
onLongPress:
|
||||
(hasText &&
|
||||
!forceSend &&
|
||||
!hasForward)
|
||||
? onScheduleMessage
|
||||
: null,
|
||||
child: SizedBox(
|
||||
width: 54,
|
||||
height: 54,
|
||||
child: Center(
|
||||
child: ComposerMorphIcon(
|
||||
action: sendMode
|
||||
? ComposerAction.send
|
||||
: videoMode
|
||||
? ComposerAction.videocam
|
||||
: ComposerAction.mic,
|
||||
color: recording
|
||||
? (_flat
|
||||
? cs.error
|
||||
: cs.onError)
|
||||
: sendMode
|
||||
? cs.primary
|
||||
: _flat
|
||||
? cs.onSurfaceVariant
|
||||
: cs.onSurface,
|
||||
),
|
||||
);
|
||||
final visual =
|
||||
_recordingButtonVisual(
|
||||
pill: pill,
|
||||
cs: cs,
|
||||
active:
|
||||
recording && !locked,
|
||||
);
|
||||
final voiceEnabled =
|
||||
!sendMode && !forceSend;
|
||||
return GestureDetector(
|
||||
onTap: voiceEnabled
|
||||
? note.toggleMode
|
||||
: null,
|
||||
onLongPressStart: voiceEnabled
|
||||
? (_) => videoMode
|
||||
? note.start()
|
||||
: voiceRec.start()
|
||||
: null,
|
||||
onLongPressMoveUpdate:
|
||||
voiceEnabled
|
||||
? (d) => videoMode
|
||||
? note.handleDrag(
|
||||
d.offsetFromOrigin,
|
||||
)
|
||||
: voiceRec.handleDrag(
|
||||
d.offsetFromOrigin,
|
||||
)
|
||||
: null,
|
||||
onLongPressEnd: voiceEnabled
|
||||
? (_) => videoMode
|
||||
? note.handleEnd()
|
||||
: voiceRec.handleEnd()
|
||||
: null,
|
||||
child: visual,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final visual = _recordingButtonVisual(
|
||||
pill: pill,
|
||||
cs: cs,
|
||||
active: recording && !locked,
|
||||
);
|
||||
final voiceEnabled =
|
||||
!sendMode && !forceSend;
|
||||
return GestureDetector(
|
||||
onTap: voiceEnabled
|
||||
? note.toggleMode
|
||||
: null,
|
||||
onLongPressStart: voiceEnabled
|
||||
? (_) => videoMode
|
||||
? note.start()
|
||||
: voiceRec.start()
|
||||
: null,
|
||||
onLongPressMoveUpdate: voiceEnabled
|
||||
? (d) => videoMode
|
||||
? note.handleDrag(
|
||||
d.offsetFromOrigin,
|
||||
)
|
||||
: voiceRec.handleDrag(
|
||||
d.offsetFromOrigin,
|
||||
)
|
||||
: null,
|
||||
onLongPressEnd: voiceEnabled
|
||||
? (_) => videoMode
|
||||
? note.handleEnd()
|
||||
: voiceRec.handleEnd()
|
||||
: null,
|
||||
child: visual,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -555,15 +546,32 @@ class ComposerInputBar extends StatelessWidget {
|
||||
required Widget child,
|
||||
VoidCallback? onTap,
|
||||
VoidCallback? onLongPress,
|
||||
}) {
|
||||
return TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(end: color),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, tinted, _) => _actionSurfaceOf(
|
||||
color: tinted ?? color,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionSurfaceOf({
|
||||
required Color color,
|
||||
required Widget child,
|
||||
VoidCallback? onTap,
|
||||
VoidCallback? onLongPress,
|
||||
}) {
|
||||
if (_flat) {
|
||||
return Material(
|
||||
color: color,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: onTap == null && onLongPress == null
|
||||
? child
|
||||
: InkWell(onTap: onTap, onLongPress: onLongPress, child: child),
|
||||
child: InkWell(onTap: onTap, onLongPress: onLongPress, child: child),
|
||||
);
|
||||
}
|
||||
return GlossyPill(
|
||||
@@ -574,6 +582,7 @@ class ComposerInputBar extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(27),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
keepInkLayer: true,
|
||||
depth: 8,
|
||||
child: child,
|
||||
);
|
||||
@@ -749,11 +758,10 @@ class ComposerInputBar extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, a, _) {
|
||||
if (a <= 0.001) return pill;
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: voiceRec.amplitude,
|
||||
builder: (context, amp, _) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: amp),
|
||||
tween: Tween(begin: 0.0, end: a <= 0.001 ? 0.0 : amp),
|
||||
duration: const Duration(milliseconds: 110),
|
||||
builder: (context, v, _) {
|
||||
final glow = a * (88.0 + v * 76.0);
|
||||
@@ -775,7 +783,7 @@ class ComposerInputBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_voiceLockChip(cs),
|
||||
_voiceLockChip(cs, a),
|
||||
Transform.scale(
|
||||
scale: 1.0 + a * 0.14 + a * v * 0.24,
|
||||
child: pill,
|
||||
@@ -789,13 +797,13 @@ class ComposerInputBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _voiceLockChip(ColorScheme cs) {
|
||||
Widget _voiceLockChip(ColorScheme cs, double reveal) {
|
||||
return Positioned(
|
||||
bottom: 62,
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: voiceRec.lockDrag,
|
||||
builder: (context, lock, _) => Opacity(
|
||||
opacity: (0.5 + lock * 0.5).clamp(0.0, 1.0),
|
||||
opacity: (reveal * (0.5 + lock * 0.5)).clamp(0.0, 1.0),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, lock * 12),
|
||||
child: Container(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/storage/chat_encryption_store.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/animated_slash_icon.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/primary_loading_button.dart';
|
||||
import '../../widgets/settings_card.dart';
|
||||
@@ -156,10 +157,10 @@ class _ChatEncryptionScreenState extends State<ChatEncryptionScreen> {
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_keyVisible
|
||||
? Symbols.visibility_off
|
||||
: Symbols.visibility,
|
||||
icon: AnimatedSlashIcon(
|
||||
icon: Symbols.visibility,
|
||||
slashedIcon: Symbols.visibility_off,
|
||||
slashed: _keyVisible,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../../core/calls/call_controller.dart';
|
||||
import '../../../core/config/app_show_extra_info.dart';
|
||||
import '../../../core/config/app_stories.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/chat_members_store.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
@@ -24,6 +25,7 @@ import '../../../l10n/app_localizations.dart';
|
||||
import '../../../models/chat_info.dart';
|
||||
import '../../../models/contact_info.dart';
|
||||
import '../../../models/story.dart';
|
||||
import '../../widgets/animated_slash_icon.dart';
|
||||
import '../../widgets/animated_text_swap.dart';
|
||||
import '../../widgets/avatar_history_screen.dart';
|
||||
import '../../widgets/chat_info/shared_content_tabs.dart';
|
||||
@@ -170,9 +172,18 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
storiesModule.storiesChanged.addListener(_onStoriesChanged);
|
||||
ChatMembersStore.instance
|
||||
.listenable(widget.chatId)
|
||||
.addListener(_onMemberCountChanged);
|
||||
_load();
|
||||
}
|
||||
|
||||
int? get _memberCount => ChatMembersStore.instance.count(widget.chatId);
|
||||
|
||||
void _onMemberCountChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _onStoriesChanged() {
|
||||
if (!mounted) return;
|
||||
setState(_refreshUnreadStories);
|
||||
@@ -181,6 +192,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
@override
|
||||
void dispose() {
|
||||
storiesModule.storiesChanged.removeListener(_onStoriesChanged);
|
||||
ChatMembersStore.instance
|
||||
.listenable(widget.chatId)
|
||||
.removeListener(_onMemberCountChanged);
|
||||
_tabScrollController.dispose();
|
||||
_bodyScrollController?.dispose();
|
||||
_avatarPageController.dispose();
|
||||
@@ -446,7 +460,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
unawaited(storiesModule.loadOwnersPreviews(fresh));
|
||||
}
|
||||
|
||||
final total = _chatInfo?.participantsCount;
|
||||
final total = _memberCount;
|
||||
if (page.members.isEmpty ||
|
||||
added == 0 ||
|
||||
page.marker == _memberMarker ||
|
||||
@@ -496,6 +510,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
Future<void> _refreshMembers() async {
|
||||
final info = await ChatInfoFetch.get(widget.chatId, forceRefresh: true);
|
||||
if (!mounted) return;
|
||||
if (info != null) _chatInfo = info;
|
||||
_contactMembers.clear();
|
||||
_otherMembers.clear();
|
||||
_seenMemberIds
|
||||
@@ -1277,8 +1294,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
color: _showRealName
|
||||
? Color.lerp(cs.primary, Colors.white, t)
|
||||
: textColor.withValues(alpha: 0.7),
|
||||
icon: Icon(
|
||||
_showRealName ? Symbols.visibility : Symbols.visibility_off,
|
||||
icon: AnimatedSlashIcon(
|
||||
icon: Symbols.visibility,
|
||||
slashedIcon: Symbols.visibility_off,
|
||||
slashed: !_showRealName,
|
||||
),
|
||||
tooltip: real,
|
||||
onPressed: () =>
|
||||
@@ -1303,10 +1322,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
return '';
|
||||
case 'CHAT':
|
||||
final total = _chatInfo?.participantsCount ?? _members.length;
|
||||
final total = _memberCount ?? _members.length;
|
||||
return '$total ${pluralRu(total, 'участник', 'участника', 'участников')}';
|
||||
case 'CHANNEL':
|
||||
final count = _chatInfo?.participantsCount ?? 0;
|
||||
final count = _memberCount ?? 0;
|
||||
return '$count ${pluralRu(count, 'подписчик', 'подписчика', 'подписчиков')}';
|
||||
default:
|
||||
return '';
|
||||
@@ -1330,7 +1349,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
|
||||
Widget _buildActions(ColorScheme cs) {
|
||||
final muteBtn = (
|
||||
icon: _isMuted ? Icons.notifications_off : Icons.notifications,
|
||||
icon: Icons.notifications,
|
||||
slashedIcon: Icons.notifications_off,
|
||||
slashed: _isMuted,
|
||||
label: _isMuted
|
||||
? l10n.chatInfoActionMuted
|
||||
: l10n.contactProfileActionSound,
|
||||
@@ -1338,16 +1359,29 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
);
|
||||
final chatBtn = (
|
||||
icon: Icons.chat_bubble,
|
||||
slashedIcon: null,
|
||||
slashed: false,
|
||||
label: l10n.contactProfileActionChat,
|
||||
onTap: _openChat,
|
||||
);
|
||||
final leaveBtn = (
|
||||
icon: Icons.exit_to_app,
|
||||
slashedIcon: null,
|
||||
slashed: false,
|
||||
label: l10n.chatInfoActionLeave,
|
||||
onTap: _leaveChat,
|
||||
);
|
||||
|
||||
final List<({IconData icon, String label, VoidCallback? onTap})> btns;
|
||||
final List<
|
||||
({
|
||||
IconData icon,
|
||||
IconData? slashedIcon,
|
||||
bool slashed,
|
||||
String label,
|
||||
VoidCallback? onTap,
|
||||
})
|
||||
>
|
||||
btns;
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
btns = [
|
||||
chatBtn,
|
||||
@@ -1355,6 +1389,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
if (!_isBot)
|
||||
(
|
||||
icon: Icons.call,
|
||||
slashedIcon: null,
|
||||
slashed: false,
|
||||
label: l10n.contactProfileActionCall,
|
||||
onTap: _confirmAndStartCall,
|
||||
),
|
||||
@@ -1371,7 +1407,14 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
Row(
|
||||
children: [
|
||||
for (int i = 0; i < btns.length; i++) ...[
|
||||
_actionBtn(cs, btns[i].icon, btns[i].label, btns[i].onTap),
|
||||
_actionBtn(
|
||||
cs,
|
||||
btns[i].icon,
|
||||
btns[i].label,
|
||||
onTap: btns[i].onTap,
|
||||
slashedIcon: btns[i].slashedIcon,
|
||||
slashed: btns[i].slashed,
|
||||
),
|
||||
if (i < btns.length - 1) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
@@ -1645,9 +1688,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
Widget _actionBtn(
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
String label, [
|
||||
String label, {
|
||||
VoidCallback? onTap,
|
||||
]) {
|
||||
IconData? slashedIcon,
|
||||
bool slashed = false,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: GlossyPill(
|
||||
onTap: onTap,
|
||||
@@ -1658,7 +1703,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: cs.primary, size: 22),
|
||||
if (slashedIcon != null)
|
||||
AnimatedSlashIcon(
|
||||
icon: icon,
|
||||
slashedIcon: slashedIcon,
|
||||
slashed: slashed,
|
||||
color: cs.primary,
|
||||
size: 22,
|
||||
)
|
||||
else
|
||||
Icon(icon, color: cs.primary, size: 22),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
|
||||
@@ -3108,6 +3108,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
Expanded(
|
||||
child: ActivitySubtitle(
|
||||
chatId: int.tryParse(id) ?? 0,
|
||||
group: chatType != 'DIALOG',
|
||||
child: messageLine,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,9 +12,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:komet/backend/modules/chat_preview.dart';
|
||||
import 'package:komet/backend/modules/chats.dart';
|
||||
import 'package:komet/backend/modules/comments.dart';
|
||||
import 'package:komet/backend/modules/file_uploader.dart';
|
||||
import 'package:komet/backend/modules/upload_notification_service.dart';
|
||||
import 'package:komet/backend/modules/media_send.dart';
|
||||
import 'package:komet/backend/modules/upload_service.dart';
|
||||
import 'package:komet/backend/modules/webapp.dart';
|
||||
import 'package:komet/frontend/screens/webapp/open_mini_app.dart';
|
||||
import 'package:komet/frontend/widgets/sending_clock_icon.dart';
|
||||
@@ -45,6 +43,7 @@ import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/push/push_service.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/chat_activity_store.dart';
|
||||
import '../../../core/storage/chat_members_store.dart';
|
||||
import '../../../core/crypto/chat_crypto_service.dart';
|
||||
import '../../../core/crypto/encrypted_photo.dart';
|
||||
import '../../../core/crypto/message_decryption_cache.dart';
|
||||
@@ -69,6 +68,7 @@ import 'chat/command_panel_controller.dart';
|
||||
import 'chat/sticker_panel_controller.dart';
|
||||
import 'chat/chat_search_controller.dart';
|
||||
import 'chat/message_search_result.dart';
|
||||
import 'chat/typing_label.dart';
|
||||
import 'chat/upload_status.dart';
|
||||
import 'chat/view/search_view.dart';
|
||||
import 'chat/view/composer_input.dart';
|
||||
@@ -317,7 +317,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final ValueNotifier<UploadStatus> _uploadStatus = ValueNotifier(
|
||||
const UploadStatus(),
|
||||
);
|
||||
StreamSubscription<UploadEvent>? _uploadSub;
|
||||
String? _uploadStatusJobId;
|
||||
ValueListenable<UploadBytes>? _uploadStatusBytes;
|
||||
StreamSubscription<Packet>? _pushSub;
|
||||
StreamSubscription<MessageEvent>? _messageEventSub;
|
||||
StreamSubscription<Map<String, CommentsInfo>>? _commentsInfoSub;
|
||||
@@ -350,10 +351,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
formatElapsed: formatVoiceElapsed,
|
||||
);
|
||||
|
||||
StreamSubscription<MediaSendEvent>? _mediaSendSub;
|
||||
StreamSubscription<UploadJobEvent>? _uploadEventSub;
|
||||
|
||||
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
|
||||
_photoUploadProgress[m.id] ?? MediaSendService.instance.progressFor(m.id);
|
||||
_photoUploadProgress[m.id] ?? UploadService.instance.progressFor(m.id);
|
||||
|
||||
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
|
||||
final existing = _reactionNotifiers[m.id];
|
||||
@@ -498,7 +499,6 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
int _otherStatus = 0;
|
||||
int? _otherSeenTime;
|
||||
int? _participantsCount;
|
||||
|
||||
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
|
||||
final ValueNotifier<List<CachedMessage>> _pendingForwards = ValueNotifier(
|
||||
@@ -661,7 +661,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
.catchError((_) {}),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_mediaSendSub = MediaSendService.instance.events.listen(_onMediaSendEvent);
|
||||
_uploadEventSub = UploadService.instance.events.listen(_onUploadEvent);
|
||||
_syncUploadStatus();
|
||||
chats.chatsChanged.addListener(_onChatsBump);
|
||||
_messageController.addListener(_onTextChanged);
|
||||
_scrollController.addListener(_onScrollForDate);
|
||||
@@ -751,6 +752,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
ChatActivityStore.instance
|
||||
.listenable(widget.chatId)
|
||||
.addListener(_recomputeHeaderStatus);
|
||||
ChatMembersStore.instance
|
||||
.listenable(widget.chatId)
|
||||
.addListener(_recomputeHeaderStatus);
|
||||
_connSub = api.stateStream.listen((_) {
|
||||
if (mounted) _recomputeHeaderStatus();
|
||||
});
|
||||
@@ -799,11 +803,6 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final link = info?['link'];
|
||||
if (link is String && link.isNotEmpty) _channelLink = link;
|
||||
}
|
||||
final count = info?['participantsCount'] as int?;
|
||||
if (count != null && count != _participantsCount) {
|
||||
_participantsCount = count;
|
||||
_recomputeHeaderStatus();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPeerKind() async {
|
||||
@@ -2009,7 +2008,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false));
|
||||
}
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_mediaSendSub?.cancel();
|
||||
_uploadEventSub?.cancel();
|
||||
chats.chatsChanged.removeListener(_onChatsBump);
|
||||
_otherUnread.dispose();
|
||||
_animojiHold.dispose();
|
||||
@@ -2039,7 +2038,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_scheduledCount.dispose();
|
||||
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
|
||||
_showAttachmentPanel.dispose();
|
||||
_uploadSub?.cancel();
|
||||
_detachUploadStatus();
|
||||
_pushSub?.cancel();
|
||||
_messageEventSub?.cancel();
|
||||
_commentsInfoSub?.cancel();
|
||||
@@ -2060,6 +2059,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
ChatActivityStore.instance
|
||||
.listenable(widget.chatId)
|
||||
.removeListener(_recomputeHeaderStatus);
|
||||
ChatMembersStore.instance
|
||||
.listenable(widget.chatId)
|
||||
.removeListener(_recomputeHeaderStatus);
|
||||
PresenceFetch.revision.removeListener(_onPresenceChanged);
|
||||
ContactsModule.revision.removeListener(_onContactsChanged);
|
||||
if (_wallpaperListening) {
|
||||
@@ -2958,7 +2960,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (_commentsMode) return;
|
||||
switch (event) {
|
||||
case MessageAddedEvent(:final message):
|
||||
if (message.senderId == _myId) return;
|
||||
if (message.senderId == _myId && !message.isControl) return;
|
||||
if (_messages.any((m) => m.id == message.id)) return;
|
||||
final nearBottom = _isNearBottom();
|
||||
final anchorId = nearBottom ? null : _viewportAnchorId();
|
||||
@@ -3292,9 +3294,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_previewChat = false;
|
||||
_subscribing = false;
|
||||
chat = result.chat;
|
||||
_participantsCount =
|
||||
result.subscribersCount ?? ((_participantsCount ?? 0) + 1);
|
||||
});
|
||||
ChatMembersStore.instance.setCount(
|
||||
widget.chatId,
|
||||
result.subscribersCount,
|
||||
);
|
||||
_recomputeHeaderStatus();
|
||||
showCustomNotification(context, 'Вы подписались на канал');
|
||||
} catch (e) {
|
||||
@@ -3596,17 +3600,27 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_headerStatusNotifier.value = _headerStatus();
|
||||
}
|
||||
|
||||
int get _memberCount =>
|
||||
ChatMembersStore.instance.count(widget.chatId) ??
|
||||
chat?.participants.length ??
|
||||
0;
|
||||
|
||||
bool get _isGroupChat =>
|
||||
widget.chatType == 'CHAT' || widget.chatType == 'CHANNEL';
|
||||
|
||||
String _headerStatus() {
|
||||
final conn = connectionStatusLabel(api.state);
|
||||
if (conn != null) return conn;
|
||||
final activity = ChatActivityStore.instance.activity(widget.chatId);
|
||||
if (activity != null) return activity.label;
|
||||
final activity = ChatActivityStore.instance.snapshot(widget.chatId);
|
||||
if (activity != null) {
|
||||
return chatActivityLabel(activity, withNames: _isGroupChat);
|
||||
}
|
||||
if (widget.chatType == 'CHAT') {
|
||||
final count = _participantsCount ?? chat?.participants.length ?? 0;
|
||||
final count = _memberCount;
|
||||
return '$count участников';
|
||||
}
|
||||
if (widget.chatType == 'CHANNEL') {
|
||||
final count = _participantsCount ?? chat?.participants.length ?? 0;
|
||||
final count = _memberCount;
|
||||
return '$count подписчиков';
|
||||
}
|
||||
if (_otherStatus == 1) return 'В сети';
|
||||
@@ -3627,6 +3641,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
userId,
|
||||
chatActivityFromType(payload['type']),
|
||||
);
|
||||
unawaited(_ensureTypingName(userId));
|
||||
}
|
||||
|
||||
Future<void> _ensureTypingName(int userId) async {
|
||||
if (!_isGroupChat) return;
|
||||
if (ContactCache.get(userId) != null) return;
|
||||
final resolved = await messagesModule.ensureContactNames({userId});
|
||||
if (resolved && mounted) _recomputeHeaderStatus();
|
||||
}
|
||||
|
||||
void _clearTyping(int userId) {
|
||||
@@ -5804,8 +5826,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
right: -3,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _newMessageCount,
|
||||
builder: (context, count, _) =>
|
||||
count <= 0 ? const SizedBox.shrink() : _unreadBadge(cs, count),
|
||||
builder: (context, count, _) => count <= 0
|
||||
? const SizedBox.shrink()
|
||||
: AnimatedValueSwap<int>(
|
||||
value: count > 99 ? 100 : count,
|
||||
alignment: Alignment.centerRight,
|
||||
builder: (context, value) => _unreadBadge(cs, value),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -6021,7 +6048,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
}
|
||||
|
||||
String _addOptimisticFileMessage(FileAttachment attachment) {
|
||||
CachedMessage _addOptimisticFileMessage(FileAttachment attachment) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempId = _nextTempId();
|
||||
final msg = CachedMessage(
|
||||
@@ -6038,7 +6065,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_bumpMessages();
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
return tempId;
|
||||
return msg;
|
||||
}
|
||||
|
||||
void _updateFileMessageStatus(
|
||||
@@ -6073,7 +6100,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
name: entry.filename,
|
||||
size: entry.size,
|
||||
),
|
||||
);
|
||||
).id;
|
||||
_showAttachmentPanel.value = false;
|
||||
try {
|
||||
final realId = await messagesModule.sendFileMessage(
|
||||
@@ -6092,7 +6119,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<bool> _sendFileById(int fileId) async {
|
||||
final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId));
|
||||
final tempId = _addOptimisticFileMessage(
|
||||
FileAttachment(fileId: fileId),
|
||||
).id;
|
||||
try {
|
||||
final realId = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
||||
final ok = realId != null;
|
||||
@@ -6205,7 +6234,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_scrollToBottom();
|
||||
|
||||
unawaited(
|
||||
MediaSendService.instance.sendPhotos(
|
||||
UploadService.instance.sendPhotos(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
tempId: tempId,
|
||||
@@ -6274,7 +6303,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
unawaited(
|
||||
MediaSendService.instance.sendVideo(
|
||||
UploadService.instance.sendVideo(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
tempId: tempId,
|
||||
@@ -6286,9 +6315,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _onMediaSendEvent(MediaSendEvent event) {
|
||||
void _onUploadEvent(UploadJobEvent event) {
|
||||
if (!mounted || event.chatId != widget.chatId) return;
|
||||
if (event is MediaSendDone) {
|
||||
_syncUploadStatus();
|
||||
if (event is UploadJobDone) {
|
||||
if (event.scheduled) {
|
||||
Haptics.send();
|
||||
_markHasScheduled();
|
||||
@@ -6309,18 +6339,52 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_messages[idx] = real;
|
||||
_bumpMessages();
|
||||
}
|
||||
} else if (event is MediaSendFailed) {
|
||||
} else if (event is UploadJobFailed) {
|
||||
if (event.scheduled) {
|
||||
Haptics.error();
|
||||
showCustomNotification(context, 'Не удалось запланировать');
|
||||
return;
|
||||
}
|
||||
_failPhotoMessage(event.tempId);
|
||||
if (event.kind == UploadKind.file) {
|
||||
showCustomNotification(context, 'Ошибка: ${event.reason}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _syncUploadStatus() {
|
||||
final job = UploadService.instance.activeFileJob(widget.chatId);
|
||||
if (job?.id == _uploadStatusJobId) return;
|
||||
_detachUploadStatus();
|
||||
if (job == null) {
|
||||
_uploadStatus.value = const UploadStatus();
|
||||
return;
|
||||
}
|
||||
_uploadStatusJobId = job.id;
|
||||
_uploadStatusBytes = job.bytes;
|
||||
job.bytes.addListener(_onUploadBytes);
|
||||
_onUploadBytes();
|
||||
}
|
||||
|
||||
void _onUploadBytes() {
|
||||
final bytes = _uploadStatusBytes?.value;
|
||||
if (bytes == null) return;
|
||||
_uploadStatus.value = UploadStatus(
|
||||
active: true,
|
||||
sent: bytes.sent,
|
||||
total: bytes.total,
|
||||
);
|
||||
}
|
||||
|
||||
void _detachUploadStatus() {
|
||||
_uploadStatusBytes?.removeListener(_onUploadBytes);
|
||||
_uploadStatusBytes = null;
|
||||
_uploadStatusJobId = null;
|
||||
}
|
||||
|
||||
void _mergePendingMedia() {
|
||||
final service = MediaSendService.instance;
|
||||
_syncUploadStatus();
|
||||
final service = UploadService.instance;
|
||||
var changed = false;
|
||||
|
||||
for (var i = _messages.length - 1; i >= 0; i--) {
|
||||
@@ -6380,7 +6444,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
showCustomNotification(context, 'Загрузка…');
|
||||
unawaited(
|
||||
MediaSendService.instance.sendPhotos(
|
||||
UploadService.instance.sendPhotos(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
tempId: _nextTempId(),
|
||||
@@ -6634,156 +6698,28 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
required int size,
|
||||
int? scheduledTime,
|
||||
}) async {
|
||||
final file = (name: filename, size: size);
|
||||
final done = Completer<void>();
|
||||
if (_myId == 0) return;
|
||||
|
||||
_showAttachmentPanel.value = false;
|
||||
_uploadStatus.value = UploadStatus(active: true, total: file.size);
|
||||
|
||||
final scheduled = scheduledTime != null;
|
||||
final tempId = scheduled
|
||||
final placeholder = scheduledTime != null
|
||||
? null
|
||||
: _addOptimisticFileMessage(
|
||||
FileAttachment(name: file.name, size: file.size),
|
||||
FileAttachment(name: filename, size: size),
|
||||
);
|
||||
ValueNotifier<List<double>>? fileProgress;
|
||||
if (tempId != null) {
|
||||
fileProgress = ValueNotifier<List<double>>(const [0]);
|
||||
_photoUploadProgress[tempId] = fileProgress;
|
||||
}
|
||||
|
||||
UploadNotificationService.start(file.name);
|
||||
|
||||
var notifLastSent = 0;
|
||||
var notifLastMs = DateTime.now().millisecondsSinceEpoch;
|
||||
var notifSpeedBps = 0;
|
||||
var notifLastPercent = -1;
|
||||
|
||||
void stopNotif() => UploadNotificationService.stop();
|
||||
|
||||
_uploadSub?.cancel();
|
||||
_uploadSub = fileUploader
|
||||
.upload(
|
||||
chatId: widget.chatId,
|
||||
file: source,
|
||||
filename: file.name,
|
||||
totalSize: file.size,
|
||||
scheduledTime: scheduledTime,
|
||||
)
|
||||
.listen(
|
||||
(event) {
|
||||
if (!mounted) return;
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
_uploadStatus.value = UploadStatus(
|
||||
active: true,
|
||||
sent: sent,
|
||||
total: total,
|
||||
);
|
||||
if (total > 0) {
|
||||
fileProgress?.value = [(sent / total).clamp(0.0, 1.0)];
|
||||
}
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = nowMs - notifLastMs;
|
||||
if (elapsed >= 500) {
|
||||
notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed)
|
||||
.round();
|
||||
notifLastSent = sent;
|
||||
notifLastMs = nowMs;
|
||||
}
|
||||
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
|
||||
if (percent != notifLastPercent) {
|
||||
notifLastPercent = percent;
|
||||
UploadNotificationService.update(
|
||||
filename: file.name,
|
||||
progressPercent: percent,
|
||||
speedBps: notifSpeedBps,
|
||||
);
|
||||
}
|
||||
case UploadDone(
|
||||
:final fileId,
|
||||
:final token,
|
||||
:final url,
|
||||
:final messageId,
|
||||
):
|
||||
stopNotif();
|
||||
FileHistoryCache.add(
|
||||
FileHistoryEntry(
|
||||
fileId: fileId,
|
||||
url: url,
|
||||
token: token,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
sentAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (scheduled) {
|
||||
Haptics.send();
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Запланировано на '
|
||||
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
|
||||
);
|
||||
} else {
|
||||
_disposePhotoProgress(tempId!);
|
||||
_updateFileMessageStatus(
|
||||
tempId,
|
||||
'sent',
|
||||
realId: messageId,
|
||||
attachment: FileAttachment(
|
||||
fileId: fileId,
|
||||
fileToken: token,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
),
|
||||
);
|
||||
}
|
||||
case UploadError(:final message):
|
||||
stopNotif();
|
||||
showCustomNotification(context, 'Ошибка: $message');
|
||||
if (tempId != null) {
|
||||
_disposePhotoProgress(tempId);
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!mounted) return;
|
||||
stopNotif();
|
||||
if (tempId != null) {
|
||||
final inFlight = _messages.firstWhere(
|
||||
(m) => m.id == tempId,
|
||||
orElse: () => CachedMessage(
|
||||
id: '',
|
||||
accountId: 0,
|
||||
chatId: 0,
|
||||
senderId: 0,
|
||||
time: 0,
|
||||
),
|
||||
);
|
||||
if (inFlight.id == tempId && inFlight.status == 'sending') {
|
||||
_disposePhotoProgress(tempId);
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
}
|
||||
}
|
||||
_uploadStatus.value = const UploadStatus();
|
||||
_uploadSub = null;
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
onError: (Object e) {
|
||||
if (!mounted) return;
|
||||
stopNotif();
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
if (tempId != null) {
|
||||
_disposePhotoProgress(tempId);
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
}
|
||||
_uploadStatus.value = const UploadStatus();
|
||||
_uploadSub = null;
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
);
|
||||
return done.future;
|
||||
final sending = UploadService.instance.sendFile(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
tempId: placeholder?.id ?? _nextTempId(),
|
||||
source: source,
|
||||
filename: filename,
|
||||
size: size,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
_syncUploadStatus();
|
||||
await sending;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/cloud_storage.dart';
|
||||
import '../../../backend/modules/upload_manager.dart';
|
||||
import '../../../backend/modules/upload_service.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
@@ -52,6 +52,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
bool _isUploading = false;
|
||||
final ValueNotifier<double> _uploadProgress = ValueNotifier(0);
|
||||
bool _animateNewCard = false;
|
||||
StreamSubscription<UploadJobEvent>? _uploadEventSub;
|
||||
UploadJob? _uploadJob;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,47 +62,73 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
_pageController = PageController(viewportFraction: _cardViewportFraction);
|
||||
_pageController.addListener(_onPageScroll);
|
||||
_checkEnv();
|
||||
_bindUploadManager();
|
||||
_uploadEventSub = UploadService.instance.events.listen(_onUploadEvent);
|
||||
}
|
||||
|
||||
void _onPageScroll() {
|
||||
_currentFilePage.value = _pageController.page?.round() ?? 0;
|
||||
}
|
||||
|
||||
void _bindUploadManager() {
|
||||
final mgr = UploadManager.instance;
|
||||
if (mgr.isActive) {
|
||||
setState(() => _isUploading = true);
|
||||
_mode.open();
|
||||
void _syncUploadJob() {
|
||||
final chatId = _envGroupId;
|
||||
final job = chatId == null
|
||||
? null
|
||||
: UploadService.instance.activeFileJob(chatId);
|
||||
if (identical(job, _uploadJob)) return;
|
||||
|
||||
_uploadJob?.progress.removeListener(_onUploadProgress);
|
||||
_uploadJob = job;
|
||||
|
||||
if (job == null) {
|
||||
_uploadProgress.value = 0;
|
||||
if (_isUploading) setState(() => _isUploading = false);
|
||||
return;
|
||||
}
|
||||
mgr.onProgress = (progress, _) {
|
||||
if (!mounted) return;
|
||||
if (!_isUploading) setState(() => _isUploading = true);
|
||||
_uploadProgress.value = progress;
|
||||
};
|
||||
mgr.onDone = (file) {
|
||||
if (!mounted) return;
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = false);
|
||||
_prependFile(file);
|
||||
};
|
||||
mgr.onError = (msg) {
|
||||
if (!mounted) return;
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = false);
|
||||
|
||||
job.progress.addListener(_onUploadProgress);
|
||||
_onUploadProgress();
|
||||
if (_isUploading) return;
|
||||
setState(() => _isUploading = true);
|
||||
_mode.open();
|
||||
}
|
||||
|
||||
void _onUploadProgress() {
|
||||
final values = _uploadJob?.progress.value;
|
||||
if (values == null || values.isEmpty) return;
|
||||
_uploadProgress.value = values.first;
|
||||
}
|
||||
|
||||
Future<void> _onUploadEvent(UploadJobEvent event) async {
|
||||
final chatId = _envGroupId;
|
||||
final accountId = _accountId;
|
||||
if (!mounted || chatId == null || accountId == null) return;
|
||||
if (event.chatId != chatId || event.kind != UploadKind.file) return;
|
||||
|
||||
_syncUploadJob();
|
||||
|
||||
if (event is UploadJobFailed) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
AppLocalizations.of(context)!.devicesGenericError(msg),
|
||||
AppLocalizations.of(context)!.devicesGenericError(event.reason),
|
||||
);
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (event is! UploadJobDone) return;
|
||||
|
||||
final newest = await CloudStorageModule.fetchLatestFile(
|
||||
messagesModule,
|
||||
accountId,
|
||||
chatId,
|
||||
expectedFileId: event.fileId,
|
||||
);
|
||||
if (!mounted || newest == null) return;
|
||||
_prependFile(newest);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final mgr = UploadManager.instance;
|
||||
mgr.onProgress = null;
|
||||
mgr.onDone = null;
|
||||
mgr.onError = null;
|
||||
_uploadEventSub?.cancel();
|
||||
_uploadJob?.progress.removeListener(_onUploadProgress);
|
||||
_mode.dispose();
|
||||
_pageController.dispose();
|
||||
_currentFilePage.dispose();
|
||||
@@ -205,6 +233,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _files = files.reversed.toList());
|
||||
_syncUploadJob();
|
||||
}
|
||||
|
||||
void _prependFile(CloudFile file) {
|
||||
@@ -268,13 +297,17 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = true);
|
||||
|
||||
await UploadManager.instance.start(
|
||||
chatId: chatId,
|
||||
final service = UploadService.instance;
|
||||
final sending = service.sendFile(
|
||||
accountId: accountId,
|
||||
file: File(picked.path!),
|
||||
chatId: chatId,
|
||||
tempId: service.newTempId(),
|
||||
source: File(picked.path!),
|
||||
filename: picked.name,
|
||||
totalSize: picked.size,
|
||||
size: picked.size,
|
||||
);
|
||||
_syncUploadJob();
|
||||
await sending;
|
||||
}
|
||||
|
||||
void _showSendByIdSheet() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../../main.dart' show accountModule;
|
||||
import '../../../backend/modules/account.dart' show TwoFactorDetails;
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../widgets/animated_slash_icon.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/primary_loading_button.dart';
|
||||
@@ -1440,8 +1441,10 @@ class _PasswordFieldState extends State<_PasswordField> {
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_visible ? Symbols.visibility_off : Symbols.visibility,
|
||||
icon: AnimatedSlashIcon(
|
||||
icon: Symbols.visibility,
|
||||
slashedIcon: Symbols.visibility_off,
|
||||
slashed: _visible,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () => setState(() => _visible = !_visible),
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../../core/utils/format.dart';
|
||||
import '../../../core/utils/update_checker.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/animated_slash_icon.dart';
|
||||
import '../../widgets/avatar_history_screen.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/info_action_sheet.dart';
|
||||
@@ -867,10 +868,10 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_isPhoneVisible
|
||||
? Symbols.visibility
|
||||
: Symbols.visibility_off,
|
||||
AnimatedSlashIcon(
|
||||
icon: Symbols.visibility,
|
||||
slashedIcon: Symbols.visibility_off,
|
||||
slashed: !_isPhoneVisible,
|
||||
size: 14,
|
||||
color: Color.lerp(
|
||||
cs.mutedText,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../../widgets/animated_slash_icon.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
|
||||
class WebQrScanScreen extends StatefulWidget {
|
||||
@@ -85,8 +86,10 @@ class _WebQrScanScreenState extends State<WebQrScanScreen> {
|
||||
valueListenable: _controller,
|
||||
builder: (context, state, _) {
|
||||
final on = state.torchState == TorchState.on;
|
||||
return Icon(
|
||||
on ? Symbols.flash_on : Symbols.flash_off,
|
||||
return AnimatedSlashIcon(
|
||||
icon: Symbols.flash_on,
|
||||
slashedIcon: Symbols.flash_off,
|
||||
slashed: !on,
|
||||
color: Colors.white,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AnimatedSlashIcon extends StatefulWidget {
|
||||
const AnimatedSlashIcon({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.slashedIcon,
|
||||
required this.slashed,
|
||||
this.size,
|
||||
this.color,
|
||||
this.fill,
|
||||
this.weight,
|
||||
this.grade,
|
||||
this.opticalSize,
|
||||
this.semanticLabel,
|
||||
this.duration = const Duration(milliseconds: 260),
|
||||
this.curve = Curves.easeInOutCubic,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final IconData slashedIcon;
|
||||
final bool slashed;
|
||||
final double? size;
|
||||
final Color? color;
|
||||
final double? fill;
|
||||
final double? weight;
|
||||
final double? grade;
|
||||
final double? opticalSize;
|
||||
final String? semanticLabel;
|
||||
final Duration duration;
|
||||
final Curve curve;
|
||||
|
||||
@override
|
||||
State<AnimatedSlashIcon> createState() => _AnimatedSlashIconState();
|
||||
}
|
||||
|
||||
class _AnimatedSlashIconState extends State<AnimatedSlashIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
value: widget.slashed ? 1 : 0,
|
||||
);
|
||||
|
||||
late final Animation<double> _wipe = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
reverseCurve: widget.curve.flipped,
|
||||
);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedSlashIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_controller.duration = widget.duration;
|
||||
if (widget.slashed == oldWidget.slashed) return;
|
||||
if (widget.slashed) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Icon _glyph(IconData data) => Icon(
|
||||
data,
|
||||
size: widget.size,
|
||||
color: widget.color,
|
||||
fill: widget.fill,
|
||||
weight: widget.weight,
|
||||
grade: widget.grade,
|
||||
opticalSize: widget.opticalSize,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _wipe,
|
||||
builder: (context, _) {
|
||||
final progress = _wipe.value;
|
||||
if (progress <= 0.001) return _glyph(widget.icon);
|
||||
if (progress >= 0.999) return _glyph(widget.slashedIcon);
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipPath(
|
||||
clipper: _SlashWipeClipper(progress, slashedSide: false),
|
||||
child: _glyph(widget.icon),
|
||||
),
|
||||
ClipPath(
|
||||
clipper: _SlashWipeClipper(progress, slashedSide: true),
|
||||
child: _glyph(widget.slashedIcon),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlashWipeClipper extends CustomClipper<Path> {
|
||||
const _SlashWipeClipper(this.progress, {required this.slashedSide});
|
||||
|
||||
static const double _seamOverlap = 0.75;
|
||||
static const double _slashStart = 0.08;
|
||||
static const double _slashEnd = 0.92;
|
||||
|
||||
final double progress;
|
||||
final bool slashedSide;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final travel = _slashStart + (_slashEnd - _slashStart) * progress;
|
||||
final cut =
|
||||
(size.width + size.height) * travel + (slashedSide ? _seamOverlap : 0);
|
||||
final slashed = _cornerPath(size, cut);
|
||||
if (slashedSide) return slashed;
|
||||
return Path.combine(
|
||||
PathOperation.difference,
|
||||
Path()..addRect(Offset.zero & size),
|
||||
slashed,
|
||||
);
|
||||
}
|
||||
|
||||
Path _cornerPath(Size size, double cut) {
|
||||
final width = size.width;
|
||||
final height = size.height;
|
||||
final path = Path()..moveTo(0, 0);
|
||||
path.lineTo(cut < width ? cut : width, 0);
|
||||
if (cut > width) path.lineTo(width, (cut - width).clamp(0, height));
|
||||
if (cut > height) path.lineTo((cut - height).clamp(0, width), height);
|
||||
path.lineTo(0, cut < height ? cut : height);
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(_SlashWipeClipper oldClipper) =>
|
||||
oldClipper.progress != progress || oldClipper.slashedSide != slashedSide;
|
||||
}
|
||||
@@ -69,24 +69,117 @@ class _AnimatedTextSwapState extends State<AnimatedTextSwap>
|
||||
final t = _t.value;
|
||||
if (t <= 0) return widget.child;
|
||||
if (t >= 1) return widget.alternate;
|
||||
return Stack(
|
||||
return buildSwapLayout(
|
||||
progress: t,
|
||||
outgoing: widget.child,
|
||||
incoming: widget.alternate,
|
||||
slideExtent: widget.slideExtent,
|
||||
alignment: widget.alignment,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildSwapLayout({
|
||||
required double progress,
|
||||
required Widget outgoing,
|
||||
required Widget incoming,
|
||||
required double slideExtent,
|
||||
required AlignmentGeometry alignment,
|
||||
}) {
|
||||
return Stack(
|
||||
alignment: alignment,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 1 - progress,
|
||||
child: FractionalTranslation(
|
||||
translation: Offset(0, -slideExtent * progress),
|
||||
child: outgoing,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: progress,
|
||||
child: FractionalTranslation(
|
||||
translation: Offset(0, slideExtent * (1 - progress)),
|
||||
child: incoming,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class AnimatedValueSwap<T> extends StatefulWidget {
|
||||
const AnimatedValueSwap({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.builder,
|
||||
this.duration = const Duration(milliseconds: 260),
|
||||
this.curve = Curves.easeOutCubic,
|
||||
this.slideExtent = 0.45,
|
||||
this.alignment = AlignmentDirectional.center,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final Widget Function(BuildContext context, T value) builder;
|
||||
final Duration duration;
|
||||
final Curve curve;
|
||||
final double slideExtent;
|
||||
final AlignmentGeometry alignment;
|
||||
|
||||
@override
|
||||
State<AnimatedValueSwap<T>> createState() => _AnimatedValueSwapState<T>();
|
||||
}
|
||||
|
||||
class _AnimatedValueSwapState<T> extends State<AnimatedValueSwap<T>>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
value: 1,
|
||||
);
|
||||
|
||||
late final Animation<double> _t = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
);
|
||||
|
||||
late T _current = widget.value;
|
||||
T? _previous;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedValueSwap<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.duration != oldWidget.duration) {
|
||||
_controller.duration = widget.duration;
|
||||
}
|
||||
if (widget.value == _current) return;
|
||||
_previous = _current;
|
||||
_current = widget.value;
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _t,
|
||||
builder: (context, _) {
|
||||
final t = _t.value;
|
||||
final previous = _previous;
|
||||
final incoming = widget.builder(context, _current);
|
||||
if (t >= 1 || previous == null) return incoming;
|
||||
return buildSwapLayout(
|
||||
progress: t,
|
||||
outgoing: widget.builder(context, previous),
|
||||
incoming: incoming,
|
||||
slideExtent: widget.slideExtent,
|
||||
alignment: widget.alignment,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 1 - t,
|
||||
child: FractionalTranslation(
|
||||
translation: Offset(0, -widget.slideExtent * t),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
opacity: t,
|
||||
child: FractionalTranslation(
|
||||
translation: Offset(0, widget.slideExtent * (1 - t)),
|
||||
child: widget.alternate,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
enum ComposerAction { mic, videocam, send }
|
||||
|
||||
const Map<(ComposerAction, ComposerAction), String> _morphs = {
|
||||
(ComposerAction.mic, ComposerAction.videocam):
|
||||
'assets/lottie/ic_mic_to_videocam.json',
|
||||
(ComposerAction.videocam, ComposerAction.mic):
|
||||
'assets/lottie/ic_videocam_to_mic.json',
|
||||
(ComposerAction.mic, ComposerAction.send):
|
||||
'assets/lottie/ic_mic_to_send.json',
|
||||
(ComposerAction.videocam, ComposerAction.send):
|
||||
'assets/lottie/ic_videocam_to_send.json',
|
||||
(ComposerAction.send, ComposerAction.mic):
|
||||
'assets/lottie/ic_send_to_mic.json',
|
||||
(ComposerAction.send, ComposerAction.videocam):
|
||||
'assets/lottie/ic_send_to_videocam.json',
|
||||
};
|
||||
|
||||
IconData composerActionIcon(ComposerAction action) => switch (action) {
|
||||
ComposerAction.mic => Symbols.mic,
|
||||
ComposerAction.videocam => Symbols.videocam,
|
||||
ComposerAction.send => Symbols.send,
|
||||
};
|
||||
|
||||
class ComposerMorphIcon extends StatefulWidget {
|
||||
const ComposerMorphIcon({
|
||||
super.key,
|
||||
required this.action,
|
||||
required this.color,
|
||||
this.size = 24,
|
||||
this.duration = const Duration(milliseconds: 400),
|
||||
});
|
||||
|
||||
final ComposerAction action;
|
||||
final Color color;
|
||||
final double size;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
State<ComposerMorphIcon> createState() => _ComposerMorphIconState();
|
||||
}
|
||||
|
||||
class _ComposerMorphIconState extends State<ComposerMorphIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static bool _warmed = false;
|
||||
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
);
|
||||
|
||||
String? _playing;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addStatusListener(_onStatus);
|
||||
_warmUp();
|
||||
}
|
||||
|
||||
void _warmUp() {
|
||||
if (_warmed) return;
|
||||
_warmed = true;
|
||||
for (final asset in _morphs.values.toSet()) {
|
||||
AssetLottie(asset).load();
|
||||
}
|
||||
}
|
||||
|
||||
void _onStatus(AnimationStatus status) {
|
||||
if (status != AnimationStatus.completed) return;
|
||||
if (_playing == null || !mounted) return;
|
||||
setState(() => _playing = null);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ComposerMorphIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_controller.duration = widget.duration;
|
||||
if (widget.action == oldWidget.action) return;
|
||||
|
||||
final asset = _morphs[(oldWidget.action, widget.action)];
|
||||
if (asset == null) {
|
||||
if (_playing != null) setState(() => _playing = null);
|
||||
return;
|
||||
}
|
||||
|
||||
_controller.stop();
|
||||
_playing = null;
|
||||
_controller.value = 0;
|
||||
setState(() => _playing = asset);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = _playing;
|
||||
if (asset == null) {
|
||||
return Icon(
|
||||
composerActionIcon(widget.action),
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
weight: 400,
|
||||
);
|
||||
}
|
||||
return SizedBox.square(
|
||||
dimension: widget.size,
|
||||
child: Lottie.asset(
|
||||
asset,
|
||||
controller: _controller,
|
||||
fit: BoxFit.contain,
|
||||
delegates: LottieDelegates(
|
||||
values: [ValueDelegate.color(const ['**'], value: widget.color)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ class GlossyPill extends StatelessWidget {
|
||||
final double? blurSigma;
|
||||
final bool liquid;
|
||||
final BackdropKey? backdropKey;
|
||||
final bool keepInkLayer;
|
||||
|
||||
const GlossyPill({
|
||||
super.key,
|
||||
@@ -111,9 +112,12 @@ class GlossyPill extends StatelessWidget {
|
||||
this.blurSigma,
|
||||
this.liquid = false,
|
||||
this.backdropKey,
|
||||
this.keepInkLayer = false,
|
||||
}) : borderRadius =
|
||||
borderRadius ?? const BorderRadius.all(Radius.circular(100));
|
||||
|
||||
bool get _inert => !keepInkLayer && onTap == null && onLongPress == null;
|
||||
|
||||
double? _sigmaFor(Color base) =>
|
||||
blurSigma != null && base.a < 1 ? blurSigma : null;
|
||||
|
||||
@@ -149,7 +153,7 @@ class GlossyPill extends StatelessWidget {
|
||||
child: LiquidGlassSurface(
|
||||
borderRadius: borderRadius,
|
||||
tint: Colors.transparent,
|
||||
child: onTap == null && onLongPress == null
|
||||
child: _inert
|
||||
? content
|
||||
: Material(
|
||||
type: MaterialType.transparency,
|
||||
@@ -178,7 +182,7 @@ class GlossyPill extends StatelessWidget {
|
||||
side: borderSide ?? BorderSide.none,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: onTap == null && onLongPress == null
|
||||
child: _inert
|
||||
? content
|
||||
: InkWell(onTap: onTap, onLongPress: onLongPress, child: content),
|
||||
);
|
||||
@@ -244,7 +248,7 @@ class GlossyPill extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (onTap == null && onLongPress == null)
|
||||
if (_inert)
|
||||
content
|
||||
else
|
||||
Material(
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../main.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'attachment/photo_hero.dart';
|
||||
import 'animated_slash_icon.dart';
|
||||
import 'chat_menu_overlay.dart';
|
||||
import 'custom_notification.dart';
|
||||
import 'liquid_glass.dart';
|
||||
@@ -1354,8 +1355,10 @@ class _VideoControlPanel extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
volume == 0 ? Symbols.volume_off : Symbols.volume_up,
|
||||
AnimatedSlashIcon(
|
||||
icon: Symbols.volume_up,
|
||||
slashedIcon: Symbols.volume_off,
|
||||
slashed: volume == 0,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
|
||||
+44
-1
@@ -1058,5 +1058,48 @@
|
||||
"downloadsClearTitle": "Clear download history?",
|
||||
"downloadsClearBody": "The files will stay on the device, but this list will be cleared.",
|
||||
"downloadsClearConfirm": "Clear",
|
||||
"downloadsHistoryCleared": "Download history cleared"
|
||||
"downloadsHistoryCleared": "Download history cleared",
|
||||
"uploadNotificationPhotos": "{count, plural, =1{Photo} other{{count} photos}}",
|
||||
"@uploadNotificationPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadNotificationVideo": "Video",
|
||||
"uploadNotificationFile": "File",
|
||||
"uploadNotificationMultiple": "{count, plural, other{Sending {count} files}}",
|
||||
"@uploadNotificationMultiple": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadNotificationPreparing": "Preparing…",
|
||||
"uploadSpeedBytes": "{value} B/s",
|
||||
"@uploadSpeedBytes": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadSpeedKb": "{value} KB/s",
|
||||
"@uploadSpeedKb": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadSpeedMb": "{value} MB/s",
|
||||
"@uploadSpeedMb": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4681,6 +4681,54 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Download history cleared'**
|
||||
String get downloadsHistoryCleared;
|
||||
|
||||
/// No description provided for @uploadNotificationPhotos.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Photo} other{{count} photos}}'**
|
||||
String uploadNotificationPhotos(int count);
|
||||
|
||||
/// No description provided for @uploadNotificationVideo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Video'**
|
||||
String get uploadNotificationVideo;
|
||||
|
||||
/// No description provided for @uploadNotificationFile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'File'**
|
||||
String get uploadNotificationFile;
|
||||
|
||||
/// No description provided for @uploadNotificationMultiple.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, other{Sending {count} files}}'**
|
||||
String uploadNotificationMultiple(int count);
|
||||
|
||||
/// No description provided for @uploadNotificationPreparing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Preparing…'**
|
||||
String get uploadNotificationPreparing;
|
||||
|
||||
/// No description provided for @uploadSpeedBytes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{value} B/s'**
|
||||
String uploadSpeedBytes(String value);
|
||||
|
||||
/// No description provided for @uploadSpeedKb.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{value} KB/s'**
|
||||
String uploadSpeedKb(String value);
|
||||
|
||||
/// No description provided for @uploadSpeedMb.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{value} MB/s'**
|
||||
String uploadSpeedMb(String value);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -2443,4 +2443,49 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadsHistoryCleared => 'Download history cleared';
|
||||
|
||||
@override
|
||||
String uploadNotificationPhotos(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count photos',
|
||||
one: 'Photo',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get uploadNotificationVideo => 'Video';
|
||||
|
||||
@override
|
||||
String get uploadNotificationFile => 'File';
|
||||
|
||||
@override
|
||||
String uploadNotificationMultiple(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Sending $count files',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get uploadNotificationPreparing => 'Preparing…';
|
||||
|
||||
@override
|
||||
String uploadSpeedBytes(String value) {
|
||||
return '$value B/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String uploadSpeedKb(String value) {
|
||||
return '$value KB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String uploadSpeedMb(String value) {
|
||||
return '$value MB/s';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2457,4 +2457,49 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadsHistoryCleared => 'История загрузок очищена';
|
||||
|
||||
@override
|
||||
String uploadNotificationPhotos(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count фото',
|
||||
one: 'Фото',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get uploadNotificationVideo => 'Видео';
|
||||
|
||||
@override
|
||||
String get uploadNotificationFile => 'Файл';
|
||||
|
||||
@override
|
||||
String uploadNotificationMultiple(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Отправка файлов: $count',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get uploadNotificationPreparing => 'Подготовка…';
|
||||
|
||||
@override
|
||||
String uploadSpeedBytes(String value) {
|
||||
return '$value Б/с';
|
||||
}
|
||||
|
||||
@override
|
||||
String uploadSpeedKb(String value) {
|
||||
return '$value КБ/с';
|
||||
}
|
||||
|
||||
@override
|
||||
String uploadSpeedMb(String value) {
|
||||
return '$value МБ/с';
|
||||
}
|
||||
}
|
||||
|
||||
+44
-1
@@ -802,5 +802,48 @@
|
||||
"downloadsClearTitle": "Очистить историю загрузок?",
|
||||
"downloadsClearBody": "Файлы останутся на устройстве, но этот список будет очищен.",
|
||||
"downloadsClearConfirm": "Очистить",
|
||||
"downloadsHistoryCleared": "История загрузок очищена"
|
||||
"downloadsHistoryCleared": "История загрузок очищена",
|
||||
"uploadNotificationPhotos": "{count, plural, =1{Фото} other{{count} фото}}",
|
||||
"@uploadNotificationPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadNotificationVideo": "Видео",
|
||||
"uploadNotificationFile": "Файл",
|
||||
"uploadNotificationMultiple": "{count, plural, other{Отправка файлов: {count}}}",
|
||||
"@uploadNotificationMultiple": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadNotificationPreparing": "Подготовка…",
|
||||
"uploadSpeedBytes": "{value} Б/с",
|
||||
"@uploadSpeedBytes": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadSpeedKb": "{value} КБ/с",
|
||||
"@uploadSpeedKb": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadSpeedMb": "{value} МБ/с",
|
||||
"@uploadSpeedMb": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/frontend/widgets/animated_slash_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
Widget _host({required bool slashed}) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AnimatedSlashIcon(
|
||||
icon: Symbols.mic,
|
||||
slashedIcon: Symbols.mic_off,
|
||||
slashed: slashed,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('в покое рисуется ровно одна исходная иконка', (tester) async {
|
||||
await tester.pumpWidget(_host(slashed: false));
|
||||
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic_off), findsNothing);
|
||||
expect(find.byType(ClipPath), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('в перечёркнутом покое рисуется ровно off-иконка', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(slashed: true));
|
||||
|
||||
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic), findsNothing);
|
||||
expect(find.byType(ClipPath), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('переключение проходит через клип обеих иконок', (tester) async {
|
||||
await tester.pumpWidget(_host(slashed: false));
|
||||
await tester.pumpWidget(_host(slashed: true));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
|
||||
expect(find.byType(ClipPath), findsNWidgets(2));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Symbols.mic_off), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('обратное переключение возвращает исходную иконку', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(slashed: true));
|
||||
await tester.pumpWidget(_host(slashed: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
expect(find.byIcon(Symbols.mic_off), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('размер и цвет прокидываются в обе иконки', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AnimatedSlashIcon(
|
||||
icon: Symbols.visibility,
|
||||
slashedIcon: Symbols.visibility_off,
|
||||
slashed: false,
|
||||
size: 14,
|
||||
color: const Color(0xFF00FF00),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final icon = tester.widget<Icon>(find.byType(Icon));
|
||||
expect(icon.size, 14);
|
||||
expect(icon.color, const Color(0xFF00FF00));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/frontend/widgets/animated_text_swap.dart';
|
||||
|
||||
Widget _host(int value) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: AnimatedValueSwap<int>(
|
||||
value: value,
|
||||
builder: (context, v) => Text('$v'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final Finder _slidingParts = find.descendant(
|
||||
of: find.byType(AnimatedValueSwap<int>),
|
||||
matching: find.byType(FractionalTranslation),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('первое значение показывается без анимации', (tester) async {
|
||||
await tester.pumpWidget(_host(3));
|
||||
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(_slidingParts, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('смена значения перелистывает старое и новое', (tester) async {
|
||||
await tester.pumpWidget(_host(1));
|
||||
await tester.pumpWidget(_host(2));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(_slidingParts, findsNWidgets(2));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('старое значение уезжает вверх, новое приходит снизу', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(1));
|
||||
await tester.pumpWidget(_host(2));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
|
||||
final outgoing = tester.widget<FractionalTranslation>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('1'),
|
||||
matching: find.byType(FractionalTranslation),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final incoming = tester.widget<FractionalTranslation>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('2'),
|
||||
matching: find.byType(FractionalTranslation),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
|
||||
expect(outgoing.translation.dy, lessThan(0));
|
||||
expect(incoming.translation.dy, greaterThan(0));
|
||||
});
|
||||
|
||||
testWidgets('тот же самый номер не запускает анимацию', (tester) async {
|
||||
await tester.pumpWidget(_host(7));
|
||||
await tester.pumpWidget(_host(7));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
|
||||
expect(find.text('7'), findsOneWidget);
|
||||
expect(_slidingParts, findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/backend/modules/messages.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:komet/core/storage/chat_activity_store.dart';
|
||||
import 'package:komet/core/storage/chat_members_store.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat/typing_label.dart';
|
||||
|
||||
const int _chatId = 900001;
|
||||
const int _alice = 900101;
|
||||
const int _bob = 900102;
|
||||
const int _carol = 900103;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
setUp(() {
|
||||
ChatMembersStore.instance.clear();
|
||||
ChatActivityStore.instance.clearChat(_chatId);
|
||||
ContactCache.clear();
|
||||
});
|
||||
|
||||
group('ChatMembersStore', () {
|
||||
test('счётчик читается из одного места и уведомляет слушателей', () {
|
||||
final seen = <int?>[];
|
||||
final listenable = ChatMembersStore.instance.listenable(_chatId);
|
||||
listenable.addListener(() => seen.add(listenable.value));
|
||||
|
||||
ChatMembersStore.instance.setCount(_chatId, 5);
|
||||
ChatMembersStore.instance.setCount(_chatId, 5);
|
||||
ChatMembersStore.instance.adjust(_chatId, 2);
|
||||
|
||||
expect(ChatMembersStore.instance.count(_chatId), 7);
|
||||
expect(seen, [5, 7]);
|
||||
});
|
||||
|
||||
test('adjust не опускает счётчик ниже нуля', () {
|
||||
ChatMembersStore.instance.setCount(_chatId, 1);
|
||||
ChatMembersStore.instance.adjust(_chatId, -5);
|
||||
expect(ChatMembersStore.instance.count(_chatId), 0);
|
||||
});
|
||||
|
||||
test('adjust без известного значения ничего не выдумывает', () {
|
||||
ChatMembersStore.instance.adjust(_chatId, 3);
|
||||
expect(ChatMembersStore.instance.count(_chatId), isNull);
|
||||
});
|
||||
|
||||
test('payload чата с сервера заполняет счётчик', () {
|
||||
ChatMembersStore.instance.applyChatPayload({
|
||||
'id': _chatId,
|
||||
'participantsCount': 12,
|
||||
});
|
||||
expect(ChatMembersStore.instance.count(_chatId), 12);
|
||||
});
|
||||
});
|
||||
|
||||
group('Подпись «печатает»', () {
|
||||
ChatActivitySnapshot snapshot(List<int> ids) {
|
||||
for (final id in ids) {
|
||||
ChatActivityStore.instance.mark(_chatId, id, ChatActivity.typing);
|
||||
}
|
||||
return ChatActivityStore.instance.snapshot(_chatId)!;
|
||||
}
|
||||
|
||||
test('в диалоге остаётся безымянная подпись', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
expect(chatActivityLabel(snapshot([_alice])), 'Печатает...');
|
||||
});
|
||||
|
||||
test('в группе показывает имя печатающего', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
expect(
|
||||
chatActivityLabel(snapshot([_alice]), withNames: true),
|
||||
'Алиса печатает...',
|
||||
);
|
||||
});
|
||||
|
||||
test('двое печатающих перечисляются', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
ContactCache.put(_bob, 'Борис');
|
||||
expect(
|
||||
chatActivityLabel(snapshot([_alice, _bob]), withNames: true),
|
||||
'Алиса и Борис печатают...',
|
||||
);
|
||||
});
|
||||
|
||||
test('трое и больше сворачиваются в «и ещё N»', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
ContactCache.put(_bob, 'Борис');
|
||||
ContactCache.put(_carol, 'Вера');
|
||||
expect(
|
||||
chatActivityLabel(snapshot([_alice, _bob, _carol]), withNames: true),
|
||||
'Алиса и ещё 2 печатают...',
|
||||
);
|
||||
});
|
||||
|
||||
test('без известного имени откатывается к общей подписи', () {
|
||||
expect(
|
||||
chatActivityLabel(snapshot([_alice]), withNames: true),
|
||||
'Печатает...',
|
||||
);
|
||||
});
|
||||
|
||||
test('стикеры получают свой глагол', () {
|
||||
ContactCache.put(_alice, 'Алиса Тестова');
|
||||
ChatActivityStore.instance.mark(_chatId, _alice, ChatActivity.sticker);
|
||||
final snap = ChatActivityStore.instance.snapshot(_chatId)!;
|
||||
expect(
|
||||
chatActivityLabel(snap, withNames: true),
|
||||
'Алиса выбирает стикер...',
|
||||
);
|
||||
});
|
||||
|
||||
test('снимок отдаёт только пользователей ведущей активности', () {
|
||||
ChatActivityStore.instance.mark(_chatId, _alice, ChatActivity.sticker);
|
||||
ChatActivityStore.instance.mark(_chatId, _bob, ChatActivity.typing);
|
||||
final snap = ChatActivityStore.instance.snapshot(_chatId)!;
|
||||
expect(snap.activity, ChatActivity.typing);
|
||||
expect(snap.userIds, [_bob]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -109,6 +109,8 @@ void main() {
|
||||
|
||||
await tester.tap(find.byIcon(Symbols.close));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
|
||||
expect(cancelCount, 1);
|
||||
expect(find.text('Пересылка от вас'), findsNothing);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:komet/frontend/widgets/composer_morph_icon.dart';
|
||||
import 'package:komet/frontend/widgets/glossy_pill.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
const _assets = [
|
||||
'assets/lottie/ic_mic_to_videocam.json',
|
||||
'assets/lottie/ic_videocam_to_mic.json',
|
||||
'assets/lottie/ic_mic_to_send.json',
|
||||
'assets/lottie/ic_videocam_to_send.json',
|
||||
'assets/lottie/ic_send_to_mic.json',
|
||||
'assets/lottie/ic_send_to_videocam.json',
|
||||
];
|
||||
|
||||
Widget _host(ComposerAction action) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: ComposerMorphIcon(action: action, color: const Color(0xFFFFFFFF)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
List<Map<String, dynamic>> _paths(Map<String, dynamic> doc) {
|
||||
final layer = (doc['layers'] as List).first as Map<String, dynamic>;
|
||||
final group = (layer['shapes'] as List).first as Map<String, dynamic>;
|
||||
return (group['it'] as List)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((item) => item['ty'] == 'sh')
|
||||
.toList();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Ассеты морфинга', () {
|
||||
test('файлы существуют и разбираются', () {
|
||||
for (final path in _assets) {
|
||||
final file = File(path);
|
||||
expect(file.existsSync(), isTrue, reason: '$path отсутствует');
|
||||
final doc =
|
||||
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
||||
expect(doc['w'], doc['h'], reason: '$path должен быть квадратным');
|
||||
expect(doc['op'], greaterThan(0));
|
||||
expect(_paths(doc), isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('обе ключевые точки контура имеют одинаковое число вершин', () {
|
||||
for (final path in _assets) {
|
||||
final doc =
|
||||
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
|
||||
for (final shape in _paths(doc)) {
|
||||
final frames = (shape['ks'] as Map)['k'] as List;
|
||||
expect(frames.length, 2, reason: '$path: ожидались две ключевые точки');
|
||||
final from = ((frames.first as Map)['s'] as List).first as Map;
|
||||
final to = ((frames.last as Map)['s'] as List).first as Map;
|
||||
for (final key in ['v', 'i', 'o']) {
|
||||
expect(
|
||||
(to[key] as List).length,
|
||||
(from[key] as List).length,
|
||||
reason: '$path: «$key» разной длины — морф не построится',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('анимации не начинаются и не заканчиваются смещением', () {
|
||||
for (final path in _assets) {
|
||||
final doc =
|
||||
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
|
||||
final layer = (doc['layers'] as List).first as Map<String, dynamic>;
|
||||
final transform = layer['ks'] as Map<String, dynamic>;
|
||||
for (final key in ['r', 's', 'p']) {
|
||||
final prop = transform[key] as Map<String, dynamic>;
|
||||
if (prop['a'] != 1) continue;
|
||||
final frames = (prop['k'] as List).cast<Map<String, dynamic>>();
|
||||
expect(
|
||||
frames.first['s'],
|
||||
frames.last['s'],
|
||||
reason: '$path: «$key» должен возвращаться в исходное значение',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('ComposerMorphIcon', () {
|
||||
testWidgets('в покое рисует обычную иконку', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.mic));
|
||||
|
||||
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||
expect(find.byType(Lottie), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('на смене состояния запускает lottie', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.mic));
|
||||
await tester.pumpWidget(_host(ComposerAction.videocam));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(Lottie), findsOneWidget);
|
||||
expect(find.byType(Icon), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('после анимации возвращает обычную иконку', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.mic));
|
||||
await tester.pumpWidget(_host(ComposerAction.send));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Symbols.send), findsOneWidget);
|
||||
expect(find.byType(Lottie), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('каждая следующая смена тоже анимируется', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.mic));
|
||||
|
||||
const sequence = [
|
||||
ComposerAction.videocam,
|
||||
ComposerAction.send,
|
||||
ComposerAction.videocam,
|
||||
ComposerAction.mic,
|
||||
ComposerAction.send,
|
||||
ComposerAction.mic,
|
||||
];
|
||||
|
||||
for (final action in sequence) {
|
||||
await tester.pumpWidget(_host(action));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(
|
||||
find.byType(Lottie),
|
||||
findsOneWidget,
|
||||
reason: 'переход в $action должен проигрываться',
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byType(Lottie),
|
||||
findsNothing,
|
||||
reason: 'переход в $action должен завершаться статикой',
|
||||
);
|
||||
expect(find.byIcon(composerActionIcon(action)), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('морф переживает появление обработчика нажатия', (tester) async {
|
||||
Widget host(bool sendMode) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: GlossyPill(
|
||||
onTap: sendMode ? () {} : null,
|
||||
keepInkLayer: true,
|
||||
child: ComposerMorphIcon(
|
||||
action: sendMode ? ComposerAction.send : ComposerAction.mic,
|
||||
color: const Color(0xFFFFFFFF),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(host(false));
|
||||
await tester.pumpWidget(host(true));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(Lottie), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Symbols.send), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('обратный переход тоже завершается статикой', (tester) async {
|
||||
await tester.pumpWidget(_host(ComposerAction.send));
|
||||
await tester.pumpWidget(_host(ComposerAction.videocam));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Symbols.videocam), findsOneWidget);
|
||||
expect(find.byType(Lottie), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
"""Собирает lottie-морфы иконок композера прямо из шрифта Material Symbols.
|
||||
|
||||
Контуры глифов берутся из MaterialSymbolsOutlined.ttf (инстанс по умолчанию —
|
||||
FILL 0, GRAD 0, opsz 24, wght 400, то есть ровно то, что рисует Icon в приложении),
|
||||
разбиваются на равное число безье-сегментов и попарно сопоставляются, чтобы
|
||||
lottie мог интерполировать один глиф в другой.
|
||||
|
||||
python3 tool/make_morph_icons.py
|
||||
|
||||
Пересобирать нужно после обновления material_symbols_icons.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
|
||||
class Font:
|
||||
def __init__(self, path):
|
||||
self.data = open(path, 'rb').read()
|
||||
self.tables = {}
|
||||
num_tables = struct.unpack('>H', self.data[4:6])[0]
|
||||
for i in range(num_tables):
|
||||
off = 12 + i * 16
|
||||
tag = self.data[off:off + 4].decode('latin1')
|
||||
t_off, t_len = struct.unpack('>II', self.data[off + 8:off + 16])
|
||||
self.tables[tag] = (t_off, t_len)
|
||||
|
||||
head_off = self.tables['head'][0]
|
||||
self.units_per_em = struct.unpack(
|
||||
'>H', self.data[head_off + 18:head_off + 20])[0]
|
||||
self.index_to_loc = struct.unpack(
|
||||
'>h', self.data[head_off + 50:head_off + 52])[0]
|
||||
maxp_off = self.tables['maxp'][0]
|
||||
self.num_glyphs = struct.unpack(
|
||||
'>H', self.data[maxp_off + 4:maxp_off + 6])[0]
|
||||
self._read_loca()
|
||||
self._read_cmap()
|
||||
|
||||
def _read_loca(self):
|
||||
off, _ = self.tables['loca']
|
||||
n = self.num_glyphs + 1
|
||||
if self.index_to_loc == 0:
|
||||
raw = struct.unpack('>%dH' % n, self.data[off:off + 2 * n])
|
||||
self.loca = [v * 2 for v in raw]
|
||||
else:
|
||||
self.loca = list(struct.unpack('>%dI' % n, self.data[off:off + 4 * n]))
|
||||
|
||||
def _read_cmap(self):
|
||||
off, _ = self.tables['cmap']
|
||||
n = struct.unpack('>H', self.data[off + 2:off + 4])[0]
|
||||
best = None
|
||||
for i in range(n):
|
||||
rec = off + 4 + i * 8
|
||||
pid, eid, sub = struct.unpack('>HHI', self.data[rec:rec + 8])
|
||||
fmt = struct.unpack('>H', self.data[off + sub:off + sub + 2])[0]
|
||||
if fmt in (4, 12):
|
||||
if best is None or fmt == 12:
|
||||
best = (fmt, off + sub)
|
||||
fmt, sub = best
|
||||
self.cmap = {}
|
||||
if fmt == 4:
|
||||
seg_x2 = struct.unpack('>H', self.data[sub + 6:sub + 8])[0]
|
||||
seg = seg_x2 // 2
|
||||
base = sub + 14
|
||||
ends = struct.unpack('>%dH' % seg, self.data[base:base + seg_x2])
|
||||
base += seg_x2 + 2
|
||||
starts = struct.unpack('>%dH' % seg, self.data[base:base + seg_x2])
|
||||
base += seg_x2
|
||||
deltas = struct.unpack('>%dh' % seg, self.data[base:base + seg_x2])
|
||||
range_off_pos = base + seg_x2
|
||||
offsets = struct.unpack(
|
||||
'>%dH' % seg, self.data[range_off_pos:range_off_pos + seg_x2])
|
||||
for i in range(seg):
|
||||
for c in range(starts[i], min(ends[i], 0xFFFF) + 1):
|
||||
if offsets[i] == 0:
|
||||
gid = (c + deltas[i]) & 0xFFFF
|
||||
else:
|
||||
p = range_off_pos + i * 2 + offsets[i] + (c - starts[i]) * 2
|
||||
gid = struct.unpack('>H', self.data[p:p + 2])[0]
|
||||
if gid:
|
||||
gid = (gid + deltas[i]) & 0xFFFF
|
||||
if gid:
|
||||
self.cmap[c] = gid
|
||||
else:
|
||||
n_groups = struct.unpack('>I', self.data[sub + 12:sub + 16])[0]
|
||||
for i in range(n_groups):
|
||||
p = sub + 16 + i * 12
|
||||
s, e, g = struct.unpack('>III', self.data[p:p + 12])
|
||||
for c in range(s, e + 1):
|
||||
self.cmap[c] = g + (c - s)
|
||||
|
||||
def contours(self, codepoint):
|
||||
gid = self.cmap[codepoint]
|
||||
return self._glyph_contours(gid)
|
||||
|
||||
def _glyph_contours(self, gid, depth=0):
|
||||
goff, _ = self.tables['glyf']
|
||||
start, end = self.loca[gid], self.loca[gid + 1]
|
||||
if start == end:
|
||||
return []
|
||||
d = self.data[goff + start:goff + end]
|
||||
n_contours = struct.unpack('>h', d[0:2])[0]
|
||||
if n_contours < 0:
|
||||
return self._composite(d, depth)
|
||||
|
||||
end_pts = struct.unpack('>%dH' % n_contours, d[10:10 + 2 * n_contours])
|
||||
n_points = end_pts[-1] + 1
|
||||
p = 10 + 2 * n_contours
|
||||
instr_len = struct.unpack('>H', d[p:p + 2])[0]
|
||||
p += 2 + instr_len
|
||||
|
||||
flags = []
|
||||
while len(flags) < n_points:
|
||||
f = d[p]
|
||||
p += 1
|
||||
flags.append(f)
|
||||
if f & 8:
|
||||
rep = d[p]
|
||||
p += 1
|
||||
flags.extend([f] * rep)
|
||||
flags = flags[:n_points]
|
||||
|
||||
xs, x = [], 0
|
||||
for f in flags:
|
||||
if f & 2:
|
||||
dx = d[p]
|
||||
p += 1
|
||||
x += dx if f & 16 else -dx
|
||||
elif not f & 16:
|
||||
dx = struct.unpack('>h', d[p:p + 2])[0]
|
||||
p += 2
|
||||
x += dx
|
||||
xs.append(x)
|
||||
|
||||
ys, y = [], 0
|
||||
for f in flags:
|
||||
if f & 4:
|
||||
dy = d[p]
|
||||
p += 1
|
||||
y += dy if f & 32 else -dy
|
||||
elif not f & 32:
|
||||
dy = struct.unpack('>h', d[p:p + 2])[0]
|
||||
p += 2
|
||||
y += dy
|
||||
ys.append(y)
|
||||
|
||||
out, first = [], 0
|
||||
for e in end_pts:
|
||||
pts = [(xs[i], ys[i], bool(flags[i] & 1)) for i in range(first, e + 1)]
|
||||
if pts:
|
||||
out.append(pts)
|
||||
first = e + 1
|
||||
return out
|
||||
|
||||
def _composite(self, d, depth):
|
||||
if depth > 4:
|
||||
return []
|
||||
out = []
|
||||
p = 10
|
||||
while True:
|
||||
flags, glyph_index = struct.unpack('>HH', d[p:p + 4])
|
||||
p += 4
|
||||
if flags & 1:
|
||||
a1, a2 = struct.unpack('>hh', d[p:p + 4])
|
||||
p += 4
|
||||
else:
|
||||
a1, a2 = struct.unpack('>bb', d[p:p + 2])
|
||||
p += 2
|
||||
sx = sy = 1.0
|
||||
s01 = s10 = 0.0
|
||||
if flags & 8:
|
||||
sx = sy = _f2dot14(d, p)
|
||||
p += 2
|
||||
elif flags & 0x40:
|
||||
sx = _f2dot14(d, p)
|
||||
sy = _f2dot14(d, p + 2)
|
||||
p += 4
|
||||
elif flags & 0x80:
|
||||
sx = _f2dot14(d, p)
|
||||
s01 = _f2dot14(d, p + 2)
|
||||
s10 = _f2dot14(d, p + 4)
|
||||
sy = _f2dot14(d, p + 6)
|
||||
p += 8
|
||||
dx, dy = (a1, a2) if flags & 2 else (0, 0)
|
||||
for contour in self._glyph_contours(glyph_index, depth + 1):
|
||||
out.append([
|
||||
(x * sx + y * s10 + dx, x * s01 + y * sy + dy, on)
|
||||
for x, y, on in contour
|
||||
])
|
||||
if not flags & 0x20:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _f2dot14(d, p):
|
||||
return struct.unpack('>h', d[p:p + 2])[0] / 16384.0
|
||||
|
||||
|
||||
def to_cubic(contour):
|
||||
"""TrueType quadratic contour -> list of cubic segments [(p0,c1,c2,p1), ...]."""
|
||||
pts = []
|
||||
for x, y, on in contour:
|
||||
pts.append((float(x), float(y), on))
|
||||
|
||||
if not pts[0][2]:
|
||||
if pts[-1][2]:
|
||||
pts = [pts[-1]] + pts[:-1]
|
||||
else:
|
||||
mx = (pts[0][0] + pts[-1][0]) / 2
|
||||
my = (pts[0][1] + pts[-1][1]) / 2
|
||||
pts = [(mx, my, True)] + pts
|
||||
|
||||
expanded = []
|
||||
for i, (x, y, on) in enumerate(pts):
|
||||
nx, ny, non = pts[(i + 1) % len(pts)]
|
||||
expanded.append((x, y, on))
|
||||
if not on and not non:
|
||||
expanded.append(((x + nx) / 2, (y + ny) / 2, True))
|
||||
|
||||
segments = []
|
||||
i = 0
|
||||
n = len(expanded)
|
||||
while i < n:
|
||||
x0, y0, on0 = expanded[i]
|
||||
assert on0
|
||||
x1, y1, on1 = expanded[(i + 1) % n]
|
||||
if on1:
|
||||
segments.append(((x0, y0), (x0, y0), (x1, y1), (x1, y1)))
|
||||
i += 1
|
||||
else:
|
||||
x2, y2, _ = expanded[(i + 2) % n]
|
||||
c1 = (x0 + 2 / 3 * (x1 - x0), y0 + 2 / 3 * (y1 - y0))
|
||||
c2 = (x2 + 2 / 3 * (x1 - x2), y2 + 2 / 3 * (y1 - y2))
|
||||
segments.append(((x0, y0), c1, c2, (x2, y2)))
|
||||
i += 2
|
||||
return segments
|
||||
|
||||
|
||||
|
||||
def _find_font():
|
||||
root = os.path.expanduser('~/.pub-cache/hosted/pub.dev')
|
||||
candidates = sorted(
|
||||
name for name in os.listdir(root)
|
||||
if name.startswith('material_symbols_icons-')
|
||||
)
|
||||
if not candidates:
|
||||
raise SystemExit('material_symbols_icons не найден в pub-cache')
|
||||
return os.path.join(root, candidates[-1], 'lib', 'fonts',
|
||||
'MaterialSymbolsOutlined.ttf')
|
||||
|
||||
FONT = os.environ.get('MATERIAL_SYMBOLS_TTF') or _find_font()
|
||||
|
||||
|
||||
UPM = 960.0
|
||||
CANVAS = 600.0
|
||||
MIN_AREA = 500.0
|
||||
|
||||
_font = Font(FONT)
|
||||
|
||||
|
||||
def _bezier(seg, t):
|
||||
(x0, y0), (x1, y1), (x2, y2), (x3, y3) = seg
|
||||
mt = 1 - t
|
||||
x = mt ** 3 * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t ** 3 * x3
|
||||
y = mt ** 3 * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t ** 3 * y3
|
||||
return x, y
|
||||
|
||||
|
||||
def _split_cubic(seg, t):
|
||||
p0, c1, c2, p3 = seg
|
||||
|
||||
def mid(a, b, k):
|
||||
return (a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k)
|
||||
|
||||
a = mid(p0, c1, t)
|
||||
b = mid(c1, c2, t)
|
||||
c = mid(c2, p3, t)
|
||||
d = mid(a, b, t)
|
||||
e = mid(b, c, t)
|
||||
f = mid(d, e, t)
|
||||
return (p0, a, d, f), (f, e, c, p3)
|
||||
|
||||
|
||||
def _seg_metrics(seg, steps=64):
|
||||
pts = [_bezier(seg, i / steps) for i in range(steps + 1)]
|
||||
acc = [0.0]
|
||||
total = 0.0
|
||||
for i in range(steps):
|
||||
total += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
|
||||
acc.append(total)
|
||||
return acc, total, steps
|
||||
|
||||
|
||||
def _t_at_length(metrics, target):
|
||||
acc, total, steps = metrics
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
for i in range(steps):
|
||||
if acc[i + 1] >= target:
|
||||
span = acc[i + 1] - acc[i]
|
||||
k = 0.0 if span <= 0 else (target - acc[i]) / span
|
||||
return (i + k) / steps
|
||||
return 1.0
|
||||
|
||||
|
||||
def _canvas_segments(contour):
|
||||
out = []
|
||||
for seg in to_cubic(contour):
|
||||
out.append(tuple(
|
||||
(x / UPM * CANVAS, (1 - y / UPM) * CANVAS) for x, y in seg
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _exact_path(contour, count):
|
||||
"""Subdivide the original beziers: geometry stays bit-for-bit the glyph."""
|
||||
segments = _canvas_segments(contour)
|
||||
metrics = [_seg_metrics(s) for s in segments]
|
||||
lengths = [m[1] for m in metrics]
|
||||
total = sum(lengths)
|
||||
if total <= 0:
|
||||
return []
|
||||
|
||||
quota = [max(1, int(round(count * length / total))) for length in lengths]
|
||||
while sum(quota) > count and max(quota) > 1:
|
||||
idx = max(range(len(quota)), key=lambda i: (quota[i], lengths[i]))
|
||||
quota[idx] -= 1
|
||||
while sum(quota) < count:
|
||||
idx = max(range(len(quota)), key=lambda i: lengths[i] / quota[i])
|
||||
quota[idx] += 1
|
||||
|
||||
pieces = []
|
||||
for seg, metric, parts in zip(segments, metrics, quota):
|
||||
rest = seg
|
||||
consumed = 0.0
|
||||
length = metric[1]
|
||||
for k in range(parts - 1):
|
||||
t_abs = _t_at_length(metric, length * (k + 1) / parts)
|
||||
span = 1.0 - consumed
|
||||
t_local = 0.0 if span <= 0 else (t_abs - consumed) / span
|
||||
t_local = min(max(t_local, 1e-4), 1 - 1e-4)
|
||||
head, rest = _split_cubic(rest, t_local)
|
||||
pieces.append(head)
|
||||
consumed = t_abs
|
||||
pieces.append(rest)
|
||||
|
||||
path = []
|
||||
n = len(pieces)
|
||||
for i, (p0, c1, _, _) in enumerate(pieces):
|
||||
prev_c2 = pieces[(i - 1) % n][2]
|
||||
path.append((
|
||||
p0,
|
||||
(prev_c2[0] - p0[0], prev_c2[1] - p0[1]),
|
||||
(c1[0] - p0[0], c1[1] - p0[1]),
|
||||
))
|
||||
return path
|
||||
|
||||
|
||||
def _area(path):
|
||||
area = 0.0
|
||||
n = len(path)
|
||||
for i in range(n):
|
||||
x0, y0 = path[i][0]
|
||||
x1, y1 = path[(i + 1) % n][0]
|
||||
area += x0 * y1 - x1 * y0
|
||||
return area / 2
|
||||
|
||||
|
||||
def glyph_paths(codepoint, count):
|
||||
out = []
|
||||
for contour in _font.contours(codepoint):
|
||||
path = _exact_path(contour, count)
|
||||
if not path:
|
||||
continue
|
||||
area = _area(path)
|
||||
if abs(area) < MIN_AREA:
|
||||
continue
|
||||
out.append((area, path))
|
||||
return out
|
||||
|
||||
|
||||
def _centroid(path):
|
||||
return (sum(p[0][0] for p in path) / len(path),
|
||||
sum(p[0][1] for p in path) / len(path))
|
||||
|
||||
|
||||
def _collapsed(path):
|
||||
cx, cy = _centroid(path)
|
||||
return [((cx, cy), (0.0, 0.0), (0.0, 0.0))] * len(path)
|
||||
|
||||
|
||||
def _rotate(path, shift):
|
||||
return path[shift:] + path[:shift]
|
||||
|
||||
|
||||
def _align(src, dst):
|
||||
n = len(src)
|
||||
best, best_cost = 0, None
|
||||
for shift in range(n):
|
||||
cost = 0.0
|
||||
for i in range(n):
|
||||
x0, y0 = src[i][0]
|
||||
x1, y1 = dst[(i + shift) % n][0]
|
||||
cost += (x0 - x1) ** 2 + (y0 - y1) ** 2
|
||||
if best_cost is None or cost < best_cost:
|
||||
best, best_cost = shift, cost
|
||||
return _rotate(dst, best)
|
||||
|
||||
|
||||
def outer_sign(shapes):
|
||||
"""The biggest contour is always an outline: its winding defines 'outer'."""
|
||||
biggest = max(shapes, key=lambda s: abs(s[0]))
|
||||
return 1.0 if biggest[0] > 0 else -1.0
|
||||
|
||||
|
||||
def pair_glyphs(from_cp, to_cp, count):
|
||||
"""[(path_from, path_to), ...] with matching vertex counts and winding."""
|
||||
src = glyph_paths(from_cp, count)
|
||||
dst = glyph_paths(to_cp, count)
|
||||
src_sign = outer_sign(src)
|
||||
dst_sign = outer_sign(dst)
|
||||
|
||||
pairs = []
|
||||
for outer in (True, False):
|
||||
a = sorted([s for s in src if (s[0] * src_sign > 0) == outer],
|
||||
key=lambda s: -abs(s[0]))
|
||||
b = sorted([s for s in dst if (s[0] * dst_sign > 0) == outer],
|
||||
key=lambda s: -abs(s[0]))
|
||||
for i in range(max(len(a), len(b))):
|
||||
if i < len(a) and i < len(b):
|
||||
pairs.append((a[i][1], _align(a[i][1], b[i][1])))
|
||||
elif i < len(a):
|
||||
pairs.append((a[i][1], _collapsed(a[i][1])))
|
||||
else:
|
||||
pairs.append((_collapsed(b[i][1]), b[i][1]))
|
||||
return pairs
|
||||
|
||||
|
||||
|
||||
MIC = 0xE31D
|
||||
CAM = 0xE04B
|
||||
SEND = 0xE163
|
||||
|
||||
POINTS = 56
|
||||
FPS = 60
|
||||
DUR = 24
|
||||
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))), 'assets', 'lottie')
|
||||
|
||||
EASE_OUT = {'x': 0.2, 'y': 0}
|
||||
EASE_IN = {'x': 0.0, 'y': 1.0}
|
||||
EASE_OUT_V = {'x': [0.2], 'y': [0]}
|
||||
EASE_IN_V = {'x': [0.0], 'y': [1.0]}
|
||||
EASE_SOFT_OUT_V = {'x': [0.33], 'y': [0]}
|
||||
EASE_SOFT_IN_V = {'x': [0.25], 'y': [1.0]}
|
||||
|
||||
|
||||
def r2(value):
|
||||
return round(value, 2)
|
||||
|
||||
|
||||
def path_value(path):
|
||||
return {
|
||||
'i': [[r2(p[1][0]), r2(p[1][1])] for p in path],
|
||||
'o': [[r2(p[2][0]), r2(p[2][1])] for p in path],
|
||||
'v': [[r2(p[0][0]), r2(p[0][1])] for p in path],
|
||||
'c': True,
|
||||
}
|
||||
|
||||
|
||||
COLLAPSE_END = 10
|
||||
GROW_START = 12
|
||||
|
||||
|
||||
def _is_point(path):
|
||||
first = path[0][0]
|
||||
return all(abs(p[0][0] - first[0]) < 0.01 and abs(p[0][1] - first[1]) < 0.01
|
||||
for p in path)
|
||||
|
||||
|
||||
def shape_item(index, path_from, path_to):
|
||||
start, end = 0, DUR
|
||||
if _is_point(path_to):
|
||||
end = COLLAPSE_END
|
||||
elif _is_point(path_from):
|
||||
start = GROW_START
|
||||
return {
|
||||
'ind': index,
|
||||
'ty': 'sh',
|
||||
'ix': index + 1,
|
||||
'ks': {
|
||||
'a': 1,
|
||||
'k': [
|
||||
{'i': EASE_IN, 'o': EASE_OUT, 't': start,
|
||||
's': [path_value(path_from)]},
|
||||
{'t': end, 's': [path_value(path_to)]},
|
||||
],
|
||||
'ix': 2,
|
||||
},
|
||||
'nm': 'Path %d' % (index + 1),
|
||||
'mn': 'ADBE Vector Shape - Group',
|
||||
'hd': False,
|
||||
}
|
||||
|
||||
|
||||
def keyframes(stops, vector):
|
||||
out = []
|
||||
for i, (frame, value) in enumerate(stops):
|
||||
entry = {'t': frame, 's': value if isinstance(value, list) else [value]}
|
||||
if i < len(stops) - 1:
|
||||
if vector:
|
||||
entry['i'] = EASE_IN_V if i == 0 else EASE_SOFT_IN_V
|
||||
entry['o'] = EASE_OUT_V if i == 0 else EASE_SOFT_OUT_V
|
||||
else:
|
||||
entry['i'] = EASE_IN
|
||||
entry['o'] = EASE_OUT
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def transform(rotation=None, scale=None, offset_x=None):
|
||||
half = CANVAS / 2
|
||||
ks = {
|
||||
'o': {'a': 0, 'k': 100, 'ix': 11},
|
||||
'r': {'a': 0, 'k': 0, 'ix': 10},
|
||||
'p': {'a': 0, 'k': [half, half, 0], 'ix': 2},
|
||||
'a': {'a': 0, 'k': [half, half, 0], 'ix': 1},
|
||||
's': {'a': 0, 'k': [100, 100, 100], 'ix': 6},
|
||||
}
|
||||
if rotation:
|
||||
ks['r'] = {'a': 1, 'k': keyframes(rotation, vector=False), 'ix': 10}
|
||||
if scale:
|
||||
stops = [(f, [v, v, 100]) for f, v in scale]
|
||||
ks['s'] = {'a': 1, 'k': keyframes(stops, vector=True), 'ix': 6}
|
||||
if offset_x:
|
||||
stops = [(f, [half + dx, half, 0]) for f, dx in offset_x]
|
||||
ks['p'] = {'a': 1, 'k': keyframes(stops, vector=True), 'ix': 2}
|
||||
return ks
|
||||
|
||||
|
||||
def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None):
|
||||
pairs = pair_glyphs(from_cp, to_cp, POINTS)
|
||||
items = [shape_item(i, a, b) for i, (a, b) in enumerate(pairs)]
|
||||
items.append({
|
||||
'ty': 'fl',
|
||||
'c': {'a': 0, 'k': [1, 1, 1, 1], 'ix': 4},
|
||||
'o': {'a': 0, 'k': 100, 'ix': 5},
|
||||
'r': 1,
|
||||
'bm': 0,
|
||||
'nm': 'Fill',
|
||||
'mn': 'ADBE Vector Graphic - Fill',
|
||||
'hd': False,
|
||||
})
|
||||
items.append({
|
||||
'ty': 'tr',
|
||||
'p': {'a': 0, 'k': [0, 0], 'ix': 2},
|
||||
'a': {'a': 0, 'k': [0, 0], 'ix': 1},
|
||||
's': {'a': 0, 'k': [100, 100], 'ix': 3},
|
||||
'r': {'a': 0, 'k': 0, 'ix': 6},
|
||||
'o': {'a': 0, 'k': 100, 'ix': 7},
|
||||
'sk': {'a': 0, 'k': 0, 'ix': 4},
|
||||
'sa': {'a': 0, 'k': 0, 'ix': 5},
|
||||
'nm': 'Transform',
|
||||
})
|
||||
|
||||
return {
|
||||
'v': '5.12.1',
|
||||
'fr': FPS,
|
||||
'ip': 0,
|
||||
'op': DUR,
|
||||
'w': int(CANVAS),
|
||||
'h': int(CANVAS),
|
||||
'nm': name,
|
||||
'ddd': 0,
|
||||
'assets': [],
|
||||
'layers': [{
|
||||
'ddd': 0,
|
||||
'ind': 1,
|
||||
'ty': 4,
|
||||
'nm': name,
|
||||
'sr': 1,
|
||||
'ks': transform(rotation, scale, offset_x),
|
||||
'ao': 0,
|
||||
'shapes': [{
|
||||
'ty': 'gr',
|
||||
'it': items,
|
||||
'nm': 'Group 1',
|
||||
'np': len(items),
|
||||
'cix': 2,
|
||||
'bm': 0,
|
||||
'ix': 1,
|
||||
'mn': 'ADBE Vector Group',
|
||||
'hd': False,
|
||||
}],
|
||||
'ip': 0,
|
||||
'op': DUR,
|
||||
'st': 0,
|
||||
'bm': 0,
|
||||
}],
|
||||
'markers': [],
|
||||
}
|
||||
|
||||
|
||||
SPECS = [
|
||||
dict(
|
||||
name='ic_mic_to_videocam',
|
||||
from_cp=MIC, to_cp=CAM,
|
||||
rotation=[(0, 0), (10, -14), (DUR, 0)],
|
||||
scale=[(0, 100), (10, 88), (DUR, 100)],
|
||||
),
|
||||
dict(
|
||||
name='ic_videocam_to_mic',
|
||||
from_cp=CAM, to_cp=MIC,
|
||||
rotation=[(0, 0), (11, 14), (DUR, 0)],
|
||||
scale=[(0, 100), (11, 111), (DUR, 100)],
|
||||
),
|
||||
dict(
|
||||
name='ic_mic_to_send',
|
||||
from_cp=MIC, to_cp=SEND,
|
||||
scale=[(0, 100), (9, 90), (DUR, 100)],
|
||||
offset_x=[(0, 0), (9, -34), (19, 12), (DUR, 0)],
|
||||
),
|
||||
dict(
|
||||
name='ic_videocam_to_send',
|
||||
from_cp=CAM, to_cp=SEND,
|
||||
rotation=[(0, 0), (9, 10), (DUR, 0)],
|
||||
scale=[(0, 100), (9, 92), (DUR, 100)],
|
||||
offset_x=[(0, 0), (9, -26), (19, 10), (DUR, 0)],
|
||||
),
|
||||
dict(
|
||||
name='ic_send_to_mic',
|
||||
from_cp=SEND, to_cp=MIC,
|
||||
rotation=[(0, 0), (10, 9), (DUR, 0)],
|
||||
scale=[(0, 100), (10, 91), (DUR, 100)],
|
||||
offset_x=[(0, 0), (10, 30), (19, -10), (DUR, 0)],
|
||||
),
|
||||
dict(
|
||||
name='ic_send_to_videocam',
|
||||
from_cp=SEND, to_cp=CAM,
|
||||
rotation=[(0, 0), (10, -11), (DUR, 0)],
|
||||
scale=[(0, 100), (10, 90), (DUR, 100)],
|
||||
offset_x=[(0, 0), (10, 24), (19, -8), (DUR, 0)],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
for spec in SPECS:
|
||||
data = build(**spec)
|
||||
path = os.path.join(OUT_DIR, spec['name'] + '.json')
|
||||
with open(path, 'w') as fh:
|
||||
json.dump(data, fh, separators=(',', ':'))
|
||||
print(f"{spec['name']:24s} {os.path.getsize(path) // 1024:3d} KB "
|
||||
f"paths={len(data['layers'][0]['shapes'][0]['it']) - 2}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user