From 79c743e86acd2897bbdcddd7aef20b75e6794399 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Thu, 6 Aug 2026 19:19:44 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=81=20=D1=80=D0=B5=D0=B0=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + CLAUDE.md | 2 + .../main/kotlin/ru/komet/app/MainActivity.kt | 52 +- .../ru/komet/app/UploadForegroundService.kt | 127 ++-- .../app/src/main/res/values-ru/strings.xml | 3 + android/app/src/main/res/values/strings.xml | 1 + assets/lottie/ic_mic_to_send.json | 1 + assets/lottie/ic_mic_to_videocam.json | 1 + assets/lottie/ic_send_to_mic.json | 1 + assets/lottie/ic_send_to_videocam.json | 1 + assets/lottie/ic_videocam_to_mic.json | 1 + assets/lottie/ic_videocam_to_send.json | 1 + lib/backend/modules/chats.dart | 49 +- lib/backend/modules/media_send.dart | 338 --------- lib/backend/modules/upload_manager.dart | 103 --- .../modules/upload_notification_service.dart | 205 +++++- lib/backend/modules/upload_service.dart | 590 ++++++++++++++++ lib/core/cache/info_cache.dart | 2 + lib/core/storage/chat_activity_store.dart | 42 +- lib/core/storage/chat_members_store.dart | 45 ++ .../screens/auth/password_2fa_screen.dart | 9 +- .../calls/call_participants_sheet.dart | 7 +- lib/frontend/screens/calls/call_screen.dart | 30 +- .../screens/chats/chat/typing_label.dart | 32 + .../chats/chat/view/chat_list_tile.dart | 16 +- .../chats/chat/view/composer_input.dart | 236 ++++--- .../screens/chats/chat_encryption_screen.dart | 9 +- .../screens/chats/chat_info_screen.dart | 76 +- .../screens/chats/chat_list_screen.dart | 1 + lib/frontend/screens/chats/chat_screen.dart | 292 +++----- .../screens/profile/cloud_storage_screen.dart | 97 ++- .../profile/password_entry_screen.dart | 7 +- .../screens/profile/settings_tab.dart | 9 +- .../screens/profile/web_qr_scan_screen.dart | 7 +- lib/frontend/widgets/animated_slash_icon.dart | 145 ++++ lib/frontend/widgets/animated_text_swap.dart | 127 +++- lib/frontend/widgets/composer_morph_icon.dart | 126 ++++ lib/frontend/widgets/glossy_pill.dart | 10 +- lib/frontend/widgets/photo_viewer.dart | 7 +- lib/l10n/app_en.arb | 45 +- lib/l10n/app_localizations.dart | 48 ++ lib/l10n/app_localizations_en.dart | 45 ++ lib/l10n/app_localizations_ru.dart | 45 ++ lib/l10n/app_ru.arb | 45 +- test/animated_slash_icon_test.dart | 85 +++ test/animated_value_swap_test.dart | 80 +++ test/chat_members_and_typing_test.dart | 122 ++++ test/composer_forward_preview_test.dart | 2 + test/composer_morph_icon_test.dart | 186 +++++ tool/make_morph_icons.py | 660 ++++++++++++++++++ 50 files changed, 3247 insertions(+), 925 deletions(-) create mode 100644 android/app/src/main/res/values-ru/strings.xml create mode 100644 assets/lottie/ic_mic_to_send.json create mode 100644 assets/lottie/ic_mic_to_videocam.json create mode 100644 assets/lottie/ic_send_to_mic.json create mode 100644 assets/lottie/ic_send_to_videocam.json create mode 100644 assets/lottie/ic_videocam_to_mic.json create mode 100644 assets/lottie/ic_videocam_to_send.json delete mode 100644 lib/backend/modules/media_send.dart delete mode 100644 lib/backend/modules/upload_manager.dart create mode 100644 lib/backend/modules/upload_service.dart create mode 100644 lib/core/storage/chat_members_store.dart create mode 100644 lib/frontend/screens/chats/chat/typing_label.dart create mode 100644 lib/frontend/widgets/animated_slash_icon.dart create mode 100644 lib/frontend/widgets/composer_morph_icon.dart create mode 100644 test/animated_slash_icon_test.dart create mode 100644 test/animated_value_swap_test.dart create mode 100644 test/chat_members_and_typing_test.dart create mode 100644 test/composer_morph_icon_test.dart create mode 100644 tool/make_morph_icons.py diff --git a/AGENTS.md b/AGENTS.md index c83c9f9..77e38ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 360cacb..dde1e2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 20f6b74..8db19d7 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -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("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("title")) + putExtra(UploadForegroundService.EXTRA_BODY, call.argument("body") ?: "") + putExtra(UploadForegroundService.EXTRA_PROGRESS, call.argument("progress") ?: 0) + putExtra( + UploadForegroundService.EXTRA_INDETERMINATE, + call.argument("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("filename") ?: "Файл" - val progress = call.argument("progress") ?: 0 - val speed = call.argument("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() diff --git a/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt b/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt index 3bd7698..ffaf6a1 100644 --- a/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt +++ b/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt @@ -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() } } diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..6bfe8d3 --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,3 @@ + + Отправка медиа + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 55b2a48..2274df6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Komet contact exchange Komet contact exchange + Sending media diff --git a/assets/lottie/ic_mic_to_send.json b/assets/lottie/ic_mic_to_send.json new file mode 100644 index 0000000..36634fe --- /dev/null +++ b/assets/lottie/ic_mic_to_send.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_mic_to_send","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_mic_to_send","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"t":0,"s":[300.0,300.0,0],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":9,"s":[266.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":19,"s":[312.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[300.0,300.0,0]}],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":9,"s":[90,90,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96],[1.86,4.53],[0.91,4.92],[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91]],"o":[[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12],[-1.85,-4.51],[-0.87,-4.71],[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87]],"v":[[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8],[230.44,303.83],[226.3,289.69],[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7]],"c":true}]},{"t":24,"s":[{"i":[[7.77,-3.27],[7.71,-3.24],[7.63,-3.21],[7.5,-3.16],[7.27,-3.06],[6.72,-2.83],[0.0,0.0],[0.0,15.7],[0.0,9.49],[0.0,8.93],[0.0,8.71],[0.0,8.57],[0.0,8.48],[0.0,8.42],[0.0,8.35],[0.0,8.3],[0.0,8.23],[0.0,8.16],[0.0,8.07],[0.0,7.93],[0.0,7.68],[0.0,7.11],[0.0,0.0],[-15.01,-6.32],[-9.05,-3.81],[-8.53,-3.59],[-8.31,-3.5],[-8.2,-3.45],[-8.11,-3.41],[-8.05,-3.39],[-8.01,-3.37],[-7.97,-3.35],[-7.93,-3.34],[-7.89,-3.32],[-7.86,-3.31],[-7.82,-3.29],[-7.77,-3.27],[-7.71,-3.24],[-7.63,-3.21],[-7.5,-3.16],[-7.27,-3.06],[-6.72,-2.83],[0.0,0.0],[15.01,-6.32],[9.05,-3.81],[8.53,-3.59],[8.31,-3.5],[8.2,-3.45],[8.11,-3.41],[8.05,-3.39],[8.01,-3.37],[7.97,-3.35],[7.93,-3.34],[7.89,-3.32],[7.86,-3.31],[7.82,-3.29]],"o":[[-8.11,3.41],[-8.2,3.45],[-8.31,3.5],[-8.53,3.59],[-9.05,3.81],[-15.01,6.32],[0.0,0.0],[0.0,-7.11],[0.0,-7.68],[0.0,-7.93],[0.0,-8.07],[0.0,-8.16],[0.0,-8.23],[0.0,-8.3],[0.0,-8.35],[0.0,-8.42],[0.0,-8.48],[0.0,-8.57],[0.0,-8.71],[0.0,-8.93],[0.0,-9.49],[0.0,-15.7],[0.0,0.0],[6.72,2.83],[7.27,3.06],[7.5,3.16],[7.63,3.21],[7.71,3.24],[7.77,3.27],[7.82,3.29],[7.86,3.31],[7.89,3.32],[7.93,3.34],[7.97,3.35],[8.01,3.37],[8.05,3.39],[8.11,3.41],[8.2,3.45],[8.31,3.5],[8.53,3.59],[9.05,3.81],[15.01,6.32],[0.0,0.0],[-6.72,2.83],[-7.27,3.06],[-7.5,3.16],[-7.63,3.21],[-7.71,3.24],[-7.77,3.27],[-7.82,3.29],[-7.86,3.31],[-7.89,3.32],[-7.93,3.34],[-7.97,3.35],[-8.01,3.37],[-8.05,3.39]],"v":[[217.48,440.01],[193.74,450.01],[169.97,460.01],[146.21,470.02],[122.45,480.02],[98.69,490.02],[75.0,500.0],[75.0,475.04],[75.0,450.02],[75.0,425.03],[75.0,400.01],[75.0,375.01],[75.0,350.01],[75.0,325.01],[75.0,300.0],[75.0,274.99],[75.0,249.99],[75.0,224.99],[75.0,199.99],[75.0,174.97],[75.0,149.98],[75.0,124.96],[75.0,100.0],[98.69,109.98],[122.45,119.98],[146.21,129.98],[169.97,139.99],[193.74,149.99],[217.48,159.99],[241.23,169.99],[264.99,180.0],[288.75,190.0],[312.5,200.0],[336.25,210.0],[360.01,220.0],[383.77,230.01],[407.52,240.01],[431.26,250.01],[455.03,260.01],[478.79,270.02],[502.55,280.02],[526.31,290.02],[550.0,300.0],[526.31,309.98],[502.55,319.98],[478.79,329.98],[455.03,339.99],[431.26,349.99],[407.52,359.99],[383.77,369.99],[360.01,380.0],[336.25,390.0],[312.5,400.0],[288.75,410.0],[264.99,420.0],[241.23,430.01]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true}]},{"t":10,"s":[{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56],[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43],[-1.86,-1.07],[-2.16,-0.53]],"o":[[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17],[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6],[1.9,1.09],[2.05,0.5]],"v":[[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35],[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81],[287.38,296.82],[293.48,299.25]],"c":true}]},{"t":24,"s":[{"i":[[-7.03,2.96],[-6.62,2.79],[-6.45,2.72],[-6.35,2.68],[-6.28,2.65],[-6.23,2.63],[-6.19,2.61],[-6.14,2.59],[-6.1,2.57],[-6.04,2.55],[-5.97,2.52],[-5.88,2.48],[-5.69,2.4],[-5.26,2.22],[0.0,0.0],[11.63,4.91],[7.03,2.96],[6.62,2.79],[6.45,2.72],[6.35,2.68],[6.28,2.65],[6.23,2.63],[6.19,2.61],[6.14,2.59],[6.1,2.57],[6.04,2.55],[5.97,2.52],[5.88,2.48],[5.69,2.4],[5.26,2.22],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.42,-2.85],[-6.95,-1.74],[-6.52,-1.63],[-6.31,-1.58],[-6.13,-1.53],[-5.9,-1.48],[-5.44,-1.36],[0.0,0.0],[11.42,-2.85],[6.95,-1.74],[6.52,-1.63],[6.31,-1.58],[6.13,-1.53],[5.9,-1.48],[5.44,-1.36],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.63,4.91]],"o":[[5.69,-2.4],[5.88,-2.48],[5.97,-2.52],[6.04,-2.55],[6.1,-2.57],[6.14,-2.59],[6.19,-2.61],[6.23,-2.63],[6.28,-2.65],[6.35,-2.68],[6.45,-2.72],[6.62,-2.79],[7.03,-2.96],[11.63,-4.91],[0.0,0.0],[-5.26,-2.22],[-5.69,-2.4],[-5.88,-2.48],[-5.97,-2.52],[-6.04,-2.55],[-6.1,-2.57],[-6.14,-2.59],[-6.19,-2.61],[-6.23,-2.63],[-6.28,-2.65],[-6.35,-2.68],[-6.45,-2.72],[-6.62,-2.79],[-7.03,-2.96],[-11.63,-4.91],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.44,1.36],[5.9,1.48],[6.13,1.53],[6.31,1.58],[6.52,1.63],[6.95,1.74],[11.42,2.85],[0.0,0.0],[-5.44,1.36],[-5.9,1.48],[-6.13,1.53],[-6.31,1.58],[-6.52,1.63],[-6.95,1.74],[-11.42,2.85],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.26,-2.22]],"v":[[162.02,409.38],[180.53,401.57],[199.05,393.75],[217.57,385.94],[236.08,378.13],[254.61,370.31],[273.12,362.5],[291.64,354.69],[310.17,346.87],[328.68,339.06],[347.2,331.25],[365.72,323.43],[384.23,315.62],[402.76,307.8],[421.25,300.0],[402.76,292.2],[384.23,284.38],[365.72,276.57],[347.2,268.75],[328.68,260.94],[310.17,253.13],[291.64,245.31],[273.12,237.5],[254.61,229.69],[236.08,221.87],[217.57,214.06],[199.05,206.25],[180.53,198.43],[162.02,190.62],[143.49,182.8],[125.0,175.0],[125.0,196.87],[125.0,218.75],[125.0,240.63],[125.0,262.5],[143.74,267.19],[162.5,271.87],[181.25,276.56],[200.0,281.25],[218.75,285.94],[237.5,290.63],[256.26,295.31],[275.0,300.0],[256.26,304.69],[237.5,309.37],[218.75,314.06],[200.0,318.75],[181.25,323.44],[162.5,328.13],[143.74,332.81],[125.0,337.5],[125.0,359.37],[125.0,381.25],[125.0,403.13],[125.0,425.0],[143.49,417.2]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"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},{"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"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_mic_to_videocam.json b/assets/lottie/ic_mic_to_videocam.json new file mode 100644 index 0000000..645cb90 --- /dev/null +++ b/assets/lottie/ic_mic_to_videocam.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_mic_to_videocam","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_mic_to_videocam","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":10,"s":[-14],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[88,88,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96],[1.86,4.53],[0.91,4.92],[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91]],"o":[[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12],[-1.85,-4.51],[-0.87,-4.71],[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87]],"v":[[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8],[230.44,303.83],[226.3,289.69],[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7]],"c":true}]},{"t":24,"s":[{"i":[[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0],[11.81,0.0],[10.88,0.0],[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89]],"o":[[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0],[-13.89,0.0],[-22.83,0.0],[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79]],"v":[[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0],[174.99,500.0],[137.49,500.0],[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true}]},{"t":10,"s":[{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56],[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43],[-1.86,-1.07],[-2.16,-0.53]],"o":[[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17],[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6],[1.9,1.09],[2.05,0.5]],"v":[[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35],[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81],[287.38,296.82],[293.48,299.25]],"c":true}]},{"t":24,"s":[{"i":[[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0],[-8.63,0.0],[-8.48,0.0]],"o":[[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0],[8.15,0.0],[8.27,0.0]],"v":[[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0],[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0],[199.99,450.0],[224.99,450.0]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"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},{"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"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_send_to_mic.json b/assets/lottie/ic_send_to_mic.json new file mode 100644 index 0000000..f06ca37 --- /dev/null +++ b/assets/lottie/ic_send_to_mic.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_send_to_mic","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_send_to_mic","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":10,"s":[9],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"t":0,"s":[300.0,300.0,0],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[330.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":19,"s":[290.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[300.0,300.0,0]}],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[91,91,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[0.0,15.7],[0.0,9.49],[0.0,8.93],[0.0,8.71],[0.0,8.57],[0.0,8.48],[0.0,8.42],[0.0,8.35],[0.0,8.3],[0.0,8.23],[0.0,8.16],[0.0,8.07],[0.0,7.93],[0.0,7.68],[0.0,7.11],[0.0,0.0],[-15.01,-6.32],[-9.05,-3.81],[-8.53,-3.59],[-8.31,-3.5],[-8.2,-3.45],[-8.11,-3.41],[-8.05,-3.39],[-8.01,-3.37],[-7.97,-3.35],[-7.93,-3.34],[-7.89,-3.32],[-7.86,-3.31],[-7.82,-3.29],[-7.77,-3.27],[-7.71,-3.24],[-7.63,-3.21],[-7.5,-3.16],[-7.27,-3.06],[-6.72,-2.83],[0.0,0.0],[15.01,-6.32],[9.05,-3.81],[8.53,-3.59],[8.31,-3.5],[8.2,-3.45],[8.11,-3.41],[8.05,-3.39],[8.01,-3.37],[7.97,-3.35],[7.93,-3.34],[7.89,-3.32],[7.86,-3.31],[7.82,-3.29],[7.77,-3.27],[7.71,-3.24],[7.63,-3.21],[7.5,-3.16],[7.27,-3.06],[6.72,-2.83]],"o":[[0.0,0.0],[0.0,-7.11],[0.0,-7.68],[0.0,-7.93],[0.0,-8.07],[0.0,-8.16],[0.0,-8.23],[0.0,-8.3],[0.0,-8.35],[0.0,-8.42],[0.0,-8.48],[0.0,-8.57],[0.0,-8.71],[0.0,-8.93],[0.0,-9.49],[0.0,-15.7],[0.0,0.0],[6.72,2.83],[7.27,3.06],[7.5,3.16],[7.63,3.21],[7.71,3.24],[7.77,3.27],[7.82,3.29],[7.86,3.31],[7.89,3.32],[7.93,3.34],[7.97,3.35],[8.01,3.37],[8.05,3.39],[8.11,3.41],[8.2,3.45],[8.31,3.5],[8.53,3.59],[9.05,3.81],[15.01,6.32],[0.0,0.0],[-6.72,2.83],[-7.27,3.06],[-7.5,3.16],[-7.63,3.21],[-7.71,3.24],[-7.77,3.27],[-7.82,3.29],[-7.86,3.31],[-7.89,3.32],[-7.93,3.34],[-7.97,3.35],[-8.01,3.37],[-8.05,3.39],[-8.11,3.41],[-8.2,3.45],[-8.31,3.5],[-8.53,3.59],[-9.05,3.81],[-15.01,6.32]],"v":[[75.0,500.0],[75.0,475.04],[75.0,450.02],[75.0,425.03],[75.0,400.01],[75.0,375.01],[75.0,350.01],[75.0,325.01],[75.0,300.0],[75.0,274.99],[75.0,249.99],[75.0,224.99],[75.0,199.99],[75.0,174.97],[75.0,149.98],[75.0,124.96],[75.0,100.0],[98.69,109.98],[122.45,119.98],[146.21,129.98],[169.97,139.99],[193.74,149.99],[217.48,159.99],[241.23,169.99],[264.99,180.0],[288.75,190.0],[312.5,200.0],[336.25,210.0],[360.01,220.0],[383.77,230.01],[407.52,240.01],[431.26,250.01],[455.03,260.01],[478.79,270.02],[502.55,280.02],[526.31,290.02],[550.0,300.0],[526.31,309.98],[502.55,319.98],[478.79,329.98],[455.03,339.99],[431.26,349.99],[407.52,359.99],[383.77,369.99],[360.01,380.0],[336.25,390.0],[312.5,400.0],[288.75,410.0],[264.99,420.0],[241.23,430.01],[217.48,440.01],[193.74,450.01],[169.97,460.01],[146.21,470.02],[122.45,480.02],[98.69,490.02]],"c":true}]},{"t":24,"s":[{"i":[[1.86,4.53],[0.91,4.92],[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91],[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96]],"o":[[-1.85,-4.51],[-0.87,-4.71],[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87],[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12]],"v":[[230.44,303.83],[226.3,289.69],[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7],[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":12,"s":[{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56]],"c":true}]},{"t":24,"s":[{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[-11.63,4.91],[-7.03,2.96],[-6.62,2.79],[-6.45,2.72],[-6.35,2.68],[-6.28,2.65],[-6.23,2.63],[-6.19,2.61],[-6.14,2.59],[-6.1,2.57],[-6.04,2.55],[-5.97,2.52],[-5.88,2.48],[-5.69,2.4],[-5.26,2.22],[0.0,0.0],[11.63,4.91],[7.03,2.96],[6.62,2.79],[6.45,2.72],[6.35,2.68],[6.28,2.65],[6.23,2.63],[6.19,2.61],[6.14,2.59],[6.1,2.57],[6.04,2.55],[5.97,2.52],[5.88,2.48],[5.69,2.4],[5.26,2.22],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.42,-2.85],[-6.95,-1.74],[-6.52,-1.63],[-6.31,-1.58],[-6.13,-1.53],[-5.9,-1.48],[-5.44,-1.36],[0.0,0.0],[11.42,-2.85],[6.95,-1.74],[6.52,-1.63],[6.31,-1.58],[6.13,-1.53],[5.9,-1.48],[5.44,-1.36],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68]],"o":[[0.0,0.0],[5.26,-2.22],[5.69,-2.4],[5.88,-2.48],[5.97,-2.52],[6.04,-2.55],[6.1,-2.57],[6.14,-2.59],[6.19,-2.61],[6.23,-2.63],[6.28,-2.65],[6.35,-2.68],[6.45,-2.72],[6.62,-2.79],[7.03,-2.96],[11.63,-4.91],[0.0,0.0],[-5.26,-2.22],[-5.69,-2.4],[-5.88,-2.48],[-5.97,-2.52],[-6.04,-2.55],[-6.1,-2.57],[-6.14,-2.59],[-6.19,-2.61],[-6.23,-2.63],[-6.28,-2.65],[-6.35,-2.68],[-6.45,-2.72],[-6.62,-2.79],[-7.03,-2.96],[-11.63,-4.91],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.44,1.36],[5.9,1.48],[6.13,1.53],[6.31,1.58],[6.52,1.63],[6.95,1.74],[11.42,2.85],[0.0,0.0],[-5.44,1.36],[-5.9,1.48],[-6.13,1.53],[-6.31,1.58],[-6.52,1.63],[-6.95,1.74],[-11.42,2.85],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55]],"v":[[125.0,425.0],[143.49,417.2],[162.02,409.38],[180.53,401.57],[199.05,393.75],[217.57,385.94],[236.08,378.13],[254.61,370.31],[273.12,362.5],[291.64,354.69],[310.17,346.87],[328.68,339.06],[347.2,331.25],[365.72,323.43],[384.23,315.62],[402.76,307.8],[421.25,300.0],[402.76,292.2],[384.23,284.38],[365.72,276.57],[347.2,268.75],[328.68,260.94],[310.17,253.13],[291.64,245.31],[273.12,237.5],[254.61,229.69],[236.08,221.87],[217.57,214.06],[199.05,206.25],[180.53,198.43],[162.02,190.62],[143.49,182.8],[125.0,175.0],[125.0,196.87],[125.0,218.75],[125.0,240.63],[125.0,262.5],[143.74,267.19],[162.5,271.87],[181.25,276.56],[200.0,281.25],[218.75,285.94],[237.5,290.63],[256.26,295.31],[275.0,300.0],[256.26,304.69],[237.5,309.37],[218.75,314.06],[200.0,318.75],[181.25,323.44],[162.5,328.13],[143.74,332.81],[125.0,337.5],[125.0,359.37],[125.0,381.25],[125.0,403.13]],"c":true}]},{"t":24,"s":[{"i":[[-1.86,-1.07],[-2.16,-0.53],[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56],[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43]],"o":[[1.9,1.09],[2.05,0.5],[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17],[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6]],"v":[[287.38,296.82],[293.48,299.25],[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35],[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"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},{"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"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_send_to_videocam.json b/assets/lottie/ic_send_to_videocam.json new file mode 100644 index 0000000..f8b0300 --- /dev/null +++ b/assets/lottie/ic_send_to_videocam.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_send_to_videocam","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_send_to_videocam","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":10,"s":[-11],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"t":0,"s":[300.0,300.0,0],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[324.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":19,"s":[292.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[300.0,300.0,0]}],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":10,"s":[90,90,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[0.0,15.7],[0.0,9.49],[0.0,8.93],[0.0,8.71],[0.0,8.57],[0.0,8.48],[0.0,8.42],[0.0,8.35],[0.0,8.3],[0.0,8.23],[0.0,8.16],[0.0,8.07],[0.0,7.93],[0.0,7.68],[0.0,7.11],[0.0,0.0],[-15.01,-6.32],[-9.05,-3.81],[-8.53,-3.59],[-8.31,-3.5],[-8.2,-3.45],[-8.11,-3.41],[-8.05,-3.39],[-8.01,-3.37],[-7.97,-3.35],[-7.93,-3.34],[-7.89,-3.32],[-7.86,-3.31],[-7.82,-3.29],[-7.77,-3.27],[-7.71,-3.24],[-7.63,-3.21],[-7.5,-3.16],[-7.27,-3.06],[-6.72,-2.83],[0.0,0.0],[15.01,-6.32],[9.05,-3.81],[8.53,-3.59],[8.31,-3.5],[8.2,-3.45],[8.11,-3.41],[8.05,-3.39],[8.01,-3.37],[7.97,-3.35],[7.93,-3.34],[7.89,-3.32],[7.86,-3.31],[7.82,-3.29],[7.77,-3.27],[7.71,-3.24],[7.63,-3.21],[7.5,-3.16],[7.27,-3.06],[6.72,-2.83]],"o":[[0.0,0.0],[0.0,-7.11],[0.0,-7.68],[0.0,-7.93],[0.0,-8.07],[0.0,-8.16],[0.0,-8.23],[0.0,-8.3],[0.0,-8.35],[0.0,-8.42],[0.0,-8.48],[0.0,-8.57],[0.0,-8.71],[0.0,-8.93],[0.0,-9.49],[0.0,-15.7],[0.0,0.0],[6.72,2.83],[7.27,3.06],[7.5,3.16],[7.63,3.21],[7.71,3.24],[7.77,3.27],[7.82,3.29],[7.86,3.31],[7.89,3.32],[7.93,3.34],[7.97,3.35],[8.01,3.37],[8.05,3.39],[8.11,3.41],[8.2,3.45],[8.31,3.5],[8.53,3.59],[9.05,3.81],[15.01,6.32],[0.0,0.0],[-6.72,2.83],[-7.27,3.06],[-7.5,3.16],[-7.63,3.21],[-7.71,3.24],[-7.77,3.27],[-7.82,3.29],[-7.86,3.31],[-7.89,3.32],[-7.93,3.34],[-7.97,3.35],[-8.01,3.37],[-8.05,3.39],[-8.11,3.41],[-8.2,3.45],[-8.31,3.5],[-8.53,3.59],[-9.05,3.81],[-15.01,6.32]],"v":[[75.0,500.0],[75.0,475.04],[75.0,450.02],[75.0,425.03],[75.0,400.01],[75.0,375.01],[75.0,350.01],[75.0,325.01],[75.0,300.0],[75.0,274.99],[75.0,249.99],[75.0,224.99],[75.0,199.99],[75.0,174.97],[75.0,149.98],[75.0,124.96],[75.0,100.0],[98.69,109.98],[122.45,119.98],[146.21,129.98],[169.97,139.99],[193.74,149.99],[217.48,159.99],[241.23,169.99],[264.99,180.0],[288.75,190.0],[312.5,200.0],[336.25,210.0],[360.01,220.0],[383.77,230.01],[407.52,240.01],[431.26,250.01],[455.03,260.01],[478.79,270.02],[502.55,280.02],[526.31,290.02],[550.0,300.0],[526.31,309.98],[502.55,319.98],[478.79,329.98],[455.03,339.99],[431.26,349.99],[407.52,359.99],[383.77,369.99],[360.01,380.0],[336.25,390.0],[312.5,400.0],[288.75,410.0],[264.99,420.0],[241.23,430.01],[217.48,440.01],[193.74,450.01],[169.97,460.01],[146.21,470.02],[122.45,480.02],[98.69,490.02]],"c":true}]},{"t":24,"s":[{"i":[[11.81,0.0],[10.88,0.0],[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89],[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0]],"o":[[-13.89,0.0],[-22.83,0.0],[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79],[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0]],"v":[[174.99,500.0],[137.49,500.0],[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31],[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[-11.63,4.91],[-7.03,2.96],[-6.62,2.79],[-6.45,2.72],[-6.35,2.68],[-6.28,2.65],[-6.23,2.63],[-6.19,2.61],[-6.14,2.59],[-6.1,2.57],[-6.04,2.55],[-5.97,2.52],[-5.88,2.48],[-5.69,2.4],[-5.26,2.22],[0.0,0.0],[11.63,4.91],[7.03,2.96],[6.62,2.79],[6.45,2.72],[6.35,2.68],[6.28,2.65],[6.23,2.63],[6.19,2.61],[6.14,2.59],[6.1,2.57],[6.04,2.55],[5.97,2.52],[5.88,2.48],[5.69,2.4],[5.26,2.22],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.42,-2.85],[-6.95,-1.74],[-6.52,-1.63],[-6.31,-1.58],[-6.13,-1.53],[-5.9,-1.48],[-5.44,-1.36],[0.0,0.0],[11.42,-2.85],[6.95,-1.74],[6.52,-1.63],[6.31,-1.58],[6.13,-1.53],[5.9,-1.48],[5.44,-1.36],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68]],"o":[[0.0,0.0],[5.26,-2.22],[5.69,-2.4],[5.88,-2.48],[5.97,-2.52],[6.04,-2.55],[6.1,-2.57],[6.14,-2.59],[6.19,-2.61],[6.23,-2.63],[6.28,-2.65],[6.35,-2.68],[6.45,-2.72],[6.62,-2.79],[7.03,-2.96],[11.63,-4.91],[0.0,0.0],[-5.26,-2.22],[-5.69,-2.4],[-5.88,-2.48],[-5.97,-2.52],[-6.04,-2.55],[-6.1,-2.57],[-6.14,-2.59],[-6.19,-2.61],[-6.23,-2.63],[-6.28,-2.65],[-6.35,-2.68],[-6.45,-2.72],[-6.62,-2.79],[-7.03,-2.96],[-11.63,-4.91],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.44,1.36],[5.9,1.48],[6.13,1.53],[6.31,1.58],[6.52,1.63],[6.95,1.74],[11.42,2.85],[0.0,0.0],[-5.44,1.36],[-5.9,1.48],[-6.13,1.53],[-6.31,1.58],[-6.52,1.63],[-6.95,1.74],[-11.42,2.85],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55]],"v":[[125.0,425.0],[143.49,417.2],[162.02,409.38],[180.53,401.57],[199.05,393.75],[217.57,385.94],[236.08,378.13],[254.61,370.31],[273.12,362.5],[291.64,354.69],[310.17,346.87],[328.68,339.06],[347.2,331.25],[365.72,323.43],[384.23,315.62],[402.76,307.8],[421.25,300.0],[402.76,292.2],[384.23,284.38],[365.72,276.57],[347.2,268.75],[328.68,260.94],[310.17,253.13],[291.64,245.31],[273.12,237.5],[254.61,229.69],[236.08,221.87],[217.57,214.06],[199.05,206.25],[180.53,198.43],[162.02,190.62],[143.49,182.8],[125.0,175.0],[125.0,196.87],[125.0,218.75],[125.0,240.63],[125.0,262.5],[143.74,267.19],[162.5,271.87],[181.25,276.56],[200.0,281.25],[218.75,285.94],[237.5,290.63],[256.26,295.31],[275.0,300.0],[256.26,304.69],[237.5,309.37],[218.75,314.06],[200.0,318.75],[181.25,323.44],[162.5,328.13],[143.74,332.81],[125.0,337.5],[125.0,359.37],[125.0,381.25],[125.0,403.13]],"c":true}]},{"t":24,"s":[{"i":[[-8.63,0.0],[-8.48,0.0],[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0]],"o":[[8.15,0.0],[8.27,0.0],[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0]],"v":[[199.99,450.0],[224.99,450.0],[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0],[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"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},{"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"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_videocam_to_mic.json b/assets/lottie/ic_videocam_to_mic.json new file mode 100644 index 0000000..438c204 --- /dev/null +++ b/assets/lottie/ic_videocam_to_mic.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_videocam_to_mic","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_videocam_to_mic","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":11,"s":[14],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":0,"k":[300.0,300.0,0],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":11,"s":[111,111,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89],[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0],[11.81,0.0],[10.88,0.0]],"o":[[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79],[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0],[-13.89,0.0],[-22.83,0.0]],"v":[[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31],[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0],[174.99,500.0],[137.49,500.0]],"c":true}]},{"t":24,"s":[{"i":[[0.0,5.08],[0.0,8.44],[0.0,5.11],[0.0,4.82],[0.0,4.69],[0.0,4.6],[0.0,4.53],[0.0,4.46],[0.0,4.38],[0.0,4.24],[0.0,3.91],[0.0,0.0],[-0.55,3.81],[-1.17,3.71],[-1.8,3.51],[-2.38,3.21],[-2.85,2.85],[-3.96,2.7],[-4.53,1.86],[-4.92,0.91],[-5.08,0.0],[-4.71,-0.87],[-4.51,-1.85],[-4.12,-2.81],[-3.59,-3.59],[-2.3,-3.09],[-1.78,-3.47],[-1.19,-3.77],[-0.57,-3.96],[0.0,-4.04],[0.0,-8.44],[0.0,-5.11],[0.0,-4.82],[0.0,-4.69],[0.0,-4.6],[0.0,-4.53],[0.0,-4.46],[0.0,-4.38],[0.0,-4.24],[0.0,-3.91],[0.0,0.0],[0.87,-4.71],[1.85,-4.51],[2.81,-4.12],[3.59,-3.59],[3.96,-2.7],[4.53,-1.86],[4.92,-0.91],[5.08,0.0],[4.71,0.87],[4.51,1.85],[4.12,2.81],[3.59,3.59],[2.7,3.96],[1.86,4.53],[0.91,4.92]],"o":[[0.0,0.0],[0.0,-3.91],[0.0,-4.24],[0.0,-4.38],[0.0,-4.46],[0.0,-4.53],[0.0,-4.6],[0.0,-4.69],[0.0,-4.82],[0.0,-5.11],[0.0,-8.44],[0.0,-4.04],[0.57,-3.96],[1.19,-3.77],[1.78,-3.47],[2.3,-3.09],[3.59,-3.59],[4.12,-2.81],[4.51,-1.85],[4.71,-0.87],[5.08,0.0],[4.92,0.91],[4.53,1.86],[3.96,2.7],[2.85,2.85],[2.38,3.21],[1.8,3.51],[1.17,3.71],[0.55,3.81],[0.0,0.0],[0.0,3.91],[0.0,4.24],[0.0,4.38],[0.0,4.46],[0.0,4.53],[0.0,4.6],[0.0,4.69],[0.0,4.82],[0.0,5.11],[0.0,8.44],[0.0,5.08],[-0.91,4.92],[-1.86,4.53],[-2.7,3.96],[-3.59,3.59],[-4.12,2.81],[-4.51,1.85],[-4.71,0.87],[-5.08,0.0],[-4.92,-0.91],[-4.53,-1.86],[-3.96,-2.7],[-3.59,-3.59],[-2.81,-4.12],[-1.85,-4.51],[-0.87,-4.71]],"v":[[225.0,275.0],[225.0,261.37],[225.0,247.74],[225.0,234.09],[225.0,220.46],[225.0,206.82],[225.0,193.18],[225.0,179.54],[225.0,165.91],[225.0,152.26],[225.0,138.63],[225.0,125.0],[225.82,113.23],[228.43,101.72],[232.91,90.81],[239.15,80.8],[246.88,71.87],[258.2,62.44],[271.17,55.44],[285.31,51.3],[300.0,50.0],[314.69,51.3],[328.83,55.44],[341.8,62.44],[353.12,71.87],[360.85,80.8],[367.09,90.81],[371.57,101.72],[374.18,113.23],[375.0,125.0],[375.0,138.63],[375.0,152.26],[375.0,165.91],[375.0,179.54],[375.0,193.18],[375.0,206.82],[375.0,220.46],[375.0,234.09],[375.0,247.74],[375.0,261.37],[375.0,275.0],[373.7,289.69],[369.56,303.83],[362.56,316.8],[353.12,328.12],[341.8,337.56],[328.83,344.56],[314.69,348.7],[300.0,350.0],[285.31,348.7],[271.17,344.56],[258.2,337.56],[246.88,328.12],[237.44,316.8],[230.44,303.83],[226.3,289.69]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":12,"s":[{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56],[300.0,385.56]],"c":true}]},{"t":24,"s":[{"i":[[0.0,0.0],[0.0,11.03],[0.0,6.68],[0.0,5.87],[0.0,0.0],[6.63,1.66],[6.41,2.46],[6.09,3.29],[5.67,4.08],[5.16,4.78],[4.62,5.36],[4.47,6.88],[3.45,7.54],[2.3,8.06],[1.12,8.4],[0.0,8.54],[-12.5,0.0],[0.0,0.0],[-0.92,-6.34],[-1.96,-6.18],[-3.01,-5.84],[-3.98,-5.34],[-4.76,-4.76],[-5.14,-3.83],[-5.76,-2.97],[-6.27,-1.99],[-6.59,-0.96],[-6.73,0.0],[-6.34,0.92],[-6.18,1.96],[-5.84,3.01],[-5.34,3.98],[-4.76,4.76],[-3.83,5.14],[-2.97,5.76],[-1.99,6.27],[-0.96,6.59],[0.0,6.73],[-12.5,0.0],[0.0,0.0],[1.08,-8.13],[2.27,-7.96],[3.48,-7.61],[4.61,-7.09],[5.57,-6.47],[5.02,-4.64],[5.58,-4.02],[6.1,-3.29],[6.53,-2.51],[6.83,-1.71],[7.02,-0.95],[0.0,-11.03],[0.0,-6.68],[0.0,-5.87],[0.0,0.0],[12.5,0.0]],"o":[[0.0,0.0],[0.0,-5.87],[0.0,-6.68],[0.0,-11.03],[-7.02,-0.95],[-6.83,-1.71],[-6.53,-2.51],[-6.1,-3.29],[-5.58,-4.02],[-5.02,-4.64],[-5.57,-6.47],[-4.61,-7.09],[-3.48,-7.61],[-2.27,-7.96],[-1.08,-8.13],[0.0,0.0],[12.5,0.0],[0.0,6.73],[0.96,6.59],[1.99,6.27],[2.97,5.76],[3.83,5.14],[4.76,4.76],[5.34,3.98],[5.84,3.01],[6.18,1.96],[6.34,0.92],[6.73,0.0],[6.59,-0.96],[6.27,-1.99],[5.76,-2.97],[5.14,-3.83],[4.76,-4.76],[3.98,-5.34],[3.01,-5.84],[1.96,-6.18],[0.92,-6.34],[0.0,0.0],[12.5,0.0],[0.0,8.54],[-1.12,8.4],[-2.3,8.06],[-3.45,7.54],[-4.47,6.88],[-4.62,5.36],[-5.16,4.78],[-5.67,4.08],[-6.09,3.29],[-6.41,2.46],[-6.63,1.66],[0.0,0.0],[0.0,5.87],[0.0,6.68],[0.0,11.03],[0.0,0.0],[-12.5,0.0]],"v":[[275.0,525.0],[275.0,505.78],[275.0,486.56],[275.0,467.34],[275.0,448.12],[254.53,444.22],[234.66,437.97],[215.72,429.27],[198.07,418.21],[181.95,405.01],[167.5,390.0],[152.44,369.98],[140.36,348.03],[131.69,324.53],[126.62,300.0],[125.0,275.0],[150.0,275.0],[175.0,275.0],[176.38,294.61],[180.76,313.77],[188.26,331.93],[198.69,348.59],[211.56,363.44],[226.41,376.31],[243.07,386.74],[261.23,394.24],[280.39,398.62],[300.0,400.0],[319.61,398.62],[338.77,394.24],[356.93,386.74],[373.59,376.31],[388.44,363.44],[401.31,348.59],[411.74,331.93],[419.24,313.77],[423.62,294.61],[425.0,275.0],[450.0,275.0],[475.0,275.0],[473.38,300.0],[468.31,324.53],[459.64,348.03],[447.56,369.98],[432.5,390.0],[418.05,405.01],[401.93,418.21],[384.28,429.27],[365.34,437.97],[345.47,444.22],[325.0,448.12],[325.0,467.34],[325.0,486.56],[325.0,505.78],[325.0,525.0],[300.0,525.0]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0],[-8.63,0.0],[-8.48,0.0],[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0],[8.15,0.0],[8.27,0.0],[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0]],"v":[[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0],[199.99,450.0],[224.99,450.0],[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0]],"c":true}]},{"t":24,"s":[{"i":[[0.0,-2.36],[0.0,0.0],[-1.17,-2.94],[-2.43,-2.43],[-1.86,-1.07],[-2.16,-0.53],[-2.29,0.0],[-2.94,1.17],[-2.43,2.43],[-1.07,1.86],[-0.53,2.16],[0.0,2.29],[0.0,5.26],[0.0,3.17],[0.0,2.99],[0.0,2.91],[0.0,2.87],[0.0,2.84],[0.0,2.82],[0.0,2.8],[0.0,2.78],[0.0,2.77],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.68],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[0.5,2.05],[1.09,1.9],[1.6,1.6],[3.01,1.2],[3.5,0.0],[2.94,-1.17],[2.43,-2.43],[1.07,-1.86],[0.53,-2.16],[0.0,-2.29],[0.0,-5.26],[0.0,-3.17],[0.0,-2.99],[0.0,-2.91],[0.0,-2.87],[0.0,-2.84],[0.0,-2.82],[0.0,-2.8],[0.0,-2.78],[0.0,-2.77],[0.0,-2.75],[0.0,-2.73],[0.0,-2.71],[0.0,-2.68],[0.0,-2.64],[0.0,-2.56]],"o":[[0.0,5.26],[0.0,3.5],[1.2,3.01],[1.6,1.6],[1.9,1.09],[2.05,0.5],[3.5,0.0],[3.01,-1.2],[1.6,-1.6],[1.09,-1.9],[0.5,-2.05],[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.68],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.77],[0.0,-2.78],[0.0,-2.8],[0.0,-2.82],[0.0,-2.84],[0.0,-2.87],[0.0,-2.91],[0.0,-2.99],[0.0,-3.17],[0.0,-5.26],[0.0,-2.29],[-0.53,-2.16],[-1.07,-1.86],[-2.43,-2.43],[-2.94,-1.17],[-3.5,0.0],[-3.01,1.2],[-1.6,1.6],[-1.09,1.9],[-0.5,2.05],[0.0,0.0],[0.0,2.36],[0.0,2.56],[0.0,2.64],[0.0,2.68],[0.0,2.71],[0.0,2.73],[0.0,2.75],[0.0,2.77],[0.0,2.78],[0.0,2.8],[0.0,2.82],[0.0,2.84],[0.0,2.87],[0.0,2.91],[0.0,2.99],[0.0,3.17]],"v":[[275.0,266.68],[275.0,275.0],[276.75,284.65],[282.19,292.81],[287.38,296.82],[293.48,299.25],[300.0,300.0],[309.65,298.25],[317.81,292.81],[321.82,287.62],[324.25,281.52],[325.0,275.0],[325.0,266.68],[325.0,258.35],[325.0,250.01],[325.0,241.68],[325.0,233.34],[325.0,225.0],[325.0,216.67],[325.0,208.34],[325.0,200.0],[325.0,191.66],[325.0,183.33],[325.0,175.0],[325.0,166.66],[325.0,158.32],[325.0,149.99],[325.0,141.65],[325.0,133.32],[325.0,125.0],[324.25,118.48],[321.82,112.38],[317.81,107.19],[309.65,101.75],[300.0,100.0],[290.35,101.75],[282.19,107.19],[278.18,112.38],[275.75,118.48],[275.0,125.0],[275.0,133.32],[275.0,141.65],[275.0,149.99],[275.0,158.32],[275.0,166.66],[275.0,175.0],[275.0,183.33],[275.0,191.66],[275.0,200.0],[275.0,208.34],[275.0,216.67],[275.0,225.0],[275.0,233.34],[275.0,241.68],[275.0,250.01],[275.0,258.35]],"c":true}]}],"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"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},{"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"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_videocam_to_send.json b/assets/lottie/ic_videocam_to_send.json new file mode 100644 index 0000000..5bab860 --- /dev/null +++ b/assets/lottie/ic_videocam_to_send.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_videocam_to_send","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_videocam_to_send","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":9,"s":[10],"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0}},{"t":24,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"t":0,"s":[300.0,300.0,0],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":9,"s":[274.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":19,"s":[310.0,300.0,0],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[300.0,300.0,0]}],"ix":2},"a":{"a":0,"k":[300.0,300.0,0],"ix":1},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"i":{"x":[0.0],"y":[1.0]},"o":{"x":[0.2],"y":[0]}},{"t":9,"s":[92,92,100],"i":{"x":[0.25],"y":[1.0]},"o":{"x":[0.33],"y":[0]}},{"t":24,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,22.83],[0.0,13.89],[0.0,13.05],[0.0,12.62],[0.0,12.26],[0.0,11.81],[0.0,10.88],[0.0,0.0],[-9.79,9.79],[-5.87,2.45],[-6.89,0.0],[-22.83,0.0],[-13.89,0.0],[-13.05,0.0],[-12.62,0.0],[-12.26,0.0],[-11.81,0.0],[-10.88,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[-14.35,14.35],[-8.68,8.68],[-7.64,7.64],[0.0,0.0],[0.0,-20.93],[0.0,-12.73],[0.0,-11.96],[0.0,-11.57],[0.0,-11.24],[0.0,-10.82],[0.0,-9.97],[0.0,0.0],[14.35,14.35],[8.68,8.68],[7.64,7.64],[0.0,0.0],[0.0,-20.65],[0.0,-12.07],[0.0,0.0],[2.46,-5.9],[4.89,-4.89],[13.75,0.0],[22.83,0.0],[13.89,0.0],[13.05,0.0],[12.62,0.0],[12.26,0.0],[11.81,0.0],[10.88,0.0]],"o":[[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-10.88],[0.0,-11.81],[0.0,-12.26],[0.0,-12.62],[0.0,-13.05],[0.0,-13.89],[0.0,-22.83],[0.0,-13.75],[4.89,-4.89],[5.9,-2.46],[0.0,0.0],[10.88,0.0],[11.81,0.0],[12.26,0.0],[12.62,0.0],[13.05,0.0],[13.89,0.0],[22.83,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,0.0],[7.64,-7.64],[8.68,-8.68],[14.35,-14.35],[0.0,0.0],[0.0,9.97],[0.0,10.82],[0.0,11.24],[0.0,11.57],[0.0,11.96],[0.0,12.73],[0.0,20.93],[0.0,0.0],[-7.64,-7.64],[-8.68,-8.68],[-14.35,-14.35],[0.0,0.0],[0.0,12.07],[0.0,20.65],[0.0,6.89],[-2.45,5.87],[-9.79,9.79],[0.0,0.0],[-10.88,0.0],[-11.81,0.0],[-12.26,0.0],[-12.62,0.0],[-13.05,0.0],[-13.89,0.0],[-22.83,0.0]],"v":[[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,412.51],[50.0,375.01],[50.0,337.51],[50.0,300.0],[50.0,262.49],[50.0,224.99],[50.0,187.49],[50.0,150.0],[64.69,114.69],[80.82,103.69],[100.0,100.0],[137.49,100.0],[174.99,100.0],[212.49,100.0],[250.0,100.0],[287.51,100.0],[325.01,100.0],[362.51,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,187.5],[450.0,225.0],[450.0,262.5],[475.0,237.5],[500.0,212.5],[525.0,187.5],[550.0,162.5],[550.0,196.86],[550.0,231.24],[550.0,265.62],[550.0,300.0],[550.0,334.38],[550.0,368.76],[550.0,403.14],[550.0,437.5],[525.0,412.5],[500.0,387.5],[475.0,362.5],[450.0,337.5],[450.0,375.0],[450.0,412.5],[450.0,450.0],[446.31,469.18],[435.31,485.31],[400.0,500.0],[362.51,500.0],[325.01,500.0],[287.51,500.0],[250.0,500.0],[212.49,500.0],[174.99,500.0],[137.49,500.0]],"c":true}]},{"t":24,"s":[{"i":[[0.0,9.49],[0.0,8.93],[0.0,8.71],[0.0,8.57],[0.0,8.48],[0.0,8.42],[0.0,8.35],[0.0,8.3],[0.0,8.23],[0.0,8.16],[0.0,8.07],[0.0,7.93],[0.0,7.68],[0.0,7.11],[0.0,0.0],[-15.01,-6.32],[-9.05,-3.81],[-8.53,-3.59],[-8.31,-3.5],[-8.2,-3.45],[-8.11,-3.41],[-8.05,-3.39],[-8.01,-3.37],[-7.97,-3.35],[-7.93,-3.34],[-7.89,-3.32],[-7.86,-3.31],[-7.82,-3.29],[-7.77,-3.27],[-7.71,-3.24],[-7.63,-3.21],[-7.5,-3.16],[-7.27,-3.06],[-6.72,-2.83],[0.0,0.0],[15.01,-6.32],[9.05,-3.81],[8.53,-3.59],[8.31,-3.5],[8.2,-3.45],[8.11,-3.41],[8.05,-3.39],[8.01,-3.37],[7.97,-3.35],[7.93,-3.34],[7.89,-3.32],[7.86,-3.31],[7.82,-3.29],[7.77,-3.27],[7.71,-3.24],[7.63,-3.21],[7.5,-3.16],[7.27,-3.06],[6.72,-2.83],[0.0,0.0],[0.0,15.7]],"o":[[0.0,-7.68],[0.0,-7.93],[0.0,-8.07],[0.0,-8.16],[0.0,-8.23],[0.0,-8.3],[0.0,-8.35],[0.0,-8.42],[0.0,-8.48],[0.0,-8.57],[0.0,-8.71],[0.0,-8.93],[0.0,-9.49],[0.0,-15.7],[0.0,0.0],[6.72,2.83],[7.27,3.06],[7.5,3.16],[7.63,3.21],[7.71,3.24],[7.77,3.27],[7.82,3.29],[7.86,3.31],[7.89,3.32],[7.93,3.34],[7.97,3.35],[8.01,3.37],[8.05,3.39],[8.11,3.41],[8.2,3.45],[8.31,3.5],[8.53,3.59],[9.05,3.81],[15.01,6.32],[0.0,0.0],[-6.72,2.83],[-7.27,3.06],[-7.5,3.16],[-7.63,3.21],[-7.71,3.24],[-7.77,3.27],[-7.82,3.29],[-7.86,3.31],[-7.89,3.32],[-7.93,3.34],[-7.97,3.35],[-8.01,3.37],[-8.05,3.39],[-8.11,3.41],[-8.2,3.45],[-8.31,3.5],[-8.53,3.59],[-9.05,3.81],[-15.01,6.32],[0.0,0.0],[0.0,-7.11]],"v":[[75.0,450.02],[75.0,425.03],[75.0,400.01],[75.0,375.01],[75.0,350.01],[75.0,325.01],[75.0,300.0],[75.0,274.99],[75.0,249.99],[75.0,224.99],[75.0,199.99],[75.0,174.97],[75.0,149.98],[75.0,124.96],[75.0,100.0],[98.69,109.98],[122.45,119.98],[146.21,129.98],[169.97,139.99],[193.74,149.99],[217.48,159.99],[241.23,169.99],[264.99,180.0],[288.75,190.0],[312.5,200.0],[336.25,210.0],[360.01,220.0],[383.77,230.01],[407.52,240.01],[431.26,250.01],[455.03,260.01],[478.79,270.02],[502.55,280.02],[526.31,290.02],[550.0,300.0],[526.31,309.98],[502.55,319.98],[478.79,329.98],[455.03,339.99],[431.26,349.99],[407.52,359.99],[383.77,369.99],[360.01,380.0],[336.25,390.0],[312.5,400.0],[288.75,410.0],[264.99,420.0],[241.23,430.01],[217.48,440.01],[193.74,450.01],[169.97,460.01],[146.21,470.02],[122.45,480.02],[98.69,490.02],[75.0,500.0],[75.0,475.04]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.0,"y":1.0},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[0.0,0.0],[-15.53,0.0],[-9.41,0.0],[-8.87,0.0],[-8.63,0.0],[-8.48,0.0],[-8.37,0.0],[-8.27,0.0],[-8.15,0.0],[-7.99,0.0],[-7.75,0.0],[-7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,15.53],[0.0,9.41],[0.0,8.87],[0.0,8.63],[0.0,8.48],[0.0,8.37],[0.0,8.27],[0.0,8.15],[0.0,7.99],[0.0,7.75],[0.0,7.15],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.53,0.0],[9.41,0.0],[8.87,0.0],[8.63,0.0],[8.48,0.0],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-15.53],[0.0,-9.41],[0.0,-8.87],[0.0,-8.63],[0.0,-8.48],[0.0,-8.37],[0.0,-8.27],[0.0,-8.15],[0.0,-7.99],[0.0,-7.75],[0.0,-7.15],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[7.15,0.0],[7.75,0.0],[7.99,0.0],[8.15,0.0],[8.27,0.0],[8.37,0.0],[8.48,0.0],[8.63,0.0],[8.87,0.0],[9.41,0.0],[15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.15],[0.0,-7.75],[0.0,-7.99],[0.0,-8.15],[0.0,-8.27],[0.0,-8.37],[0.0,-8.48],[0.0,-8.63],[0.0,-8.87],[0.0,-9.41],[0.0,-15.53],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.15,0.0],[-7.75,0.0],[-7.99,0.0],[-8.15,0.0],[-8.27,0.0],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,7.15],[0.0,7.75],[0.0,7.99],[0.0,8.15],[0.0,8.27],[0.0,8.37],[0.0,8.48],[0.0,8.63],[0.0,8.87],[0.0,9.41],[0.0,15.53],[0.0,0.0],[0.0,0.0]],"v":[[100.0,450.0],[124.97,450.0],[149.97,450.0],[174.99,450.0],[199.99,450.0],[224.99,450.0],[250.0,450.0],[275.01,450.0],[300.01,450.0],[325.01,450.0],[350.03,450.0],[375.03,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.03],[400.0,400.03],[400.0,375.01],[400.0,350.01],[400.0,325.01],[400.0,300.0],[400.0,274.99],[400.0,249.99],[400.0,224.99],[400.0,199.97],[400.0,174.97],[400.0,150.0],[400.0,150.0],[400.0,150.0],[375.03,150.0],[350.03,150.0],[325.01,150.0],[300.01,150.0],[275.01,150.0],[250.0,150.0],[224.99,150.0],[199.99,150.0],[174.99,150.0],[149.97,150.0],[124.97,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,174.97],[100.0,199.97],[100.0,224.99],[100.0,249.99],[100.0,274.99],[100.0,300.0],[100.0,325.01],[100.0,350.01],[100.0,375.01],[100.0,400.03],[100.0,425.03],[100.0,450.0],[100.0,450.0]],"c":true}]},{"t":24,"s":[{"i":[[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.63,4.91],[-7.03,2.96],[-6.62,2.79],[-6.45,2.72],[-6.35,2.68],[-6.28,2.65],[-6.23,2.63],[-6.19,2.61],[-6.14,2.59],[-6.1,2.57],[-6.04,2.55],[-5.97,2.52],[-5.88,2.48],[-5.69,2.4],[-5.26,2.22],[0.0,0.0],[11.63,4.91],[7.03,2.96],[6.62,2.79],[6.45,2.72],[6.35,2.68],[6.28,2.65],[6.23,2.63],[6.19,2.61],[6.14,2.59],[6.1,2.57],[6.04,2.55],[5.97,2.52],[5.88,2.48],[5.69,2.4],[5.26,2.22],[0.0,0.0],[0.0,-12.55],[0.0,-7.6],[0.0,-6.68],[0.0,0.0],[-11.42,-2.85],[-6.95,-1.74],[-6.52,-1.63],[-6.31,-1.58],[-6.13,-1.53],[-5.9,-1.48],[-5.44,-1.36],[0.0,0.0],[11.42,-2.85],[6.95,-1.74],[6.52,-1.63],[6.31,-1.58],[6.13,-1.53],[5.9,-1.48],[5.44,-1.36]],"o":[[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.26,-2.22],[5.69,-2.4],[5.88,-2.48],[5.97,-2.52],[6.04,-2.55],[6.1,-2.57],[6.14,-2.59],[6.19,-2.61],[6.23,-2.63],[6.28,-2.65],[6.35,-2.68],[6.45,-2.72],[6.62,-2.79],[7.03,-2.96],[11.63,-4.91],[0.0,0.0],[-5.26,-2.22],[-5.69,-2.4],[-5.88,-2.48],[-5.97,-2.52],[-6.04,-2.55],[-6.1,-2.57],[-6.14,-2.59],[-6.19,-2.61],[-6.23,-2.63],[-6.28,-2.65],[-6.35,-2.68],[-6.45,-2.72],[-6.62,-2.79],[-7.03,-2.96],[-11.63,-4.91],[0.0,0.0],[0.0,6.68],[0.0,7.6],[0.0,12.55],[0.0,0.0],[5.44,1.36],[5.9,1.48],[6.13,1.53],[6.31,1.58],[6.52,1.63],[6.95,1.74],[11.42,2.85],[0.0,0.0],[-5.44,1.36],[-5.9,1.48],[-6.13,1.53],[-6.31,1.58],[-6.52,1.63],[-6.95,1.74],[-11.42,2.85]],"v":[[125.0,337.5],[125.0,359.37],[125.0,381.25],[125.0,403.13],[125.0,425.0],[143.49,417.2],[162.02,409.38],[180.53,401.57],[199.05,393.75],[217.57,385.94],[236.08,378.13],[254.61,370.31],[273.12,362.5],[291.64,354.69],[310.17,346.87],[328.68,339.06],[347.2,331.25],[365.72,323.43],[384.23,315.62],[402.76,307.8],[421.25,300.0],[402.76,292.2],[384.23,284.38],[365.72,276.57],[347.2,268.75],[328.68,260.94],[310.17,253.13],[291.64,245.31],[273.12,237.5],[254.61,229.69],[236.08,221.87],[217.57,214.06],[199.05,206.25],[180.53,198.43],[162.02,190.62],[143.49,182.8],[125.0,175.0],[125.0,196.87],[125.0,218.75],[125.0,240.63],[125.0,262.5],[143.74,267.19],[162.5,271.87],[181.25,276.56],[200.0,281.25],[218.75,285.94],[237.5,290.63],[256.26,295.31],[275.0,300.0],[256.26,304.69],[237.5,309.37],[218.75,314.06],[200.0,318.75],[181.25,323.44],[162.5,328.13],[143.74,332.81]],"c":true}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"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},{"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"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 39b915e..5754597 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -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 chatsChanged = ValueNotifier(0); void _bump() => chatsChanged.value = chatsChanged.value + 1; + static const Set _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 _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 existing = const {}; Map? existingRow; if (preloadedExisting != null) { @@ -1260,6 +1298,7 @@ class ChatsModule { final rows = >[]; for (final c in chats.whereType()) { final map = c.cast(); + 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.from(chats.first as Map); + final info = Map.from(chats.first as Map); + ChatMembersStore.instance.applyChatPayload(info); + return info; } Future> 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(), 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}'); diff --git a/lib/backend/modules/media_send.dart b/lib/backend/modules/media_send.dart deleted file mode 100644 index 56e5d76..0000000 --- a/lib/backend/modules/media_send.dart +++ /dev/null @@ -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 _events = - StreamController.broadcast(); - - static const int _historyLimit = 60; - static const int _photoConcurrency = 3; - static const int _photoAttempts = 3; - - final Map>> _progress = {}; - final Map> _pending = {}; - final Map _completed = {}; - final Set _failed = {}; - - Stream get events => _events.stream; - - ValueListenable>? progressFor(String tempId) => - _progress[tempId]; - - List pendingFor(int chatId) => - List.unmodifiable(_pending[chatId] ?? const []); - - CachedMessage? completedFor(String tempId) => _completed[tempId]; - - bool didFail(String tempId) => _failed.contains(tempId); - - Future 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(), - caption: caption.isEmpty ? null : caption, - scheduledTime: scheduledTime, - ); - }, - ); - } - - Future 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 _run({ - required int accountId, - required int chatId, - required String tempId, - required int slots, - required Future?> Function( - ValueNotifier> progress, - ) - upload, - CachedMessage? placeholder, - int? scheduledTime, - }) async { - final scheduled = scheduledTime != null; - final progress = ValueNotifier>( - List.filled(slots < 1 ? 1 : slots, 0), - ); - _progress[tempId] = progress; - if (placeholder != null) { - (_pending[chatId] ??= []).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> _uploadPhotos( - List<({File file, GalleryItem? item})> jobs, - ValueNotifier> progress, - ) async { - final tokens = List.filled(jobs.length, null); - var nextIndex = 0; - var failed = false; - - Future 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 _uploadOnePhoto( - ({File file, GalleryItem? item}) job, - int index, - ValueNotifier> 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> progress, - int index, - double value, - ) { - final next = List.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.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, - ); - } -} diff --git a/lib/backend/modules/upload_manager.dart b/lib/backend/modules/upload_manager.dart deleted file mode 100644 index 1c0c9c4..0000000 --- a/lib/backend/modules/upload_manager.dart +++ /dev/null @@ -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? _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 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 cancel() async { - await _sub?.cancel(); - _sub = null; - await UploadNotificationService.stop(); - } -} diff --git a/lib/backend/modules/upload_notification_service.dart b/lib/backend/modules/upload_notification_service.dart index 180370f..c65989b 100644 --- a/lib/backend/modules/upload_notification_service.dart +++ b/lib/backend/modules/upload_notification_service.dart @@ -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 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 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 _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 {}); + } + + 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 = { + '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( + context, + AppLocalizations, + ); + if (scoped != null) return scoped; + } + final code = WidgetsBinding.instance.platformDispatcher.locale.languageCode; + return lookupAppLocalizations(Locale(code == 'ru' ? 'ru' : 'en')); + } + + static Future _invoke(String method, Map args) async { try { - await _ch.invokeMethod('update', { - 'filename': filename, - 'progress': progressPercent, - 'speed': speedBps, - }); + await _channel.invokeMethod(method, args); } catch (_) {} - } - - static Future stop() async { - if (!Platform.isAndroid) return; - try { await _ch.invokeMethod('stop'); } catch (_) {} } } diff --git a/lib/backend/modules/upload_service.dart b/lib/backend/modules/upload_service.dart new file mode 100644 index 0000000..149a427 --- /dev/null +++ b/lib/backend/modules/upload_service.dart @@ -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.filled(slots < 1 ? 1 : slots, 0), + _total = List.filled(slots < 1 ? 1 : slots, 0), + progress = ValueNotifier>( + List.filled(slots < 1 ? 1 : slots, 0), + ), + bytes = ValueNotifier(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> progress; + final ValueNotifier bytes; + + int? resultFileId; + String? resultFileToken; + + final int _slots; + final List _sent; + final List _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.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 _events = + StreamController.broadcast(); + + final Map _jobs = {}; + final Map _completed = {}; + final Set _failed = {}; + + int _tempIdCounter = 0; + + Stream get events => _events.stream; + + String newTempId() => + 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; + + UploadJob? job(String tempId) => _jobs[tempId]; + + ValueListenable>? 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 pendingFor(int chatId) { + final pending = []; + for (final job in _jobs.values) { + final placeholder = job.placeholder; + if (job.chatId == chatId && placeholder != null) pending.add(placeholder); + } + return List.unmodifiable(pending); + } + + CachedMessage? completedFor(String tempId) => _completed[tempId]; + + bool didFail(String tempId) => _failed.contains(tempId); + + Future 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(), + caption: caption.isEmpty ? null : caption, + scheduledTime: scheduledTime, + ); + if (sent == null) return null; + return CachedMessage.fromPushPayload(accountId, chatId, sent); + }, + ); + } + + Future 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 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 _uploadFile({ + required int chatId, + required UploadJob job, + required File source, + required String filename, + required int size, + int? scheduledTime, + }) async { + final result = Completer(); + late final StreamSubscription 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 _run( + UploadJob job, + Future 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> _uploadPhotos( + List<({File file, GalleryItem? item})> jobs, + UploadJob job, + ) async { + final tokens = List.filled(jobs.length, null); + var nextIndex = 0; + var failed = false; + + Future 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 _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.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, + ); + } +} diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart index 640600f..140ef62 100644 --- a/lib/core/cache/info_cache.dart +++ b/lib/core/cache/info_cache.dart @@ -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.from(first)); } } diff --git a/lib/core/storage/chat_activity_store.dart b/lib/core/storage/chat_activity_store.dart index 975e9c1..dd5d193 100644 --- a/lib/core/storage/chat_activity_store.dart +++ b/lib/core/storage/chat_activity_store.dart @@ -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 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> _users = {}; final Map> _timers = {}; - final Map> _notifiers = {}; + final Map> _notifiers = {}; - ValueListenable listenable(int chatId) => + ValueListenable listenable(int chatId) => _notifiers.putIfAbsent( chatId, - () => ValueNotifier(_current(chatId)), + () => ValueNotifier(_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, () => {}); @@ -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 = []; + users.forEach((userId, activity) { + if (activity == leading) ids.add(userId); + }); + return ChatActivitySnapshot(activity: leading, userIds: ids); } void _sync(int chatId) { diff --git a/lib/core/storage/chat_members_store.dart b/lib/core/storage/chat_members_store.dart new file mode 100644 index 0000000..f4d569a --- /dev/null +++ b/lib/core/storage/chat_members_store.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; + +class ChatMembersStore { + ChatMembersStore._(); + + static final ChatMembersStore instance = ChatMembersStore._(); + + final Map _counts = {}; + final Map> _notifiers = {}; + + ValueListenable listenable(int chatId) => _notifiers.putIfAbsent( + chatId, + () => ValueNotifier(_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; + } + } +} diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index 2a6fd9e..bdd7e6b 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -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 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: () { diff --git a/lib/frontend/screens/calls/call_participants_sheet.dart b/lib/frontend/screens/calls/call_participants_sheet.dart index 1a0fda8..bfdccb3 100644 --- a/lib/frontend/screens/calls/call_participants_sheet.dart +++ b/lib/frontend/screens/calls/call_participants_sheet.dart @@ -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, ), diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 2b05243..d21653a 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -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 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 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(), ), ), ), diff --git a/lib/frontend/screens/chats/chat/typing_label.dart b/lib/frontend/screens/chats/chat/typing_label.dart new file mode 100644 index 0000000..5bfca1a --- /dev/null +++ b/lib/frontend/screens/chats/chat/typing_label.dart @@ -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 = []; + 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; +} diff --git a/lib/frontend/screens/chats/chat/view/chat_list_tile.dart b/lib/frontend/screens/chats/chat/view/chat_list_tile.dart index 41cad1b..f72c1c8 100644 --- a/lib/frontend/screens/chats/chat/view/chat_list_tile.dart +++ b/lib/frontend/screens/chats/chat/view/chat_list_tile.dart @@ -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 createState() => _ActivitySubtitleState(); } class _ActivitySubtitleState extends State { - ChatActivity _lastActivity = ChatActivity.typing; + String _lastLabel = ChatActivity.typing.label.toLowerCase(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return ValueListenableBuilder( + return ValueListenableBuilder( 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, diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index e2a1d6f..11fab2e 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -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( valueListenable: hasText, - builder: (context, hasText, _) => - ValueListenableBuilder( - valueListenable: voiceRec.locked, - builder: (context, locked, _) => - ValueListenableBuilder( - valueListenable: voiceRec.isRecording, - builder: (context, recording, _) => - ValueListenableBuilder( - 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( + valueListenable: voiceRec.locked, + builder: (context, locked, _) => + ValueListenableBuilder( + valueListenable: voiceRec.isRecording, + builder: (context, recording, _) => + ValueListenableBuilder( + 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( + 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( valueListenable: voiceRec.amplitude, builder: (context, amp, _) => TweenAnimationBuilder( - 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( 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( diff --git a/lib/frontend/screens/chats/chat_encryption_screen.dart b/lib/frontend/screens/chats/chat_encryption_screen.dart index 76e4c97..0eed735 100644 --- a/lib/frontend/screens/chats/chat_encryption_screen.dart +++ b/lib/frontend/screens/chats/chat_encryption_screen.dart @@ -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 { 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: () => diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 40495e3..731c326 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -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 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 @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 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 } Future _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 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 } 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 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 ); 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 if (!_isBot) ( icon: Icons.call, + slashedIcon: null, + slashed: false, label: l10n.contactProfileActionCall, onTap: _confirmAndStartCall, ), @@ -1371,7 +1407,14 @@ class _ChatInfoScreenState extends State 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 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 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, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 9b6c0b4..3c050a5 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -3108,6 +3108,7 @@ class _ChatListScreenState extends State Expanded( child: ActivitySubtitle( chatId: int.tryParse(id) ?? 0, + group: chatType != 'DIALOG', child: messageLine, ), ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 5dfdf26..9986dfa 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 final ValueNotifier _uploadStatus = ValueNotifier( const UploadStatus(), ); - StreamSubscription? _uploadSub; + String? _uploadStatusJobId; + ValueListenable? _uploadStatusBytes; StreamSubscription? _pushSub; StreamSubscription? _messageEventSub; StreamSubscription>? _commentsInfoSub; @@ -350,10 +351,10 @@ class _ChatScreenState extends State formatElapsed: formatVoiceElapsed, ); - StreamSubscription? _mediaSendSub; + StreamSubscription? _uploadEventSub; ValueListenable>? _photoProgressFor(CachedMessage m) => - _photoUploadProgress[m.id] ?? MediaSendService.instance.progressFor(m.id); + _photoUploadProgress[m.id] ?? UploadService.instance.progressFor(m.id); ValueNotifier?> _reactionNotifierFor(CachedMessage m) { final existing = _reactionNotifiers[m.id]; @@ -498,7 +499,6 @@ class _ChatScreenState extends State int _otherStatus = 0; int? _otherSeenTime; - int? _participantsCount; final ValueNotifier _replyTo = ValueNotifier(null); final ValueNotifier> _pendingForwards = ValueNotifier( @@ -661,7 +661,8 @@ class _ChatScreenState extends State .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 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 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 _loadPeerKind() async { @@ -2009,7 +2008,7 @@ class _ChatScreenState extends State 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 _scheduledCount.dispose(); _showAttachmentPanel.removeListener(_onAttachPanelToggle); _showAttachmentPanel.dispose(); - _uploadSub?.cancel(); + _detachUploadStatus(); _pushSub?.cancel(); _messageEventSub?.cancel(); _commentsInfoSub?.cancel(); @@ -2060,6 +2059,9 @@ class _ChatScreenState extends State 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 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 _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 _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 userId, chatActivityFromType(payload['type']), ); + unawaited(_ensureTypingName(userId)); + } + + Future _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 right: -3, child: ValueListenableBuilder( valueListenable: _newMessageCount, - builder: (context, count, _) => - count <= 0 ? const SizedBox.shrink() : _unreadBadge(cs, count), + builder: (context, count, _) => count <= 0 + ? const SizedBox.shrink() + : AnimatedValueSwap( + value: count > 99 ? 100 : count, + alignment: Alignment.centerRight, + builder: (context, value) => _unreadBadge(cs, value), + ), ), ), ], @@ -6021,7 +6048,7 @@ class _ChatScreenState extends State } } - 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 _bumpMessages(); Haptics.send(); _scrollToBottom(); - return tempId; + return msg; } void _updateFileMessageStatus( @@ -6073,7 +6100,7 @@ class _ChatScreenState extends State name: entry.filename, size: entry.size, ), - ); + ).id; _showAttachmentPanel.value = false; try { final realId = await messagesModule.sendFileMessage( @@ -6092,7 +6119,9 @@ class _ChatScreenState extends State } Future _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 _scrollToBottom(); unawaited( - MediaSendService.instance.sendPhotos( + UploadService.instance.sendPhotos( accountId: _myId, chatId: widget.chatId, tempId: tempId, @@ -6274,7 +6303,7 @@ class _ChatScreenState extends State } unawaited( - MediaSendService.instance.sendVideo( + UploadService.instance.sendVideo( accountId: _myId, chatId: widget.chatId, tempId: tempId, @@ -6286,9 +6315,10 @@ class _ChatScreenState extends State ); } - 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 _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 showCustomNotification(context, 'Загрузка…'); unawaited( - MediaSendService.instance.sendPhotos( + UploadService.instance.sendPhotos( accountId: _myId, chatId: widget.chatId, tempId: _nextTempId(), @@ -6634,156 +6698,28 @@ class _ChatScreenState extends State required int size, int? scheduledTime, }) async { - final file = (name: filename, size: size); - final done = Completer(); + 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>? fileProgress; - if (tempId != null) { - fileProgress = ValueNotifier>(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; } } diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index c921cea..3a797a7 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -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 bool _isUploading = false; final ValueNotifier _uploadProgress = ValueNotifier(0); bool _animateNewCard = false; + StreamSubscription? _uploadEventSub; + UploadJob? _uploadJob; @override void initState() { @@ -60,47 +62,73 @@ class _CloudStorageScreenState extends State _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 _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 ); if (!mounted) return; setState(() => _files = files.reversed.toList()); + _syncUploadJob(); } void _prependFile(CloudFile file) { @@ -268,13 +297,17 @@ class _CloudStorageScreenState extends State _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() { diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index 1256a46..1419864 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -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), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 44ebb30..39e5726 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -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 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, diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart index a8d8b4b..d0e9b55 100644 --- a/lib/frontend/screens/profile/web_qr_scan_screen.dart +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -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 { 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, ); }, diff --git a/lib/frontend/widgets/animated_slash_icon.dart b/lib/frontend/widgets/animated_slash_icon.dart new file mode 100644 index 0000000..3a96525 --- /dev/null +++ b/lib/frontend/widgets/animated_slash_icon.dart @@ -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 createState() => _AnimatedSlashIconState(); +} + +class _AnimatedSlashIconState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + value: widget.slashed ? 1 : 0, + ); + + late final Animation _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 { + 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; +} diff --git a/lib/frontend/widgets/animated_text_swap.dart b/lib/frontend/widgets/animated_text_swap.dart index 00630ec..cd6de9d 100644 --- a/lib/frontend/widgets/animated_text_swap.dart +++ b/lib/frontend/widgets/animated_text_swap.dart @@ -69,24 +69,117 @@ class _AnimatedTextSwapState extends State 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 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> createState() => _AnimatedValueSwapState(); +} + +class _AnimatedValueSwapState extends State> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + value: 1, + ); + + late final Animation _t = CurvedAnimation( + parent: _controller, + curve: widget.curve, + ); + + late T _current = widget.value; + T? _previous; + + @override + void didUpdateWidget(AnimatedValueSwap 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, - ), - ), - ], ); }, ); diff --git a/lib/frontend/widgets/composer_morph_icon.dart b/lib/frontend/widgets/composer_morph_icon.dart new file mode 100644 index 0000000..4a23c61 --- /dev/null +++ b/lib/frontend/widgets/composer_morph_icon.dart @@ -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 createState() => _ComposerMorphIconState(); +} + +class _ComposerMorphIconState extends State + 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)], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/glossy_pill.dart b/lib/frontend/widgets/glossy_pill.dart index 9a6dc17..6c687a0 100644 --- a/lib/frontend/widgets/glossy_pill.dart +++ b/lib/frontend/widgets/glossy_pill.dart @@ -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( diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 0c434dd..9d28ebc 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -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, ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 864f655..0e19c20 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 44c1d69..6afec2f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 78e2cdd..c691f1d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index bf26399..89d6c85 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 МБ/с'; + } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 558e5d6..64937f6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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" + } + } + } } diff --git a/test/animated_slash_icon_test.dart b/test/animated_slash_icon_test.dart new file mode 100644 index 0000000..8b23618 --- /dev/null +++ b/test/animated_slash_icon_test.dart @@ -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(find.byType(Icon)); + expect(icon.size, 14); + expect(icon.color, const Color(0xFF00FF00)); + }); +} diff --git a/test/animated_value_swap_test.dart b/test/animated_value_swap_test.dart new file mode 100644 index 0000000..c6c66dc --- /dev/null +++ b/test/animated_value_swap_test.dart @@ -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( + value: value, + builder: (context, v) => Text('$v'), + ), + ), + ), +); + +final Finder _slidingParts = find.descendant( + of: find.byType(AnimatedValueSwap), + 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( + find + .ancestor( + of: find.text('1'), + matching: find.byType(FractionalTranslation), + ) + .first, + ); + final incoming = tester.widget( + 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); + }); +} diff --git a/test/chat_members_and_typing_test.dart b/test/chat_members_and_typing_test.dart new file mode 100644 index 0000000..a14797b --- /dev/null +++ b/test/chat_members_and_typing_test.dart @@ -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 = []; + 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 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]); + }); + }); +} diff --git a/test/composer_forward_preview_test.dart b/test/composer_forward_preview_test.dart index fb66173..b435429 100644 --- a/test/composer_forward_preview_test.dart +++ b/test/composer_forward_preview_test.dart @@ -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); diff --git a/test/composer_morph_icon_test.dart b/test/composer_morph_icon_test.dart new file mode 100644 index 0000000..c7ab26a --- /dev/null +++ b/test/composer_morph_icon_test.dart @@ -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> _paths(Map doc) { + final layer = (doc['layers'] as List).first as Map; + final group = (layer['shapes'] as List).first as Map; + return (group['it'] as List) + .cast>() + .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; + 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; + 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; + final layer = (doc['layers'] as List).first as Map; + final transform = layer['ks'] as Map; + for (final key in ['r', 's', 'p']) { + final prop = transform[key] as Map; + if (prop['a'] != 1) continue; + final frames = (prop['k'] as List).cast>(); + 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); + }); + }); +} diff --git a/tool/make_morph_icons.py b/tool/make_morph_icons.py new file mode 100644 index 0000000..bcf2ad4 --- /dev/null +++ b/tool/make_morph_icons.py @@ -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()