diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 0000000..3017132 --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,24 @@ +name: Setup Rust +description: > + Install the Rust toolchain with platform targets and a build cache, so + cargokit can compile the native libraries during the Flutter build. kolibri + comes from pub.dev and builds inside the pub cache, so only the in-repo crate + is listed here; the shared cargo registry and git checkouts are cached anyway. + +inputs: + targets: + description: Comma-separated rustup targets for the platform being built. + required: true + +runs: + using: composite + steps: + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.targets }} + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: native/komet_crypto/rust diff --git a/.github/workflows/build-android-fcm.yml b/.github/workflows/build-android-fcm.yml index fe613a2..3fef1cb 100644 --- a/.github/workflows/build-android-fcm.yml +++ b/.github/workflows/build-android-fcm.yml @@ -41,6 +41,11 @@ jobs: channel: 'stable' cache: true + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - name: Get dependencies run: flutter pub get diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index c15b65f..390a215 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -41,6 +41,11 @@ jobs: channel: 'stable' cache: true + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - name: Get dependencies run: flutter pub get diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index b216815..17c3560 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -37,6 +37,11 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + - name: Get dependencies run: flutter pub get @@ -74,6 +79,9 @@ jobs: get-task-allow + application-identifierru.komet.app + keychain-access-groups + ru.komet.app PLIST diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index da87ce4..d47216f 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -42,6 +42,11 @@ jobs: sudo apt-get update sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev libmpv-dev mpv libopus-dev libogg-dev + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: x86_64-unknown-linux-gnu + - name: Get dependencies run: flutter pub get diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 760e1e3..0c07ea5 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -37,6 +37,11 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + - name: Install create-dmg run: brew install create-dmg diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 14f93d8..ede96ba 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -37,6 +37,11 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: x86_64-pc-windows-msvc + - name: Get dependencies run: flutter pub get diff --git a/.github/workflows/flutter-dev.yml b/.github/workflows/flutter-dev.yml index 6545875..92e7052 100644 --- a/.github/workflows/flutter-dev.yml +++ b/.github/workflows/flutter-dev.yml @@ -29,6 +29,11 @@ jobs: channel: 'stable' cache: true + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - name: Install dependencies run: flutter pub get @@ -66,3 +71,32 @@ jobs: - name: Build Android APK run: flutter build apk --release --flavor komet + + build-ios: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.3' + channel: 'stable' + cache: true + + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + + - name: Install dependencies + run: flutter pub get + + - name: Build iOS (no codesign) + run: | + flutter config --no-enable-swift-package-manager + flutter build ios --release --no-codesign diff --git a/.github/workflows/flutter-main.yml b/.github/workflows/flutter-main.yml index a863adb..3e065f9 100644 --- a/.github/workflows/flutter-main.yml +++ b/.github/workflows/flutter-main.yml @@ -28,6 +28,11 @@ jobs: channel: 'stable' cache: true + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - name: Install dependencies run: flutter pub get @@ -66,7 +71,7 @@ jobs: - name: Build Android APK run: flutter build apk --release --flavor komet - web-linux: + linux: runs-on: ubuntu-latest steps: - name: Checkout code @@ -85,12 +90,14 @@ jobs: sudo apt-get update sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: x86_64-unknown-linux-gnu + - name: Install dependencies run: flutter pub get - - name: Build Web - run: flutter build web --release - - name: Build Linux run: flutter build linux --release @@ -108,6 +115,11 @@ jobs: flutter-version: '3.44.3' channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: x86_64-pc-windows-msvc + - name: Install dependencies run: flutter pub get @@ -128,6 +140,11 @@ jobs: flutter-version: '3.44.3' channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin,x86_64-apple-darwin + - name: Install dependencies run: flutter pub get diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index c646b02..573b309 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -42,6 +42,11 @@ jobs: channel: 'stable' cache: true + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - name: Get dependencies run: flutter pub get @@ -110,6 +115,10 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: x86_64-pc-windows-msvc - name: Get dependencies run: flutter pub get - name: Build Windows @@ -142,6 +151,10 @@ jobs: run: | sudo apt-get update sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev libmpv-dev mpv libopus-dev libogg-dev + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: x86_64-unknown-linux-gnu - name: Get dependencies run: flutter pub get - name: Build Linux @@ -169,6 +182,10 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin - name: Get dependencies run: flutter pub get - name: Fix opus/ogg libs for macOS @@ -199,6 +216,10 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios - name: Get dependencies run: flutter pub get - name: Build iOS (no codesign) @@ -225,6 +246,9 @@ jobs: platform-application get-task-allow + application-identifierru.komet.app + keychain-access-groups + ru.komet.app com.apple.private.security.no-container diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index 1f9f247..d6ea1b4 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -37,6 +37,11 @@ jobs: channel: 'stable' cache: true + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - name: Get dependencies run: flutter pub get @@ -105,6 +110,10 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: x86_64-pc-windows-msvc - name: Get dependencies run: flutter pub get - name: Build Windows @@ -137,6 +146,10 @@ jobs: run: | sudo apt-get update sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: x86_64-unknown-linux-gnu - name: Get dependencies run: flutter pub get - name: Build Linux @@ -164,6 +177,10 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin - name: Get dependencies run: flutter pub get - name: Build macOS @@ -192,6 +209,10 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios - name: Get dependencies run: flutter pub get - name: Build iOS (no codesign) @@ -212,6 +233,9 @@ jobs: platform-application get-task-allow + application-identifierru.komet.app + keychain-access-groups + ru.komet.app com.apple.private.security.no-container diff --git a/.gitignore b/.gitignore index eba31df..d108e96 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,9 @@ maxmint/ maxtun/ turnprobe/ test/live_server_probe_test.dart + +# local kolibri development: point the plugin at third_party/kolibri and the +# Rust core at third_party/kolibri/kolibri-net instead of the published ones +pubspec_overrides.yaml +.cargo/ +third_party/kolibri/ diff --git a/AGENTS.md b/AGENTS.md index c894244..ff1e7da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,6 @@ Лучше качество чем количество Когда при исправления какой то ошибки/добавление новой возникает ситуация 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 +Если делаешь кнопку, где иконка переключается перечёркнутая/неперечёркнутая (вспышка, микрофон, звук, уведомления) — она должна анимироваться lottie-иконкой, а не подменяться мгновенно: добавь спеку в SLASH_SPECS в tool/make_morph_icons.py (fill=1.0, если кнопка рисует Icon(..., fill: 1)), прогони python3 tool/make_morph_icons.py и выводи через LottieSlashIcon. Ассеты в assets/lottie/ руками не правь — только через генератор. diff --git a/CLAUDE.md b/CLAUDE.md index fa0a52d..4be5eb0 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 | @@ -68,6 +70,21 @@ Incoming packets: transport → dispatcher → backend module → state → UI r - **Use `showCustomNotification(context, 'text')`** for all user-facing notifications — never use SnackBars. - When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**. - Quality over quantity. +- **Never leave real data in test files**, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead. +- **A button whose icon toggles between plain and slashed** (flash on/off, mic muted, sound, notifications) **must animate with a Lottie icon** — never swap two `Icon`s instantly. See *Animated icons* below. + +## Animated icons + +Everything in `assets/lottie/` is generated from the Material Symbols font by `tool/make_morph_icons.py` (stdlib-only Python, no deps). Never hand-edit the JSON — add a spec and re-run `python3 tool/make_morph_icons.py`. + +| Kind | Spec list | Widget | +|------|-----------|--------| +| Morph between two glyphs | `SPECS` | `ComposerMorphIcon` | +| Plain ↔ slashed toggle | `SLASH_SPECS` | `LottieSlashIcon` | + +A slash spec takes the plain and slashed codepoints; the generator lays both glyphs out as static layers and sweeps a mask across the diagonal, so the slash looks drawn on top of the icon. Pass `fill=1.0` when the button renders `Icon(..., fill: 1)` — contours are then taken from the `FILL=1` instance of the variable font. + +`LottieSlashIcon` plays the asset forward when `slashed` turns true and backward when it turns false, so a single asset covers both directions. The older `AnimatedSlashIcon` (clip wipe over two glyphs) stays where it is already used; new buttons use the Lottie one. ## Localization diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..bf8d421 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6c621f0..0501773 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,10 @@ + + + + @@ -23,6 +27,7 @@ + @@ -64,6 +69,15 @@ + + + + + + + + + @@ -71,6 +85,23 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt b/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt index 8700101..13a1ac5 100644 --- a/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt +++ b/android/app/src/main/kotlin/ru/komet/app/CallForegroundService.kt @@ -8,7 +8,9 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person +import androidx.core.app.ServiceCompat object CallState { @Volatile @@ -20,8 +22,34 @@ class CallForegroundService : Service() { companion object { const val ACTION_START = "ru.komet.app.CALL_ONGOING_START" const val ACTION_STOP = "ru.komet.app.CALL_ONGOING_STOP" + const val ACTION_SCREEN_SHARE = "ru.komet.app.CALL_SCREEN_SHARE" + const val EXTRA_SCREEN_SHARE = "screenShare" const val ONGOING_ID = 424243 + @Volatile + var screenShare = false + + @Volatile + private var running: CallForegroundService? = null + + fun setScreenShare(ctx: Context, enabled: Boolean, caller: String) { + screenShare = enabled + val intent = Intent(ctx, CallForegroundService::class.java).apply { + action = ACTION_SCREEN_SHARE + putExtra(CallConst.EXTRA_CALLER, caller) + putExtra(EXTRA_SCREEN_SHARE, enabled) + } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(intent) + } else { + ctx.startService(intent) + } + } catch (e: Exception) { + Log.w("KometFcm", "screen share FGS update failed: ${e.message}") + } + } + fun start(ctx: Context, caller: String) { CallState.inCall = true val intent = Intent(ctx, CallForegroundService::class.java).apply { @@ -41,6 +69,13 @@ class CallForegroundService : Service() { fun stop(ctx: Context) { CallState.inCall = false + screenShare = false + val service = running + if (service != null) { + service.shutdown() + return + } + NotificationManagerCompat.from(ctx).cancel(ONGOING_ID) try { ctx.startService( Intent(ctx, CallForegroundService::class.java).apply { @@ -54,17 +89,30 @@ class CallForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null + override fun onCreate() { + super.onCreate() + running = this + } + override fun onDestroy() { + if (running === this) running = null CallState.inCall = false super.onDestroy() } + private fun shutdown() { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_STOP -> { - @Suppress("DEPRECATION") - stopForeground(true) - stopSelf() + ACTION_STOP -> shutdown() + ACTION_SCREEN_SHARE -> { + screenShare = intent.getBooleanExtra(EXTRA_SCREEN_SHARE, false) + val caller = intent.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок" + CallNotifier.ensureChannel(this) + startAsForeground(caller) } else -> { val caller = intent?.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок" @@ -103,15 +151,16 @@ class CallForegroundService : Service() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - ONGOING_ID, notif, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) + var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + if (screenShare) { + types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + } + startForeground(ONGOING_ID, notif, types) } else { startForeground(ONGOING_ID, notif) } } catch (e: Exception) { - Log.w("KometFcm", "startForeground(mic) failed: ${e.message}") + Log.w("KometFcm", "startForeground failed: ${e.message}") stopSelf() } } diff --git a/android/app/src/main/kotlin/ru/komet/app/ChatNotifications.kt b/android/app/src/main/kotlin/ru/komet/app/ChatNotifications.kt new file mode 100644 index 0000000..df3f5ac --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/ChatNotifications.kt @@ -0,0 +1,22 @@ +package ru.komet.app + +import android.content.Intent +import io.flutter.plugin.common.EventChannel + +object ChatNotifications { + const val EXTRA_CHAT = "komet_chat" + + @Volatile + var activeChatId: Long = 0L + + @Volatile + var sink: EventChannel.EventSink? = null + + fun isDisplayed(chatId: Long): Boolean = + AppState.resumed && activeChatId == chatId + + fun chatIdFrom(intent: Intent?): Long { + val id = intent?.getLongExtra(EXTRA_CHAT, 0L) ?: 0L + return if (id > 0L) id else 0L + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt b/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt new file mode 100644 index 0000000..5638a48 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/FkmChannel.kt @@ -0,0 +1,173 @@ +package ru.komet.app + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors + +object FkmChannel { + private const val NAME = "ru.komet.app/fkm" + const val NOTIF_PERMS_REQUEST = 7713 + + private val main = Handler(Looper.getMainLooper()) + private val worker = Executors.newSingleThreadExecutor() + + private var channel: MethodChannel? = null + private var permResult: MethodChannel.Result? = null + + fun attach(engine: FlutterEngine, activity: Activity) { + val ctx = activity.applicationContext + FkmState.restore(ctx) + val ch = MethodChannel(engine.dartExecutor.binaryMessenger, NAME) + channel = ch + ch.setMethodCallHandler { call, result -> + when (call.method) { + "isEnabled" -> result.success(FkmState.enabled) + + "setEnabled" -> { + val enabled = call.argument("enabled") ?: false + FkmState.applyEnabled(ctx, enabled) + if (enabled) FkmService.start(ctx) else FkmService.stop(ctx) + result.success(null) + } + + "setConnected" -> { + FkmState.connected = call.argument("connected") ?: false + FkmService.refresh(ctx) + result.success(null) + } + + "showMessage" -> result.success(deliver(ctx, call, "showMessage")) + + "showCall" -> result.success(deliver(ctx, call, "showCall")) + + "editMessage" -> result.success( + update(ctx, call, "editMessage") { notifier, data -> + notifier.editMessage(data) + }, + ) + + "removeMessage" -> result.success( + update(ctx, call, "removeMessage") { notifier, data -> + notifier.removeMessage(data) + }, + ) + + "hasNotificationPermission" -> + result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled()) + + "requestNotificationPermission" -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + NotificationManagerCompat.from(ctx).areNotificationsEnabled() + ) { + result.success( + NotificationManagerCompat.from(ctx).areNotificationsEnabled(), + ) + } else { + permResult?.success(false) + permResult = result + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + NOTIF_PERMS_REQUEST, + ) + } + } + + "isIgnoringBatteryOptimizations" -> { + val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + result.success(power.isIgnoringBatteryOptimizations(ctx.packageName)) + } + + "requestIgnoreBatteryOptimizations" -> { + try { + activity.startActivity( + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:${ctx.packageName}"), + ), + ) + } catch (e: Exception) { + Log.w("Fkm", "battery settings failed: ${e.message}") + } + result.success(null) + } + + else -> result.notImplemented() + } + } + } + + // Отрисовка тянет аватарку по сети — только не на главном потоке. + private fun deliver(ctx: Context, call: MethodCall, tag: String): Boolean { + val data = call.argument>("data") ?: return false + worker.execute { + try { + KometNotifier(ctx).handle(data) + FkmState.countDelivered(ctx) + FkmService.refresh(ctx) + } catch (e: Exception) { + Log.w("Fkm", "$tag failed: ${e.message}") + } + } + return true + } + + // Правка и удаление ничего не «доставляют» — счётчик они не трогают. + private fun update( + ctx: Context, + call: MethodCall, + tag: String, + action: (KometNotifier, Map) -> Unit, + ): Boolean { + val data = call.argument>("data") ?: return false + worker.execute { + try { + action(KometNotifier(ctx), data) + } catch (e: Exception) { + Log.w("Fkm", "$tag failed: ${e.message}") + } + } + return true + } + + fun detach() { + channel?.setMethodCallHandler(null) + channel = null + permResult?.success(false) + permResult = null + } + + fun onPermissionResult(grantResults: IntArray) { + val pending = permResult ?: return + permResult = null + pending.success( + grantResults.isNotEmpty() && + grantResults.all { it == PackageManager.PERMISSION_GRANTED }, + ) + } + + fun notifyDisabled() { + main.post { + try { + channel?.invokeMethod("disabled", null) + } catch (e: Exception) { + Log.w("Fkm", "notifyDisabled failed: ${e.message}") + } + } + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/FkmService.kt b/android/app/src/main/kotlin/ru/komet/app/FkmService.kt new file mode 100644 index 0000000..0a5fe09 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/FkmService.kt @@ -0,0 +1,254 @@ +package ru.komet.app + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +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 + +object FkmState { + private const val PREFS = "komet_fkm" + private const val KEY_ENABLED = "enabled" + private const val KEY_DELIVERED = "delivered" + + @Volatile + var enabled = false + private set + + @Volatile + var connected = false + + @Volatile + var delivered = 0 + private set + + private fun prefs(ctx: Context) = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun restore(ctx: Context) { + val p = prefs(ctx) + enabled = p.getBoolean(KEY_ENABLED, false) + delivered = p.getInt(KEY_DELIVERED, 0) + } + + fun applyEnabled(ctx: Context, value: Boolean) { + enabled = value + // Счётчик обнуляем только на выключении, чтобы перезапуск приложения + // не сбрасывал накопленное. + if (!value) { + delivered = 0 + connected = false + } + prefs(ctx).edit() + .putBoolean(KEY_ENABLED, value) + .putInt(KEY_DELIVERED, delivered) + .apply() + } + + fun countDelivered(ctx: Context) { + delivered += 1 + prefs(ctx).edit().putInt(KEY_DELIVERED, delivered).apply() + } +} + +object FkmNotification { + const val CHANNEL_ID = "komet_fkm" + const val NOTIFICATION_ID = 424244 + + fun build(ctx: Context): Notification { + ensureChannel(ctx) + + val status = if (FkmState.connected) { + ctx.getString(R.string.fkm_status_active) + } else { + ctx.getString(R.string.fkm_status_inactive) + } + val title = ctx.getString(R.string.fkm_title) + val text = ctx.getString(R.string.fkm_status_line, status, FkmState.delivered) + + val immutable = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + val open = PendingIntent.getActivity( + ctx, + 0, + Intent(ctx, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + immutable, + ) + val disable = PendingIntent.getBroadcast( + ctx, + 1, + Intent(ctx, FkmDisableReceiver::class.java).apply { + action = FkmDisableReceiver.ACTION_DISABLE + }, + immutable, + ) + + return NotificationCompat.Builder(ctx, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setColor(CallConst.ACCENT) + .setContentTitle(title) + .setContentText(text) + .setStyle( + NotificationCompat.BigTextStyle() + .setBigContentTitle(title) + .bigText("$text\n\n${ctx.getString(R.string.fkm_explain)}"), + ) + .setContentIntent(open) + .addAction(0, ctx.getString(R.string.fkm_disable), disable) + .setOngoing(true) + .setSilent(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .build() + } + + fun ensureChannel(ctx: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = manager(ctx) + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + ctx.getString(R.string.fkm_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = ctx.getString(R.string.fkm_channel_description) + setShowBadge(false) + setSound(null, null) + enableVibration(false) + enableLights(false) + }, + ) + } + + fun manager(ctx: Context): NotificationManager = + ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager +} + +class FkmService : Service() { + + companion object { + fun start(ctx: Context) { + val intent = Intent(ctx, FkmService::class.java) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(intent) + } else { + ctx.startService(intent) + } + } catch (e: Exception) { + Log.w("Fkm", "service start failed: ${e.message}") + } + } + + // Перерисовка уже висящего уведомления, без перезапуска сервиса. + fun refresh(ctx: Context) { + if (!FkmState.enabled) return + try { + FkmNotification.manager(ctx) + .notify(FkmNotification.NOTIFICATION_ID, FkmNotification.build(ctx)) + } catch (e: Exception) { + Log.w("Fkm", "notification refresh failed: ${e.message}") + } + } + + fun stop(ctx: Context) { + try { + ctx.stopService(Intent(ctx, FkmService::class.java)) + } catch (e: Exception) { + Log.w("Fkm", "service stop failed: ${e.message}") + } + } + } + + private var inForeground = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + FkmState.restore(applicationContext) + FkmNotification.ensureChannel(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!FkmState.enabled) { + stopForeground(STOP_FOREGROUND_REMOVE) + inForeground = false + stopSelf() + return START_NOT_STICKY + } + goForeground() + return START_STICKY + } + + override fun onDestroy() { + if (inForeground) { + stopForeground(STOP_FOREGROUND_REMOVE) + inForeground = false + } + super.onDestroy() + } + + private fun goForeground() { + val notification = FkmNotification.build(this) + if (inForeground) { + FkmNotification.manager(this) + .notify(FkmNotification.NOTIFICATION_ID, notification) + return + } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + FkmNotification.NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(FkmNotification.NOTIFICATION_ID, notification) + } + inForeground = true + } catch (e: Exception) { + Log.w("Fkm", "startForeground failed: ${e.message}") + stopSelf() + } + } +} + +class FkmDisableReceiver : BroadcastReceiver() { + companion object { + const val ACTION_DISABLE = "ru.komet.app.FKM_DISABLE" + } + + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action != ACTION_DISABLE) return + val app = ctx.applicationContext + FkmState.applyEnabled(app, false) + FkmService.stop(app) + FkmChannel.notifyDisabled() + } +} + +class FkmBootReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + val app = ctx.applicationContext + FkmState.restore(app) + if (!FkmState.enabled) return + // Движка после ребута нет — уведомление честно скажет «не активно», + // пока приложение не откроют. + FkmState.connected = false + FkmService.start(app) + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt index 96e54c1..0cb9c8f 100644 --- a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt +++ b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt @@ -5,11 +5,13 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.ShortcutInfo import android.graphics.Bitmap import android.graphics.Typeface import android.os.Build import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import android.util.Log import androidx.core.app.NotificationCompat @@ -29,6 +31,12 @@ class KometFcmService : FirebaseMessagingService() { val data = message.data Log.d("KometFcm", "onMessageReceived type=${data["type"]} keys=${data.keys}") if (data.isEmpty()) return + val type = data["type"] + FkmState.restore(applicationContext) + if (FkmState.enabled && type != "InboundCall" && type != "CallFinished") { + Log.d("KometFcm", "message push dropped: FKM handles messages") + return + } KometNotifier(applicationContext).handle(data) } } @@ -50,7 +58,18 @@ class KometNotifier(private val ctx: Context) { private const val ACCENT = 0xFF7C6BF0.toInt() } - private data class Hist(val text: String, val key: String, val name: String, val ts: Long) + private data class Hist( + val text: String, + val key: String, + val name: String, + val ts: Long, + val mid: String, + val deleted: Boolean, + ) + + // Что нужно для перерисовки чата, когда нового пуша нет: правка и удаление + // приходят без заголовка, аккаунта и признака группы. + private data class ChatMeta(val title: String, val account: Int, val group: Boolean) fun handle(data: Map) { when (data["type"]) { @@ -72,23 +91,107 @@ class KometNotifier(private val ctx: Context) { } } + private fun notifIdOf(chatId: Long): Int = (chatId and 0x7fffffff).toInt() + private fun showMessage(data: Map) { val chatId = data["mc"]?.toLongOrNull() ?: return + val notifId = notifIdOf(chatId) + if (ChatNotifications.isDisplayed(chatId)) { + manager().cancel(notifId) + clearChat(chatId) + syncSummary(notifId, null) + return + } val senderId = data["suid"] ?: "" val senderName = data["userName"] ?: data["title"] ?: "MAX" val chatTitle = data["title"] ?: senderName val text = data["msg"] ?: data["body"] ?: data["text"] ?: "Новое сообщение" val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull() ?: System.currentTimeMillis() - val isGroup = chatTitle != senderName - val notifId = (chatId and 0x7fffffff).toInt() ensureChannel() val active = activeIds() - if (!active.contains(notifId)) clearHistory(chatId) - val history = appendHistory(chatId, Hist(text, senderId, senderName, ts)) + if (!active.contains(notifId)) clearChat(chatId) + val meta = ChatMeta(chatTitle, data["c"]?.toIntOrNull() ?: 0, chatTitle != senderName) + saveMeta(chatId, meta) + val history = appendHistory( + chatId, + Hist(text, senderId, senderName, ts, data["msgid"] ?: "", false), + ) + render(chatId, notifId, meta, history, alertOnce = false) + updateSummary(notifId, senderName, text, ts, active) + } + + // Сообщение отредактировали — правим текст в уже висящем уведомлении. + fun editMessage(data: Map) { + val chatId = data["mc"]?.toLongOrNull() ?: return + val mid = data["msgid"]?.takeIf { it.isNotEmpty() } ?: return + val text = data["msg"]?.takeIf { it.isNotEmpty() } ?: return + + val history = loadHistory(chatId) + val index = history.indexOfLast { it.mid == mid } + if (index < 0) return + val old = history[index] + if (old.deleted || old.text == text) return + + val updated = history.toMutableList() + updated[index] = old.copy(text = text) + saveHistory(chatId, updated) + + val notifId = notifIdOf(chatId) + if (!activeIds().contains(notifId)) return + val meta = loadMeta(chatId) ?: return + render(chatId, notifId, meta, updated, alertOnce = true) + syncSummary(notifId, updated.last()) + } + + // Сообщение удалили. keep — включено «показывать удалённые сообщения»: + // тогда строка остаётся в шторке, но зачёркнутой и с пометкой. + fun removeMessage(data: Map) { + val chatId = data["mc"]?.toLongOrNull() ?: return + val mid = data["msgid"]?.takeIf { it.isNotEmpty() } ?: return + val keep = data["keep"] == "true" + + val history = loadHistory(chatId) + val index = history.indexOfLast { it.mid == mid } + if (index < 0) return + if (keep && history[index].deleted) return + + val updated = history.toMutableList() + if (keep) { + updated[index] = updated[index].copy(deleted = true) + } else { + updated.removeAt(index) + } + + val notifId = notifIdOf(chatId) + if (updated.isEmpty()) { + clearChat(chatId) + manager().cancel(notifId) + syncSummary(notifId, null) + return + } + + saveHistory(chatId, updated) + if (!activeIds().contains(notifId)) return + val meta = loadMeta(chatId) ?: return + render(chatId, notifId, meta, updated, alertOnce = true) + syncSummary(notifId, updated.last()) + } + + private fun render( + chatId: Long, + notifId: Int, + meta: ChatMeta, + history: List, + alertOnce: Boolean, + ) { + if (history.isEmpty()) return + ensureChannel() + + val newest = history.last() val avatarCache = HashMap() val personCache = HashMap() fun avatarFor(key: String, name: String): Bitmap = @@ -102,17 +205,16 @@ class KometNotifier(private val ctx: Context) { .build() } - val senderPerson = personFor(senderId, senderName) val shortcutId = "chat_$chatId" - publishShortcut(shortcutId, chatId, chatTitle, senderPerson) + publishShortcut(shortcutId, chatId, meta.title, personFor(newest.key, newest.name)) val style = NotificationCompat.MessagingStyle(Person.Builder().setName("Вы").build()) - if (isGroup) { - style.conversationTitle = chatTitle + if (meta.group) { + style.conversationTitle = meta.title style.isGroupConversation = true } for (h in history) { - style.addMessage(h.text, h.ts, personFor(h.key, h.name)) + style.addMessage(displayText(h), h.ts, personFor(h.key, h.name)) } val builder = NotificationCompat.Builder(ctx, CHANNEL_ID) @@ -121,26 +223,38 @@ class KometNotifier(private val ctx: Context) { .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setAutoCancel(true) .setGroup(GROUP_KEY) - .setWhen(ts) + .setWhen(newest.ts) .setShowWhen(true) + .setOnlyAlertOnce(alertOnce) .setContentIntent(openIntent(notifId, chatId)) .setShortcutId(shortcutId) .setLocusId(LocusIdCompat(shortcutId)) .setStyle(style) - .setLargeIcon(avatarFor(senderId, senderName)) + .setLargeIcon(avatarFor(newest.key, newest.name)) - val account = data["c"]?.toIntOrNull() ?: 0 - if (account != 0) { - builder.addAction(replyAction(notifId, account, chatId, data["msgid"]?.toLongOrNull())) + if (meta.account != 0) { + val replyTo = history.lastOrNull { !it.deleted }?.mid?.toLongOrNull() + builder.addAction(replyAction(notifId, meta.account, chatId, replyTo)) } manager().notify(notifId, builder.build()) - updateSummary(notifId, chatId, senderName, text, ts, active) } + private fun displayText(h: Hist): CharSequence { + if (!h.deleted) return h.text + val sb = SpannableStringBuilder(ctx.getString(R.string.notif_deleted_prefix)) + sb.append(' ') + val start = sb.length + sb.append(h.text) + sb.setSpan(StrikethroughSpan(), start, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + return sb + } + + private fun plainText(h: Hist): String = + if (h.deleted) "${ctx.getString(R.string.notif_deleted_prefix)} ${h.text}" else h.text + private fun updateSummary( notifId: Int, - chatId: Long, senderName: String, text: String, ts: Long, @@ -153,27 +267,60 @@ class KometNotifier(private val ctx: Context) { val k = keys.next() val id = k.toIntOrNull() ?: continue if (id == notifId) continue - if (activeBefore.contains(id)) kept.put(k, reg.getJSONObject(k)) + val entry = reg.optJSONObject(k) ?: continue + if (activeBefore.contains(id)) kept.put(k, entry) } kept.put( notifId.toString(), JSONObject().put("n", senderName).put("t", text).put("ts", ts), ) saveRegistry(kept) + publishSummary(entriesOf(kept)) + } - if (kept.length() < 2) { - manager().cancel(SUMMARY_ID) - return + // `newest` == null — уведомление чата ушло из шторки. + private fun syncSummary(notifId: Int, newest: Hist?) { + val active = activeIds() + val reg = loadRegistry() + val kept = JSONObject() + val keys = reg.keys() + while (keys.hasNext()) { + val k = keys.next() + val id = k.toIntOrNull() ?: continue + if (id == notifId) continue + val entry = reg.optJSONObject(k) ?: continue + if (active.contains(id)) kept.put(k, entry) } + if (newest != null && active.contains(notifId)) { + kept.put( + notifId.toString(), + JSONObject() + .put("n", newest.name) + .put("t", plainText(newest)) + .put("ts", newest.ts), + ) + } + saveRegistry(kept) + publishSummary(entriesOf(kept)) + } + private fun entriesOf(reg: JSONObject): List> { val entries = ArrayList>() - val kk = kept.keys() - while (kk.hasNext()) { - val k = kk.next() - val o = kept.getJSONObject(k) + val keys = reg.keys() + while (keys.hasNext()) { + val o = reg.optJSONObject(keys.next()) ?: continue entries.add(Triple(o.optString("n"), o.optString("t"), o.optLong("ts"))) } entries.sortByDescending { it.third } + return entries + } + + private fun publishSummary(entries: List>) { + if (entries.size < 2) { + manager().cancel(SUMMARY_ID) + return + } + val newest = entries.first() val inbox = NotificationCompat.InboxStyle() for (e in entries.take(6)) inbox.addLine(boldLine(e.first, e.second)) @@ -185,11 +332,12 @@ class KometNotifier(private val ctx: Context) { .setGroup(GROUP_KEY) .setGroupSummary(true) .setAutoCancel(true) - .setWhen(ts) + .setWhen(newest.third) .setShowWhen(true) .setNumber(entries.size) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setContentTitle("Komet") - .setContentText(boldLine(senderName, text)) + .setContentText(boldLine(newest.first, newest.second)) .setStyle(inbox) .build() manager().notify(SUMMARY_ID, summary) @@ -239,17 +387,15 @@ class KometNotifier(private val ctx: Context) { private fun publishShortcut(id: String, chatId: Long, title: String, person: Person) { try { - val intent = (ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) - ?: Intent(Intent.ACTION_VIEW)).apply { - action = Intent.ACTION_VIEW - putExtra("komet_chat", chatId) - } + val intent = chatIntent(chatId).setAction(Intent.ACTION_VIEW) val shortcut = ShortcutInfoCompat.Builder(ctx, id) .setShortLabel(title) .setLongLived(true) .setIntent(intent) .setPerson(person) .setIcon(person.icon) + .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) + .setLocusId(LocusIdCompat(id)) .build() ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut) } catch (e: Exception) { @@ -257,12 +403,26 @@ class KometNotifier(private val ctx: Context) { } } - private fun openIntent(notifId: Int, chatId: Long): PendingIntent? { - val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) ?: return null - launch.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - launch.putExtra("komet_chat", chatId) + private fun chatIntent(chatId: Long): Intent { + val launcher = ctx.packageManager + .getLaunchIntentForPackage(ctx.packageName)?.component + val intent = if (launcher != null) { + Intent().setComponent(launcher) + } else { + Intent(ctx, MainActivity::class.java) + } + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ) + intent.putExtra(ChatNotifications.EXTRA_CHAT, chatId) + return intent + } + + private fun openIntent(notifId: Int, chatId: Long): PendingIntent { val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - return PendingIntent.getActivity(ctx, notifId, launch, flags) + return PendingIntent.getActivity(ctx, notifId, chatIntent(chatId), flags) } private fun activeIds(): Set = try { @@ -273,30 +433,74 @@ class KometNotifier(private val ctx: Context) { private fun pushPrefs() = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - private fun appendHistory(chatId: Long, item: Hist): List { - val prefs = pushPrefs() - val key = "hist_$chatId" + private fun loadHistory(chatId: Long): List { val arr = try { - JSONArray(prefs.getString(key, "[]")) + JSONArray(pushPrefs().getString("hist_$chatId", "[]")) } catch (e: Exception) { JSONArray() } - arr.put( - JSONObject().put("t", item.text).put("k", item.key) - .put("n", item.name).put("ts", item.ts), - ) - while (arr.length() > HISTORY_LIMIT) arr.remove(0) - prefs.edit().putString(key, arr.toString()).apply() val out = ArrayList(arr.length()) for (i in 0 until arr.length()) { - val o = arr.getJSONObject(i) - out.add(Hist(o.optString("t"), o.optString("k"), o.optString("n"), o.optLong("ts"))) + val o = arr.optJSONObject(i) ?: continue + out.add( + Hist( + o.optString("t"), + o.optString("k"), + o.optString("n"), + o.optLong("ts"), + o.optString("m"), + o.optBoolean("d"), + ), + ) } return out } - private fun clearHistory(chatId: Long) { - pushPrefs().edit().remove("hist_$chatId").apply() + private fun saveHistory(chatId: Long, items: List) { + val arr = JSONArray() + for (h in items) { + arr.put( + JSONObject() + .put("t", h.text) + .put("k", h.key) + .put("n", h.name) + .put("ts", h.ts) + .put("m", h.mid) + .put("d", h.deleted), + ) + } + pushPrefs().edit().putString("hist_$chatId", arr.toString()).apply() + } + + private fun appendHistory(chatId: Long, item: Hist): List { + val items = ArrayList(loadHistory(chatId)) + items.add(item) + while (items.size > HISTORY_LIMIT) items.removeAt(0) + saveHistory(chatId, items) + return items + } + + private fun clearChat(chatId: Long) { + pushPrefs().edit().remove("hist_$chatId").remove("meta_$chatId").apply() + } + + private fun saveMeta(chatId: Long, meta: ChatMeta) { + val json = JSONObject() + .put("t", meta.title) + .put("a", meta.account) + .put("g", meta.group) + .toString() + pushPrefs().edit().putString("meta_$chatId", json).apply() + } + + private fun loadMeta(chatId: Long): ChatMeta? { + val raw = pushPrefs().getString("meta_$chatId", null) ?: return null + return try { + val o = JSONObject(raw) + ChatMeta(o.optString("t"), o.optInt("a"), o.optBoolean("g")) + } catch (e: Exception) { + null + } } private fun loadRegistry(): JSONObject = try { 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 30f6d86..d05cd8c 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 @@ -75,14 +76,24 @@ class MainActivity : FlutterActivity() { @Volatile private var exchangingEmitted = false private var pendingCall: Map? = null + private var pendingChat: Long = 0L + private var pendingShare: Map? = null + private var pendingShareTask: java.util.concurrent.Future?>? = null + private val shareExecutor = java.util.concurrent.Executors.newSingleThreadExecutor() + private val shareHandler = Handler(Looper.getMainLooper()) private companion object { + @Volatile + var keepAwake = false + const val LOG_TAG = "VpnBypass" + const val SHARE_TAG = "ShareIntake" const val NFC_TAG = "NfcExchange" - const val CALL_ENGINE_ID = "komet_call_engine" + const val KEEP_ENGINE_ID = "komet_keep_engine" const val NFC_PHASE_MIN_MS = 350L const val NFC_PHASE_JITTER_MS = 400 const val BLE_PERMS_REQUEST = 7711 + const val NOTE_PERMS_REQUEST = 7712 val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK @@ -189,37 +200,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() @@ -231,15 +251,29 @@ class MainActivity : FlutterActivity() { "ru.komet.app/video_note", ).setMethodCallHandler { call, result -> when (call.method) { + "permission" -> requestNotePermissions(result) "init" -> { val front = call.argument("front") ?: true - val rec = VideoNoteRecorder(applicationContext, flutterEngine.renderer) + val size = call.argument("size") ?: 480 + val fps = call.argument("fps") ?: 30 + val rec = VideoNoteRecorder( + applicationContext, + flutterEngine.renderer, + size, + fps, + ) noteRecorder?.dispose() noteRecorder = rec rec.init(front, result) } "start" -> noteRecorder?.start(result) ?: result.error("NOT_READY", "recorder not initialized", null) + "switch" -> noteRecorder?.switchCamera(result) + "torch" -> noteRecorder?.setTorch( + call.argument("on") ?: false, + result, + ) + ?: result.error("NOT_READY", "recorder not initialized", null) "stop" -> noteRecorder?.stop(result) ?: result.error("NOT_READY", "recorder not initialized", null) "dispose" -> { @@ -266,6 +300,32 @@ class MainActivity : FlutterActivity() { cropSquare(input, output, size, result) } } + "probe" -> { + val input = call.argument("input") + if (input == null) { + result.error("BAD_ARGS", "input required", null) + } else { + probeVideo(input, result) + } + } + "frames" -> { + val input = call.argument("input") + val times = call.argument>("times") + if (input == null || times == null) { + result.error("BAD_ARGS", "input/times required", null) + } else { + videoFrames( + input, + times, + call.argument("size") ?: 256, + call.argument("precise") == true, + result, + ) + } + } + "edit" -> editVideo(call, result) + "editProgress" -> editVideoProgress(result) + "editCancel" -> editVideoCancel(result) else -> result.notImplemented() } } @@ -287,6 +347,25 @@ class MainActivity : FlutterActivity() { CallForegroundService.start(applicationContext, caller) result.success(null) } + "ensureOngoing" -> { + val caller = call.argument("caller") ?: "Звонок" + CallForegroundService.start(applicationContext, caller) + result.success(null) + } + "setScreenShare" -> { + val enabled = call.argument("enabled") ?: false + val caller = call.argument("caller") ?: "Звонок" + CallForegroundService.setScreenShare( + applicationContext, + enabled, + caller, + ) + result.success(null) + } + "dropOngoing" -> { + CallForegroundService.stop(applicationContext) + result.success(null) + } "notifyEnded" -> { CallRinger.stop() NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) @@ -323,6 +402,19 @@ class MainActivity : FlutterActivity() { } } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/screen", + ).setMethodCallHandler { call, result -> + when (call.method) { + "setKeepAwake" -> { + setKeepAwake(call.argument("enabled") == true) + result.success(null) + } + else -> result.notImplemented() + } + } + EventChannel( flutterEngine.dartExecutor.binaryMessenger, "ru.komet.app/calls_events", @@ -335,12 +427,101 @@ class MainActivity : FlutterActivity() { CallEvents.sink = null } }) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/notifications", + ).setMethodCallHandler { call, result -> + when (call.method) { + "consumeInitialChat" -> { + stashChatOpen(intent, emit = false) + val chatId = pendingChat + pendingChat = 0L + result.success(if (chatId > 0L) chatId else null) + } + "setActiveChat" -> { + ChatNotifications.activeChatId = longArg(call.argument("chatId")) + result.success(null) + } + "clearActiveChat" -> { + ChatNotifications.activeChatId = 0L + result.success(null) + } + else -> result.notImplemented() + } + } + + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/notification_events", + ).setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ChatNotifications.sink = events + } + + override fun onCancel(arguments: Any?) { + ChatNotifications.sink = null + } + }) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/share", + ).setMethodCallHandler { call, result -> + when (call.method) { + "consumeInitialShare" -> { + stashShare(intent, emit = false) + val ready = pendingShare + val task = pendingShareTask + if (ready != null) { + pendingShare = null + result.success(ready) + } else if (task != null) { + pendingShareTask = null + shareExecutor.execute { + val payload = try { + task.get() + } catch (e: Exception) { + Log.w(SHARE_TAG, "materialize failed: $e") + null + } + shareHandler.post { result.success(payload) } + } + } else { + result.success(null) + } + } + "clearCache" -> { + ShareIntake.clearCache(applicationContext) + result.success(null) + } + else -> result.notImplemented() + } + } + + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/share_events", + ).setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ShareIntake.sink = events + } + + override fun onCancel(arguments: Any?) { + ShareIntake.sink = null + } + }) + + FkmChannel.attach(flutterEngine, this) } override fun onCreate(savedInstanceState: Bundle?) { if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags() super.onCreate(savedInstanceState) + applyKeepAwake() intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) } + stashChatOpen(intent, emit = false) + stashShare(intent, emit = false) } override fun onNewIntent(intent: Intent) { @@ -350,11 +531,56 @@ class MainActivity : FlutterActivity() { applyCallWindowFlags() stashCall(intent, emit = true) } + stashChatOpen(intent, emit = true) + stashShare(intent, emit = true) + } + + private fun stashChatOpen(source: Intent?, emit: Boolean) { + val chatId = ChatNotifications.chatIdFrom(source) + if (chatId == 0L) return + source?.removeExtra(ChatNotifications.EXTRA_CHAT) + val sink = ChatNotifications.sink + if (emit && sink != null) { + sink.success(chatId) + } else { + pendingChat = chatId + } + } + + private fun stashShare(source: Intent?, emit: Boolean) { + if (!ShareIntake.isShare(source)) return + val intent = source ?: return + val snapshot = ShareIntake.snapshot(intent) ?: return + intent.action = Intent.ACTION_MAIN + intent.removeExtra(Intent.EXTRA_STREAM) + intent.removeExtra(Intent.EXTRA_TEXT) + val task = shareExecutor.submit?> { + ShareIntake.materialize(applicationContext, snapshot) + } + if (!emit) { + pendingShareTask = task + return + } + shareExecutor.execute { + val payload = try { + task.get() + } catch (e: Exception) { + Log.w(SHARE_TAG, "materialize failed: $e") + null + } ?: return@execute + shareHandler.post { + val sink = ShareIntake.sink + if (sink != null) sink.success(payload) else pendingShare = payload + } + } } private fun stashCall(intent: Intent, emit: Boolean) { val json = intent.getStringExtra(CallConst.EXTRA_CALL) ?: return val action = intent.getStringExtra(CallConst.EXTRA_ACTION) ?: CallConst.ACTION_RING + intent.removeExtra(CallConst.EXTRA_CALL) + intent.removeExtra(CallConst.EXTRA_ACTION) + intent.removeExtra(CallConst.EXTRA_CALLER) if (action == CallConst.ACTION_ANSWER) CallRinger.stop() val map = mapOf("data" to json, "action" to action) val sink = CallEvents.sink @@ -373,8 +599,7 @@ class MainActivity : FlutterActivity() { @Suppress("DEPRECATION") window.addFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, ) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -391,12 +616,24 @@ class MainActivity : FlutterActivity() { @Suppress("DEPRECATION") window.clearFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, ) } } + private fun setKeepAwake(enabled: Boolean) { + keepAwake = enabled + runOnUiThread { applyKeepAwake() } + } + + private fun applyKeepAwake() { + if (keepAwake) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + // Центр-кроп видео в квадрат size×size (без искажений) через media3 // Transformer: LAYOUT_SCALE_TO_FIT_WITH_CROP заполняет квадрат и обрезает // лишнее по бокам. @@ -462,6 +699,31 @@ class MainActivity : FlutterActivity() { } } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun probeVideo(input: String, result: MethodChannel.Result) = + VideoEditor.probe(input, result) + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun videoFrames( + input: String, + times: List, + size: Int, + precise: Boolean, + result: MethodChannel.Result, + ) = VideoEditor.frames(input, times, size, precise, result) + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun editVideo(call: MethodCall, result: MethodChannel.Result) = + VideoEditor.edit(this, call, result) + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun editVideoProgress(result: MethodChannel.Result) = + VideoEditor.progress(result) + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun editVideoCancel(result: MethodChannel.Result) = + VideoEditor.cancel(result) + private fun nfcStatus(): Map { val adapter = nfcAdapter return mapOf( @@ -566,12 +828,46 @@ class MainActivity : FlutterActivity() { } } + private var notePermResult: MethodChannel.Result? = null + + private fun isGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == + PackageManager.PERMISSION_GRANTED + + private fun notePermissionState(): Map = mapOf( + "camera" to isGranted(Manifest.permission.CAMERA), + "microphone" to isGranted(Manifest.permission.RECORD_AUDIO), + ) + + private fun requestNotePermissions(result: MethodChannel.Result) { + val state = notePermissionState() + if (state.values.all { it } || notePermResult != null) { + result.success(state); return + } + notePermResult = result + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO), + NOTE_PERMS_REQUEST, + ) + } + override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == FkmChannel.NOTIF_PERMS_REQUEST) { + FkmChannel.onPermissionResult(grantResults) + return + } + if (requestCode == NOTE_PERMS_REQUEST) { + val pending = notePermResult + notePermResult = null + pending?.success(notePermissionState()) + return + } if (requestCode != BLE_PERMS_REQUEST) return if (!NfcExchange.active) return val granted = grantResults.isNotEmpty() && @@ -641,30 +937,36 @@ class MainActivity : FlutterActivity() { } } + // Движок переживает смерть активити, пока идёт звонок или включён FKM: + // в обоих случаях в фоне должно жить то же соединение, что и в UI. + private fun keepEngineAlive(): Boolean = CallState.inCall || FkmState.enabled + override fun provideFlutterEngine(context: Context): FlutterEngine? { val cache = FlutterEngineCache.getInstance() - val cached = cache.get(CALL_ENGINE_ID) + val cached = cache.get(KEEP_ENGINE_ID) if (cached != null) { - if (CallState.inCall) return cached - cache.remove(CALL_ENGINE_ID) + if (keepEngineAlive()) return cached + cache.remove(KEEP_ENGINE_ID) cached.destroy() } return super.provideFlutterEngine(context) } - override fun shouldDestroyEngineWithHost(): Boolean = !CallState.inCall + override fun shouldDestroyEngineWithHost(): Boolean = !keepEngineAlive() override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { - if (!CallState.inCall) { - FlutterEngineCache.getInstance().remove(CALL_ENGINE_ID) + if (!keepEngineAlive()) { + FlutterEngineCache.getInstance().remove(KEEP_ENGINE_ID) + FkmChannel.detach() } super.cleanUpFlutterEngine(flutterEngine) } override fun onDestroy() { - if (CallState.inCall && isFinishing) { - Log.d("KometFcm", "task removed during call, caching engine") - flutterEngine?.let { FlutterEngineCache.getInstance().put(CALL_ENGINE_ID, it) } + shareExecutor.shutdown() + if (keepEngineAlive() && isFinishing) { + Log.d("KometFcm", "task removed, caching engine (call=${CallState.inCall} fkm=${FkmState.enabled})") + flutterEngine?.let { FlutterEngineCache.getInstance().put(KEEP_ENGINE_ID, it) } } super.onDestroy() } @@ -672,6 +974,7 @@ class MainActivity : FlutterActivity() { override fun onResume() { super.onResume() AppState.resumed = true + stashChatOpen(intent, emit = true) } override fun onPause() { diff --git a/android/app/src/main/kotlin/ru/komet/app/ShareIntake.kt b/android/app/src/main/kotlin/ru/komet/app/ShareIntake.kt new file mode 100644 index 0000000..3bf9245 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/ShareIntake.kt @@ -0,0 +1,154 @@ +package ru.komet.app + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import android.webkit.MimeTypeMap +import io.flutter.plugin.common.EventChannel +import java.io.File +import java.util.concurrent.atomic.AtomicLong + +object ShareIntake { + + private const val TAG = "ShareIntake" + private const val CACHE_DIR = "shared_in" + private const val MAX_FILES = 30 + + private val seq = AtomicLong(0L) + + @Volatile + var sink: EventChannel.EventSink? = null + + fun isShare(intent: Intent?): Boolean { + val action = intent?.action ?: return false + return action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE + } + + class Snapshot( + val uris: List, + val text: String?, + val subject: String?, + val intentType: String?, + ) + + fun snapshot(intent: Intent): Snapshot? { + val uris = collectUris(intent).take(MAX_FILES) + val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) + if (uris.isEmpty() && text.isNullOrBlank()) return null + return Snapshot(uris, text, subject, intent.type) + } + + fun materialize(context: Context, snapshot: Snapshot): Map? { + val files = ArrayList>() + for (uri in snapshot.uris) { + val copied = copyToCache(context, uri, snapshot.intentType) + if (copied != null) files.add(copied) + } + + if (files.isEmpty() && snapshot.text.isNullOrBlank()) return null + + return mapOf( + "files" to files, + "text" to snapshot.text, + "subject" to snapshot.subject, + ) + } + + fun clearCache(context: Context) { + try { + val dir = File(context.cacheDir, CACHE_DIR) + if (!dir.isDirectory) return + dir.listFiles()?.forEach { it.delete() } + } catch (e: Exception) { + Log.w(TAG, "cache cleanup failed: $e") + } + } + + private fun collectUris(intent: Intent): List { + if (intent.action == Intent.ACTION_SEND_MULTIPLE) { + val list = if (android.os.Build.VERSION.SDK_INT >= 33) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + return list?.filterNotNull() ?: emptyList() + } + val single = if (android.os.Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + return if (single != null) listOf(single) else emptyList() + } + + private fun copyToCache(context: Context, uri: Uri, intentType: String?): Map? { + val resolver = context.contentResolver + val mime = resolveMime(resolver, uri, intentType) + val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime) + + return try { + val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() } + val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}") + resolver.openInputStream(uri).use { input -> + if (input == null) return null + target.outputStream().use { output -> input.copyTo(output) } + } + if (target.length() <= 0L) { + target.delete() + return null + } + mapOf( + "path" to target.absolutePath, + "name" to displayName, + "mime" to mime, + "size" to target.length(), + ) + } catch (e: Exception) { + Log.w(TAG, "cannot read $uri: $e") + null + } + } + + private fun resolveMime(resolver: ContentResolver, uri: Uri, intentType: String?): String { + val fromResolver = resolver.getType(uri) + if (!fromResolver.isNullOrBlank() && fromResolver != "*/*") return fromResolver + val ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + if (!ext.isNullOrBlank()) { + val guessed = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.lowercase()) + if (!guessed.isNullOrBlank()) return guessed + } + if (!intentType.isNullOrBlank() && intentType != "*/*") return intentType + return "application/octet-stream" + } + + private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? { + if (uri.scheme == ContentResolver.SCHEME_FILE) return uri.lastPathSegment + return try { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null + } + } catch (e: Exception) { + Log.w(TAG, "display name for $uri: $e") + null + } + } + + private fun fallbackName(uri: Uri, mime: String): String { + val last = uri.lastPathSegment?.substringAfterLast('/') + if (!last.isNullOrBlank() && last.contains('.')) return last + val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin" + return "shared_${System.currentTimeMillis()}.$ext" + } + + private fun sanitize(name: String): String { + val cleaned = name.replace(Regex("[^A-Za-z0-9._-]"), "_") + return if (cleaned.length <= 64) cleaned else cleaned.takeLast(64) + } +} 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/kotlin/ru/komet/app/VideoEditor.kt b/android/app/src/main/kotlin/ru/komet/app/VideoEditor.kt new file mode 100644 index 0000000..7f55c32 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/VideoEditor.kt @@ -0,0 +1,352 @@ +package ru.komet.app + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaCodecInfo +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.media3.common.Effect +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.BitmapOverlay +import androidx.media3.effect.Crop +import androidx.media3.effect.OverlayEffect +import androidx.media3.effect.Presentation +import androidx.media3.effect.RgbMatrix +import androidx.media3.effect.ScaleAndRotateTransformation +import androidx.media3.effect.TextureOverlay +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultEncoderFactory +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.Effects +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.ProgressHolder +import androidx.media3.transformer.Transformer +import androidx.media3.transformer.VideoEncoderSettings +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream +import java.io.File + +@UnstableApi +object VideoEditor { + private const val LOG_TAG = "VideoEditor" + + private val main = Handler(Looper.getMainLooper()) + private val progressHolder = ProgressHolder() + + private var transformer: Transformer? = null + private var overlayBitmap: Bitmap? = null + private var pending: MethodChannel.Result? = null + + private class ColorMatrixEffect(private val values: FloatArray) : RgbMatrix { + override fun getMatrix(presentationTimeUs: Long, useHdr: Boolean) = values + } + + fun probe(input: String, result: MethodChannel.Result) { + Thread { + val data = try { + readInfo(input) + } catch (e: Exception) { + Log.w(LOG_TAG, "probe failed: ${e.message}") + null + } + main.post { result.success(data) } + }.start() + } + + private fun readInfo(input: String): Map? { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(input) + val rotation = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) + ?.toIntOrNull() ?: 0 + val rawWidth = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull() ?: 0 + val rawHeight = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull() ?: 0 + val duration = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() ?: 0L + if (rawWidth <= 0 || rawHeight <= 0) return null + val swap = rotation % 180 != 0 + val track = readTrackInfo(input) + return mapOf( + "width" to if (swap) rawHeight else rawWidth, + "height" to if (swap) rawWidth else rawHeight, + "durationMs" to duration, + "fps" to track.first, + "hasAudio" to track.second, + ) + } finally { + retriever.release() + } + } + + private fun readTrackInfo(input: String): Pair { + val extractor = MediaExtractor() + var fps = 30.0 + var hasAudio = false + try { + extractor.setDataSource(input) + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(MediaFormat.KEY_MIME) ?: continue + if (mime.startsWith("audio/")) hasAudio = true + if (mime.startsWith("video/") && + format.containsKey(MediaFormat.KEY_FRAME_RATE) + ) { + fps = try { + format.getInteger(MediaFormat.KEY_FRAME_RATE).toDouble() + } catch (_: ClassCastException) { + format.getFloat(MediaFormat.KEY_FRAME_RATE).toDouble() + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "track info failed: ${e.message}") + } finally { + extractor.release() + } + return Pair(if (fps > 0) fps else 30.0, hasAudio) + } + + fun frames( + input: String, + times: List, + size: Int, + precise: Boolean, + result: MethodChannel.Result, + ) { + Thread { + val out = ArrayList(times.size) + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(input) + for (ms in times) { + out.add(grabFrame(retriever, ms.toLong(), size, precise)) + } + } catch (e: Exception) { + Log.w(LOG_TAG, "frames failed: ${e.message}") + while (out.size < times.size) out.add(null) + } finally { + retriever.release() + } + main.post { result.success(out) } + }.start() + } + + private fun grabFrame( + retriever: MediaMetadataRetriever, + timeMs: Long, + size: Int, + precise: Boolean, + ): ByteArray? { + val us = timeMs * 1000L + val option = if (precise) { + MediaMetadataRetriever.OPTION_CLOSEST + } else { + MediaMetadataRetriever.OPTION_CLOSEST_SYNC + } + val bitmap = ( + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + retriever.getScaledFrameAtTime(us, option, size, size) + } else { + retriever.getFrameAtTime(us, option) + } + } catch (e: Exception) { + Log.w(LOG_TAG, "frame at $timeMs failed: ${e.message}") + null + } + ) ?: return null + return try { + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 82, stream) + stream.toByteArray() + } finally { + bitmap.recycle() + } + } + + fun edit(context: Context, call: MethodCall, result: MethodChannel.Result) { + val input = call.argument("input") + val output = call.argument("output") + if (input == null || output == null) { + result.error("BAD_ARGS", "input/output required", null) + return + } + release() + pending = result + try { + val effects = buildEffects(call) + val item = MediaItem.Builder() + .setUri(Uri.fromFile(File(input))) + .setClippingConfiguration(buildClipping(call)) + .build() + val edited = EditedMediaItem.Builder(item) + .setRemoveAudio(call.argument("removeAudio") == true) + .setEffects(Effects(emptyList(), effects)) + .build() + + val builder = Transformer.Builder(context) + .addListener(object : Transformer.Listener { + override fun onCompleted( + composition: Composition, + exportResult: ExportResult, + ) = finish(true) + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException, + ) { + Log.w(LOG_TAG, "export failed: ${exportException.message}") + finish(false) + } + }) + val bitrate = call.argument("bitrate") + if (bitrate != null && bitrate > 0) { + builder.setEncoderFactory( + DefaultEncoderFactory.Builder(context) + .setRequestedVideoEncoderSettings( + VideoEncoderSettings.Builder() + .setBitrate(bitrate) + .setBitrateMode( + MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR, + ) + .build(), + ) + .build(), + ) + } + val transformer = builder.build() + this.transformer = transformer + transformer.start(edited, output) + } catch (e: Exception) { + Log.w(LOG_TAG, "export start failed: ${e.message}") + finish(false) + } + } + + private fun finish(ok: Boolean) { + val result = pending + pending = null + release() + result?.success(ok) + } + + private fun buildClipping(call: MethodCall): MediaItem.ClippingConfiguration { + val builder = MediaItem.ClippingConfiguration.Builder() + val start = call.argument("startMs")?.toLong() + val end = call.argument("endMs")?.toLong() + if (start != null && start > 0) builder.setStartPositionMs(start) + if (end != null && end > 0) builder.setEndPositionMs(end) + return builder.build() + } + + private fun buildEffects(call: MethodCall): List { + val effects = mutableListOf() + val rotation = call.argument("rotationDegrees")?.toFloat() ?: 0f + val flipH = call.argument("flipH") == true + if (flipH || kotlin.math.abs(rotation) > 0.01f) { + effects.add( + ScaleAndRotateTransformation.Builder() + .setScale(if (flipH) -1f else 1f, 1f) + .setRotationDegrees(rotation) + .build(), + ) + } + val crop = call.argument>("crop") + if (crop != null && crop.size == 4) { + effects.add( + Crop( + crop[0].toFloat(), + crop[1].toFloat(), + crop[2].toFloat(), + crop[3].toFloat(), + ), + ) + } + val width = call.argument("outWidth") ?: 0 + val height = call.argument("outHeight") ?: 0 + if (width > 0 && height > 0) { + effects.add( + Presentation.createForWidthAndHeight( + width, + height, + Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP, + ), + ) + } + val matrix = call.argument>("rgbMatrix") + if (matrix != null && matrix.size == 16) { + effects.add( + ColorMatrixEffect(FloatArray(16) { matrix[it].toFloat() }), + ) + } + val overlay = call.argument("overlay") + if (overlay != null) { + val bitmap = BitmapFactory.decodeFile(overlay) + if (bitmap != null) { + overlayBitmap = bitmap + effects.add( + OverlayEffect( + listOf( + BitmapOverlay.createStaticBitmapOverlay(bitmap), + ), + ), + ) + } + } + return effects + } + + fun progress(result: MethodChannel.Result) { + val active = transformer + if (active == null) { + result.success(-1) + return + } + val state = try { + active.getProgress(progressHolder) + } catch (_: IllegalStateException) { + result.success(-1) + return + } + result.success( + if (state == Transformer.PROGRESS_STATE_AVAILABLE) { + progressHolder.progress + } else { + -1 + }, + ) + } + + fun cancel(result: MethodChannel.Result) { + try { + transformer?.cancel() + } catch (e: Exception) { + Log.w(LOG_TAG, "cancel failed: ${e.message}") + } + finish(false) + result.success(null) + } + + private fun release() { + transformer = null + overlayBitmap?.recycle() + overlayBitmap = null + } +} diff --git a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt index 7704453..00c9fc7 100644 --- a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt +++ b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt @@ -23,6 +23,7 @@ import android.os.Build import android.os.Handler import android.os.HandlerThread import android.util.Log +import android.util.Range import android.util.Size import android.view.Surface import androidx.core.content.ContextCompat @@ -35,22 +36,35 @@ import java.nio.FloatBuffer // Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный // кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит -// одновременно в превью (Flutter Texture) и в MediaRecorder (480×480, H.264, +// одновременно в превью (Flutter Texture) и в MediaRecorder (квадрат, H.264, // framework MediaMuxer). Так делает официальный клиент через CameraX — выход // проходит серверный валидатор (media3-перекод его НЕ проходит). +// По умолчанию 480×480@30 как у официального клиента; размер и fps +// настраиваются из дев-меню. class VideoNoteRecorder( private val context: Context, private val textureRegistry: TextureRegistry, + requestedEdge: Int = 480, + requestedFps: Int = 30, ) { private val tag = "VideoNoteRecorder" - private val edge = 480 - private val bitrate = 1_024_000 - private val fps = 30 + private val edge = requestedEdge.coerceIn(240, 1080) + private val fps = requestedFps.coerceIn(24, 60) + // 1 Мбит/с — базовый битрейт официального клиента для 480×480@30; + // масштабируем по площади кадра и частоте. + private val bitrate = + (1_024_000L * edge * edge / (480L * 480L) * fps / 30L).toInt() private var cameraId = "" private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT private var sensorOrientation = 270 private var camSize = Size(1280, 720) + private var fpsRange: Range? = null + private var hasOis = false + private var hasEis = false + private var hasFlash = false + private var torchOn = false + private var previewRequest: CaptureRequest.Builder? = null private var cameraDevice: CameraDevice? = null private var session: CameraCaptureSession? = null @@ -95,23 +109,44 @@ class VideoNoteRecorder( CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP, ) camSize = pickCamSize(map) + fpsRange = pickFpsRange( + ch.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES), + ) + hasOis = ch.get( + CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION, + )?.contains( + CameraCharacteristics.LENS_OPTICAL_STABILIZATION_MODE_ON, + ) == true + hasEis = ch.get( + CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES, + )?.contains( + CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON, + ) == true + hasFlash = ch.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true + if (!hasFlash) torchOn = false return true } } return false } - // Поддерживаемый камерой размер вывода (для SurfaceTexture), близкий к 720p. + // Поддерживаемый камерой размер вывода (для SurfaceTexture): короткая + // сторона не меньше edge, целимся в ближайший 16:9 (720p для 480/720, + // 1080p для 1080). private fun pickCamSize(map: StreamConfigurationMap?): Size { + val targetShort = maxOf(720, edge) + val targetLong = targetShort * 16 / 9 + val fallback = Size(targetLong, targetShort) val sizes = map?.getOutputSizes(SurfaceTexture::class.java) - ?: return Size(1280, 720) - var best = sizes.firstOrNull() ?: Size(1280, 720) + ?: return fallback + var best = sizes.firstOrNull() ?: fallback var bestScore = Int.MAX_VALUE for (s in sizes) { val longSide = maxOf(s.width, s.height) val shortSide = minOf(s.width, s.height) if (shortSide < edge) continue - val score = kotlin.math.abs(longSide - 1280) + kotlin.math.abs(shortSide - 720) + val score = kotlin.math.abs(longSide - targetLong) + + kotlin.math.abs(shortSide - targetShort) if (score < bestScore) { bestScore = score best = s @@ -120,12 +155,28 @@ class VideoNoteRecorder( return best } + // Диапазон AE под запрошенный fps: предпочитаем фиксированный [fps, fps], + // иначе самый узкий диапазон, включающий fps; если 60 недоступно — + // максимально быстрый из имеющихся. + private fun pickFpsRange(ranges: Array>?): Range? { + if (ranges == null || ranges.isEmpty()) return null + val covering = ranges.filter { it.lower <= fps && fps <= it.upper } + if (covering.isNotEmpty()) { + return covering.minByOrNull { (it.upper - it.lower) * 1000 + (fps - it.lower) } + } + return ranges.maxByOrNull { it.upper * 1000 - (it.upper - it.lower) } + } + fun init(facingFront: Boolean, rawResult: MethodChannel.Result) { val result = OnceResult(rawResult) if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ) { - result.error("NO_PERMISSION", "camera permission required", null) + result.error("NO_CAMERA_PERMISSION", "camera permission required", null) + return + } + if (!hasMicPermission()) { + result.error("NO_MIC_PERMISSION", "microphone permission required", null) return } try { @@ -213,7 +264,7 @@ class VideoNoteRecorder( recordWindow?.let { w -> w.makeCurrent() GLES20.glViewport(0, 0, edge, edge) - prog.draw(oesTexId, stMatrix, camSize, lensFacing, false) + prog.draw(oesTexId, stMatrix, camSize, lensFacing, true) w.setPresentationTime(System.nanoTime()) w.swap() } @@ -252,9 +303,37 @@ class VideoNoteRecorder( session = s val req = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) req.addTarget(camSurface) + fpsRange?.let { + req.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it) + } + // Оптическая стабилизация, если линза умеет; иначе электронная. + // Обе сразу включать нельзя — на многих устройствах конфликтуют. + if (hasOis) { + req.set( + CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, + CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON, + ) + } else if (hasEis) { + req.set( + CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, + CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON, + ) + } + previewRequest = req + applyTorch(req) s.setRepeatingRequest(req.build(), null, camHandler) - Log.i(tag, "preview session configured") - result.success(mapOf("textureId" to textureId, "size" to edge)) + Log.i( + tag, + "preview session configured fpsRange=$fpsRange " + + "ois=$hasOis eis=$hasEis flash=$hasFlash", + ) + result.success( + mapOf( + "textureId" to textureId, + "size" to edge, + "hasFlash" to hasFlash, + ), + ) } } catch (e: Exception) { Log.e(tag, "preview session failed", e) @@ -283,7 +362,7 @@ class VideoNoteRecorder( try { rec.setVideoEncodingProfileLevel( android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh, - android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3, + avcLevel(), ) } catch (e: Exception) { Log.w(tag, "profile level: ${e.message}") @@ -295,10 +374,101 @@ class VideoNoteRecorder( recorderSurface = rec.surface } + // Минимальный уровень AVC, вмещающий выбранные размер и fps + // (Level 3 — как у официального клиента для 480@30). + private fun avcLevel(): Int { + return when { + edge <= 480 && fps <= 30 -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3 + edge <= 720 && fps <= 30 -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel31 + edge <= 720 -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel32 + fps <= 30 -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel4 + else -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel42 + } + } + + // Смена камеры на лету (в т.ч. во время записи): GL-конвейер и + // MediaRecorder не трогаем, пересоздаются только CameraDevice и сессия — + // кадры новой камеры продолжают приходить в тот же SurfaceTexture. + private fun applyTorch(req: CaptureRequest.Builder) { + req.set( + CaptureRequest.FLASH_MODE, + if (torchOn && hasFlash) { + CaptureRequest.FLASH_MODE_TORCH + } else { + CaptureRequest.FLASH_MODE_OFF + }, + ) + } + + fun setTorch(on: Boolean, result: MethodChannel.Result) { + if (!hasFlash) { + result.success(false); return + } + val s = session + val req = previewRequest + if (s == null || req == null) { + result.error("NOT_READY", "no preview session", null); return + } + torchOn = on + try { + applyTorch(req) + s.setRepeatingRequest(req.build(), null, camHandler) + result.success(torchOn) + } catch (e: Exception) { + Log.e(tag, "torch failed", e) + torchOn = false + result.error("TORCH_FAILED", e.message, null) + } + } + + fun switchCamera(rawResult: MethodChannel.Result) { + val result = OnceResult(rawResult) + if (cameraDevice == null || !glReady) { + result.error("NOT_READY", "camera not initialized", null); return + } + val newFacing = if (lensFacing == CameraCharacteristics.LENS_FACING_FRONT) { + CameraCharacteristics.LENS_FACING_BACK + } else { + CameraCharacteristics.LENS_FACING_FRONT + } + try { session?.close() } catch (_: Exception) {} + session = null + previewRequest = null + try { cameraDevice?.close() } catch (_: Exception) {} + cameraDevice = null + if (!selectCamera(newFacing)) { + result.error("NO_CAMERA", "no camera for facing $newFacing", null) + return + } + val entry = flutterEntry + if (entry == null) { + result.error("NOT_READY", "texture released", null) + return + } + glHandler?.post { + camTexture?.setDefaultBufferSize(camSize.width, camSize.height) + } + Log.i(tag, "switching camera to $cameraId facing=$lensFacing cam=$camSize") + openCamera(result, entry.id()) + } + + private fun hasMicPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + fun start(result: MethodChannel.Result) { if (cameraDevice == null || !glReady) { result.error("NOT_READY", "camera not initialized", null); return } + if (!hasMicPermission()) { + result.error("NO_MIC_PERMISSION", "microphone permission required", null) + return + } try { val path = File(context.cacheDir, "note_${System.nanoTime()}.mp4").absolutePath outputPath = path 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..a198418 --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,13 @@ + + Отправка медиа + Сервис уведомлений + Держит фоновое соединение с сервером + Komet · сервис уведомлений + Соединение активно + Соединение не активно + %1$s · принято %2$d + Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM. + Выключить + Удалено: + Отправить в чат + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 55b2a48..230c6b5 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,15 @@ Komet contact exchange Komet contact exchange + Sending media + Notification service + Keeps the background connection to the server alive + Komet · notification service + Connection active + Connection inactive + %1$s · %2$d delivered + This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM. + Turn off + Deleted: + Send to a chat diff --git a/assets/debug/fake_video_note.mp4 b/assets/debug/fake_video_note.mp4 new file mode 100644 index 0000000..9e1a713 Binary files /dev/null and b/assets/debug/fake_video_note.mp4 differ diff --git a/assets/lottie/ic_call.json b/assets/lottie/ic_call.json new file mode 100644 index 0000000..d38198a --- /dev/null +++ b/assets/lottie/ic_call.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":30,"w":500,"h":500,"nm":"ic_call","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_call","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":4,"s":[-17],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":9,"s":[14],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":14,"s":[-10],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":19,"s":[6],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":24,"s":[-3],"i":{"x":[0.42],"y":[1]},"o":{"x":[0.58],"y":[0]}},{"t":30,"s":[0]}],"ix":10},"p":{"a":0,"k":[250.0,250.0,0],"ix":2,"l":2},"a":{"a":0,"k":[250.0,250.0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[13.393,-0.0],[71.528,71.528],[0.0,80.556],[-13.393,-0.0],[0.0,0.0],[-2.778,-10.417],[0.0,0.0],[5.714,-5.713],[0.0,0.0],[-24.306,-23.611],[-31.25,-16.667],[0.0,0.0],[-8.818,-1.389],[0.0,0.0],[0.0,-10.355],[0.0,0.0]],"o":[[-80.556,-0.0],[-71.528,-71.528],[0.0,-13.393],[0.0,0.0],[9.722,-0.0],[0.0,0.0],[1.438,9.972],[0.0,0.0],[18.056,30.556],[25.694,26.389],[0.0,0.0],[6.944,-7.639],[0.0,0.0],[10.417,2.778],[0.0,0.0],[0.0,13.393]],"v":[[414.063,437.5],[174.479,325.521],[62.5,85.937],[85.938,62.5],[158.854,62.5],[178.646,80.729],[192.671,146.167],[186.86,169.91],[134.896,222.396],[196.875,302.604],[282.292,367.708],[331.771,316.667],[357.292,307.812],[419.271,321.354],[437.5,343.75],[437.5,414.062]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-17.361,-41.667],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[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,27.083]],"v":[[119.271,193.75],[161.458,151.042],[161.458,151.042],[161.458,151.042],[149.479,93.75],[149.479,93.75],[149.479,93.75],[93.75,93.75],[93.75,93.75],[93.75,93.75]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-31.25,-1.389],[0.0,-0.0],[0.0,-0.0],[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":[[28.472,13.194],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[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":[[311.458,382.812],[406.25,406.25],[406.25,406.25],[406.25,406.25],[406.25,350.521],[406.25,350.521],[406.25,350.521],[352.604,339.583],[352.604,339.583],[352.604,339.583]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,27.083],[0.0,-0.0],[0.0,-0.0],[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":[[-17.361,-41.667],[0.0,-0.0],[0.0,-0.0],[0.0,0.0],[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":[[119.271,193.75],[93.75,93.75],[93.75,93.75],[93.75,93.75],[149.479,93.75],[149.479,93.75],[149.479,93.75],[161.458,151.042],[161.458,151.042],[161.458,151.042]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[28.472,13.194],[0.0,0.0],[0.0,-0.0],[0.0,-0.0],[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],[-31.25,-1.389]],"v":[[311.458,382.812],[352.604,339.583],[352.604,339.583],[352.604,339.583],[406.25,350.521],[406.25,350.521],[406.25,350.521],[406.25,406.25],[406.25,406.25],[406.25,406.25]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,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":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":30,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_chat.json b/assets/lottie/ic_chat.json new file mode 100644 index 0000000..cd04262 --- /dev/null +++ b/assets/lottie/ic_chat.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":30,"w":600,"h":600,"nm":"ic_chat","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_chat","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"ix":10,"k":[{"t":0,"s":[0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":6,"s":[-22],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":14,"s":[9],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":20,"s":[-4],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":25,"s":[1.5],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":30,"s":[0]}]},"p":{"a":1,"ix":2,"l":2,"k":[{"t":0,"s":[300,300,0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":6,"s":[284,300,0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":14,"s":[306,300,0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":20,"s":[298,300,0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":30,"s":[300,300,0]}]},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[-16.667,-0.0],[0.0,0.0],[0.0,-16.667],[0.0,0.0],[16.667,-0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,-16.667],[0.0,0.0],[16.667,-0.0],[0.0,0.0],[0.0,16.667],[0.0,0.0],[0.0,0.0]],"v":[[41.667,458.333],[41.667,72.917],[72.917,41.667],[427.083,41.667],[458.333,72.917],[458.333,343.75],[427.083,375.0],[125.0,375.0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"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]],"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]],"v":[[111.458,343.75],[427.083,343.75],[427.083,343.75],[427.083,343.75],[427.083,72.917],[427.083,72.917],[427.083,72.917],[72.917,72.917],[72.917,72.917],[72.917,72.917],[72.917,385.417]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"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]],"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]],"v":[[111.458,343.75],[72.917,385.417],[72.917,72.917],[72.917,72.917],[72.917,72.917],[427.083,72.917],[427.083,72.917],[427.083,72.917],[427.083,343.75],[427.083,343.75],[427.083,343.75]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,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":30,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_contacts.json b/assets/lottie/ic_contacts.json new file mode 100644 index 0000000..dbce99c --- /dev/null +++ b/assets/lottie/ic_contacts.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":31,"w":600,"h":600,"nm":"ic_contacts","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ic_contacts","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"ix":10,"k":[{"t":0,"s":[0],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":9,"s":[22],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":15,"s":[18],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":22,"s":[-5],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":27,"s":[2],"i":{"x":[0.4],"y":[1]},"o":{"x":[0.6],"y":[0]}},{"t":31,"s":[0]}]},"p":{"a":0,"k":[248,532,0],"ix":2,"l":2},"a":{"a":0,"k":[250,479,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,16.667],[0.0,0.0],[-16.667,0.0],[0.0,0.0],[0.0,-17.187],[0.0,0.0],[16.667,-0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[-16.667,-0.0],[0.0,0.0],[0.0,-17.187],[0.0,0.0],[16.667,0.0],[0.0,0.0],[0.0,16.667],[0.0,0.0],[0.0,0.0]],"v":[[250.0,479.167],[186.979,416.146],[93.75,416.146],[62.5,384.896],[62.5,72.396],[93.75,41.146],[406.25,41.146],[437.5,72.396],[437.5,384.896],[406.25,416.146],[313.021,416.146]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-61.183,-0.0],[-41.667,-38.889],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[41.667,-38.889],[61.183,-0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[93.75,365.625],[249.946,300.521],[406.25,365.625],[406.25,72.396],[406.25,72.396],[93.75,72.396],[93.75,72.396]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[-20.139,0.0],[-13.889,13.889],[0.0,20.139],[13.889,13.889],[20.139,-0.0],[13.889,-13.889],[0.0,-20.139],[-13.889,-13.889]],"o":[[20.139,0.0],[13.889,-13.889],[0.0,-20.139],[-13.889,-13.889],[-20.139,-0.0],[-13.889,13.889],[0.0,20.139],[13.889,13.889]],"v":[[251.042,269.792],[302.083,248.958],[322.917,197.917],[302.083,146.875],[251.042,126.042],[200.0,146.875],[179.167,197.917],[200.0,248.958]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,-7.639],[0.0,0.0],[0.0,7.341],[61.111,-0.0],[41.667,-38.889]],"o":[[0.0,0.0],[0.0,-7.639],[-41.667,-38.889],[-61.111,-0.0],[0.0,7.341]],"v":[[93.75,384.896],[406.25,384.896],[406.25,365.625],[250.0,300.521],[93.75,365.625]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"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]],"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]],"v":[[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917],[251.563,197.917]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[41.667,-38.889],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[61.111,-0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-41.667,-38.889],[-61.111,-0.0]],"v":[[93.75,365.625],[93.75,72.396],[93.75,72.396],[406.25,72.396],[406.25,72.396],[406.25,365.625],[250.0,300.521]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,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":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":31,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lottie/ic_flash_on_to_off.json b/assets/lottie/ic_flash_on_to_off.json new file mode 100644 index 0000000..0506aa6 --- /dev/null +++ b/assets/lottie/ic_flash_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_flash_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":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":[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,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"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],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-14.07,0.0],[-8.52,0.0],[-8.04,0.0],[-7.81,0.0],[-7.67,0.0],[-7.56,0.0],[-7.44,0.0],[-7.29,0.0],[-7.06,0.0],[-6.52,0.0],[0.0,0.0],[3.81,-13.32],[2.32,-8.1],[2.17,-7.61],[2.1,-7.36],[2.04,-7.15],[1.97,-6.89],[1.81,-6.35],[0.0,0.0],[-14.35,0.0],[-8.68,0.0],[-7.64,0.0],[0.0,0.0],[7.27,-10.51],[4.44,-6.41],[4.13,-5.97],[3.92,-5.67],[3.59,-5.19],[0.0,0.0],[8.91,8.91],[0.0,0.0],[0.0,0.0],[8.44,0.0],[0.0,0.0],[7.34,7.34],[0.0,0.0],[-3.61,12.56],[-2.2,7.65],[-2.06,7.17],[-1.98,6.89],[-1.9,6.61],[-1.75,6.07],[0.0,0.0],[13.3,0.0],[8.11,0.0],[7.56,0.0],[7.17,0.0],[6.57,0.0],[0.0,0.0],[0.0,-13.44],[0.0,0.0],[9.18,9.18],[5.36,5.36],[0.0,0.0],[0.0,13.44]],"o":[[0.0,0.0],[6.52,0.0],[7.06,0.0],[7.29,0.0],[7.44,0.0],[7.56,0.0],[7.67,0.0],[7.81,0.0],[8.04,0.0],[8.52,0.0],[14.07,0.0],[0.0,0.0],[-1.81,6.35],[-1.97,6.89],[-2.04,7.15],[-2.1,7.36],[-2.17,7.61],[-2.32,8.1],[-3.81,13.32],[0.0,0.0],[7.64,0.0],[8.68,0.0],[14.35,0.0],[0.0,0.0],[-3.59,5.19],[-3.92,5.67],[-4.13,5.97],[-4.44,6.41],[-7.27,10.51],[0.0,0.0],[-8.91,-8.91],[0.0,0.0],[0.0,0.0],[-8.44,0.0],[0.0,0.0],[-7.34,-7.34],[0.0,0.0],[1.75,-6.07],[1.9,-6.61],[1.98,-6.89],[2.06,-7.17],[2.2,-7.65],[3.61,-12.56],[0.0,0.0],[-6.57,0.0],[-7.17,0.0],[-7.56,0.0],[-8.11,0.0],[-13.3,0.0],[0.0,0.0],[0.0,13.44],[0.0,0.0],[-5.36,-5.36],[-9.18,-9.18],[0.0,0.0],[0.0,-13.44]],"v":[[175.0,50.0],[197.72,50.0],[220.43,50.0],[243.18,50.0],[265.91,50.0],[288.64,50.0],[311.36,50.0],[334.09,50.0],[356.82,50.0],[379.57,50.0],[402.28,50.0],[425.0,50.0],[418.75,71.87],[412.5,93.75],[406.25,115.62],[400.0,137.5],[393.75,159.38],[387.5,181.25],[381.25,203.13],[375.0,225.0],[400.0,225.0],[425.0,225.0],[450.0,225.0],[475.0,225.0],[462.82,242.6],[450.63,260.21],[438.44,277.81],[426.25,295.42],[414.06,313.03],[401.88,330.62],[384.06,312.81],[366.25,295.0],[380.0,275.0],[363.12,275.0],[346.25,275.0],[331.56,260.31],[316.88,245.63],[322.85,224.83],[328.84,204.03],[334.82,183.21],[340.8,162.41],[346.79,141.6],[352.77,120.79],[358.75,100.0],[336.47,100.0],[314.17,100.0],[291.88,100.0],[269.58,100.0],[247.28,100.0],[225.0,100.0],[225.0,126.87],[225.0,153.75],[208.33,137.08],[191.67,120.42],[175.0,103.75],[175.0,76.88]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,17.25],[0.0,10.51],[0.0,9.85],[0.0,9.47],[0.0,9.08],[0.0,8.34],[0.0,0.0],[18.75,0.0],[0.0,0.0],[0.0,19.05],[0.0,11.13],[0.0,0.0],[12.13,12.13],[7.39,7.39],[6.92,6.92],[6.66,6.66],[6.38,6.38],[5.86,5.86],[0.0,0.0],[-8.91,8.91],[0.0,0.0],[-13.86,-13.86],[-8.36,-8.36],[-7.87,-7.87],[-7.68,-7.68],[-7.56,-7.56],[-7.49,-7.49],[-7.44,-7.44],[-7.4,-7.4],[-7.36,-7.36],[-7.33,-7.33],[-7.3,-7.3],[-7.27,-7.27],[-7.24,-7.24],[-7.2,-7.2],[-7.16,-7.16],[-7.1,-7.1],[-7.02,-7.02],[-6.91,-6.91],[-6.7,-6.7],[-6.2,-6.2],[0.0,0.0],[8.91,-8.91],[0.0,0.0],[12.99,12.99],[7.91,7.91],[7.42,7.42],[7.13,7.13],[6.84,6.84],[6.28,6.28],[0.0,0.0],[11.02,-15.86],[6.71,-9.67],[6.18,-8.89],[5.6,-8.06]],"o":[[0.0,0.0],[0.0,-8.34],[0.0,-9.08],[0.0,-9.47],[0.0,-9.85],[0.0,-10.51],[0.0,-17.25],[0.0,0.0],[-18.75,0.0],[0.0,0.0],[0.0,-11.13],[0.0,-19.05],[0.0,0.0],[-5.86,-5.86],[-6.38,-6.38],[-6.66,-6.66],[-6.92,-6.92],[-7.39,-7.39],[-12.13,-12.13],[0.0,0.0],[8.91,-8.91],[0.0,0.0],[6.2,6.2],[6.7,6.7],[6.91,6.91],[7.02,7.02],[7.1,7.1],[7.16,7.16],[7.2,7.2],[7.24,7.24],[7.27,7.27],[7.3,7.3],[7.33,7.33],[7.36,7.36],[7.4,7.4],[7.44,7.44],[7.49,7.49],[7.56,7.56],[7.68,7.68],[7.87,7.87],[8.36,8.36],[13.86,13.86],[0.0,0.0],[-8.91,8.91],[0.0,0.0],[-6.28,-6.28],[-6.84,-6.84],[-7.13,-7.13],[-7.42,-7.42],[-7.91,-7.91],[-12.99,-12.99],[0.0,0.0],[-5.6,8.06],[-6.18,8.89],[-6.71,9.67],[-11.02,15.86]],"v":[[250.0,550.0],[250.0,521.44],[250.0,492.87],[250.0,464.29],[250.0,435.71],[250.0,407.13],[250.0,378.56],[250.0,350.0],[212.5,350.0],[175.0,350.0],[175.0,315.42],[175.0,280.83],[175.0,246.25],[154.92,226.17],[134.83,206.08],[114.73,185.98],[94.64,165.89],[74.55,145.8],[54.46,125.71],[34.38,105.62],[52.19,87.81],[70.0,70.0],[91.84,91.84],[113.78,113.78],[135.68,135.68],[157.6,157.6],[179.5,179.5],[201.4,201.4],[223.32,223.32],[245.23,245.23],[267.14,267.14],[289.05,289.05],[310.95,310.95],[332.86,332.86],[354.77,354.77],[376.68,376.68],[398.6,398.6],[420.5,420.5],[442.4,442.4],[464.32,464.32],[486.22,486.22],[508.16,508.16],[530.0,530.0],[512.19,547.81],[494.38,565.62],[472.87,544.12],[451.35,522.6],[429.82,501.07],[408.3,479.55],[386.78,458.03],[365.26,436.51],[343.75,415.0],[325.01,441.99],[306.25,469.0],[287.5,496.0],[268.74,523.01]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,6.32],[0.0,3.85],[0.0,3.54],[0.0,3.21],[0.0,0.0],[-6.92,0.0],[-4.19,0.0],[-3.96,0.0],[-3.85,0.0],[-3.78,0.0],[-3.73,0.0],[-3.69,0.0],[-3.63,0.0],[-3.56,0.0],[-3.45,0.0],[-3.19,0.0],[0.0,0.0],[2.01,-6.99],[1.21,-4.22],[1.15,-3.98],[1.11,-3.88],[1.1,-3.82],[1.08,-3.77],[1.07,-3.73],[1.06,-3.69],[1.05,-3.64],[1.03,-3.57],[1.0,-3.46],[0.92,-3.2],[0.0,0.0],[-4.21,-4.21],[-2.55,-2.55],[-2.24,-2.24],[0.0,0.0],[-6.2,0.0],[-3.62,0.0],[0.0,0.0],[3.44,-5.0],[0.0,0.0],[5.23,5.23],[3.16,3.16],[2.98,2.98],[2.9,2.9],[2.86,2.86],[2.82,2.82],[2.8,2.8],[2.78,2.78],[2.77,2.77],[2.75,2.75],[2.73,2.73],[2.71,2.71],[2.68,2.68],[2.63,2.63],[2.55,2.55],[2.36,2.36]],"o":[[0.0,0.0],[0.0,-3.21],[0.0,-3.54],[0.0,-3.85],[0.0,-6.32],[0.0,0.0],[3.19,0.0],[3.45,0.0],[3.56,0.0],[3.63,0.0],[3.69,0.0],[3.73,0.0],[3.78,0.0],[3.85,0.0],[3.96,0.0],[4.19,0.0],[6.92,0.0],[0.0,0.0],[-0.92,3.2],[-1.0,3.46],[-1.03,3.57],[-1.05,3.64],[-1.06,3.69],[-1.07,3.73],[-1.08,3.77],[-1.1,3.82],[-1.11,3.88],[-1.15,3.98],[-1.21,4.22],[-2.01,6.99],[0.0,0.0],[2.24,2.24],[2.55,2.55],[4.21,4.21],[0.0,0.0],[3.62,0.0],[6.2,0.0],[0.0,0.0],[-3.44,5.0],[0.0,0.0],[-2.36,-2.36],[-2.55,-2.55],[-2.63,-2.63],[-2.68,-2.68],[-2.71,-2.71],[-2.73,-2.73],[-2.75,-2.75],[-2.77,-2.77],[-2.78,-2.78],[-2.8,-2.8],[-2.82,-2.82],[-2.86,-2.86],[-2.9,-2.9],[-2.98,-2.98],[-3.16,-3.16],[-5.23,-5.23]],"v":[[225.0,153.75],[225.0,143.0],[225.0,132.25],[225.0,121.5],[225.0,110.75],[225.0,100.0],[236.13,100.0],[247.28,100.0],[258.43,100.0],[269.58,100.0],[280.73,100.0],[291.88,100.0],[303.02,100.0],[314.17,100.0],[325.32,100.0],[336.47,100.0],[347.62,100.0],[358.75,100.0],[355.53,111.19],[352.31,122.4],[349.09,133.6],[345.87,144.8],[342.64,156.01],[339.42,167.21],[336.2,178.41],[332.98,189.62],[329.76,200.82],[326.54,212.02],[323.32,223.23],[320.09,234.43],[316.88,245.63],[324.22,252.97],[331.56,260.31],[338.91,267.66],[346.25,275.0],[357.5,275.0],[368.75,275.0],[380.0,275.0],[373.13,285.0],[366.25,295.0],[357.96,286.71],[349.65,278.4],[341.33,270.08],[333.02,261.77],[324.71,253.46],[316.4,245.15],[308.09,236.84],[299.78,228.53],[291.47,220.22],[283.16,211.91],[274.85,203.6],[266.54,195.29],[258.23,186.98],[249.92,178.67],[241.6,170.35],[233.29,162.04]],"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":"slashed","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":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":[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,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"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],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-5.45,7.83],[-3.31,4.76],[-3.12,4.48],[-3.02,4.34],[-2.95,4.24],[-2.88,4.14],[-2.78,4.0],[-2.57,3.69],[0.0,0.0],[10.22,0.0],[6.19,0.0],[5.44,0.0],[0.0,0.0],[-2.81,9.85],[-1.7,5.97],[-1.61,5.63],[-1.56,5.47],[-1.53,5.37],[-1.51,5.29],[-1.49,5.21],[-1.46,5.1],[-1.41,4.94],[-1.3,4.56],[0.0,0.0],[10.18,0.0],[6.19,0.0],[5.82,0.0],[5.63,0.0],[5.47,0.0],[5.26,0.0],[4.85,0.0],[0.0,0.0],[0.0,-9.59],[0.0,-5.8],[0.0,-5.47],[0.0,-5.32],[0.0,-5.24],[0.0,-5.17],[0.0,-5.12],[0.0,-5.06],[0.0,-5.0],[0.0,-4.9],[0.0,-4.75],[0.0,-4.39],[0.0,0.0],[-8.81,0.0],[-5.37,0.0],[-4.94,0.0],[-4.48,0.0],[0.0,0.0],[0.0,-8.95],[0.0,-5.46],[0.0,-5.09],[0.0,-4.83],[0.0,-4.42]],"o":[[0.0,0.0],[2.57,-3.69],[2.78,-4.0],[2.88,-4.14],[2.95,-4.24],[3.02,-4.34],[3.12,-4.48],[3.31,-4.76],[5.45,-7.83],[0.0,0.0],[-5.44,0.0],[-6.19,0.0],[-10.22,0.0],[0.0,0.0],[1.3,-4.56],[1.41,-4.94],[1.46,-5.1],[1.49,-5.21],[1.51,-5.29],[1.53,-5.37],[1.56,-5.47],[1.61,-5.63],[1.7,-5.97],[2.81,-9.85],[0.0,0.0],[-4.85,0.0],[-5.26,0.0],[-5.47,0.0],[-5.63,0.0],[-5.82,0.0],[-6.19,0.0],[-10.18,0.0],[0.0,0.0],[0.0,4.39],[0.0,4.75],[0.0,4.9],[0.0,5.0],[0.0,5.06],[0.0,5.12],[0.0,5.17],[0.0,5.24],[0.0,5.32],[0.0,5.47],[0.0,5.8],[0.0,9.59],[0.0,0.0],[4.48,0.0],[4.94,0.0],[5.37,0.0],[8.81,0.0],[0.0,0.0],[0.0,4.42],[0.0,4.83],[0.0,5.09],[0.0,5.46],[0.0,8.95]],"v":[[300.0,390.0],[308.88,377.23],[317.77,364.45],[326.66,351.67],[335.55,338.89],[344.45,326.11],[353.34,313.33],[362.23,300.55],[371.12,287.77],[380.0,275.0],[362.19,275.0],[344.37,275.0],[326.56,275.0],[308.75,275.0],[313.29,259.1],[317.84,243.2],[322.39,227.27],[326.93,211.37],[331.48,195.45],[336.02,179.55],[340.57,163.63],[345.11,147.73],[349.66,131.8],[354.21,115.9],[358.75,100.0],[342.04,100.0],[325.32,100.0],[308.6,100.0],[291.88,100.0],[275.15,100.0],[258.43,100.0],[241.71,100.0],[225.0,100.0],[225.0,115.37],[225.0,130.76],[225.0,146.15],[225.0,161.53],[225.0,176.92],[225.0,192.31],[225.0,207.69],[225.0,223.08],[225.0,238.47],[225.0,253.85],[225.0,269.24],[225.0,284.63],[225.0,300.0],[239.99,300.0],[255.0,300.0],[270.0,300.0],[285.01,300.0],[300.0,300.0],[300.0,314.99],[300.0,330.0],[300.0,345.0],[300.0,360.0],[300.0,375.01]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,17.25],[0.0,10.51],[0.0,9.85],[0.0,9.47],[0.0,9.08],[0.0,8.34],[0.0,0.0],[13.77,0.0],[8.05,0.0],[0.0,0.0],[0.0,16.89],[0.0,10.23],[0.0,9.65],[0.0,9.38],[0.0,9.21],[0.0,9.07],[0.0,8.93],[0.0,8.75],[0.0,8.48],[0.0,7.82],[0.0,0.0],[-17.02,0.0],[-10.35,0.0],[-9.74,0.0],[-9.44,0.0],[-9.23,0.0],[-9.01,0.0],[-8.7,0.0],[-8.02,0.0],[0.0,0.0],[4.31,-15.09],[2.63,-9.19],[2.46,-8.62],[2.37,-8.28],[2.27,-7.94],[2.09,-7.3],[0.0,0.0],[-14.35,0.0],[-8.68,0.0],[-7.64,0.0],[0.0,0.0],[9.41,-13.59],[5.67,-8.19],[5.35,-7.73],[5.22,-7.54],[5.14,-7.42],[5.08,-7.34],[5.03,-7.27],[4.99,-7.21],[4.95,-7.16],[4.91,-7.09],[4.85,-7.01],[4.77,-6.88],[4.62,-6.67],[4.26,-6.16]],"o":[[0.0,0.0],[0.0,-8.34],[0.0,-9.08],[0.0,-9.47],[0.0,-9.85],[0.0,-10.51],[0.0,-17.25],[0.0,0.0],[-8.05,0.0],[-13.77,0.0],[0.0,0.0],[0.0,-7.82],[0.0,-8.48],[0.0,-8.75],[0.0,-8.93],[0.0,-9.07],[0.0,-9.21],[0.0,-9.38],[0.0,-9.65],[0.0,-10.23],[0.0,-16.89],[0.0,0.0],[8.02,0.0],[8.7,0.0],[9.01,0.0],[9.23,0.0],[9.44,0.0],[9.74,0.0],[10.35,0.0],[17.02,0.0],[0.0,0.0],[-2.09,7.3],[-2.27,7.94],[-2.37,8.28],[-2.46,8.62],[-2.63,9.19],[-4.31,15.09],[0.0,0.0],[7.64,0.0],[8.68,0.0],[14.35,0.0],[0.0,0.0],[-4.26,6.16],[-4.62,6.67],[-4.77,6.88],[-4.85,7.01],[-4.91,7.09],[-4.95,7.16],[-4.99,7.21],[-5.03,7.27],[-5.08,7.34],[-5.14,7.42],[-5.22,7.54],[-5.35,7.73],[-5.67,8.19],[-9.41,13.59]],"v":[[250.0,550.0],[250.0,521.44],[250.0,492.87],[250.0,464.29],[250.0,435.71],[250.0,407.13],[250.0,378.56],[250.0,350.0],[225.0,350.0],[200.0,350.0],[175.0,350.0],[175.0,322.74],[175.0,295.48],[175.0,268.18],[175.0,240.91],[175.0,213.64],[175.0,186.36],[175.0,159.09],[175.0,131.82],[175.0,104.52],[175.0,77.26],[175.0,50.0],[202.76,50.0],[230.54,50.0],[258.33,50.0],[286.11,50.0],[313.89,50.0],[341.67,50.0],[369.46,50.0],[397.24,50.0],[425.0,50.0],[417.86,74.99],[410.72,99.99],[403.57,125.0],[396.43,150.0],[389.28,175.01],[382.14,200.01],[375.0,225.0],[400.0,225.0],[425.0,225.0],[450.0,225.0],[475.0,225.0],[460.01,246.65],[445.02,268.3],[430.02,289.98],[415.01,311.65],[400.01,333.32],[385.0,354.99],[370.0,376.66],[355.0,398.34],[340.0,420.01],[324.99,441.68],[309.99,463.35],[294.98,485.02],[279.98,506.7],[264.99,528.35]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,8.95],[0.0,5.46],[0.0,5.09],[0.0,4.83],[0.0,4.42],[0.0,0.0],[8.81,0.0],[5.37,0.0],[4.94,0.0],[4.48,0.0],[0.0,0.0],[0.0,9.59],[0.0,5.8],[0.0,5.47],[0.0,5.32],[0.0,5.24],[0.0,5.17],[0.0,5.12],[0.0,5.06],[0.0,5.0],[0.0,4.9],[0.0,4.75],[0.0,4.39],[0.0,0.0],[-10.18,0.0],[-6.19,0.0],[-5.82,0.0],[-5.63,0.0],[-5.47,0.0],[-5.26,0.0],[-4.85,0.0],[0.0,0.0],[2.81,-9.85],[1.7,-5.97],[1.61,-5.63],[1.56,-5.47],[1.53,-5.37],[1.51,-5.29],[1.49,-5.21],[1.46,-5.1],[1.41,-4.94],[1.3,-4.56],[0.0,0.0],[-10.22,0.0],[-6.19,0.0],[-5.44,0.0],[0.0,0.0],[5.45,-7.83],[3.31,-4.76],[3.12,-4.48],[3.02,-4.34],[2.95,-4.24],[2.88,-4.14],[2.78,-4.0],[2.57,-3.69]],"o":[[0.0,0.0],[0.0,-4.42],[0.0,-4.83],[0.0,-5.09],[0.0,-5.46],[0.0,-8.95],[0.0,0.0],[-4.48,0.0],[-4.94,0.0],[-5.37,0.0],[-8.81,0.0],[0.0,0.0],[0.0,-4.39],[0.0,-4.75],[0.0,-4.9],[0.0,-5.0],[0.0,-5.06],[0.0,-5.12],[0.0,-5.17],[0.0,-5.24],[0.0,-5.32],[0.0,-5.47],[0.0,-5.8],[0.0,-9.59],[0.0,0.0],[4.85,0.0],[5.26,0.0],[5.47,0.0],[5.63,0.0],[5.82,0.0],[6.19,0.0],[10.18,0.0],[0.0,0.0],[-1.3,4.56],[-1.41,4.94],[-1.46,5.1],[-1.49,5.21],[-1.51,5.29],[-1.53,5.37],[-1.56,5.47],[-1.61,5.63],[-1.7,5.97],[-2.81,9.85],[0.0,0.0],[5.44,0.0],[6.19,0.0],[10.22,0.0],[0.0,0.0],[-2.57,3.69],[-2.78,4.0],[-2.88,4.14],[-2.95,4.24],[-3.02,4.34],[-3.12,4.48],[-3.31,4.76],[-5.45,7.83]],"v":[[300.0,390.0],[300.0,375.01],[300.0,360.0],[300.0,345.0],[300.0,330.0],[300.0,314.99],[300.0,300.0],[285.01,300.0],[270.0,300.0],[255.0,300.0],[239.99,300.0],[225.0,300.0],[225.0,284.63],[225.0,269.24],[225.0,253.85],[225.0,238.47],[225.0,223.08],[225.0,207.69],[225.0,192.31],[225.0,176.92],[225.0,161.53],[225.0,146.15],[225.0,130.76],[225.0,115.37],[225.0,100.0],[241.71,100.0],[258.43,100.0],[275.15,100.0],[291.88,100.0],[308.6,100.0],[325.32,100.0],[342.04,100.0],[358.75,100.0],[354.21,115.9],[349.66,131.8],[345.11,147.73],[340.57,163.63],[336.02,179.55],[331.48,195.45],[326.93,211.37],[322.39,227.27],[317.84,243.2],[313.29,259.1],[308.75,275.0],[326.56,275.0],[344.38,275.0],[362.19,275.0],[380.0,275.0],[371.12,287.77],[362.23,300.55],[353.34,313.33],[344.45,326.11],[335.55,338.89],[326.66,351.67],[317.77,364.45],[308.88,377.23]],"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":"plain","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_on_to_off.json b/assets/lottie/ic_mic_on_to_off.json new file mode 100644 index 0000000..e414c4e --- /dev/null +++ b/assets/lottie/ic_mic_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_mic_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":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":[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,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"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],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.9,-1.31],[2.04,2.04],[1.24,1.24],[1.17,1.17],[1.13,1.13],[1.11,1.11],[1.1,1.1],[1.08,1.08],[1.06,1.06],[1.02,1.02],[0.94,0.94],[0.0,0.0],[-0.76,1.36],[-0.71,1.39],[-0.65,1.41],[-0.6,1.44],[-0.54,1.46],[-0.48,1.49],[-0.42,1.51],[-0.36,1.53],[-0.3,1.54],[-0.24,1.55],[-0.18,1.56],[-0.12,1.57],[-0.06,1.57],[0.0,1.57],[-3.08,0.0],[-1.87,0.0],[-1.76,0.0],[-1.71,0.0],[-1.68,0.0],[-1.65,0.0],[-1.61,0.0],[-1.56,0.0],[-1.44,0.0],[0.0,0.0],[0.05,-1.76],[0.1,-1.76],[0.16,-1.75],[0.21,-1.75],[0.27,-1.74],[0.32,-1.73],[0.38,-1.72],[0.44,-1.71],[0.5,-1.7],[0.55,-1.68],[0.53,-1.49],[0.57,-1.47],[0.61,-1.46],[0.65,-1.44],[0.69,-1.43],[0.72,-1.41],[0.76,-1.39],[0.8,-1.37],[0.83,-1.35],[0.86,-1.33]],"o":[[0.0,0.0],[-0.94,-0.94],[-1.02,-1.02],[-1.06,-1.06],[-1.08,-1.08],[-1.1,-1.1],[-1.11,-1.11],[-1.13,-1.13],[-1.17,-1.17],[-1.24,-1.24],[-2.04,-2.04],[0.82,-1.34],[0.77,-1.37],[0.71,-1.4],[0.66,-1.42],[0.6,-1.45],[0.54,-1.47],[0.48,-1.49],[0.42,-1.51],[0.36,-1.52],[0.3,-1.53],[0.24,-1.54],[0.18,-1.55],[0.12,-1.56],[0.06,-1.56],[0.0,0.0],[1.44,0.0],[1.56,0.0],[1.61,0.0],[1.65,0.0],[1.68,0.0],[1.71,0.0],[1.76,0.0],[1.87,0.0],[3.08,0.0],[0.0,1.78],[-0.05,1.78],[-0.1,1.77],[-0.16,1.77],[-0.21,1.76],[-0.27,1.75],[-0.33,1.74],[-0.38,1.73],[-0.44,1.71],[-0.5,1.7],[-0.49,1.5],[-0.53,1.49],[-0.57,1.47],[-0.61,1.46],[-0.65,1.44],[-0.69,1.42],[-0.72,1.4],[-0.76,1.38],[-0.79,1.36],[-0.83,1.34],[-0.86,1.32]],"v":[[443.75,373.75],[440.46,370.46],[437.16,367.16],[433.86,363.86],[430.57,360.57],[427.27,357.27],[423.98,353.98],[420.68,350.68],[417.39,347.39],[414.09,344.09],[410.79,340.79],[407.5,337.5],[409.87,333.45],[412.08,329.31],[414.13,325.09],[416.02,320.79],[417.73,316.43],[419.27,311.99],[420.62,307.5],[421.8,302.95],[422.79,298.36],[423.59,293.73],[424.22,289.07],[424.65,284.39],[424.91,279.7],[425.0,275.0],[429.99,275.0],[435.0,275.0],[440.0,275.0],[445.0,275.0],[450.0,275.0],[455.0,275.0],[460.0,275.0],[465.0,275.0],[470.01,275.0],[475.0,275.0],[474.92,280.3],[474.69,285.6],[474.3,290.89],[473.75,296.17],[473.03,301.42],[472.14,306.65],[471.08,311.85],[469.85,317.01],[468.45,322.12],[466.88,327.19],[465.33,331.67],[463.68,336.12],[461.9,340.51],[460.02,344.86],[458.01,349.16],[455.9,353.41],[453.68,357.6],[451.35,361.73],[448.92,365.8],[446.38,369.81]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-2.29,0.0],[-2.05,-0.5],[-1.9,-1.09],[-1.6,-1.6],[-0.9,-1.34],[-0.61,-1.53],[-0.29,-1.65],[0.0,-1.7],[0.0,-4.07],[0.0,-2.45],[0.0,-2.31],[0.0,-2.25],[0.0,-2.22],[0.0,-2.2],[0.0,-2.18],[0.0,-2.17],[0.0,-2.16],[0.0,-2.15],[0.0,-2.14],[0.0,-2.13],[0.0,-2.12],[0.0,-2.11],[0.0,-2.09],[0.0,-2.07],[0.0,-2.03],[0.0,-1.97],[0.0,-1.82],[0.0,0.0],[2.81,2.81],[1.7,1.7],[1.61,1.61],[1.56,1.56],[1.53,1.53],[1.51,1.51],[1.49,1.49],[1.46,1.46],[1.41,1.41],[1.3,1.3],[0.0,0.0],[0.0,4.08],[0.0,2.47],[0.0,2.33],[0.0,2.26],[0.0,2.23],[0.0,2.2],[0.0,2.17],[0.0,2.14],[0.0,2.1],[0.0,2.03],[0.0,1.88],[0.0,0.0],[-0.5,2.05],[-1.09,1.9],[-1.6,1.6],[-1.86,1.07],[-2.16,0.53]],"o":[[2.29,0.0],[2.16,0.53],[1.86,1.07],[1.2,1.2],[0.92,1.38],[0.6,1.51],[0.28,1.57],[0.0,0.0],[0.0,1.82],[0.0,1.97],[0.0,2.03],[0.0,2.07],[0.0,2.09],[0.0,2.11],[0.0,2.12],[0.0,2.13],[0.0,2.14],[0.0,2.15],[0.0,2.16],[0.0,2.17],[0.0,2.18],[0.0,2.2],[0.0,2.22],[0.0,2.25],[0.0,2.31],[0.0,2.45],[0.0,4.07],[0.0,0.0],[-1.3,-1.3],[-1.41,-1.41],[-1.46,-1.46],[-1.49,-1.49],[-1.51,-1.51],[-1.53,-1.53],[-1.56,-1.56],[-1.61,-1.61],[-1.7,-1.7],[-2.81,-2.81],[0.0,0.0],[0.0,-1.88],[0.0,-2.03],[0.0,-2.1],[0.0,-2.14],[0.0,-2.17],[0.0,-2.2],[0.0,-2.23],[0.0,-2.26],[0.0,-2.33],[0.0,-2.47],[0.0,-4.08],[0.0,-2.29],[0.53,-2.16],[1.07,-1.86],[1.6,-1.6],[1.9,-1.09],[2.05,-0.5]],"v":[[300.0,100.0],[306.52,100.75],[312.62,103.18],[317.81,107.19],[320.95,110.99],[323.25,115.35],[324.58,120.09],[325.0,125.0],[325.0,131.42],[325.0,137.86],[325.0,144.3],[325.0,150.74],[325.0,157.18],[325.0,163.62],[325.0,170.06],[325.0,176.5],[325.0,182.94],[325.0,189.38],[325.0,195.81],[325.0,202.25],[325.0,208.69],[325.0,215.13],[325.0,221.57],[325.0,228.01],[325.0,234.45],[325.0,240.89],[325.0,247.33],[325.0,253.75],[320.46,249.21],[315.91,244.66],[311.36,240.11],[306.82,235.57],[302.27,231.02],[297.73,226.48],[293.18,221.93],[288.64,217.39],[284.09,212.84],[279.54,208.29],[275.0,203.75],[275.0,197.2],[275.0,190.63],[275.0,184.06],[275.0,177.5],[275.0,170.94],[275.0,164.37],[275.0,157.81],[275.0,151.25],[275.0,144.69],[275.0,138.12],[275.0,131.55],[275.0,125.0],[275.75,118.48],[278.18,112.38],[282.19,107.19],[287.38,103.18],[293.48,100.75]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[1.25,-3.75],[6.46,6.46],[3.91,3.91],[3.44,3.44],[0.0,0.0],[0.0,9.8],[0.0,5.96],[0.0,5.6],[0.0,5.42],[0.0,5.26],[0.0,5.07],[0.0,4.67],[0.0,0.0],[4.79,4.79],[7.08,0.0],[4.79,-4.79],[0.0,-7.08],[0.0,-9.25],[0.0,-5.64],[0.0,-5.19],[0.0,-4.7],[0.0,0.0],[5.88,5.88],[3.58,3.58],[3.29,3.29],[2.98,2.98],[0.0,0.0],[0.0,7.19],[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],[0.0,-5.08],[0.0,-9.24],[0.0,-5.61],[0.0,-5.28],[0.0,-5.13],[0.0,-5.03],[0.0,-4.94],[0.0,-4.84],[0.0,-4.68],[0.0,-4.32],[0.0,0.0],[1.04,-3.75]],"o":[[0.0,0.0],[-3.44,-3.44],[-3.91,-3.91],[-6.46,-6.46],[0.0,0.0],[0.0,-4.67],[0.0,-5.07],[0.0,-5.26],[0.0,-5.42],[0.0,-5.6],[0.0,-5.96],[0.0,-9.8],[0.0,-7.08],[-4.79,-4.79],[-7.08,0.0],[-4.79,4.79],[0.0,0.0],[0.0,4.7],[0.0,5.19],[0.0,5.64],[0.0,9.25],[0.0,0.0],[-2.98,-2.98],[-3.29,-3.29],[-3.58,-3.58],[-5.88,-5.88],[0.0,0.0],[0.0,-7.19],[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],[0.0,0.0],[0.0,4.32],[0.0,4.68],[0.0,4.84],[0.0,4.94],[0.0,5.03],[0.0,5.13],[0.0,5.28],[0.0,5.61],[0.0,9.24],[0.0,4.58],[-1.04,3.75]],"v":[[370.0,298.75],[358.75,287.5],[347.5,276.25],[336.25,265.0],[325.0,253.75],[325.0,237.66],[325.0,221.57],[325.0,205.47],[325.0,189.38],[325.0,173.28],[325.0,157.18],[325.0,141.09],[325.0,125.0],[317.81,107.19],[300.0,100.0],[282.19,107.19],[275.0,125.0],[275.0,140.74],[275.0,156.5],[275.0,172.25],[275.0,188.01],[275.0,203.75],[265.0,193.75],[255.0,183.75],[245.0,173.75],[235.0,163.75],[225.0,153.75],[225.0,139.38],[225.0,125.0],[226.3,110.31],[230.44,96.17],[237.44,83.2],[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],[362.56,83.2],[369.56,96.17],[373.7,110.31],[375.0,125.0],[375.0,139.98],[375.0,154.99],[375.0,169.99],[375.0,185.0],[375.0,200.0],[375.0,215.0],[375.0,230.01],[375.0,245.01],[375.0,260.02],[375.0,275.0],[373.44,287.5]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,9.11],[0.0,5.55],[0.0,5.11],[0.0,4.63],[0.0,0.0],[5.02,1.1],[4.91,1.54],[4.77,2.0],[4.58,2.45],[4.34,2.9],[4.06,3.31],[3.76,3.67],[3.45,3.99],[3.41,4.79],[2.91,5.13],[2.36,5.45],[1.76,5.7],[1.15,5.89],[0.56,6.01],[0.0,6.05],[-9.18,0.0],[-5.36,0.0],[0.0,0.0],[-0.64,-5.32],[-1.34,-5.23],[-2.08,-5.03],[-2.79,-4.74],[-3.42,-4.36],[-3.95,-3.95],[-4.22,-3.31],[-4.65,-2.74],[-5.04,-2.08],[-5.33,-1.37],[-5.51,-0.66],[-5.59,0.0],[-6.63,1.06],[-6.44,2.22],[-5.75,3.05],[-5.24,3.99],[-6.54,-6.54],[-3.82,-3.82],[0.0,0.0],[4.31,-2.86],[4.55,-2.5],[4.75,-2.13],[5.06,-1.68],[5.23,-1.18],[5.34,-0.68],[0.0,-9.11],[0.0,-5.55],[0.0,-5.11],[0.0,-4.63],[0.0,0.0],[9.18,0.0],[5.36,0.0]],"o":[[0.0,0.0],[0.0,-4.63],[0.0,-5.11],[0.0,-5.55],[0.0,-9.11],[-5.23,-0.7],[-5.14,-1.13],[-5.01,-1.57],[-4.82,-2.02],[-4.58,-2.46],[-4.3,-2.88],[-4.0,-3.25],[-3.68,-3.59],[-3.96,-4.57],[-3.49,-4.91],[-2.95,-5.21],[-2.36,-5.47],[-1.75,-5.66],[-1.13,-5.78],[-0.54,-5.84],[0.0,0.0],[5.36,0.0],[9.18,0.0],[0.0,5.59],[0.66,5.51],[1.37,5.33],[2.08,5.04],[2.74,4.65],[3.31,4.22],[3.95,3.95],[4.36,3.42],[4.74,2.79],[5.03,2.08],[5.23,1.34],[5.32,0.64],[6.99,0.0],[6.81,-1.09],[6.31,-2.17],[5.82,-3.09],[0.0,0.0],[3.82,3.82],[6.54,6.54],[-4.05,3.21],[-4.29,2.85],[-4.5,2.48],[-4.85,2.18],[-5.02,1.67],[-5.14,1.16],[0.0,0.0],[0.0,4.63],[0.0,5.11],[0.0,5.55],[0.0,9.11],[0.0,0.0],[-5.36,0.0],[-9.18,0.0]],"v":[[275.0,525.0],[275.0,509.51],[275.0,494.0],[275.0,478.5],[275.0,462.99],[275.0,447.5],[259.63,444.8],[244.54,440.8],[229.87,435.46],[215.78,428.75],[202.4,420.72],[189.84,411.45],[178.2,401.05],[167.5,389.69],[156.45,375.65],[146.85,360.59],[138.88,344.6],[132.7,327.85],[128.35,310.53],[125.81,292.85],[125.0,275.0],[141.67,275.0],[158.33,275.0],[175.0,275.0],[175.95,291.36],[178.95,307.46],[184.12,323.01],[191.43,337.67],[200.68,351.19],[211.56,363.44],[223.81,374.32],[237.33,383.57],[251.99,390.88],[267.54,396.05],[283.64,399.05],[300.0,400.0],[320.43,398.4],[340.31,393.44],[358.4,385.61],[375.0,375.0],[386.87,386.87],[398.75,398.75],[410.62,410.62],[398.08,419.74],[384.82,427.77],[370.94,434.69],[356.08,440.47],[340.71,444.74],[325.0,447.5],[325.0,462.99],[325.0,478.5],[325.0,494.0],[325.0,509.51],[325.0,525.0],[308.33,525.0],[291.67,525.0]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[11.25,11.25],[6.78,6.78],[6.37,6.37],[6.23,6.23],[6.13,6.13],[6.07,6.07],[6.03,6.03],[6.0,6.0],[5.98,5.98],[5.96,5.96],[5.93,5.93],[5.92,5.92],[5.9,5.9],[5.89,5.89],[5.87,5.87],[5.85,5.85],[5.84,5.84],[5.82,5.82],[5.79,5.79],[5.75,5.75],[5.71,5.71],[5.66,5.66],[5.57,5.57],[5.39,5.39],[4.99,4.99],[0.0,0.0],[-8.75,8.75],[0.0,0.0],[-11.25,-11.25],[-6.78,-6.78],[-6.37,-6.37],[-6.23,-6.23],[-6.13,-6.13],[-6.07,-6.07],[-6.03,-6.03],[-6.0,-6.0],[-5.98,-5.98],[-5.96,-5.96],[-5.93,-5.93],[-5.92,-5.92],[-5.9,-5.9],[-5.89,-5.89],[-5.87,-5.87],[-5.85,-5.85],[-5.84,-5.84],[-5.82,-5.82],[-5.79,-5.79],[-5.75,-5.75],[-5.71,-5.71],[-5.66,-5.66],[-5.57,-5.57],[-5.39,-5.39],[-4.99,-4.99],[0.0,0.0],[8.75,-8.75]],"o":[[0.0,0.0],[-4.99,-4.99],[-5.39,-5.39],[-5.57,-5.57],[-5.66,-5.66],[-5.71,-5.71],[-5.75,-5.75],[-5.79,-5.79],[-5.82,-5.82],[-5.84,-5.84],[-5.85,-5.85],[-5.87,-5.87],[-5.89,-5.89],[-5.9,-5.9],[-5.92,-5.92],[-5.93,-5.93],[-5.96,-5.96],[-5.98,-5.98],[-6.0,-6.0],[-6.03,-6.03],[-6.07,-6.07],[-6.13,-6.13],[-6.23,-6.23],[-6.37,-6.37],[-6.78,-6.78],[-11.25,-11.25],[0.0,0.0],[8.75,-8.75],[0.0,0.0],[4.99,4.99],[5.39,5.39],[5.57,5.57],[5.66,5.66],[5.71,5.71],[5.75,5.75],[5.79,5.79],[5.82,5.82],[5.84,5.84],[5.85,5.85],[5.87,5.87],[5.89,5.89],[5.9,5.9],[5.92,5.92],[5.93,5.93],[5.96,5.96],[5.98,5.98],[6.0,6.0],[6.03,6.03],[6.07,6.07],[6.13,6.13],[6.23,6.23],[6.37,6.37],[6.78,6.78],[11.25,11.25],[0.0,0.0],[-8.75,8.75]],"v":[[495.0,565.0],[477.37,547.37],[459.64,529.64],[441.97,511.97],[424.25,494.25],[406.54,476.54],[388.85,458.85],[371.17,441.17],[353.48,423.48],[335.78,405.78],[318.08,388.08],[300.39,370.39],[282.7,352.7],[265.0,335.0],[247.3,317.3],[229.61,299.61],[211.92,281.92],[194.22,264.22],[176.52,246.52],[158.83,228.83],[141.15,211.15],[123.46,193.46],[105.75,175.75],[88.03,158.03],[70.36,140.36],[52.63,122.63],[35.0,105.0],[52.5,87.5],[70.0,70.0],[87.63,87.63],[105.36,105.36],[123.03,123.03],[140.75,140.75],[158.46,158.46],[176.15,176.15],[193.83,193.83],[211.52,211.52],[229.22,229.22],[246.92,246.92],[264.61,264.61],[282.3,282.3],[300.0,300.0],[317.7,317.7],[335.39,335.39],[353.08,353.08],[370.78,370.78],[388.48,388.48],[406.17,406.17],[423.85,423.85],[441.54,441.54],[459.25,459.25],[476.97,476.97],[494.64,494.64],[512.37,512.37],[530.0,530.0],[512.5,547.5]],"c":true},"ix":2},"nm":"Path 5","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":"slashed","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":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":[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,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"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],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"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},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-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],[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]],"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,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]],"v":[[300.0,100.0],[309.65,101.75],[317.81,107.19],[321.82,112.38],[324.25,118.48],[325.0,125.0],[325.0,133.32],[325.0,141.65],[325.0,149.99],[325.0,158.32],[325.0,166.66],[325.0,175.0],[325.0,183.33],[325.0,191.66],[325.0,200.0],[325.0,208.34],[325.0,216.67],[325.0,225.0],[325.0,233.34],[325.0,241.68],[325.0,250.01],[325.0,258.35],[325.0,266.68],[325.0,275.0],[323.25,284.65],[317.81,292.81],[312.62,296.82],[306.52,299.25],[300.0,300.0],[290.35,298.25],[282.19,292.81],[278.18,287.62],[275.75,281.52],[275.0,275.0],[275.0,266.68],[275.0,258.35],[275.0,250.01],[275.0,241.68],[275.0,233.34],[275.0,225.0],[275.0,216.67],[275.0,208.34],[275.0,200.0],[275.0,191.66],[275.0,183.33],[275.0,175.0],[275.0,166.66],[275.0,158.32],[275.0,149.99],[275.0,141.65],[275.0,133.32],[275.0,125.0],[275.75,118.48],[278.18,112.38],[282.19,107.19],[290.35,101.75]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"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 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"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},"ix":2},"nm":"Path 4","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":"plain","np":6,"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_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_on_to_off.json b/assets/lottie/ic_videocam_on_to_off.json new file mode 100644 index 0000000..e759f3f --- /dev/null +++ b/assets/lottie/ic_videocam_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_videocam_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":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":[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,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"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],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[675.65,-597.15],[-597.15,675.65],[-1869.94,-597.15],[-597.15,-1869.94]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1197.15,-75.65],[-75.65,1197.15],[-1348.44,-75.65],[-75.65,-1348.44]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[11.75,11.75],[7.16,7.16],[6.59,6.59],[5.97,5.97],[0.0,0.0],[0.0,-10.47],[0.0,0.0],[9.18,9.18],[5.36,5.36],[0.0,0.0],[0.0,15.47],[0.0,9.42],[0.0,8.83],[0.0,8.49],[0.0,8.14],[0.0,7.48],[0.0,0.0],[0.0,0.0],[0.0,0.0],[15.47,0.0],[9.42,0.0],[8.83,0.0],[8.49,0.0],[8.14,0.0],[7.48,0.0],[0.0,0.0],[9.18,9.18],[5.36,5.36],[0.0,0.0],[-17.46,0.0],[-10.62,0.0],[-9.98,0.0],[-9.65,0.0],[-9.38,0.0],[-9.03,0.0],[-8.32,0.0],[0.0,0.0],[-9.79,-9.79],[0.0,-13.75],[0.0,-16.14],[0.0,-9.77],[0.0,-8.59],[0.0,0.0],[-11.75,11.75],[-7.16,7.16],[-6.59,6.59],[-5.97,5.97],[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]],"o":[[0.0,0.0],[-5.97,-5.97],[-6.59,-6.59],[-7.16,-7.16],[-11.75,-11.75],[0.0,0.0],[0.0,10.47],[0.0,0.0],[-5.36,-5.36],[-9.18,-9.18],[0.0,0.0],[0.0,-7.48],[0.0,-8.14],[0.0,-8.49],[0.0,-8.83],[0.0,-9.42],[0.0,-15.47],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-7.48,0.0],[-8.14,0.0],[-8.49,0.0],[-8.83,0.0],[-9.42,0.0],[-15.47,0.0],[0.0,0.0],[-5.36,-5.36],[-9.18,-9.18],[0.0,0.0],[8.32,0.0],[9.03,0.0],[9.38,0.0],[9.65,0.0],[9.98,0.0],[10.62,0.0],[17.46,0.0],[13.75,0.0],[9.79,9.79],[0.0,0.0],[0.0,8.59],[0.0,9.77],[0.0,16.14],[0.0,0.0],[5.97,-5.97],[6.59,-6.59],[7.16,-7.16],[11.75,-11.75],[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]],"v":[[550.0,437.5],[530.01,417.51],[510.0,397.5],[490.0,377.5],[469.99,357.49],[450.0,337.5],[450.0,358.44],[450.0,379.38],[433.33,362.71],[416.67,346.04],[400.0,329.38],[400.0,303.76],[400.0,278.13],[400.0,252.5],[400.0,226.87],[400.0,201.24],[400.0,175.61],[400.0,150.0],[400.0,150.0],[400.0,150.0],[374.39,150.0],[348.76,150.0],[323.13,150.0],[297.5,150.0],[271.87,150.0],[246.24,150.0],[220.63,150.0],[203.96,133.33],[187.29,116.67],[170.62,100.0],[199.29,100.0],[227.96,100.0],[256.63,100.0],[285.31,100.0],[313.99,100.0],[342.66,100.0],[371.34,100.0],[400.0,100.0],[435.31,114.69],[450.0,150.0],[450.0,178.12],[450.0,206.25],[450.0,234.38],[450.0,262.5],[469.99,242.51],[490.0,222.5],[510.0,202.5],[530.01,182.49],[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]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[12.17,12.17],[7.34,7.34],[6.89,6.89],[6.73,6.73],[6.63,6.63],[6.56,6.56],[6.52,6.52],[6.49,6.49],[6.46,6.46],[6.44,6.44],[6.42,6.42],[6.4,6.4],[6.39,6.39],[6.37,6.37],[6.35,6.35],[6.33,6.33],[6.32,6.32],[6.29,6.29],[6.26,6.26],[6.22,6.22],[6.18,6.18],[6.12,6.12],[6.03,6.03],[5.83,5.83],[5.4,5.4],[0.0,0.0],[-8.75,8.75],[0.0,0.0],[-12.17,-12.17],[-7.34,-7.34],[-6.89,-6.89],[-6.73,-6.73],[-6.63,-6.63],[-6.56,-6.56],[-6.52,-6.52],[-6.49,-6.49],[-6.46,-6.46],[-6.44,-6.44],[-6.42,-6.42],[-6.4,-6.4],[-6.39,-6.39],[-6.37,-6.37],[-6.35,-6.35],[-6.33,-6.33],[-6.32,-6.32],[-6.29,-6.29],[-6.26,-6.26],[-6.22,-6.22],[-6.18,-6.18],[-6.12,-6.12],[-6.03,-6.03],[-5.83,-5.83],[-5.4,-5.4],[0.0,0.0],[8.75,-8.75]],"o":[[0.0,0.0],[-5.4,-5.4],[-5.83,-5.83],[-6.03,-6.03],[-6.12,-6.12],[-6.18,-6.18],[-6.22,-6.22],[-6.26,-6.26],[-6.29,-6.29],[-6.32,-6.32],[-6.33,-6.33],[-6.35,-6.35],[-6.37,-6.37],[-6.39,-6.39],[-6.4,-6.4],[-6.42,-6.42],[-6.44,-6.44],[-6.46,-6.46],[-6.49,-6.49],[-6.52,-6.52],[-6.56,-6.56],[-6.63,-6.63],[-6.73,-6.73],[-6.89,-6.89],[-7.34,-7.34],[-12.17,-12.17],[0.0,0.0],[8.75,-8.75],[0.0,0.0],[5.4,5.4],[5.83,5.83],[6.03,6.03],[6.12,6.12],[6.18,6.18],[6.22,6.22],[6.26,6.26],[6.29,6.29],[6.32,6.32],[6.33,6.33],[6.35,6.35],[6.37,6.37],[6.39,6.39],[6.4,6.4],[6.42,6.42],[6.44,6.44],[6.46,6.46],[6.49,6.49],[6.52,6.52],[6.56,6.56],[6.63,6.63],[6.73,6.73],[6.89,6.89],[7.34,7.34],[12.17,12.17],[0.0,0.0],[-8.75,8.75]],"v":[[513.75,583.75],[494.68,564.68],[475.51,545.51],[456.4,526.4],[437.23,507.23],[418.08,488.08],[398.95,468.95],[379.83,449.83],[360.7,430.7],[341.56,411.56],[322.41,392.41],[303.28,373.28],[284.14,354.14],[265.0,335.0],[245.86,315.86],[226.72,296.72],[207.59,277.59],[188.44,258.44],[169.3,239.3],[150.17,220.17],[131.05,201.05],[111.92,181.92],[92.77,162.77],[73.6,143.6],[54.49,124.49],[35.32,105.32],[16.25,86.25],[33.75,68.75],[51.25,51.25],[70.32,70.32],[89.49,89.49],[108.6,108.6],[127.77,127.77],[146.92,146.92],[166.05,166.05],[185.17,185.17],[204.3,204.3],[223.44,223.44],[242.59,242.59],[261.72,261.72],[280.86,280.86],[300.0,300.0],[319.14,319.14],[338.28,338.28],[357.41,357.41],[376.56,376.56],[395.7,395.7],[414.83,414.83],[433.95,433.95],[453.08,453.08],[472.23,472.23],[491.4,491.4],[510.51,510.51],[529.68,529.68],[548.75,548.75],[531.25,566.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-7.04,0.0],[-4.25,0.0],[-4.01,0.0],[-3.91,0.0],[-3.84,0.0],[-3.8,0.0],[-3.77,0.0],[-3.75,0.0],[-3.72,0.0],[-3.69,0.0],[-3.66,0.0],[-3.62,0.0],[-3.56,0.0],[-3.45,0.0],[-3.19,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-7.04],[0.0,-4.25],[0.0,-4.01],[0.0,-3.91],[0.0,-3.84],[0.0,-3.8],[0.0,-3.77],[0.0,-3.75],[0.0,-3.72],[0.0,-3.69],[0.0,-3.66],[0.0,-3.62],[0.0,-3.56],[0.0,-3.45],[0.0,-3.19],[0.0,0.0],[5.17,5.17],[3.11,3.11],[2.93,2.93],[2.86,2.86],[2.82,2.82],[2.79,2.79],[2.77,2.77],[2.75,2.75],[2.74,2.74],[2.73,2.73],[2.72,2.72],[2.71,2.71],[2.7,2.7],[2.69,2.69],[2.68,2.68],[2.66,2.66],[2.64,2.64],[2.61,2.61],[2.57,2.57],[2.49,2.49],[2.31,2.31]],"o":[[0.0,0.0],[3.19,0.0],[3.45,0.0],[3.56,0.0],[3.62,0.0],[3.66,0.0],[3.69,0.0],[3.72,0.0],[3.75,0.0],[3.77,0.0],[3.8,0.0],[3.84,0.0],[3.91,0.0],[4.01,0.0],[4.25,0.0],[7.04,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,3.19],[0.0,3.45],[0.0,3.56],[0.0,3.62],[0.0,3.66],[0.0,3.69],[0.0,3.72],[0.0,3.75],[0.0,3.77],[0.0,3.8],[0.0,3.84],[0.0,3.91],[0.0,4.01],[0.0,4.25],[0.0,7.04],[0.0,0.0],[-2.31,-2.31],[-2.49,-2.49],[-2.57,-2.57],[-2.61,-2.61],[-2.64,-2.64],[-2.66,-2.66],[-2.68,-2.68],[-2.69,-2.69],[-2.7,-2.7],[-2.71,-2.71],[-2.72,-2.72],[-2.73,-2.73],[-2.74,-2.74],[-2.75,-2.75],[-2.77,-2.77],[-2.79,-2.79],[-2.82,-2.82],[-2.86,-2.86],[-2.93,-2.93],[-3.11,-3.11],[-5.17,-5.17]],"v":[[220.63,150.0],[231.82,150.0],[243.04,150.0],[254.25,150.0],[265.46,150.0],[276.67,150.0],[287.89,150.0],[299.1,150.0],[310.31,150.0],[321.53,150.0],[332.74,150.0],[343.95,150.0],[355.16,150.0],[366.38,150.0],[377.59,150.0],[388.81,150.0],[400.0,150.0],[400.0,150.0],[400.0,150.0],[400.0,161.19],[400.0,172.41],[400.0,183.62],[400.0,194.84],[400.0,206.05],[400.0,217.26],[400.0,228.47],[400.0,239.69],[400.0,250.9],[400.0,262.11],[400.0,273.33],[400.0,284.54],[400.0,295.75],[400.0,306.96],[400.0,318.18],[400.0,329.38],[391.86,321.24],[383.7,313.08],[375.55,304.92],[367.4,296.78],[359.24,288.62],[351.08,280.46],[342.93,272.3],[334.78,264.15],[326.62,256.0],[318.47,247.84],[310.31,239.69],[302.16,231.53],[294.0,223.38],[285.85,215.22],[277.7,207.07],[269.54,198.92],[261.38,190.76],[253.22,182.6],[245.08,174.45],[236.92,166.3],[228.76,158.14]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,10.99],[0.0,6.64],[0.0,6.25],[0.0,6.1],[0.0,6.0],[0.0,5.94],[0.0,5.89],[0.0,5.85],[0.0,5.81],[0.0,5.76],[0.0,5.71],[0.0,5.65],[0.0,5.55],[0.0,5.38],[0.0,4.97],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-9.81,-9.81],[-5.91,-5.91],[-5.57,-5.57],[-5.43,-5.43],[-5.35,-5.35],[-5.3,-5.3],[-5.26,-5.26],[-5.22,-5.22],[-5.2,-5.2],[-5.17,-5.17],[-5.14,-5.14],[-5.1,-5.1],[-5.06,-5.06],[-5.01,-5.01],[-4.92,-4.92],[-4.77,-4.77],[-4.41,-4.41],[0.0,0.0],[0.0,0.0],[0.0,0.0],[10.99,0.0],[6.64,0.0],[6.25,0.0],[6.1,0.0],[6.0,0.0],[5.94,0.0],[5.89,0.0],[5.85,0.0],[5.81,0.0],[5.76,0.0],[5.71,0.0],[5.65,0.0],[5.55,0.0],[5.38,0.0],[4.97,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-4.97],[0.0,-5.38],[0.0,-5.55],[0.0,-5.65],[0.0,-5.71],[0.0,-5.76],[0.0,-5.81],[0.0,-5.85],[0.0,-5.89],[0.0,-5.94],[0.0,-6.0],[0.0,-6.1],[0.0,-6.25],[0.0,-6.64],[0.0,-10.99],[0.0,0.0],[0.0,0.0],[0.0,0.0],[4.41,4.41],[4.77,4.77],[4.92,4.92],[5.01,5.01],[5.06,5.06],[5.1,5.1],[5.14,5.14],[5.17,5.17],[5.2,5.2],[5.22,5.22],[5.26,5.26],[5.3,5.3],[5.35,5.35],[5.43,5.43],[5.57,5.57],[5.91,5.91],[9.81,9.81],[0.0,0.0],[0.0,0.0],[0.0,0.0],[-4.97,0.0],[-5.38,0.0],[-5.55,0.0],[-5.65,0.0],[-5.71,0.0],[-5.76,0.0],[-5.81,0.0],[-5.85,0.0],[-5.89,0.0],[-5.94,0.0],[-6.0,0.0],[-6.1,0.0],[-6.25,0.0],[-6.64,0.0],[-10.99,0.0]],"v":[[100.0,450.0],[100.0,450.0],[100.0,450.0],[100.0,432.53],[100.0,415.01],[100.0,397.52],[100.0,380.01],[100.0,362.51],[100.0,345.01],[100.0,327.5],[100.0,310.0],[100.0,292.5],[100.0,274.99],[100.0,257.49],[100.0,239.99],[100.0,222.48],[100.0,204.99],[100.0,187.47],[100.0,170.0],[100.0,170.0],[100.0,170.0],[115.54,185.54],[131.09,201.09],[146.64,216.64],[162.2,232.2],[177.77,247.77],[193.32,263.32],[208.88,278.88],[224.44,294.44],[240.0,310.0],[255.56,325.56],[271.12,341.12],[286.68,356.68],[302.23,372.23],[317.8,387.8],[333.36,403.36],[348.91,418.91],[364.46,434.46],[380.0,450.0],[380.0,450.0],[380.0,450.0],[362.53,450.0],[345.01,450.0],[327.52,450.0],[310.01,450.0],[292.51,450.0],[275.01,450.0],[257.5,450.0],[240.0,450.0],[222.5,450.0],[204.99,450.0],[187.49,450.0],[169.99,450.0],[152.48,450.0],[134.99,450.0],[117.47,450.0]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[-12.5,-12.5],[0.0,0.0],[12.5,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[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,0.0],[0.0,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],[0.0,0.0],[0.0,0.0],[0.0,12.5],[0.0,0.0],[-12.5,-12.5],[0.0,0.0],[0.0,0.0],[9.79,-9.79],[13.75,0.0],[20.43,0.0],[12.41,0.0],[11.68,0.0],[11.33,0.0],[11.07,0.0],[10.81,0.0],[10.44,0.0],[9.62,0.0],[0.0,0.0],[9.79,9.79],[0.0,13.75],[0.0,20.43],[0.0,12.41],[0.0,11.68],[0.0,11.33],[0.0,11.07],[0.0,10.81],[0.0,10.44],[0.0,9.62],[0.0,0.0],[-9.79,9.79],[-13.75,0.0]],"o":[[0.0,0.0],[12.5,12.5],[0.0,0.0],[-12.5,0.0],[0.0,0.0],[0.0,0.0],[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,0.0],[0.0,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],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,-12.5],[0.0,0.0],[12.5,12.5],[0.0,0.0],[0.0,13.75],[-9.79,9.79],[0.0,0.0],[-9.62,0.0],[-10.44,0.0],[-10.81,0.0],[-11.07,0.0],[-11.33,0.0],[-11.68,0.0],[-12.41,0.0],[-20.43,0.0],[-13.75,0.0],[-9.79,-9.79],[0.0,0.0],[0.0,-9.62],[0.0,-10.44],[0.0,-10.81],[0.0,-11.07],[0.0,-11.33],[0.0,-11.68],[0.0,-12.41],[0.0,-20.43],[0.0,-13.75],[9.79,-9.79],[0.0,0.0]],"v":[[100.0,100.0],[125.0,125.0],[150.0,150.0],[125.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,150.0],[100.0,187.49],[100.0,224.99],[100.0,262.49],[100.0,300.0],[100.0,337.51],[100.0,375.01],[100.0,412.51],[100.0,450.0],[100.0,450.0],[100.0,450.0],[137.49,450.0],[174.99,450.0],[212.49,450.0],[250.0,450.0],[287.51,450.0],[325.01,450.0],[362.51,450.0],[400.0,450.0],[400.0,450.0],[400.0,450.0],[400.0,425.0],[400.0,400.0],[425.0,425.0],[450.0,450.0],[450.0,450.0],[435.31,485.31],[400.0,500.0],[366.69,500.0],[333.35,500.0],[300.01,500.0],[266.67,500.0],[233.33,500.0],[199.99,500.0],[166.65,500.0],[133.31,500.0],[100.0,500.0],[64.69,485.31],[50.0,450.0],[50.0,416.69],[50.0,383.35],[50.0,350.01],[50.0,316.67],[50.0,283.33],[50.0,249.99],[50.0,216.65],[50.0,183.31],[50.0,150.0],[64.69,114.69],[100.0,100.0]],"c":true},"ix":2},"nm":"Path 5","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":"slashed","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":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":[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,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"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],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[675.65,-597.15],[-597.15,675.65],[675.65,1948.44],[1948.44,675.65]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1197.15,-75.65],[-75.65,1197.15],[1197.15,2469.94],[2469.94,1197.15]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"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},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"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},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[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],[8.37,0.0],[8.27,0.0],[8.15,0.0],[7.99,0.0],[7.75,0.0],[7.15,0.0]],"o":[[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],[-8.37,0.0],[-8.48,0.0],[-8.63,0.0],[-8.87,0.0],[-9.41,0.0],[-15.53,0.0]],"v":[[100.0,450.0],[100.0,450.0],[100.0,450.0],[100.0,425.03],[100.0,400.03],[100.0,375.01],[100.0,350.01],[100.0,325.01],[100.0,300.0],[100.0,274.99],[100.0,249.99],[100.0,224.99],[100.0,199.97],[100.0,174.97],[100.0,150.0],[100.0,150.0],[100.0,150.0],[124.97,150.0],[149.97,150.0],[174.99,150.0],[199.99,150.0],[224.99,150.0],[250.0,150.0],[275.01,150.0],[300.01,150.0],[325.01,150.0],[350.03,150.0],[375.03,150.0],[400.0,150.0],[400.0,150.0],[400.0,150.0],[400.0,174.97],[400.0,199.97],[400.0,224.99],[400.0,249.99],[400.0,274.99],[400.0,300.0],[400.0,325.01],[400.0,350.01],[400.0,375.01],[400.0,400.03],[400.0,425.03],[400.0,450.0],[400.0,450.0],[400.0,450.0],[375.03,450.0],[350.03,450.0],[325.01,450.0],[300.01,450.0],[275.01,450.0],[250.0,450.0],[224.99,450.0],[199.99,450.0],[174.99,450.0],[149.97,450.0],[124.97,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":"plain","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_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/assets/lottie/ic_volume_on_to_off.json b/assets/lottie/ic_volume_on_to_off.json new file mode 100644 index 0000000..ed5f670 --- /dev/null +++ b/assets/lottie/ic_volume_on_to_off.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":24,"w":600,"h":600,"nm":"ic_volume_on_to_off","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slashed","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":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":[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,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"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],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[-1851.19,-578.4],[-578.4,-1851.19]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[-1367.19,-94.4],[-94.4,-1367.19]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.0,0.0],[13.88,13.88],[8.11,8.11],[0.0,0.0],[11.67,-4.79],[12.5,-2.92],[0.0,12.81],[0.0,0.0],[-5.62,2.08],[-5.0,2.92],[15.26,15.26],[8.92,8.92],[0.0,0.0],[0.0,-18.65],[0.0,-11.29],[0.0,-9.93],[0.0,0.0],[14.69,14.69],[8.95,8.95],[8.23,8.23],[7.46,7.46],[0.0,0.0],[18.36,0.0],[10.73,0.0],[0.0,0.0],[0.0,21.52],[0.0,13.03],[0.0,11.45],[0.0,0.0],[-20.0,0.0],[0.0,0.0],[14.1,14.1],[8.59,8.59],[7.91,7.91],[7.16,7.16],[0.0,0.0],[0.0,0.0],[-15.3,-15.3],[-9.21,-9.21],[-8.69,-8.69],[-8.46,-8.46],[-8.33,-8.33],[-8.27,-8.27],[-8.19,-8.19],[-8.15,-8.15],[-8.11,-8.11],[-8.06,-8.06],[-8.03,-8.03],[-7.98,-7.98],[-7.93,-7.93],[-7.87,-7.87],[-7.78,-7.78],[-7.65,-7.65],[-7.42,-7.42],[-6.86,-6.86],[0.0,0.0]],"o":[[0.0,0.0],[-8.11,-8.11],[-13.88,-13.88],[-10.42,6.67],[-11.67,4.79],[0.0,0.0],[0.0,-12.81],[5.83,-2.08],[5.62,-2.08],[0.0,0.0],[-8.92,-8.92],[-15.26,-15.26],[0.0,0.0],[0.0,9.93],[0.0,11.29],[0.0,18.65],[0.0,0.0],[-7.46,-7.46],[-8.23,-8.23],[-8.95,-8.95],[-14.69,-14.69],[0.0,0.0],[-10.73,0.0],[-18.36,0.0],[0.0,0.0],[0.0,-11.45],[0.0,-13.03],[0.0,-21.52],[0.0,0.0],[20.0,0.0],[0.0,0.0],[-7.16,-7.16],[-7.91,-7.91],[-8.59,-8.59],[-14.1,-14.1],[0.0,0.0],[0.0,0.0],[6.86,6.86],[7.42,7.42],[7.65,7.65],[7.78,7.78],[7.87,7.87],[7.93,7.93],[7.98,7.98],[8.03,8.03],[8.06,8.06],[8.11,8.11],[8.15,8.15],[8.19,8.19],[8.27,8.27],[8.33,8.33],[8.46,8.46],[8.69,8.69],[9.21,9.21],[15.3,15.3],[0.0,0.0]],"v":[[495.0,565.0],[469.79,539.79],[444.58,514.58],[419.38,489.38],[386.25,506.56],[350.0,518.12],[350.0,492.5],[350.0,466.88],[367.19,460.62],[383.12,453.12],[355.42,425.42],[327.71,397.71],[300.0,370.0],[300.0,402.5],[300.0,435.0],[300.0,467.5],[300.0,500.0],[275.01,475.01],[250.0,450.0],[225.0,425.0],[199.99,399.99],[175.0,375.0],[141.67,375.0],[108.33,375.0],[75.0,375.0],[75.0,337.5],[75.0,300.0],[75.0,262.5],[75.0,225.0],[115.0,225.0],[155.0,225.0],[131.01,201.01],[107.0,177.0],[83.0,153.0],[58.99,128.99],[35.0,105.0],[70.0,70.0],[94.19,94.19],[118.4,118.4],[142.62,142.62],[166.83,166.83],[191.03,191.03],[215.26,215.26],[239.46,239.46],[263.68,263.68],[287.89,287.89],[312.11,312.11],[336.32,336.32],[360.54,360.54],[384.74,384.74],[408.97,408.97],[433.17,433.17],[457.38,457.38],[481.6,481.6],[505.81,505.81],[530.0,530.0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[2.83,-4.4],[5.2,5.2],[3.15,3.15],[2.77,2.77],[0.0,0.0],[-1.96,4.43],[-1.58,4.59],[-1.18,4.73],[-0.79,4.85],[-0.39,4.92],[0.0,4.95],[0.44,5.27],[0.91,5.23],[1.4,5.14],[1.9,5.0],[2.38,4.82],[2.83,4.6],[3.23,4.35],[3.51,3.94],[3.88,3.61],[4.24,3.23],[4.55,2.81],[4.82,2.37],[5.04,1.93],[5.2,1.5],[0.0,7.35],[0.0,4.45],[0.0,3.91],[0.0,0.0],[-4.8,-1.39],[-4.71,-1.7],[-4.6,-2.02],[-4.46,-2.34],[-4.29,-2.65],[-4.1,-2.96],[-3.89,-3.25],[-3.66,-3.52],[-3.43,-3.77],[-3.19,-3.98],[-2.87,-4.1],[-2.6,-4.29],[-2.3,-4.47],[-1.98,-4.63],[-1.65,-4.77],[-1.31,-4.89],[-0.97,-4.98],[-0.63,-5.05],[-0.31,-5.09],[0.0,-5.11],[0.37,-5.33],[0.75,-5.3],[1.14,-5.25],[1.53,-5.16],[1.81,-4.87],[2.16,-4.74],[2.5,-4.58]],"o":[[0.0,0.0],[-2.77,-2.77],[-3.15,-3.15],[-5.2,-5.2],[2.35,-4.29],[1.97,-4.45],[1.58,-4.59],[1.19,-4.76],[0.79,-4.83],[0.39,-4.86],[0.0,-5.43],[-0.45,-5.39],[-0.93,-5.31],[-1.41,-5.18],[-1.9,-5.0],[-2.36,-4.77],[-2.78,-4.51],[-3.22,-4.34],[-3.58,-4.02],[-3.93,-3.65],[-4.24,-3.24],[-4.52,-2.79],[-4.75,-2.34],[-4.92,-1.89],[0.0,0.0],[0.0,-3.91],[0.0,-4.45],[0.0,-7.35],[4.98,1.13],[4.9,1.42],[4.79,1.73],[4.65,2.04],[4.49,2.35],[4.3,2.66],[4.09,2.95],[3.86,3.23],[3.62,3.48],[3.37,3.71],[3.19,3.98],[2.92,4.17],[2.63,4.34],[2.32,4.51],[1.99,4.65],[1.65,4.76],[1.3,4.86],[0.96,4.93],[0.62,4.97],[0.3,4.99],[0.0,5.43],[-0.37,5.41],[-0.75,5.35],[-1.14,5.27],[-1.49,5.05],[-1.83,4.92],[-2.17,4.77],[-2.5,4.59]],"v":[[490.0,420.0],[480.94,410.94],[471.88,401.88],[462.81,392.81],[453.75,383.75],[460.22,370.68],[465.55,357.11],[469.69,343.12],[472.66,328.7],[474.42,314.09],[475.0,299.37],[474.34,283.33],[472.29,267.4],[468.8,251.72],[463.83,236.45],[457.41,221.72],[449.64,207.67],[440.62,194.38],[430.52,181.95],[419.32,170.51],[407.07,160.19],[393.88,151.12],[379.86,143.37],[365.19,136.96],[350.0,131.88],[350.0,119.06],[350.0,106.25],[350.0,93.44],[350.0,80.63],[364.68,84.4],[379.1,89.09],[393.18,94.71],[406.84,101.28],[420.0,108.79],[432.6,117.23],[444.56,126.54],[455.84,136.66],[466.41,147.53],[476.25,159.06],[485.34,171.19],[493.62,183.87],[501.01,197.09],[507.47,210.8],[512.93,224.93],[517.36,239.42],[520.76,254.18],[523.15,269.14],[524.55,284.23],[525.0,299.37],[524.45,315.52],[522.78,331.58],[519.94,347.48],[515.94,363.12],[510.98,378.0],[505.0,392.49],[498.0,406.52]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.69,-1.94],[2.51,2.51],[1.52,1.52],[1.43,1.43],[1.39,1.39],[1.37,1.37],[1.36,1.36],[1.34,1.34],[1.33,1.33],[1.32,1.32],[1.3,1.3],[1.28,1.28],[1.24,1.24],[1.15,1.15],[0.0,0.0],[0.0,3.63],[0.0,2.2],[0.0,2.07],[0.0,2.01],[0.0,1.98],[0.0,1.96],[0.0,1.94],[0.0,1.92],[0.0,1.9],[0.0,1.88],[0.0,1.85],[0.0,1.79],[0.0,1.65],[0.0,0.0],[-1.67,-0.88],[-1.62,-0.97],[-1.57,-1.06],[-1.51,-1.14],[-1.45,-1.23],[-1.38,-1.31],[-1.3,-1.38],[-1.23,-1.46],[-1.15,-1.52],[-1.07,-1.58],[-0.99,-1.64],[-0.9,-1.67],[-0.82,-1.72],[-0.73,-1.76],[-0.65,-1.79],[-0.55,-1.83],[-0.46,-1.86],[-0.37,-1.88],[-0.27,-1.9],[-0.18,-1.91],[-0.09,-1.92],[0.0,-1.92],[0.11,-2.05],[0.23,-2.04],[0.35,-2.03],[0.46,-2.0],[0.58,-1.97]],"o":[[0.0,0.0],[-1.15,-1.15],[-1.24,-1.24],[-1.28,-1.28],[-1.3,-1.3],[-1.32,-1.32],[-1.33,-1.33],[-1.34,-1.34],[-1.36,-1.36],[-1.37,-1.37],[-1.39,-1.39],[-1.43,-1.43],[-1.52,-1.52],[-2.51,-2.51],[0.0,0.0],[0.0,-1.65],[0.0,-1.79],[0.0,-1.85],[0.0,-1.88],[0.0,-1.9],[0.0,-1.92],[0.0,-1.94],[0.0,-1.96],[0.0,-1.98],[0.0,-2.01],[0.0,-2.07],[0.0,-2.2],[0.0,-3.63],[1.73,0.81],[1.69,0.9],[1.64,0.98],[1.58,1.06],[1.52,1.15],[1.45,1.23],[1.38,1.31],[1.3,1.38],[1.22,1.45],[1.14,1.51],[1.05,1.57],[0.99,1.65],[0.91,1.69],[0.83,1.73],[0.74,1.77],[0.65,1.8],[0.55,1.83],[0.46,1.85],[0.36,1.87],[0.27,1.88],[0.18,1.89],[0.09,1.9],[0.0,2.07],[-0.12,2.06],[-0.23,2.05],[-0.35,2.02],[-0.46,1.99],[-0.58,1.96]],"v":[[406.25,336.25],[402.24,332.24],[398.22,328.22],[394.2,324.2],[390.18,320.18],[386.16,316.16],[382.14,312.14],[378.12,308.12],[374.11,304.11],[370.09,300.09],[366.07,296.07],[362.05,292.05],[358.03,288.03],[354.01,284.01],[350.0,280.0],[350.0,274.21],[350.0,268.4],[350.0,262.59],[350.0,256.79],[350.0,250.98],[350.0,245.18],[350.0,239.38],[350.0,233.57],[350.0,227.77],[350.0,221.96],[350.0,216.16],[350.0,210.35],[350.0,204.54],[350.0,198.75],[355.1,201.29],[360.06,204.09],[364.87,207.14],[369.51,210.45],[373.96,214.01],[378.2,217.81],[382.23,221.85],[386.02,226.1],[389.57,230.56],[392.88,235.2],[395.94,240.0],[398.78,244.98],[401.37,250.08],[403.72,255.31],[405.79,260.66],[407.59,266.09],[409.11,271.62],[410.35,277.21],[411.3,282.86],[411.97,288.56],[412.37,294.27],[412.5,300.0],[412.33,306.17],[411.81,312.32],[410.94,318.44],[409.72,324.46],[408.15,330.41]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[2.55,2.55],[1.54,1.54],[1.45,1.45],[1.42,1.42],[1.39,1.39],[1.38,1.38],[1.37,1.37],[1.36,1.36],[1.35,1.35],[1.34,1.34],[1.33,1.33],[1.31,1.31],[1.29,1.29],[1.25,1.25],[1.15,1.15],[0.0,0.0],[-2.41,2.41],[-1.45,1.45],[-1.37,1.37],[-1.33,1.33],[-1.31,1.31],[-1.3,1.3],[-1.29,1.29],[-1.28,1.28],[-1.27,1.27],[-1.27,1.27],[-1.26,1.26],[-1.25,1.25],[-1.23,1.23],[-1.21,1.21],[-1.17,1.17],[-1.09,1.09],[0.0,0.0],[0.0,-3.59],[0.0,-2.15],[0.0,-2.03],[0.0,-1.99],[0.0,-1.95],[0.0,-1.94],[0.0,-1.92],[0.0,-1.91],[0.0,-1.9],[0.0,-1.9],[0.0,-1.89],[0.0,-1.88],[0.0,-1.88],[0.0,-1.87],[0.0,-1.86],[0.0,-1.85],[0.0,-1.84],[0.0,-1.83],[0.0,-1.81],[0.0,-1.78],[0.0,-1.73],[0.0,-1.59]],"o":[[0.0,0.0],[-1.15,-1.15],[-1.25,-1.25],[-1.29,-1.29],[-1.31,-1.31],[-1.33,-1.33],[-1.34,-1.34],[-1.35,-1.35],[-1.36,-1.36],[-1.37,-1.37],[-1.38,-1.38],[-1.39,-1.39],[-1.42,-1.42],[-1.45,-1.45],[-1.54,-1.54],[-2.55,-2.55],[0.0,0.0],[1.09,-1.09],[1.17,-1.17],[1.21,-1.21],[1.23,-1.23],[1.25,-1.25],[1.26,-1.26],[1.27,-1.27],[1.27,-1.27],[1.28,-1.28],[1.29,-1.29],[1.3,-1.3],[1.31,-1.31],[1.33,-1.33],[1.37,-1.37],[1.45,-1.45],[2.41,-2.41],[0.0,0.0],[0.0,1.59],[0.0,1.73],[0.0,1.78],[0.0,1.81],[0.0,1.83],[0.0,1.84],[0.0,1.85],[0.0,1.86],[0.0,1.87],[0.0,1.88],[0.0,1.88],[0.0,1.89],[0.0,1.9],[0.0,1.9],[0.0,1.91],[0.0,1.92],[0.0,1.94],[0.0,1.95],[0.0,1.99],[0.0,2.03],[0.0,2.15],[0.0,3.59]],"v":[[300.0,230.0],[295.94,225.94],[291.88,221.88],[287.82,217.82],[283.75,213.75],[279.69,209.69],[275.63,205.63],[271.56,201.56],[267.5,197.5],[263.44,193.44],[259.37,189.37],[255.31,185.31],[251.25,181.25],[247.18,177.18],[243.12,173.12],[239.06,169.06],[235.0,165.0],[238.82,161.18],[242.64,157.36],[246.47,153.53],[250.29,149.71],[254.12,145.88],[257.94,142.06],[261.76,138.24],[265.59,134.41],[269.41,130.59],[273.24,126.76],[277.06,122.94],[280.88,119.12],[284.71,115.29],[288.53,111.47],[292.36,107.64],[296.18,103.82],[300.0,100.0],[300.0,105.65],[300.0,111.29],[300.0,116.94],[300.0,122.61],[300.0,128.25],[300.0,133.91],[300.0,139.56],[300.0,145.21],[300.0,150.87],[300.0,156.52],[300.0,162.17],[300.0,167.83],[300.0,173.48],[300.0,179.13],[300.0,184.79],[300.0,190.44],[300.0,196.09],[300.0,201.75],[300.0,207.39],[300.0,213.06],[300.0,218.71],[300.0,224.35]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,4.47],[0.0,2.72],[0.0,2.56],[0.0,2.47],[0.0,2.4],[0.0,2.31],[0.0,2.13],[0.0,0.0],[3.06,3.06],[1.86,1.86],[1.75,1.75],[1.7,1.7],[1.66,1.66],[1.62,1.62],[1.57,1.57],[1.44,1.44],[0.0,0.0],[0.0,0.0],[4.93,0.0],[2.99,0.0],[2.82,0.0],[2.74,0.0],[2.68,0.0],[2.64,0.0],[2.58,0.0],[2.49,0.0],[2.3,0.0],[0.0,0.0],[0.0,-4.31],[0.0,-2.63],[0.0,-2.46],[0.0,-2.37],[0.0,-2.27],[0.0,-2.09],[0.0,0.0],[-4.39,0.0],[-2.67,0.0],[-2.51,0.0],[-2.44,0.0],[-2.39,0.0],[-2.35,0.0],[-2.3,0.0],[-2.22,0.0],[-2.05,0.0],[0.0,0.0],[-3.03,-3.03],[-1.83,-1.83],[-1.73,-1.73],[-1.68,-1.68],[-1.65,-1.65],[-1.62,-1.62],[-1.6,-1.6],[-1.57,-1.57],[-1.52,-1.52],[-1.4,-1.4]],"o":[[0.0,0.0],[0.0,-2.13],[0.0,-2.31],[0.0,-2.4],[0.0,-2.47],[0.0,-2.56],[0.0,-2.72],[0.0,-4.47],[0.0,0.0],[-1.44,-1.44],[-1.57,-1.57],[-1.62,-1.62],[-1.66,-1.66],[-1.7,-1.7],[-1.75,-1.75],[-1.86,-1.86],[-3.06,-3.06],[0.0,0.0],[0.0,0.0],[-2.3,0.0],[-2.49,0.0],[-2.58,0.0],[-2.64,0.0],[-2.68,0.0],[-2.74,0.0],[-2.82,0.0],[-2.99,0.0],[-4.93,0.0],[0.0,0.0],[0.0,2.09],[0.0,2.27],[0.0,2.37],[0.0,2.46],[0.0,2.63],[0.0,4.31],[0.0,0.0],[2.05,0.0],[2.22,0.0],[2.3,0.0],[2.35,0.0],[2.39,0.0],[2.44,0.0],[2.51,0.0],[2.67,0.0],[4.39,0.0],[0.0,0.0],[1.4,1.4],[1.52,1.52],[1.57,1.57],[1.6,1.6],[1.62,1.62],[1.65,1.65],[1.68,1.68],[1.73,1.73],[1.83,1.83],[3.03,3.03]],"v":[[250.0,378.75],[250.0,371.41],[250.0,364.06],[250.0,356.72],[250.0,349.37],[250.0,342.03],[250.0,334.69],[250.0,327.34],[250.0,320.0],[245.0,315.0],[240.0,310.0],[235.0,305.0],[230.0,300.0],[225.0,295.0],[220.0,290.0],[215.0,285.0],[210.0,280.0],[205.0,275.0],[205.0,275.0],[197.01,275.0],[189.01,275.0],[181.0,275.0],[173.0,275.0],[165.0,275.0],[157.0,275.0],[149.0,275.0],[140.99,275.0],[132.99,275.0],[125.0,275.0],[125.0,282.14],[125.0,289.28],[125.0,296.43],[125.0,303.57],[125.0,310.72],[125.0,317.86],[125.0,325.0],[132.12,325.0],[139.24,325.0],[146.37,325.0],[153.5,325.0],[160.62,325.0],[167.75,325.0],[174.88,325.0],[182.01,325.0],[189.13,325.0],[196.25,325.0],[201.13,329.88],[206.02,334.77],[210.91,339.66],[215.79,344.54],[220.68,349.43],[225.57,354.32],[230.46,359.21],[235.34,364.09],[240.23,368.98],[245.12,373.87]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0.0,0.0],[3.03,3.03],[1.83,1.83],[1.73,1.73],[1.68,1.68],[1.65,1.65],[1.62,1.62],[1.6,1.6],[1.57,1.57],[1.52,1.52],[1.4,1.4],[0.0,0.0],[4.39,0.0],[2.67,0.0],[2.51,0.0],[2.44,0.0],[2.39,0.0],[2.35,0.0],[2.3,0.0],[2.22,0.0],[2.05,0.0],[0.0,0.0],[0.0,4.31],[0.0,2.63],[0.0,2.46],[0.0,2.37],[0.0,2.27],[0.0,2.09],[0.0,0.0],[-4.93,0.0],[-2.99,0.0],[-2.82,0.0],[-2.74,0.0],[-2.68,0.0],[-2.64,0.0],[-2.58,0.0],[-2.49,0.0],[-2.3,0.0],[0.0,0.0],[0.0,0.0],[-3.06,-3.06],[-1.86,-1.86],[-1.75,-1.75],[-1.7,-1.7],[-1.66,-1.66],[-1.62,-1.62],[-1.57,-1.57],[-1.44,-1.44],[0.0,0.0],[0.0,-4.47],[0.0,-2.72],[0.0,-2.56],[0.0,-2.47],[0.0,-2.4],[0.0,-2.31],[0.0,-2.13]],"o":[[0.0,0.0],[-1.4,-1.4],[-1.52,-1.52],[-1.57,-1.57],[-1.6,-1.6],[-1.62,-1.62],[-1.65,-1.65],[-1.68,-1.68],[-1.73,-1.73],[-1.83,-1.83],[-3.03,-3.03],[0.0,0.0],[-2.05,0.0],[-2.22,0.0],[-2.3,0.0],[-2.35,0.0],[-2.39,0.0],[-2.44,0.0],[-2.51,0.0],[-2.67,0.0],[-4.39,0.0],[0.0,0.0],[0.0,-2.09],[0.0,-2.27],[0.0,-2.37],[0.0,-2.46],[0.0,-2.63],[0.0,-4.31],[0.0,0.0],[2.3,0.0],[2.49,0.0],[2.58,0.0],[2.64,0.0],[2.68,0.0],[2.74,0.0],[2.82,0.0],[2.99,0.0],[4.93,0.0],[0.0,0.0],[0.0,0.0],[1.44,1.44],[1.57,1.57],[1.62,1.62],[1.66,1.66],[1.7,1.7],[1.75,1.75],[1.86,1.86],[3.06,3.06],[0.0,0.0],[0.0,2.13],[0.0,2.31],[0.0,2.4],[0.0,2.47],[0.0,2.56],[0.0,2.72],[0.0,4.47]],"v":[[250.0,378.75],[245.12,373.87],[240.23,368.98],[235.34,364.09],[230.46,359.21],[225.57,354.32],[220.68,349.43],[215.79,344.54],[210.91,339.66],[206.02,334.77],[201.13,329.88],[196.25,325.0],[189.13,325.0],[182.01,325.0],[174.88,325.0],[167.75,325.0],[160.63,325.0],[153.5,325.0],[146.37,325.0],[139.24,325.0],[132.12,325.0],[125.0,325.0],[125.0,317.86],[125.0,310.72],[125.0,303.57],[125.0,296.43],[125.0,289.28],[125.0,282.14],[125.0,275.0],[132.99,275.0],[140.99,275.0],[149.0,275.0],[157.0,275.0],[165.0,275.0],[173.0,275.0],[181.0,275.0],[189.01,275.0],[197.01,275.0],[205.0,275.0],[205.0,275.0],[210.0,280.0],[215.0,285.0],[220.0,290.0],[225.0,295.0],[230.0,300.0],[235.0,305.0],[240.0,310.0],[245.0,315.0],[250.0,320.0],[250.0,327.34],[250.0,334.69],[250.0,342.03],[250.0,349.38],[250.0,356.72],[250.0,364.06],[250.0,371.41]],"c":true},"ix":2},"nm":"Path 6","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":"slashed","np":8,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":24,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"plain","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":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":[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,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"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],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[694.4,-578.4],[-578.4,694.4],[694.4,1967.19],[1967.19,694.4]],"c":true}]},{"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[1178.4,-94.4],[-94.4,1178.4],[1178.4,2451.19],[2451.19,1178.4]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Wipe"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[7.15,-1.61],[0.0,9.41],[0.0,5.5],[0.0,0.0],[-6.76,2.88],[-6.37,3.77],[-5.85,4.65],[-5.22,5.44],[-4.53,6.1],[-3.15,5.31],[-2.57,5.65],[-1.93,5.94],[-1.27,6.16],[-0.62,6.29],[0.0,6.35],[0.6,6.13],[1.25,6.06],[1.93,5.92],[2.59,5.7],[3.22,5.42],[3.78,5.08],[5.1,5.32],[5.82,4.62],[6.46,3.82],[6.96,2.96],[7.32,2.11],[0.0,9.41],[0.0,5.5],[0.0,0.0],[-6.78,-2.17],[-6.57,-2.81],[-6.29,-3.46],[-5.93,-4.11],[-5.52,-4.71],[-5.05,-5.25],[-4.57,-5.71],[-3.93,-5.95],[-3.34,-6.33],[-2.7,-6.67],[-2.01,-6.95],[-1.32,-7.15],[-0.64,-7.27],[0.0,-7.32],[0.62,-7.09],[1.29,-7.02],[2.0,-6.89],[2.7,-6.69],[3.38,-6.41],[4.01,-6.08],[4.57,-5.71],[4.95,-5.14],[5.45,-4.65],[5.92,-4.1],[6.34,-3.49],[6.69,-2.86],[6.96,-2.22]],"o":[[0.0,0.0],[0.0,-5.5],[0.0,-9.41],[7.32,-2.11],[6.96,-2.96],[6.46,-3.82],[5.82,-4.62],[5.1,-5.32],[3.78,-5.08],[3.22,-5.42],[2.59,-5.7],[1.93,-5.92],[1.25,-6.06],[0.6,-6.13],[0.0,-6.35],[-0.62,-6.29],[-1.27,-6.16],[-1.93,-5.94],[-2.57,-5.65],[-3.15,-5.31],[-4.53,-6.1],[-5.22,-5.44],[-5.85,-4.65],[-6.37,-3.77],[-6.76,-2.88],[0.0,0.0],[0.0,-5.5],[0.0,-9.41],[7.15,1.61],[6.96,2.22],[6.69,2.86],[6.34,3.49],[5.92,4.1],[5.45,4.65],[4.95,5.14],[4.57,5.71],[4.01,6.08],[3.38,6.41],[2.7,6.69],[2.0,6.89],[1.29,7.02],[0.62,7.09],[0.0,7.32],[-0.64,7.27],[-1.32,7.15],[-2.01,6.95],[-2.7,6.67],[-3.34,6.33],[-3.93,5.95],[-4.57,5.71],[-5.05,5.25],[-5.52,4.71],[-5.93,4.11],[-6.29,3.46],[-6.57,2.81],[-6.78,2.17]],"v":[[350.0,518.12],[350.0,501.04],[350.0,483.96],[350.0,466.88],[371.13,459.39],[391.14,449.29],[409.6,436.59],[426.17,421.5],[440.62,404.38],[451.02,388.79],[459.71,372.19],[466.5,354.73],[471.29,336.62],[474.1,318.09],[475.0,299.37],[474.1,280.66],[471.29,262.13],[466.5,244.02],[459.71,226.56],[451.02,209.96],[440.62,194.38],[426.17,177.25],[409.6,162.16],[391.14,149.46],[371.13,139.36],[350.0,131.88],[350.0,114.79],[350.0,97.71],[350.0,80.63],[370.9,86.3],[391.19,93.85],[410.65,103.33],[429.06,114.72],[446.22,127.93],[461.97,142.78],[476.25,159.06],[488.99,176.55],[500.01,195.17],[509.13,214.8],[516.2,235.25],[521.17,256.31],[524.07,277.75],[525.0,299.37],[524.07,321.0],[521.17,342.44],[516.2,363.5],[509.13,383.95],[500.01,403.58],[488.99,422.2],[476.25,439.69],[461.97,455.97],[446.22,470.82],[429.06,484.03],[410.65,495.42],[391.19,504.9],[370.9,512.45]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,11.42],[0.0,6.95],[0.0,6.52],[0.0,6.31],[0.0,6.13],[0.0,5.9],[0.0,5.44],[0.0,0.0],[-11.75,0.0],[-7.16,0.0],[-6.59,0.0],[-5.97,0.0],[0.0,0.0],[-8.51,8.51],[-5.17,5.17],[-4.87,4.87],[-4.72,4.72],[-4.61,4.61],[-4.5,4.5],[-4.35,4.35],[-4.01,4.01],[0.0,0.0],[0.0,-12.64],[0.0,-7.62],[0.0,-7.18],[0.0,-7.0],[0.0,-6.9],[0.0,-6.83],[0.0,-6.78],[0.0,-6.74],[0.0,-6.71],[0.0,-6.68],[0.0,-6.65],[0.0,-6.62],[0.0,-6.58],[0.0,-6.54],[0.0,-6.49],[0.0,-6.43],[0.0,-6.32],[0.0,-6.12],[0.0,-5.66],[0.0,0.0],[8.51,8.51],[5.17,5.17],[4.87,4.87],[4.72,4.72],[4.61,4.61],[4.5,4.5],[4.35,4.35],[4.01,4.01],[0.0,0.0],[11.75,0.0],[7.16,0.0],[6.59,0.0],[5.97,0.0]],"o":[[0.0,0.0],[0.0,-5.44],[0.0,-5.9],[0.0,-6.13],[0.0,-6.31],[0.0,-6.52],[0.0,-6.95],[0.0,-11.42],[0.0,0.0],[5.97,0.0],[6.59,0.0],[7.16,0.0],[11.75,0.0],[0.0,0.0],[4.01,-4.01],[4.35,-4.35],[4.5,-4.5],[4.61,-4.61],[4.72,-4.72],[4.87,-4.87],[5.17,-5.17],[8.51,-8.51],[0.0,0.0],[0.0,5.66],[0.0,6.12],[0.0,6.32],[0.0,6.43],[0.0,6.49],[0.0,6.54],[0.0,6.58],[0.0,6.62],[0.0,6.65],[0.0,6.68],[0.0,6.71],[0.0,6.74],[0.0,6.78],[0.0,6.83],[0.0,6.9],[0.0,7.0],[0.0,7.18],[0.0,7.62],[0.0,12.64],[0.0,0.0],[-4.01,-4.01],[-4.35,-4.35],[-4.5,-4.5],[-4.61,-4.61],[-4.72,-4.72],[-4.87,-4.87],[-5.17,-5.17],[-8.51,-8.51],[0.0,0.0],[-5.97,0.0],[-6.59,0.0],[-7.16,0.0],[-11.75,0.0]],"v":[[75.0,375.0],[75.0,356.26],[75.0,337.5],[75.0,318.75],[75.0,300.0],[75.0,281.25],[75.0,262.5],[75.0,243.74],[75.0,225.0],[94.99,225.0],[115.0,225.0],[135.0,225.0],[155.01,225.0],[175.0,225.0],[188.88,211.12],[202.77,197.23],[216.66,183.34],[230.55,169.45],[244.45,155.55],[258.34,141.66],[272.23,127.77],[286.12,113.88],[300.0,100.0],[300.0,119.95],[300.0,139.96],[300.0,159.97],[300.0,179.97],[300.0,199.99],[300.0,219.99],[300.0,239.99],[300.0,259.99],[300.0,280.0],[300.0,300.0],[300.0,320.0],[300.0,340.01],[300.0,360.01],[300.0,380.01],[300.0,400.01],[300.0,420.03],[300.0,440.03],[300.0,460.04],[300.0,480.05],[300.0,500.0],[286.12,486.12],[272.23,472.23],[258.34,458.34],[244.45,444.45],[230.55,430.55],[216.66,416.66],[202.77,402.77],[188.88,388.88],[175.0,375.0],[155.01,375.0],[135.0,375.0],[115.0,375.0],[94.99,375.0]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.38,-1.11],[0.0,5.33],[0.0,3.2],[0.0,3.02],[0.0,2.94],[0.0,2.9],[0.0,2.87],[0.0,2.85],[0.0,2.84],[0.0,2.83],[0.0,2.82],[0.0,2.81],[0.0,2.8],[0.0,2.79],[0.0,2.78],[0.0,2.77],[0.0,2.76],[0.0,2.75],[0.0,2.73],[0.0,2.71],[0.0,2.69],[0.0,2.64],[0.0,2.56],[0.0,2.36],[0.0,0.0],[-2.26,-1.25],[-2.17,-1.42],[-2.06,-1.58],[-1.94,-1.74],[-1.81,-1.89],[-1.66,-2.03],[-1.51,-2.15],[-1.36,-2.26],[-1.2,-2.32],[-1.04,-2.4],[-0.87,-2.47],[-0.7,-2.53],[-0.52,-2.58],[-0.34,-2.62],[-0.17,-2.64],[0.0,-2.65],[0.17,-2.56],[0.34,-2.55],[0.52,-2.53],[0.7,-2.49],[0.88,-2.44],[1.05,-2.38],[1.21,-2.31],[1.36,-2.23],[1.5,-2.1],[1.65,-1.99],[1.8,-1.87],[1.95,-1.73],[2.08,-1.58],[2.19,-1.43],[2.3,-1.27]],"o":[[0.0,0.0],[0.0,-2.36],[0.0,-2.56],[0.0,-2.64],[0.0,-2.69],[0.0,-2.71],[0.0,-2.73],[0.0,-2.75],[0.0,-2.76],[0.0,-2.77],[0.0,-2.78],[0.0,-2.79],[0.0,-2.8],[0.0,-2.81],[0.0,-2.82],[0.0,-2.83],[0.0,-2.84],[0.0,-2.85],[0.0,-2.87],[0.0,-2.9],[0.0,-2.94],[0.0,-3.02],[0.0,-3.2],[0.0,-5.33],[2.39,1.12],[2.3,1.28],[2.2,1.44],[2.08,1.59],[1.94,1.74],[1.8,1.88],[1.65,2.01],[1.49,2.12],[1.37,2.27],[1.21,2.35],[1.05,2.42],[0.87,2.48],[0.7,2.53],[0.52,2.56],[0.34,2.59],[0.17,2.6],[0.0,2.61],[-0.17,2.6],[-0.34,2.58],[-0.52,2.54],[-0.7,2.49],[-0.87,2.43],[-1.04,2.36],[-1.19,2.28],[-1.37,2.24],[-1.52,2.13],[-1.67,2.01],[-1.81,1.87],[-1.94,1.72],[-2.06,1.57],[-2.17,1.41],[-2.26,1.25]],"v":[[350.0,400.0],[350.0,391.63],[350.0,383.25],[350.0,374.85],[350.0,366.48],[350.0,358.08],[350.0,349.69],[350.0,341.31],[350.0,332.92],[350.0,324.54],[350.0,316.15],[350.0,307.76],[350.0,299.38],[350.0,290.99],[350.0,282.6],[350.0,274.21],[350.0,265.83],[350.0,257.44],[350.0,249.06],[350.0,240.67],[350.0,232.27],[350.0,223.9],[350.0,215.5],[350.0,207.12],[350.0,198.75],[356.98,202.31],[363.69,206.35],[370.08,210.88],[376.11,215.88],[381.74,221.33],[386.93,227.2],[391.66,233.44],[395.94,240.0],[399.78,246.88],[403.15,254.0],[406.03,261.33],[408.39,268.85],[410.21,276.51],[411.49,284.28],[412.25,292.13],[412.5,300.0],[412.25,307.76],[411.49,315.49],[410.19,323.15],[408.36,330.69],[406.0,338.09],[403.13,345.31],[399.76,352.3],[395.94,359.06],[391.65,365.58],[386.89,371.77],[381.69,377.59],[376.06,382.99],[370.03,387.95],[363.65,392.44],[356.95,396.46]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.0,0.0],[4.09,-4.09],[2.49,-2.49],[2.34,-2.34],[2.26,-2.26],[2.2,-2.2],[2.12,-2.12],[1.95,-1.95],[0.0,0.0],[5.42,0.0],[3.3,0.0],[3.1,0.0],[3.0,0.0],[2.91,0.0],[2.8,0.0],[2.58,0.0],[0.0,0.0],[0.0,-4.97],[0.0,-3.03],[0.0,-2.83],[0.0,-2.68],[0.0,-2.45],[0.0,0.0],[-5.42,0.0],[-3.3,0.0],[-3.1,0.0],[-3.0,0.0],[-2.91,0.0],[-2.8,0.0],[-2.58,0.0],[0.0,0.0],[-4.09,-4.09],[-2.49,-2.49],[-2.34,-2.34],[-2.26,-2.26],[-2.2,-2.2],[-2.12,-2.12],[-1.95,-1.95],[0.0,0.0],[0.0,5.52],[0.0,3.32],[0.0,3.14],[0.0,3.06],[0.0,3.01],[0.0,2.98],[0.0,2.96],[0.0,2.94],[0.0,2.92],[0.0,2.91],[0.0,2.89],[0.0,2.87],[0.0,2.85],[0.0,2.82],[0.0,2.77],[0.0,2.68],[0.0,2.48]],"o":[[0.0,0.0],[-1.95,1.95],[-2.12,2.12],[-2.2,2.2],[-2.26,2.26],[-2.34,2.34],[-2.49,2.49],[-4.09,4.09],[0.0,0.0],[-2.58,0.0],[-2.8,0.0],[-2.91,0.0],[-3.0,0.0],[-3.1,0.0],[-3.3,0.0],[-5.42,0.0],[0.0,0.0],[0.0,2.45],[0.0,2.68],[0.0,2.83],[0.0,3.03],[0.0,4.97],[0.0,0.0],[2.58,0.0],[2.8,0.0],[2.91,0.0],[3.0,0.0],[3.1,0.0],[3.3,0.0],[5.42,0.0],[0.0,0.0],[1.95,1.95],[2.12,2.12],[2.2,2.2],[2.26,2.26],[2.34,2.34],[2.49,2.49],[4.09,4.09],[0.0,0.0],[0.0,-2.48],[0.0,-2.68],[0.0,-2.77],[0.0,-2.82],[0.0,-2.85],[0.0,-2.87],[0.0,-2.89],[0.0,-2.91],[0.0,-2.92],[0.0,-2.94],[0.0,-2.96],[0.0,-2.98],[0.0,-3.01],[0.0,-3.06],[0.0,-3.14],[0.0,-3.32],[0.0,-5.52]],"v":[[250.0,221.25],[243.28,227.97],[236.56,234.69],[229.85,241.4],[223.12,248.12],[216.4,254.85],[209.69,261.56],[202.97,268.28],[196.25,275.0],[187.35,275.0],[178.44,275.0],[169.53,275.0],[160.62,275.0],[151.72,275.0],[142.81,275.0],[133.9,275.0],[125.0,275.0],[125.0,283.33],[125.0,291.67],[125.0,300.0],[125.0,308.33],[125.0,316.67],[125.0,325.0],[133.9,325.0],[142.81,325.0],[151.72,325.0],[160.62,325.0],[169.53,325.0],[178.44,325.0],[187.35,325.0],[196.25,325.0],[202.97,331.72],[209.69,338.44],[216.4,345.15],[223.12,351.88],[229.85,358.6],[236.56,365.31],[243.28,372.03],[250.0,378.75],[250.0,370.01],[250.0,361.26],[250.0,352.51],[250.0,343.76],[250.0,335.01],[250.0,326.25],[250.0,317.5],[250.0,308.75],[250.0,300.0],[250.0,291.25],[250.0,282.5],[250.0,273.75],[250.0,264.99],[250.0,256.24],[250.0,247.49],[250.0,238.74],[250.0,229.99]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.0,0.0],[4.09,4.09],[2.49,2.49],[2.34,2.34],[2.26,2.26],[2.2,2.2],[2.12,2.12],[1.95,1.95],[0.0,0.0],[5.42,0.0],[3.3,0.0],[3.1,0.0],[3.0,0.0],[2.91,0.0],[2.8,0.0],[2.58,0.0],[0.0,0.0],[0.0,4.97],[0.0,3.03],[0.0,2.83],[0.0,2.68],[0.0,2.45],[0.0,0.0],[-5.42,0.0],[-3.3,0.0],[-3.1,0.0],[-3.0,0.0],[-2.91,0.0],[-2.8,0.0],[-2.58,0.0],[0.0,0.0],[-4.09,4.09],[-2.49,2.49],[-2.34,2.34],[-2.26,2.26],[-2.2,2.2],[-2.12,2.12],[-1.95,1.95],[0.0,0.0],[0.0,-5.52],[0.0,-3.32],[0.0,-3.14],[0.0,-3.06],[0.0,-3.01],[0.0,-2.98],[0.0,-2.96],[0.0,-2.94],[0.0,-2.92],[0.0,-2.91],[0.0,-2.89],[0.0,-2.87],[0.0,-2.85],[0.0,-2.82],[0.0,-2.77],[0.0,-2.68],[0.0,-2.48]],"o":[[0.0,0.0],[-1.95,-1.95],[-2.12,-2.12],[-2.2,-2.2],[-2.26,-2.26],[-2.34,-2.34],[-2.49,-2.49],[-4.09,-4.09],[0.0,0.0],[-2.58,0.0],[-2.8,0.0],[-2.91,0.0],[-3.0,0.0],[-3.1,0.0],[-3.3,0.0],[-5.42,0.0],[0.0,0.0],[0.0,-2.45],[0.0,-2.68],[0.0,-2.83],[0.0,-3.03],[0.0,-4.97],[0.0,0.0],[2.58,0.0],[2.8,0.0],[2.91,0.0],[3.0,0.0],[3.1,0.0],[3.3,0.0],[5.42,0.0],[0.0,0.0],[1.95,-1.95],[2.12,-2.12],[2.2,-2.2],[2.26,-2.26],[2.34,-2.34],[2.49,-2.49],[4.09,-4.09],[0.0,0.0],[0.0,2.48],[0.0,2.68],[0.0,2.77],[0.0,2.82],[0.0,2.85],[0.0,2.87],[0.0,2.89],[0.0,2.91],[0.0,2.92],[0.0,2.94],[0.0,2.96],[0.0,2.98],[0.0,3.01],[0.0,3.06],[0.0,3.14],[0.0,3.32],[0.0,5.52]],"v":[[250.0,378.75],[243.28,372.03],[236.56,365.31],[229.85,358.6],[223.13,351.87],[216.4,345.15],[209.69,338.44],[202.97,331.72],[196.25,325.0],[187.35,325.0],[178.44,325.0],[169.53,325.0],[160.62,325.0],[151.72,325.0],[142.81,325.0],[133.9,325.0],[125.0,325.0],[125.0,316.67],[125.0,308.33],[125.0,300.0],[125.0,291.67],[125.0,283.33],[125.0,275.0],[133.9,275.0],[142.81,275.0],[151.72,275.0],[160.62,275.0],[169.53,275.0],[178.44,275.0],[187.35,275.0],[196.25,275.0],[202.97,268.28],[209.69,261.56],[216.4,254.85],[223.12,248.12],[229.85,241.4],[236.56,234.69],[243.28,227.97],[250.0,221.25],[250.0,229.99],[250.0,238.74],[250.0,247.49],[250.0,256.24],[250.0,264.99],[250.0,273.75],[250.0,282.5],[250.0,291.25],[250.0,300.0],[250.0,308.75],[250.0,317.5],[250.0,326.25],[250.0,335.01],[250.0,343.76],[250.0,352.51],[250.0,361.26],[250.0,370.01]],"c":true},"ix":2},"nm":"Path 5","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":"plain","np":7,"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/ios/Podfile b/ios/Podfile index fc81fe8..9eb6693 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -69,8 +69,98 @@ target 'Runner' do end end +# libopus ships with ogg_opus_player as a static xcframework. Nothing in the app +# references its encoder entry points, so the linker would drop them and +# `DynamicLibrary.process()` (OpusOggEncoder) would find nothing. Force-loading +# the slice keeps the whole library, which is what voice-message encoding needs. +# +# The slice is referenced in the plugin source tree rather than through +# `PODS_XCFRAMEWORKS_BUILD_DIR`: with `use_frameworks!` CocoaPods copies vendored +# xcframeworks into the pod target's own build dir, so for Runner that variable +# expands to a file nothing ever produces and Xcode fails on the missing input. +OPUS_XCFRAMEWORK = 'ogg_opus_player/darwin/Frameworks/libopus.xcframework'.freeze +OPUS_LIB_VAR = 'KOMET_OPUS_LIB'.freeze +OPUS_FORCE_LOAD = %(-force_load "$(#{OPUS_LIB_VAR})").freeze +OPUS_XCFRAMEWORK_DIR = + File.expand_path(File.join('.symlinks', 'plugins', OPUS_XCFRAMEWORK), __dir__).freeze + +def opus_slice(simulator) + slice = Dir.glob(File.join(OPUS_XCFRAMEWORK_DIR, 'ios-*')).find do |dir| + name = File.basename(dir) + next false if name.include?('maccatalyst') + next false unless File.exist?(File.join(dir, 'libopus.a')) + + name.end_with?('-simulator') == simulator + end + kind = simulator ? 'simulator' : 'device' + raise "libopus.xcframework has no ios #{kind} slice in #{OPUS_XCFRAMEWORK_DIR}" if slice.nil? + + File.basename(slice) +end + +# permission_handler compiles every permission handler unless told otherwise; +# the unused ones reference APIs that make App Store review ask for usage +# descriptions the app has no reason to declare. +PERMISSION_MACROS = %w[ + PERMISSION_CAMERA=1 + PERMISSION_MICROPHONE=1 + PERMISSION_PHOTOS=1 + PERMISSION_PHOTOS_ADD_ONLY=1 + PERMISSION_LOCATION=1 + PERMISSION_LOCATION_WHENINUSE=1 + PERMISSION_CONTACTS=1 + PERMISSION_NOTIFICATIONS=1 + PERMISSION_LOCATION_ALWAYS=0 + PERMISSION_MEDIA_LIBRARY=0 + PERMISSION_EVENTS=0 + PERMISSION_EVENTS_FULL_ACCESS=0 + PERMISSION_REMINDERS=0 + PERMISSION_SPEECH_RECOGNIZER=0 + PERMISSION_SENSORS=0 + PERMISSION_BLUETOOTH=0 + PERMISSION_APP_TRACKING_TRANSPARENCY=0 + PERMISSION_CRITICAL_ALERTS=0 + PERMISSION_ASSISTANT=0 +].freeze + post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + + next unless target.name == 'permission_handler_apple' + + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = + ['$(inherited)'] + PERMISSION_MACROS + end + end + + opus_xcframework = "$(SRCROOT)/.symlinks/plugins/#{OPUS_XCFRAMEWORK}" + opus_device_lib = "#{opus_xcframework}/#{opus_slice(false)}/libopus.a" + opus_simulator_lib = "#{opus_xcframework}/#{opus_slice(true)}/libopus.a" + + installer.aggregate_targets.each do |target| + next unless target.name == 'Pods-Runner' + + %w[debug profile release].each do |name| + xcconfig = target.xcconfig_path(name) + next unless File.exist?(xcconfig) + + contents = File.read(xcconfig) + next if contents.include?(OPUS_LIB_VAR) + + contents += "\n" unless contents.end_with?("\n") + contents += "#{OPUS_LIB_VAR}[sdk=iphoneos*] = #{opus_device_lib}\n" + contents += "#{OPUS_LIB_VAR}[sdk=iphonesimulator*] = #{opus_simulator_lib}\n" + + if contents =~ /^OTHER_LDFLAGS = .*$/ + contents = contents.sub(/^OTHER_LDFLAGS = (.*)$/) do + "OTHER_LDFLAGS = #{Regexp.last_match(1)} #{OPUS_FORCE_LOAD}" + end + else + contents += "OTHER_LDFLAGS = $(inherited) #{OPUS_FORCE_LOAD}\n" + end + File.write(xcconfig, contents) + end end end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ffaa7a0..657cb8e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + AA11BB22CC33DD44EE550101 /* KometVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550001 /* KometVideo.swift */; }; + AA11BB22CC33DD44EE550102 /* KometVideoNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */; }; + AA11BB22CC33DD44EE550103 /* KometNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550003 /* KometNotifications.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -56,6 +59,10 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550001 /* KometVideo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideo.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideoNote.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550003 /* KometNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometNotifications.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE550004 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -157,6 +164,10 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + AA11BB22CC33DD44EE550001 /* KometVideo.swift */, + AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */, + AA11BB22CC33DD44EE550003 /* KometNotifications.swift */, + AA11BB22CC33DD44EE550004 /* Runner.entitlements */, ); path = Runner; sourceTree = ""; @@ -396,6 +407,9 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + AA11BB22CC33DD44EE550101 /* KometVideo.swift in Sources */, + AA11BB22CC33DD44EE550102 /* KometVideoNote.swift in Sources */, + AA11BB22CC33DD44EE550103 /* KometNotifications.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -495,6 +509,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -677,6 +692,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -699,6 +715,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 948ca04..942fbe6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,50 +1,177 @@ import Flutter import UIKit +final class KometStreamHandler: NSObject, FlutterStreamHandler { + private let onSink: (FlutterEventSink?) -> Void + + init(onSink: @escaping (FlutterEventSink?) -> Void) { + self.onSink = onSink + } + + func onListen( + withArguments arguments: Any?, + eventSink events: @escaping FlutterEventSink + ) -> FlutterError? { + onSink(events) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + onSink(nil) + return nil + } +} + @main @objc class AppDelegate: FlutterAppDelegate { + private var channels: [FlutterMethodChannel] = [] + private var eventChannels: [FlutterEventChannel] = [] + private var streamHandlers: [KometStreamHandler] = [] + private var videoNote: KometVideoNote? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + KometNotifications.shared.start() - let controller = window?.rootViewController as? FlutterViewController - if let messenger = controller?.binaryMessenger { - let channel = FlutterMethodChannel( - name: "ru.komet.app/app_icon", - binaryMessenger: messenger - ) - channel.setMethodCallHandler { (call, result) in - guard call.method == "setAppIcon" else { - result(FlutterMethodNotImplemented) - return - } - let args = call.arguments as? [String: Any] - let name = args?["name"] as? String - let iconName: String? = (name == "DefaultIcon") ? nil : name - if !UIApplication.shared.supportsAlternateIcons { - result(FlutterError( - code: "UNSUPPORTED", - message: "Alternate icons are not supported", - details: nil - )) - return - } - UIApplication.shared.setAlternateIconName(iconName) { error in - if let error = error { - result(FlutterError( - code: "APPLY_FAILED", - message: error.localizedDescription, - details: nil - )) - } else { - result(nil) - } - } - } + if let controller = window?.rootViewController as? FlutterViewController { + let messenger = controller.binaryMessenger + registerAppIcon(messenger) + registerVideo(messenger) + registerVideoNote(messenger) + registerNotifications(messenger) + registerScreen(messenger) } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + private func method(_ name: String, _ messenger: FlutterBinaryMessenger, + _ handler: @escaping FlutterMethodCallHandler) { + let channel = FlutterMethodChannel(name: name, binaryMessenger: messenger) + channel.setMethodCallHandler(handler) + channels.append(channel) + } + + private func events(_ name: String, _ messenger: FlutterBinaryMessenger, + _ onSink: @escaping (FlutterEventSink?) -> Void) { + let handler = KometStreamHandler(onSink: onSink) + let channel = FlutterEventChannel(name: name, binaryMessenger: messenger) + channel.setStreamHandler(handler) + streamHandlers.append(handler) + eventChannels.append(channel) + } + + private func registerAppIcon(_ messenger: FlutterBinaryMessenger) { + method("ru.komet.app/app_icon", messenger) { call, result in + switch call.method { + case "getAppIcon": + result(UIApplication.shared.alternateIconName) + case "setAppIcon": + let requested = (call.arguments as? [String: Any])?["name"] as? String + let iconName: String? = (requested?.isEmpty ?? true) ? nil : requested + guard UIApplication.shared.supportsAlternateIcons else { + result(FlutterError(code: "UNSUPPORTED", + message: "Alternate icons are not supported", + details: nil)) + return + } + guard UIApplication.shared.alternateIconName != iconName else { + result(nil) + return + } + UIApplication.shared.setAlternateIconName(iconName) { error in + DispatchQueue.main.async { + if let error = error { + result(FlutterError(code: "APPLY_FAILED", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + } + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func registerVideo(_ messenger: FlutterBinaryMessenger) { + method("ru.komet.app/video", messenger) { call, result in + KometVideo.shared.handle(call, result: result) + } + } + + private func registerVideoNote(_ messenger: FlutterBinaryMessenger) { + guard let textures = registrar(forPlugin: "KometVideoNote")?.textures() else { return } + + method("ru.komet.app/video_note", messenger) { [weak self] call, result in + guard let self = self else { return } + switch call.method { + case "permission": + KometVideoNote.requestPermission(result) + case "init": + let args = call.arguments as? [String: Any] ?? [:] + self.videoNote?.dispose() + let recorder = KometVideoNote(registry: textures) + self.videoNote = recorder + recorder.initialize( + front: (args["front"] as? NSNumber)?.boolValue ?? true, + edge: (args["size"] as? NSNumber)?.intValue ?? 480, + fps: (args["fps"] as? NSNumber)?.intValue ?? 30, + result: result) + case "start": + self.withRecorder(result) { $0.start(result: result) } + case "switch": + self.withRecorder(result) { $0.switchCamera(result: result) } + case "torch": + let on = ((call.arguments as? [String: Any])?["on"] as? NSNumber)?.boolValue ?? false + self.withRecorder(result) { $0.setTorch(on: on, result: result) } + case "stop": + self.withRecorder(result) { $0.stop(result: result) } + case "dispose": + self.videoNote?.dispose() + self.videoNote = nil + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func withRecorder(_ result: @escaping FlutterResult, + _ body: (KometVideoNote) -> Void) { + guard let recorder = videoNote else { + result(FlutterError(code: "NOT_READY", message: "recorder not initialized", details: nil)) + return + } + body(recorder) + } + + private func registerScreen(_ messenger: FlutterBinaryMessenger) { + method("ru.komet.app/screen", messenger) { call, result in + switch call.method { + case "setKeepAwake": + let enabled = ((call.arguments as? [String: Any])?["enabled"] as? NSNumber)?.boolValue ?? false + DispatchQueue.main.async { + UIApplication.shared.isIdleTimerDisabled = enabled + result(nil) + } + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func registerNotifications(_ messenger: FlutterBinaryMessenger) { + method("ru.komet.app/notifications", messenger) { call, result in + KometNotifications.shared.handle(call, result: result) + } + events("ru.komet.app/notification_events", messenger) { sink in + KometNotifications.shared.attach(sink) + } + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6637b38..7bd3efc 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -70,6 +70,8 @@ Доступ к галерее нужен, чтобы отправлять фото и видео в чатах. NSPhotoLibraryAddUsageDescription Доступ к галерее нужен, чтобы сохранять полученные фото и видео. + NSContactsUsageDescription + Доступ к контактам нужен, чтобы показывать имена собеседников так, как они записаны в вашей телефонной книге. CFBundleURLTypes @@ -82,5 +84,31 @@ + CFBundleLocalizations + + ru + en + + UIBackgroundModes + + audio + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + tel + telprompt + sms + mailto + maps + comgooglemaps + yandexmaps + yandexnavi + diff --git a/ios/Runner/KometNotifications.swift b/ios/Runner/KometNotifications.swift new file mode 100644 index 0000000..c82c0bc --- /dev/null +++ b/ios/Runner/KometNotifications.swift @@ -0,0 +1,105 @@ +import Flutter +import UIKit +import UserNotifications + +final class KometNotifications: NSObject { + static let shared = KometNotifications() + + private static let chatKeys = ["komet_chat", "chatId", "chat_id"] + + private var sink: FlutterEventSink? + private var pendingChatId: Int64 = 0 + private var activeChatId: Int64 = 0 + + func start() { + UNUserNotificationCenter.current().delegate = self + } + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "consumeInitialChat": + let chatId = pendingChatId + pendingChatId = 0 + result(chatId > 0 ? NSNumber(value: chatId) : nil) + case "setActiveChat": + activeChatId = Self.chatId(from: call.arguments) + dismissDelivered(chatId: activeChatId) + result(nil) + case "clearActiveChat": + activeChatId = 0 + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + func attach(_ sink: FlutterEventSink?) { + self.sink = sink + } + + func deliver(chatId: Int64) { + guard chatId > 0 else { return } + if let sink = sink { + sink(NSNumber(value: chatId)) + } else { + pendingChatId = chatId + } + } + + private func dismissDelivered(chatId: Int64) { + guard chatId > 0 else { return } + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { delivered in + let identifiers = delivered + .filter { Self.chatId(from: $0.request.content.userInfo) == chatId } + .map { $0.request.identifier } + guard !identifiers.isEmpty else { return } + center.removeDeliveredNotifications(withIdentifiers: identifiers) + } + } + + private static func chatId(from raw: Any?) -> Int64 { + if let number = raw as? NSNumber { return number.int64Value } + if let text = raw as? String { return Int64(text) ?? 0 } + if let map = raw as? [AnyHashable: Any] { + for key in chatKeys { + if let value = map[key], let parsed = optionalChatId(value) { return parsed } + } + } + return 0 + } + + private static func optionalChatId(_ raw: Any) -> Int64? { + if let number = raw as? NSNumber { return number.int64Value } + if let text = raw as? String { return Int64(text) } + return nil + } +} + +extension KometNotifications: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let chatId = Self.chatId(from: notification.request.content.userInfo) + if chatId > 0, chatId == activeChatId { + completionHandler([]) + return + } + if #available(iOS 14.0, *) { + completionHandler([.banner, .list, .sound, .badge]) + } else { + completionHandler([.alert, .sound, .badge]) + } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + deliver(chatId: Self.chatId(from: response.notification.request.content.userInfo)) + completionHandler() + } +} diff --git a/ios/Runner/KometVideo.swift b/ios/Runner/KometVideo.swift new file mode 100644 index 0000000..9ec0bd8 --- /dev/null +++ b/ios/Runner/KometVideo.swift @@ -0,0 +1,333 @@ +import AVFoundation +import CoreImage +import Flutter +import UIKit + +private struct VideoExportSpec { + let input: String + let output: String + let startMs: Int? + let endMs: Int? + let removeAudio: Bool + let rotationDegrees: Double + let flipH: Bool + let crop: [Double]? + let outWidth: Int + let outHeight: Int + let rgbMatrix: [Double]? + let overlay: String? + let centerSquare: Bool + + init?(_ arguments: Any?) { + guard let args = arguments as? [String: Any], + let input = args["input"] as? String, + let output = args["output"] as? String else { return nil } + self.input = input + self.output = output + startMs = (args["startMs"] as? NSNumber)?.intValue + endMs = (args["endMs"] as? NSNumber)?.intValue + removeAudio = (args["removeAudio"] as? NSNumber)?.boolValue ?? false + rotationDegrees = (args["rotationDegrees"] as? NSNumber)?.doubleValue ?? 0 + flipH = (args["flipH"] as? NSNumber)?.boolValue ?? false + crop = (args["crop"] as? [NSNumber])?.map { $0.doubleValue } + outWidth = (args["outWidth"] as? NSNumber)?.intValue ?? 0 + outHeight = (args["outHeight"] as? NSNumber)?.intValue ?? 0 + rgbMatrix = (args["rgbMatrix"] as? [NSNumber])?.map { $0.doubleValue } + overlay = args["overlay"] as? String + centerSquare = false + } + + init(input: String, output: String, edge: Int) { + self.input = input + self.output = output + startMs = nil + endMs = nil + removeAudio = false + rotationDegrees = 0 + flipH = false + crop = nil + outWidth = edge + outHeight = edge + rgbMatrix = nil + overlay = nil + centerSquare = true + } +} + +final class KometVideo { + static let shared = KometVideo() + + private let queue = DispatchQueue(label: "ru.komet.app.video", qos: .userInitiated) + private var session: AVAssetExportSession? + private var cancelled = false + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "probe": + probe(call.arguments, result) + case "frames": + frames(call.arguments, result) + case "cropSquare": + cropSquare(call.arguments, result) + case "edit": + guard let spec = VideoExportSpec(call.arguments) else { + result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil)) + return + } + export(spec) { ok in result(NSNumber(value: ok)) } + case "editProgress": + let value = session.map { Int(($0.progress * 100).rounded()) } ?? -1 + result(NSNumber(value: value)) + case "editCancel": + cancelled = true + session?.cancelExport() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + private func probe(_ arguments: Any?, _ result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], let input = args["input"] as? String else { + result(FlutterError(code: "BAD_ARGS", message: "input required", details: nil)) + return + } + queue.async { + let asset = AVURLAsset(url: URL(fileURLWithPath: input)) + guard let track = asset.tracks(withMediaType: .video).first else { + Self.reply(result, nil) + return + } + let size = track.naturalSize.applying(track.preferredTransform) + let seconds = CMTimeGetSeconds(asset.duration) + let durationMs = seconds.isFinite && seconds > 0 ? Int((seconds * 1000).rounded()) : 0 + let fps = Double(track.nominalFrameRate) + let payload: [String: Any] = [ + "width": Int(abs(size.width).rounded()), + "height": Int(abs(size.height).rounded()), + "durationMs": durationMs, + "fps": fps > 0 ? fps : 30.0, + "hasAudio": !asset.tracks(withMediaType: .audio).isEmpty, + ] + Self.reply(result, payload) + } + } + + private func frames(_ arguments: Any?, _ result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let input = args["input"] as? String, + let times = args["times"] as? [NSNumber] else { + result(FlutterError(code: "BAD_ARGS", message: "input/times required", details: nil)) + return + } + let edge = (args["size"] as? NSNumber)?.intValue ?? 256 + let precise = (args["precise"] as? NSNumber)?.boolValue ?? false + queue.async { + let asset = AVURLAsset(url: URL(fileURLWithPath: input)) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: edge, height: edge) + if precise { + generator.requestedTimeToleranceBefore = .zero + generator.requestedTimeToleranceAfter = .zero + } + var output: [Any] = [] + for time in times { + let at = CMTime(value: CMTimeValue(time.int64Value), timescale: 1000) + guard let cgImage = try? generator.copyCGImage(at: at, actualTime: nil), + let data = UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9) else { + output.append(NSNull()) + continue + } + output.append(FlutterStandardTypedData(bytes: data)) + } + Self.reply(result, output) + } + } + + private func cropSquare(_ arguments: Any?, _ result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let input = args["input"] as? String, + let output = args["output"] as? String else { + result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil)) + return + } + let edge = (args["size"] as? NSNumber)?.intValue ?? 480 + export(VideoExportSpec(input: input, output: output, edge: edge)) { ok in + if ok { + result(output) + } else { + result(FlutterError(code: "TRANSCODE_FAILED", message: "export failed", details: nil)) + } + } + } + + private func export(_ spec: VideoExportSpec, completion: @escaping (Bool) -> Void) { + queue.async { + self.cancelled = false + let asset = AVURLAsset(url: URL(fileURLWithPath: spec.input)) + guard let videoTrack = asset.tracks(withMediaType: .video).first else { + Self.reply { completion(false) } + return + } + + let totalSeconds = CMTimeGetSeconds(asset.duration) + let start = CMTime(value: CMTimeValue(spec.startMs ?? 0), timescale: 1000) + let endMs = spec.endMs ?? (totalSeconds.isFinite ? Int((totalSeconds * 1000).rounded()) : 0) + let end = CMTime(value: CMTimeValue(max(endMs, spec.startMs ?? 0)), timescale: 1000) + let range = CMTimeRange(start: start, end: end) + guard range.duration.seconds > 0 else { + Self.reply { completion(false) } + return + } + + let composition = AVMutableComposition() + guard let compositionVideo = composition.addMutableTrack( + withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { + Self.reply { completion(false) } + return + } + do { + try compositionVideo.insertTimeRange(range, of: videoTrack, at: .zero) + } catch { + Self.reply { completion(false) } + return + } + compositionVideo.preferredTransform = videoTrack.preferredTransform + + if !spec.removeAudio, + let audioTrack = asset.tracks(withMediaType: .audio).first, + let compositionAudio = composition.addMutableTrack( + withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) { + try? compositionAudio.insertTimeRange(range, of: audioTrack, at: .zero) + } + + let natural = videoTrack.naturalSize.applying(videoTrack.preferredTransform) + let outWidth = spec.outWidth > 0 ? spec.outWidth : Int(abs(natural.width).rounded()) + let outHeight = spec.outHeight > 0 ? spec.outHeight : Int(abs(natural.height).rounded()) + guard outWidth > 0, outHeight > 0 else { + Self.reply { completion(false) } + return + } + + let transform = videoTrack.preferredTransform + let overlay = spec.overlay.flatMap { UIImage(contentsOfFile: $0) }.flatMap { CIImage(image: $0) } + let renderSize = CGSize(width: outWidth, height: outHeight) + + let videoComposition = AVMutableVideoComposition(asset: composition) { request in + let image = Self.render( + request.sourceImage, + spec: spec, + transform: transform, + overlay: overlay, + renderSize: renderSize) + request.finish(with: image, context: nil) + } + videoComposition.renderSize = renderSize + + let outputURL = URL(fileURLWithPath: spec.output) + try? FileManager.default.removeItem(at: outputURL) + + guard let session = AVAssetExportSession( + asset: composition, presetName: AVAssetExportPresetHighestQuality) else { + Self.reply { completion(false) } + return + } + session.outputURL = outputURL + session.outputFileType = .mp4 + session.videoComposition = videoComposition + session.shouldOptimizeForNetworkUse = true + self.session = session + + session.exportAsynchronously { + let ok = session.status == .completed && !self.cancelled + self.session = nil + if !ok { try? FileManager.default.removeItem(at: outputURL) } + Self.reply { completion(ok) } + } + } + } + + private static func render( + _ source: CIImage, + spec: VideoExportSpec, + transform: CGAffineTransform, + overlay: CIImage?, + renderSize: CGSize + ) -> CIImage { + var image = normalized(source.transformed(by: transform)) + + if spec.flipH { + image = normalized(image.transformed(by: CGAffineTransform(scaleX: -1, y: 1))) + } + if abs(spec.rotationDegrees) > 0.01 { + let radians = CGFloat(spec.rotationDegrees * .pi / 180) + image = normalized(image.transformed(by: CGAffineTransform(rotationAngle: radians))) + } + if spec.centerSquare { + let extent = image.extent + let side = min(extent.width, extent.height) + image = normalized(image.cropped(to: CGRect( + x: extent.midX - side / 2, + y: extent.midY - side / 2, + width: side, + height: side))) + } else if let crop = spec.crop, crop.count == 4 { + let extent = image.extent + let left = CGFloat((crop[0] + 1) / 2) + let right = CGFloat((crop[1] + 1) / 2) + let bottom = CGFloat((1 - crop[2]) / 2) + let top = CGFloat((1 - crop[3]) / 2) + let rect = CGRect( + x: extent.minX + left * extent.width, + y: extent.minY + (1 - bottom) * extent.height, + width: max(1, (right - left) * extent.width), + height: max(1, (bottom - top) * extent.height)) + image = normalized(image.cropped(to: rect)) + } + + let extent = image.extent + if extent.width > 0, extent.height > 0 { + image = image.transformed(by: CGAffineTransform( + scaleX: renderSize.width / extent.width, + y: renderSize.height / extent.height)) + image = normalized(image) + } + + if let matrix = spec.rgbMatrix, matrix.count == 16, + let filter = CIFilter(name: "CIColorMatrix") { + filter.setValue(image, forKey: kCIInputImageKey) + filter.setValue(vector(matrix, 0, 4, 8), forKey: "inputRVector") + filter.setValue(vector(matrix, 1, 5, 9), forKey: "inputGVector") + filter.setValue(vector(matrix, 2, 6, 10), forKey: "inputBVector") + filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector") + filter.setValue(vector(matrix, 12, 13, 14), forKey: "inputBiasVector") + if let output = filter.outputImage { image = output } + } + + if let overlay = overlay { + image = overlay.composited(over: image) + } + + return image.cropped(to: CGRect(origin: .zero, size: renderSize)) + } + + private static func vector(_ m: [Double], _ x: Int, _ y: Int, _ z: Int) -> CIVector { + CIVector(x: CGFloat(m[x]), y: CGFloat(m[y]), z: CGFloat(m[z]), w: 0) + } + + private static func normalized(_ image: CIImage) -> CIImage { + let extent = image.extent + guard extent.origin != .zero else { return image } + return image.transformed( + by: CGAffineTransform(translationX: -extent.origin.x, y: -extent.origin.y)) + } + + private static func reply(_ result: @escaping FlutterResult, _ value: Any?) { + DispatchQueue.main.async { result(value) } + } + + private static func reply(_ block: @escaping () -> Void) { + DispatchQueue.main.async(execute: block) + } +} diff --git a/ios/Runner/KometVideoNote.swift b/ios/Runner/KometVideoNote.swift new file mode 100644 index 0000000..af1f2b0 --- /dev/null +++ b/ios/Runner/KometVideoNote.swift @@ -0,0 +1,402 @@ +import AVFoundation +import CoreImage +import Flutter +import UIKit + +final class KometVideoNoteTexture: NSObject, FlutterTexture { + private let lock = NSLock() + private var latest: CVPixelBuffer? + + func push(_ buffer: CVPixelBuffer) { + lock.lock() + latest = buffer + lock.unlock() + } + + func copyPixelBuffer() -> Unmanaged? { + lock.lock() + defer { lock.unlock() } + guard let buffer = latest else { return nil } + return Unmanaged.passRetained(buffer) + } +} + +final class KometVideoNote: NSObject { + private let registry: FlutterTextureRegistry + private let queue = DispatchQueue(label: "ru.komet.app.videonote", qos: .userInitiated) + private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) + + private let session = AVCaptureSession() + private let videoOutput = AVCaptureVideoDataOutput() + private let audioOutput = AVCaptureAudioDataOutput() + private let texture = KometVideoNoteTexture() + + private var textureId: Int64 = 0 + private var deviceInput: AVCaptureDeviceInput? + private var audioInput: AVCaptureDeviceInput? + private var position: AVCaptureDevice.Position = .front + private var edge: Int = 480 + private var fps: Int = 30 + + private var pixelBufferPool: CVPixelBufferPool? + private var writer: AVAssetWriter? + private var writerVideo: AVAssetWriterInput? + private var writerAudio: AVAssetWriterInput? + private var adaptor: AVAssetWriterInputPixelBufferAdaptor? + private var outputURL: URL? + private var recording = false + private var sessionStarted = false + + init(registry: FlutterTextureRegistry) { + self.registry = registry + super.init() + } + + static func requestPermission(_ result: @escaping FlutterResult) { + AVCaptureDevice.requestAccess(for: .video) { video in + AVCaptureDevice.requestAccess(for: .audio) { audio in + DispatchQueue.main.async { + result(["camera": NSNumber(value: video), "microphone": NSNumber(value: audio)]) + } + } + } + } + + func initialize(front: Bool, edge: Int, fps: Int, result: @escaping FlutterResult) { + self.position = front ? .front : .back + self.edge = max(16, edge) + self.fps = max(1, fps) + + queue.async { + guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else { + Self.fail(result, "NO_CAMERA_PERMISSION", "camera permission required") + return + } + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required") + return + } + do { + try self.configureSession() + } catch { + Self.fail(result, "NO_CAMERA", error.localizedDescription) + return + } + self.session.startRunning() + + DispatchQueue.main.async { + if self.textureId == 0 { + self.textureId = self.registry.register(self.texture) + } + result(["textureId": NSNumber(value: self.textureId), "hasFlash": self.hasTorch()]) + } + } + } + + func switchCamera(result: @escaping FlutterResult) { + queue.async { + self.position = self.position == .front ? .back : .front + do { + try self.configureSession() + } catch { + Self.fail(result, "NO_CAMERA", error.localizedDescription) + return + } + DispatchQueue.main.async { result(nil) } + } + } + + func setTorch(on: Bool, result: @escaping FlutterResult) { + queue.async { + guard let device = self.deviceInput?.device, device.hasTorch else { + DispatchQueue.main.async { result(NSNumber(value: false)) } + return + } + var applied = false + if (try? device.lockForConfiguration()) != nil { + device.torchMode = on ? .on : .off + device.unlockForConfiguration() + applied = on + } + DispatchQueue.main.async { result(NSNumber(value: applied)) } + } + } + + func start(result: @escaping FlutterResult) { + queue.async { + guard !self.recording else { + DispatchQueue.main.async { result(nil) } + return + } + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required") + return + } + do { + try self.prepareWriter() + } catch { + Self.fail(result, "START_FAILED", error.localizedDescription) + return + } + self.sessionStarted = false + self.recording = true + DispatchQueue.main.async { result(nil) } + } + } + + func stop(result: @escaping FlutterResult) { + queue.async { + guard self.recording, let writer = self.writer else { + Self.fail(result, "NOT_RECORDING", "no active recording") + return + } + self.recording = false + self.writerVideo?.markAsFinished() + self.writerAudio?.markAsFinished() + let url = self.outputURL + writer.finishWriting { + let ok = writer.status == .completed + self.writer = nil + self.writerVideo = nil + self.writerAudio = nil + self.adaptor = nil + self.outputURL = nil + let path: String? = ok ? url?.path : nil + DispatchQueue.main.async { + result(path) + } + } + } + } + + func dispose() { + queue.sync { + if self.recording { + self.recording = false + self.writerVideo?.markAsFinished() + self.writerAudio?.markAsFinished() + self.writer?.cancelWriting() + self.writer = nil + } + if self.session.isRunning { self.session.stopRunning() } + for input in self.session.inputs { self.session.removeInput(input) } + for output in self.session.outputs { self.session.removeOutput(output) } + self.deviceInput = nil + self.audioInput = nil + self.pixelBufferPool = nil + } + if textureId != 0 { + registry.unregisterTexture(textureId) + textureId = 0 + } + } + + private func hasTorch() -> Bool { + deviceInput?.device.hasTorch ?? false + } + + private func configureSession() throws { + session.beginConfiguration() + defer { session.commitConfiguration() } + + if let existing = deviceInput { + session.removeInput(existing) + deviceInput = nil + } + + guard let device = AVCaptureDevice.default( + .builtInWideAngleCamera, for: .video, position: position) + ?? AVCaptureDevice.default(for: .video) else { + throw NSError(domain: "KometVideoNote", code: 1, + userInfo: [NSLocalizedDescriptionKey: "no camera found"]) + } + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + throw NSError(domain: "KometVideoNote", code: 2, + userInfo: [NSLocalizedDescriptionKey: "camera input rejected"]) + } + session.addInput(input) + deviceInput = input + + if session.canSetSessionPreset(.hd1280x720) { + session.sessionPreset = .hd1280x720 + } + + if audioInput == nil, + let microphone = AVCaptureDevice.default(for: .audio), + let input = try? AVCaptureDeviceInput(device: microphone), + session.canAddInput(input) { + session.addInput(input) + audioInput = input + } + + if !session.outputs.contains(videoOutput) { + videoOutput.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ] + videoOutput.alwaysDiscardsLateVideoFrames = true + videoOutput.setSampleBufferDelegate(self, queue: queue) + if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) } + } + if !session.outputs.contains(audioOutput) { + audioOutput.setSampleBufferDelegate(self, queue: queue) + if session.canAddOutput(audioOutput) { session.addOutput(audioOutput) } + } + + if let connection = videoOutput.connection(with: .video) { + if connection.isVideoOrientationSupported { + connection.videoOrientation = .portrait + } + if connection.isVideoMirroringSupported { + connection.automaticallyAdjustsVideoMirroring = false + connection.isVideoMirrored = position == .front + } + } + + if (try? device.lockForConfiguration()) != nil { + let duration = CMTimeMake(value: 1, timescale: Int32(fps)) + if device.activeFormat.videoSupportedFrameRateRanges.contains(where: { + $0.minFrameRate <= Double(fps) && Double(fps) <= $0.maxFrameRate + }) { + device.activeVideoMinFrameDuration = duration + device.activeVideoMaxFrameDuration = duration + } + device.unlockForConfiguration() + } + + pixelBufferPool = Self.makePool(edge: edge) + } + + private func prepareWriter() throws { + let directory = FileManager.default.temporaryDirectory + let url = directory.appendingPathComponent( + "komet_note_\(Int(Date().timeIntervalSince1970 * 1000)).mp4") + try? FileManager.default.removeItem(at: url) + + let writer = try AVAssetWriter(outputURL: url, fileType: .mp4) + let videoSettings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: edge, + AVVideoHeightKey: edge, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: 1_024_000, + AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel, + ], + ] + let video = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + video.expectsMediaDataInRealTime = true + guard writer.canAdd(video) else { + throw NSError(domain: "KometVideoNote", code: 3, + userInfo: [NSLocalizedDescriptionKey: "video input rejected"]) + } + writer.add(video) + + let audioSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: 1, + AVSampleRateKey: 44100, + AVEncoderBitRateKey: 64000, + ] + let audio = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) + audio.expectsMediaDataInRealTime = true + if writer.canAdd(audio) { writer.add(audio) } + + adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: video, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: edge, + kCVPixelBufferHeightKey as String: edge, + ]) + + guard writer.startWriting() else { + throw NSError(domain: "KometVideoNote", code: 4, + userInfo: [NSLocalizedDescriptionKey: "writer refused to start"]) + } + + self.writer = writer + self.writerVideo = video + self.writerAudio = writer.inputs.contains(audio) ? audio : nil + self.outputURL = url + } + + private static func makePool(edge: Int) -> CVPixelBufferPool? { + let attributes: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: edge, + kCVPixelBufferHeightKey as String: edge, + kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any](), + ] + var pool: CVPixelBufferPool? + CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attributes as CFDictionary, &pool) + return pool + } + + private func squareBuffer(from source: CVPixelBuffer) -> CVPixelBuffer? { + guard let pool = pixelBufferPool else { return nil } + var target: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &target) + == kCVReturnSuccess, let output = target else { return nil } + + let image = CIImage(cvPixelBuffer: source) + let extent = image.extent + let side = min(extent.width, extent.height) + let cropped = image.cropped(to: CGRect( + x: extent.midX - side / 2, + y: extent.midY - side / 2, + width: side, + height: side)) + let scale = CGFloat(edge) / side + let scaled = cropped + .transformed(by: CGAffineTransform(translationX: -cropped.extent.origin.x, + y: -cropped.extent.origin.y)) + .transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + + ciContext.render(scaled, to: output) + return output + } + + private static func fail(_ result: @escaping FlutterResult, _ code: String, _ message: String) { + DispatchQueue.main.async { + result(FlutterError(code: code, message: message, details: nil)) + } + } +} + +extension KometVideoNote: AVCaptureVideoDataOutputSampleBufferDelegate, + AVCaptureAudioDataOutputSampleBufferDelegate { + func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + if output === audioOutput { + appendAudio(sampleBuffer) + return + } + guard let source = CMSampleBufferGetImageBuffer(sampleBuffer), + let square = squareBuffer(from: source) else { return } + + texture.push(square) + let id = textureId + if id != 0 { + DispatchQueue.main.async { self.registry.textureFrameAvailable(id) } + } + + guard recording, let writer = writer, let input = writerVideo, + let adaptor = adaptor else { return } + let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + if !sessionStarted { + writer.startSession(atSourceTime: timestamp) + sessionStarted = true + } + guard input.isReadyForMoreMediaData else { return } + adaptor.append(square, withPresentationTime: timestamp) + } + + private func appendAudio(_ sampleBuffer: CMSampleBuffer) { + guard recording, sessionStarted, let input = writerAudio, + input.isReadyForMoreMediaData else { return } + input.append(sampleBuffer) + } +} diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..1eb2e97 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)ru.komet.app + + + diff --git a/lib/backend/api.dart b/lib/backend/api.dart index c43766e..9a80f72 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -1,53 +1,66 @@ import 'dart:async'; -import 'dart:typed_data'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:kolibri/kolibri.dart'; +import 'package:timezone/data/latest_all.dart' as tz; import '../core/cache/self_presence.dart'; import '../core/config/config.dart'; import '../core/config/countries.dart'; import '../core/config/komet_settings.dart'; +import '../core/config/proxy_config.dart'; import '../core/protocol/opcode_map.dart'; import '../core/protocol/packet.dart'; import '../core/storage/device_identity.dart'; import '../core/storage/spoofing_service.dart'; -import '../core/transport/connection.dart'; import '../core/transport/dispatcher.dart'; -import '../core/transport/receiver.dart'; -import '../core/transport/sender.dart'; +import '../core/transport/tls_config.dart'; import '../core/transport/traffic_monitor.dart'; import '../core/transport/vpn_bypass.dart'; import '../core/utils/debug_session_log.dart'; import '../core/utils/logger.dart'; -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter_timezone/flutter_timezone.dart'; -import 'package:timezone/data/latest_all.dart' as tz; -import 'dart:io'; - enum SessionState { disconnected, connecting, connected, online } /// Клиент API. /// -/// Подключение, хэндшейк, пинг, реконнект. +/// Тонкий адаптер над Rust-ядром [KolibriSession] (пакет kolibri): подключение, +/// хэндшейк, пинг и реконнект живут в ядре, здесь — оркестрация жизненного цикла +/// и сохранение прежнего интерфейса для модулей (Packet/пуши/стримы). class Api { - final Connection _connection = Connection(); - final PacketReceiver _receiver = PacketReceiver(); - final PacketSender _sender = PacketSender(); + KolibriSession? _session; + + /// Роутер пушей (ответы на запросы ядро матчит само, диспетчер держим только + /// ради registerHandler/pushStream). final PacketDispatcher _dispatcher = PacketDispatcher(); + StreamSubscription<(int, Map)>? _pushSub; + StreamSubscription? _wireLogSub; SessionState _sessionState = SessionState.disconnected; final _stateController = StreamController.broadcast(); final _sessionExpiredController = StreamController.broadcast(); final _handshakeSuccessController = StreamController.broadcast(); - Map? _userAgent; + final _errorController = StreamController.broadcast(); + Map? _userAgent; Map? get userAgent => _userAgent; int? _callsSeed; String? _deviceId; + String? _callsDevice; + String? _callsOsVersion; int? get callsSeed => _callsSeed; String? get deviceId => _deviceId; + String? get callsDevice => _callsDevice; + String? get callsOsVersion => _callsOsVersion; + + /// Сырой доступ к сессии ядра — для медиа-загрузок (data-plane). + KolibriSession? get session => _session; String? spoofScope; @@ -63,28 +76,25 @@ class Api { _sessionExpiredController.stream; Stream get handshakeSuccessStream => _handshakeSuccessController.stream; - Stream get errorStream => _dispatcher.errorStream; + Stream get errorStream => _errorController.stream; SessionState get state => _sessionState; - StreamSubscription? _dataSubscription; - StreamSubscription? _socketStateSubscription; - Timer? _pingTimer; + Timer? _livenessTimer; Timer? _reconnectTimer; Timer? _connectWatchdog; int _connectGen = 0; int _reconnectAttempts = 0; bool _autoReconnect = false; int _sessionEpoch = 0; + bool? _lastInteractive; static const Duration _connectWatchdogTimeout = Duration(seconds: 75); static const Duration _shouldArmTimeout = Duration(seconds: 5); static const Duration _endpointTimeout = Duration(seconds: 5); + static const Duration _livenessInterval = Duration(seconds: 5); int get sessionEpoch => _sessionEpoch; - /// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля. - bool _bypassActive = false; - // Публичное API /// Подключается к серверу, шлёт хэндшейк, запускает пинг. @@ -100,32 +110,18 @@ class Api { _armConnectWatchdog(gen); try { - _dataSubscription = _connection.dataStream.listen(_onDataReceived); - _socketStateSubscription = _connection.stateStream.listen((socketState) { - if (socketState == SocketState.disconnected && - _sessionState != SessionState.disconnected) { - _onDisconnected(); - } - }); - - bool bypassArmed; + bool useBypass; try { - bypassArmed = await VpnBypassService.instance.shouldArm().timeout( + useBypass = await VpnBypassService.instance.shouldArm().timeout( _shouldArmTimeout, ); } catch (e) { logger.w('connect: shouldArm завис/упал ($e) — без обхода VPN'); - bypassArmed = false; + useBypass = false; } if (gen != _connectGen) return; - if (!bypassArmed) _bypassActive = false; - final useBypass = _bypassActive && bypassArmed; - final attemptTimeout = bypassArmed && !useBypass - ? const Duration(seconds: 8) - : null; - - ({String host, int port}) endpoint; + ({String host, int port, bool trustMincifryCa}) endpoint; try { endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout); } catch (e) { @@ -133,84 +129,75 @@ class Api { endpoint = ( host: ServerConfig.defaultHost, port: ServerConfig.defaultPort, + trustMincifryCa: ServerConfig.defaultTrustMincifryCa, ); } if (gen != _connectGen) return; + setTrustMincifryCa(enabled: endpoint.trustMincifryCa); + + final (session, wireLog) = await _buildSessionOptions(endpoint); + if (gen != _connectGen) return; + logger.i( 'connect: endpoint ${endpoint.host}:${endpoint.port}, bypass=$useBypass', ); - try { - await _connection.connect( - endpoint.host, - endpoint.port, - bypassVpn: useBypass, - timeout: attemptTimeout, - ); - } catch (e) { - if (gen != _connectGen) return; - await _handleConnectFailure( - e, - phase: 'Не удалось подключиться', - bypassArmed: bypassArmed, - useBypass: useBypass, - bypassWhy: 'подключение не удалось', - ); - return; + + if (useBypass) { + try { + await VpnBypassService.instance.bind(); + } catch (e) { + logger.w('connect: VPN bind не удался ($e)'); + } } - if (gen != _connectGen) return; + + _session = session; + // Подписываемся на wire-лог ядра ДО connect(), чтобы поймать пакеты + // SESSION_INIT-хендшейка (иначе они уходят до listen и теряются). + _wireLogSub = wireLog.listen(_onWireLog); + _pushSub = session.pushesMap().listen(_onPush); + TrafficMonitor.instance.recordEvent( + 'connect', + endpoint: '${endpoint.host}:${endpoint.port}', + ); _setSessionState(SessionState.connected); _reconnectAttempts = 0; + HandshakeInfo info; try { logger.i('connect: сокет готов, отправляю хэндшейк'); - final response = await sendHandshake(); - if (gen != _connectGen) return; - if (response.isOk) { - _callsSeed = response.payload['callsSeed'] as int?; - _registrationCountries = _parseRegistrationCountries( - response.payload, - ); - _sessionState = SessionState.online; - _sessionEpoch++; - _cancelConnectWatchdog(); - _startPinging(); - logger.i('Сессия онлайн, хэндшейк ок'); - if (_onReconnectCallback != null) { - try { - await _onReconnectCallback!(); - } catch (e) { - logger.w('Авто-логин при хэндшейке не удался: $e'); - } - } - if (_sessionState == SessionState.online) { - _stateController.add(SessionState.online); - _handshakeSuccessController.add( - response.payload['device_name'] as String? ?? 'Unknown', - ); - } - } else { - logger.e('Хэндшейк отклонён: ${response.payload}'); - await _handleConnectFailure( - StateError('хэндшейк отклонён сервером'), - phase: 'Хэндшейк отклонён', - bypassArmed: bypassArmed, - useBypass: useBypass, - bypassWhy: 'хэндшейк отклонён', - disconnectSocket: true, - ); - } + info = await session.connect(); } catch (e) { if (gen != _connectGen) return; - await _handleConnectFailure( - e, - phase: 'Ошибка хэндшейка', - bypassArmed: bypassArmed, - useBypass: useBypass, - bypassWhy: 'хэндшейк не прошёл', - disconnectSocket: true, - ); + await _handleConnectFailure(e, phase: 'Ошибка хэндшейка'); + return; + } finally { + if (useBypass) { + try { + await VpnBypassService.instance.restoreDefault(); + } catch (_) {} + } + } + if (gen != _connectGen) return; + + _callsSeed = info.callsSeed?.toInt(); + _registrationCountries = _parseRegistrationCountries(info.payloadMap); + _sessionState = SessionState.online; + _sessionEpoch++; + _cancelConnectWatchdog(); + _startLiveness(); + logger.i('Сессия онлайн, хэндшейк ок'); + if (_onReconnectCallback != null) { + try { + await _onReconnectCallback!(); + } catch (e) { + logger.w('Авто-логин при хэндшейке не удался: $e'); + } + } + if (_sessionState == SessionState.online) { + _stateController.add(SessionState.online); + _handshakeSuccessController.add(info.deviceName ?? 'Unknown'); } } catch (e, st) { logger.e('connect: непредвиденная ошибка: $e\n$st'); @@ -244,9 +231,6 @@ class Api { _connectGen++; _cancelConnectWatchdog(); _cleanup(); - try { - await _connection.disconnect(); - } catch (_) {} _setSessionState(SessionState.disconnected); if (_autoReconnect) _scheduleReconnect(); } @@ -254,37 +238,22 @@ class Api { Future _handleConnectFailure( Object error, { required String phase, - required bool bypassArmed, - required bool useBypass, - required String bypassWhy, - bool disconnectSocket = false, }) async { logger.e('$phase: $error'); _cancelConnectWatchdog(); if (_sessionState != SessionState.disconnected) { _cleanup(); - if (disconnectSocket) await _connection.disconnect(); _setSessionState(SessionState.disconnected); - _armBypassIfPossible(bypassArmed, useBypass, bypassWhy); _scheduleReconnect(); } } - void _armBypassIfPossible(bool armed, bool alreadyBypassing, String why) { - if (armed && !alreadyBypassing && !_bypassActive) { - _bypassActive = true; - logger.w('VPN bypass: $why — следующая попытка мимо VPN'); - } - } - /// Отключается без автореконнекта. Future disconnect() async { _autoReconnect = false; - _bypassActive = false; _connectGen++; _reconnectTimer?.cancel(); _cleanup(); - await _connection.disconnect(); _setSessionState(SessionState.disconnected); } @@ -303,145 +272,49 @@ class Api { } } - Future sendHandshake() async { - final deviceInfo = DeviceInfoPlugin(); - - String deviceType = 'ANDROID'; - String osVersion = ''; - String deviceName = 'Unknown'; - String architecture = 'arm64'; - String appVersion = SpoofingService.hardcodedAppVersion; - int buildNumber = SpoofingService.hardcodedBuildNumber; - String screen = '420dpi 420dpi 1080x2340'; - - if (!_tzInitialized) { - tz.initializeTimeZones(); - _tzInitialized = true; - } - final timeZoneName = await FlutterTimezone.getLocalTimezone(); - String timezone = timeZoneName.identifier; - String locale = 'ru'; - String deviceLocale = Platform.localeName.substring(0, 2); - String deviceId = await DeviceIdentity.deviceId(); - String pushDeviceType = 'GCM'; - String instanceId = await DeviceIdentity.instanceId(); - int clientSessionId = DeviceIdentity.clientSessionId; - - if (Platform.isLinux) { - final linuxInfo = await deviceInfo.linuxInfo; - osVersion = linuxInfo.name; - architecture = _archFromPlatformVersion(); - } else if (Platform.isIOS) { - final iosInfo = await deviceInfo.iosInfo; - osVersion = iosInfo.systemVersion; - deviceName = iosInfo.utsname.machine; - } else if (Platform.isAndroid) { - final androidInfo = await deviceInfo.androidInfo; - osVersion = 'Android ${androidInfo.version.release}'; - deviceName = '${androidInfo.manufacturer} ${androidInfo.model}'; - architecture = androidInfo.supportedAbis.first; - } else if (Platform.isWindows) { - final windowsInfo = await deviceInfo.windowsInfo; - osVersion = windowsInfo.productName; - architecture = _archFromPlatformVersion(); - } - - final spoofed = await SpoofingService.getSpoofedSessionData( - scope: spoofScope, - ); - if (spoofed != null) { - final sDeviceType = spoofed['device_type'] as String?; - if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType; - final sDeviceName = spoofed['device_name'] as String?; - if (sDeviceName != null && sDeviceName.isNotEmpty) { - deviceName = sDeviceName; - } - final sOsVersion = spoofed['os_version'] as String?; - if (sOsVersion != null && sOsVersion.isNotEmpty) osVersion = sOsVersion; - final sScreen = spoofed['screen'] as String?; - if (sScreen != null && sScreen.isNotEmpty) screen = sScreen; - final sTimezone = spoofed['timezone'] as String?; - if (sTimezone != null && sTimezone.isNotEmpty) timezone = sTimezone; - final sLocale = spoofed['locale'] as String?; - if (sLocale != null && sLocale.isNotEmpty) { - locale = sLocale; - deviceLocale = sLocale.split(RegExp(r'[-_]')).first; - } - final sDeviceLocale = spoofed['device_locale'] as String?; - if (sDeviceLocale != null && sDeviceLocale.isNotEmpty) { - deviceLocale = sDeviceLocale; - } - final sDeviceId = spoofed['device_id'] as String?; - if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId; - appVersion = (spoofed['app_version'] as String?) ?? appVersion; - architecture = (spoofed['arch'] as String?) ?? architecture; - final sBuild = spoofed['build_number']; - if (sBuild is int) { - buildNumber = sBuild; - } else if (sBuild is String) { - buildNumber = int.tryParse(sBuild) ?? buildNumber; - } - final sPushType = spoofed['push_device_type'] as String?; - if (sPushType != null && sPushType.isNotEmpty) pushDeviceType = sPushType; - final sInstanceId = spoofed['instance_id'] as String?; - if (sInstanceId != null && sInstanceId.isNotEmpty) { - instanceId = sInstanceId; - } - final sClientSession = spoofed['client_session_id']; - if (sClientSession is int) clientSessionId = sClientSession; - } - - _userAgent = { - 'deviceType': deviceType, - 'appVersion': appVersion, - 'osVersion': osVersion, - 'timezone': timezone, - 'screen': screen, - 'pushDeviceType': pushDeviceType, - 'arch': architecture, - 'locale': locale, - 'buildNumber': buildNumber, - 'deviceName': deviceName, - 'deviceLocale': deviceLocale, - }; - - _deviceId = deviceId; - - final payload = { - 'mt_instanceid': instanceId, - 'userAgent': _userAgent, - 'clientSessionId': clientSessionId, - 'deviceId': deviceId, - }; - - return sendRequest(Opcode.sessionInit, payload); - } - /// Отправляет запрос и ждёт ответ от сервера. - Future sendRequest(int opcode, Map payload) { - final seq = _sender.send(_connection, opcode, payload); - DebugSessionLog.instance.recordRequest(opcode, seq, payload); - return _dispatcher - .registerPending(seq) + Future sendRequest( + int opcode, + Map payload, { + bool silent = false, + }) async { + final session = _session; + if (session == null) { + throw StateError('Нет соединения (${Opcode.name(opcode)})'); + } + // Лог запроса/ответа ведётся из wire-лога ядра (_onWireLog) по настоящему + // проводному seq, поэтому здесь ничего не пишем. + final KolibriResponse resp = await session + .requestMapFull(opcode, Map.from(payload)) .timeout( ServerConfig.requestTimeout, onTimeout: () => throw TimeoutException('${Opcode.name(opcode)} таймаут'), - ) - .then( - (packet) { - DebugSessionLog.instance.recordResponse( - seq, - packet.cmd, - packet.payload, - ); - return packet; - }, - onError: (Object e, StackTrace st) { - DebugSessionLog.instance.recordError(seq, e); - Error.throwWithStackTrace(e, st); - }, ); + + final packet = Packet( + cmd: resp.cmd, + opcode: resp.opcode, + payload: resp.payload, + ); + + if (packet.isError) { + if (isSessionExpiredPayload(packet.payload)) { + final ex = SessionExpiredException( + messageFromErrorPayload(packet.payload), + ); + _sessionExpiredController.add(ex); + throw ex; + } + final text = _serverErrorText(packet.payload); + if (text != null && !silent) _errorController.add(text); + final err = PacketError( + messageFromErrorPayload(packet.payload), + errorKey: resp.errorKey, + ); + throw err; + } + return packet; } Future?> sendRequestMap( @@ -485,14 +358,290 @@ class Api { _reconnectTimer?.cancel(); _cleanup(); _dispatcher.dispose(); - await _connection.dispose(); await _stateController.close(); await _sessionExpiredController.close(); await _handshakeSuccessController.close(); + await _errorController.close(); } // Внутрянка + /// Строит устройство-поля и создаёт сессию ядра. Заодно заполняет + /// [_userAgent] и [_deviceId] для геттеров. + Future<(KolibriSession, Stream)> _buildSessionOptions( + ({String host, int port, bool trustMincifryCa}) endpoint, + ) async { + final deviceInfo = DeviceInfoPlugin(); + + String deviceType = 'ANDROID'; + String osVersion = ''; + String deviceName = 'Unknown'; + String architecture = 'arm64-v8a'; + String appVersion = SpoofingService.hardcodedAppVersion; + int buildNumber = SpoofingService.hardcodedBuildNumber; + String screen = '420dpi 420dpi 1080x2340'; + + if (!_tzInitialized) { + tz.initializeTimeZones(); + _tzInitialized = true; + } + final timeZoneName = await FlutterTimezone.getLocalTimezone(); + String timezone = timeZoneName.identifier; + String locale = 'ru'; + String deviceLocale = Platform.localeName.substring(0, 2); + String deviceId = await DeviceIdentity.deviceId(); + String pushDeviceType = 'GCM'; + String instanceId = await DeviceIdentity.instanceId(); + int clientSessionId = DeviceIdentity.clientSessionId; + + String? androidManufacturer; + String? androidModel; + int? androidSdkInt; + + if (Platform.isLinux) { + final linuxInfo = await deviceInfo.linuxInfo; + osVersion = linuxInfo.name; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + osVersion = iosInfo.systemVersion; + deviceName = iosInfo.utsname.machine; + } else if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + osVersion = 'Android ${androidInfo.version.release}'; + deviceName = '${androidInfo.manufacturer} ${androidInfo.model}'; + androidManufacturer = androidInfo.manufacturer; + androidModel = androidInfo.model; + androidSdkInt = androidInfo.version.sdkInt; + } else if (Platform.isWindows) { + final windowsInfo = await deviceInfo.windowsInfo; + osVersion = windowsInfo.productName; + } + + String? spoofUserAgent; + final spoofed = await SpoofingService.getSpoofedSessionData( + scope: spoofScope, + ); + if (spoofed != null) { + spoofUserAgent = spoofed['user_agent'] as String?; + final sDeviceType = spoofed['device_type'] as String?; + if (sDeviceType != null && sDeviceType != 'IOS') deviceType = sDeviceType; + final sDeviceName = spoofed['device_name'] as String?; + if (sDeviceName != null && sDeviceName.isNotEmpty) { + deviceName = sDeviceName; + } + final sOsVersion = spoofed['os_version'] as String?; + if (sOsVersion != null && sOsVersion.isNotEmpty) osVersion = sOsVersion; + final sScreen = spoofed['screen'] as String?; + if (sScreen != null && sScreen.isNotEmpty) screen = sScreen; + final sTimezone = spoofed['timezone'] as String?; + if (sTimezone != null && sTimezone.isNotEmpty) timezone = sTimezone; + final sLocale = spoofed['locale'] as String?; + if (sLocale != null && sLocale.isNotEmpty) { + locale = sLocale; + deviceLocale = sLocale.split(RegExp(r'[-_]')).first; + } + final sDeviceLocale = spoofed['device_locale'] as String?; + if (sDeviceLocale != null && sDeviceLocale.isNotEmpty) { + deviceLocale = sDeviceLocale; + } + final sDeviceId = spoofed['device_id'] as String?; + if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId; + appVersion = (spoofed['app_version'] as String?) ?? appVersion; + architecture = (spoofed['arch'] as String?) ?? architecture; + final sBuild = spoofed['build_number']; + if (sBuild is int) { + buildNumber = sBuild; + } else if (sBuild is String) { + buildNumber = int.tryParse(sBuild) ?? buildNumber; + } + final sPushType = spoofed['push_device_type'] as String?; + if (sPushType != null && sPushType.isNotEmpty) pushDeviceType = sPushType; + final sInstanceId = spoofed['instance_id'] as String?; + if (sInstanceId != null && sInstanceId.isNotEmpty) { + instanceId = sInstanceId; + } + final sClientSession = spoofed['client_session_id']; + if (sClientSession is int) clientSessionId = sClientSession; + } + + _callsDevice = _resolveCallsDevice( + spoofed: spoofed != null, + deviceName: deviceName, + spoofUserAgent: spoofUserAgent, + manufacturer: androidManufacturer, + model: androidModel, + ); + _callsOsVersion = _resolveCallsOsVersion( + spoofed: spoofed != null, + osVersion: osVersion, + sdkInt: androidSdkInt, + ); + + _userAgent = { + 'deviceType': deviceType, + 'appVersion': appVersion, + 'osVersion': osVersion, + 'timezone': timezone, + 'screen': screen, + 'pushDeviceType': pushDeviceType, + 'arch': architecture, + 'locale': locale, + 'buildNumber': buildNumber, + 'deviceName': deviceName, + 'deviceLocale': deviceLocale, + }; + _deviceId = deviceId; + + final insecureTls = await TlsConfig.isInsecureAllowed(); + final proxy = await _buildProxyUrl(); + + return openSessionWithWireLog( + host: endpoint.host, + port: endpoint.port, + deviceId: deviceId, + instanceId: instanceId, + appVersion: appVersion, + buildNumber: buildNumber, + deviceType: deviceType, + osVersion: osVersion, + timezone: timezone, + screen: screen, + pushDeviceType: pushDeviceType, + arch: architecture, + locale: locale, + deviceName: deviceName, + deviceLocale: deviceLocale, + clientSessionId: clientSessionId, + pingIntervalSecs: ServerConfig.pingInterval.inSeconds, + pingInteractive: !KometSettings.ghostMode.value, + autoReconnect: false, + insecureTls: insecureTls, + proxy: proxy, + ); + } + + static String? _resolveCallsDevice({ + required bool spoofed, + required String deviceName, + String? spoofUserAgent, + String? manufacturer, + String? model, + }) { + if (!spoofed && + manufacturer != null && + manufacturer.isNotEmpty && + model != null && + model.isNotEmpty) { + return '$manufacturer/$model'; + } + final parts = deviceName.trim().split(RegExp(r'\s+')) + ..removeWhere((p) => p.isEmpty); + if (parts.isEmpty) return null; + final fallbackModel = parts.length > 1 + ? parts.sublist(1).join(' ') + : parts.first; + return '${parts.first}/' + '${_modelFromUserAgent(spoofUserAgent) ?? fallbackModel}'; + } + + static String? _modelFromUserAgent(String? userAgent) { + if (userAgent == null || userAgent.isEmpty) return null; + final match = RegExp(r'Android\s+[\d.]+;\s*([^;)]+)').firstMatch(userAgent); + final model = match + ?.group(1) + ?.replaceFirst(RegExp(r'\s+Build/.*$'), '') + .trim(); + return model == null || model.isEmpty ? null : model; + } + + static String _resolveCallsOsVersion({ + required bool spoofed, + required String osVersion, + int? sdkInt, + }) { + if (!spoofed && sdkInt != null && sdkInt > 0) return '$sdkInt'; + final release = RegExp( + r'^Android\s+(\d+)', + ).firstMatch(osVersion.trim())?.group(1); + return '${_androidSdkForRelease(int.tryParse(release ?? ''))}'; + } + + static int _androidSdkForRelease(int? release) => switch (release) { + null => 34, + <= 9 => 28, + 10 => 29, + 11 => 30, + 12 => 31, + 13 => 33, + 14 => 34, + 15 => 35, + _ => 36, + }; + + static Future _buildProxyUrl() async { + final p = await ProxyConfig.load(); + if (!p.isEnabled) return null; + final scheme = p.type == ProxyType.socks5 ? 'socks5h' : 'http'; + final auth = p.hasCredentials + ? '${Uri.encodeComponent(p.username!)}:' + '${Uri.encodeComponent(p.password!)}@' + : ''; + return '$scheme://$auth${p.host}:${p.port}'; + } + + void _onPush((int, Map) event) { + final packet = Packet( + cmd: CmdType.push, + opcode: event.$1, + payload: event.$2, + ); + // Учёт трафика пушей ведётся из wire-лога ядра (_onWireLog). + _dispatcher.dispatch(packet); + } + + /// Единый источник лога трафика: ядро отдаёт сюда каждый пакет обеих сторон — + /// включая SESSION_INIT-хендшейк и пинги — с настоящим проводным seq. Раньше + /// лог вёлся вручную из [sendRequest] по локальному счётчику, из-за чего + /// хендшейк/пинги в дамп не попадали, а seq был смещён относительно провода. + void _onWireLog(WireLogEvent e) { + final payload = _decodeWireJson(e.json); + final cmd = _wireCmdCode(e.cmd); + if (e.direction == 'out') { + DebugSessionLog.instance.recordRequest(e.opcode, e.seq, payload); + TrafficMonitor.instance.recordOutgoing(e.opcode, payload, e.seq, 0); + return; + } + // Входящие: ответы матчатся по seq, пуши идут только в монитор трафика. + if (e.cmd != 'push') { + DebugSessionLog.instance.recordResponse(e.seq, cmd, payload); + } + TrafficMonitor.instance.recordIncoming( + Packet(cmd: cmd, seq: e.seq, opcode: e.opcode, payload: payload), + 0, + ); + } + + static int _wireCmdCode(String cmd) { + switch (cmd) { + case 'ok': + return CmdType.ok; + case 'not_found': + return CmdType.notFound; + case 'error': + return CmdType.error; + default: + return CmdType.request; // 'request' и 'push' + } + } + + static dynamic _decodeWireJson(String json) { + try { + return jsonDecode(json); + } catch (_) { + return json; + } + } + void _setSessionState(SessionState state) { if (_sessionState == state) return; _sessionState = state; @@ -500,35 +649,6 @@ class Api { logger.i('Сессия: ${state.name}'); } - Future _onDataReceived(Uint8List data) async { - final List rawPackets; - try { - rawPackets = _receiver.feed(data); - } on ReceiverOverflowException catch (e) { - logger.e('$e — форсируем реконнект'); - if (_sessionState != SessionState.disconnected) { - unawaited(_forceReconnect()); - } - return; - } - for (final raw in rawPackets) { - final Packet packet; - try { - packet = await unpackPacket(raw); - } catch (e) { - logger.e('PacketReceiver: ошибка распаковки: $e'); - continue; - } - TrafficMonitor.instance.recordIncoming(packet, raw.length); - if (packet.isError && isSessionExpiredPayload(packet.payload)) { - _sessionExpiredController.add( - SessionExpiredException(messageFromErrorPayload(packet.payload)), - ); - } - _dispatcher.dispatch(packet); - } - } - void _onDisconnected() { _connectGen++; _cleanup(); @@ -536,13 +656,18 @@ class Api { if (_autoReconnect) _scheduleReconnect(); } + /// Пробный запрос-пинг: если не ответил — форсируем реконнект. Future _probeLiveness() async { if (_sessionState != SessionState.online) return; + final session = _session; + if (session == null) return; final epoch = _sessionEpoch; try { - await sendRequest(Opcode.ping, { - 'interactive': !KometSettings.ghostMode.value, - }).timeout(const Duration(seconds: 6)); + await session + .requestMapFull(Opcode.ping, { + 'interactive': !KometSettings.ghostMode.value, + }) + .timeout(const Duration(seconds: 6)); } catch (_) { if (_sessionEpoch != epoch || _sessionState != SessionState.online) { return; @@ -555,7 +680,6 @@ class Api { Future _forceReconnect() async { _connectGen++; _cleanup(); - await _connection.disconnect(); _reconnectAttempts = 0; _reconnectTimer?.cancel(); _setSessionState(SessionState.disconnected); @@ -564,12 +688,20 @@ class Api { void _cleanup() { _cancelConnectWatchdog(); - _pingTimer?.cancel(); - _dataSubscription?.cancel(); - _socketStateSubscription?.cancel(); - _dataSubscription = null; - _socketStateSubscription = null; - _receiver.reset(); + _livenessTimer?.cancel(); + _livenessTimer = null; + _pushSub?.cancel(); + _pushSub = null; + _wireLogSub?.cancel(); + _wireLogSub = null; + _lastInteractive = null; + final session = _session; + _session = null; + if (session != null) { + try { + session.disconnect(); + } catch (_) {} + } _dispatcher.clearPending(); _handshakeSuccessController.add('disconnected'); } @@ -584,17 +716,44 @@ class Api { _onReconnectCallback = callback; } - void _startPinging() { - _pingTimer?.cancel(); - sendPing(interactive: !KometSettings.ghostMode.value); - _pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) { - sendPing(interactive: !KometSettings.ghostMode.value); - }); + /// Поллит состояние ядра (стрима состояний нет) — детект разрыва, плюс + /// синхронизация interactive-флага пинга и присутствия. + void _startLiveness() { + _livenessTimer?.cancel(); + _lastInteractive = !KometSettings.ghostMode.value; + _livenessTimer = Timer.periodic(_livenessInterval, (_) => _tickLiveness()); + } + + void _tickLiveness() { + final session = _session; + if (session == null || _sessionState != SessionState.online) return; + final st = session.state(); + if (st != 'online' && st != 'connected') { + logger.w('kolibri сессия "$st" — реконнект'); + _onDisconnected(); + return; + } + final interactive = !KometSettings.ghostMode.value; + if (interactive != _lastInteractive) { + _lastInteractive = interactive; + try { + session.setPingInteractive(interactive: interactive); + } catch (_) {} + } + if (interactive) { + SelfPresence.markOnline(); + } else { + SelfPresence.markOfflineFromPing(); + } } void sendPing({required bool interactive}) { - if (_connection.isConnected) { - _sender.send(_connection, Opcode.ping, {'interactive': interactive}); + final session = _session; + if (session != null && _sessionState == SessionState.online) { + try { + session.setPingInteractive(interactive: interactive); + } catch (_) {} + _lastInteractive = interactive; if (interactive) { SelfPresence.markOnline(); } else { @@ -603,9 +762,13 @@ class Api { } } - static String _archFromPlatformVersion() { - final v = Platform.version; - return v.substring(v.indexOf('_') + 1, v.length - 1); + static String? _serverErrorText(dynamic payload) { + if (payload is! Map) return null; + for (final key in ['localizedMessage', 'title']) { + final v = payload[key]; + if (v is String && v.trim().isNotEmpty) return v.trim(); + } + return null; } static List? _parseRegistrationCountries(dynamic payload) { diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart index a5b3d02..e171b77 100644 --- a/lib/backend/models/chat_folder.dart +++ b/lib/backend/models/chat_folder.dart @@ -1,83 +1,205 @@ +class FolderFilter { + static const int unread = 0; + static const int read = 1; + static const int channel = 2; + static const int chat = 3; + static const int dialog = 4; + static const int owner = 5; + static const int admin = 6; + static const int muted = 7; + static const int contact = 8; + static const int notContact = 9; + static const int bot = 10; + static const int notMuted = 11; + static const int markedUnread = 12; + static const int org = 13; + + static const Set chatTypes = { + contact, + notContact, + chat, + channel, + bot, + dialog, + org, + }; + + static const Set roles = {owner, admin}; + + static const Set showOnly = { + unread, + read, + muted, + notMuted, + markedUnread, + }; + + static const Map _byName = { + 'UNREAD': unread, + 'READ': read, + 'CHANNEL': channel, + 'CHAT': chat, + 'GROUP': chat, + 'DIALOG': dialog, + 'OWNER': owner, + 'ADMIN': admin, + 'MUTED': muted, + 'CONTACT': contact, + 'NOT_CONTACT': notContact, + 'BOT': bot, + 'NOT_MUTED': notMuted, + 'MARKED_UNREAD': markedUnread, + 'ORG': org, + }; + + static int? parse(dynamic raw) { + if (raw is int) return raw; + if (raw is String) return int.tryParse(raw) ?? _byName[raw]; + return null; + } +} + +class FolderOption { + static const int hideEmpty = 0; + static const int noDelete = 1; + static const int noTitleEdit = 2; + static const int noFiltersEdit = 3; + static const int chatSuggest = 4; + + static const Map _byName = { + 'HIDE_EMPTY': hideEmpty, + 'NO_DELETE': noDelete, + 'NO_TITLE_EDIT': noTitleEdit, + 'NO_FILTERS_EDIT': noFiltersEdit, + 'CHAT_SUGGEST': chatSuggest, + }; + + static int? parse(dynamic raw) { + if (raw is int) return raw; + if (raw is String) return int.tryParse(raw) ?? _byName[raw]; + return null; + } +} + class ChatFolder { final String id; final String title; final String? emoji; - final List? include; - final List filters; - final bool hideEmpty; + final List include; + final List filters; + final List options; + final List favorites; final List widgets; - final List? favorites; final Map? filterSubjects; - final List? options; + final int updateTime; + final int? sourceId; - ChatFolder({ + const ChatFolder({ required this.id, required this.title, this.emoji, - this.include, - required this.filters, - required this.hideEmpty, - required this.widgets, - this.favorites, + this.include = const [], + this.filters = const [], + this.options = const [], + this.favorites = const [], + this.widgets = const [], this.filterSubjects, - this.options, + this.updateTime = 0, + this.sourceId, }); - static List? _parseIntList(dynamic raw) { - return (raw as List?)?.map((e) { - if (e is int) return e; - if (e is String) return int.tryParse(e) ?? 0; - return 0; - }).toList(); + bool get hideEmpty => options.contains(FolderOption.hideEmpty); + + bool get canDelete => !options.contains(FolderOption.noDelete); + + bool get canEditTitle => !options.contains(FolderOption.noTitleEdit); + + bool get canEditFilters => !options.contains(FolderOption.noFiltersEdit); + + static List _parseIds(dynamic raw) { + if (raw is! List) return []; + return raw + .map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e); + return null; + }) + .whereType() + .toList(); + } + + static List _parseCodes(dynamic raw, int? Function(dynamic) parse) { + if (raw is! List) return []; + return raw.map(parse).whereType().toList(); + } + + static Map? _parseMap(dynamic raw) { + if (raw is Map) return raw; + if (raw is Map) return Map.from(raw); + return null; } factory ChatFolder.fromJson(Map json) { + final options = _parseCodes(json['options'], FolderOption.parse); + if (json['hideEmpty'] == true && + !options.contains(FolderOption.hideEmpty)) { + options.add(FolderOption.hideEmpty); + } return ChatFolder( id: json['id']?.toString() ?? '', title: json['title']?.toString() ?? '', emoji: json['emoji']?.toString(), - include: _parseIntList(json['include']), - filters: - (json['filters'] as List?)?.map((e) { - if (e is int) return e; - if (e is String) return int.tryParse(e) ?? e; - return e; - }).toList() ?? - [], - hideEmpty: json['hideEmpty'] ?? false, + include: _parseIds(json['include']), + filters: _parseCodes(json['filters'], FolderFilter.parse), + options: options, + favorites: _parseIds(json['favorites']), widgets: - (json['widgets'] as List?)?.map((w) { - if (w is Map) { - return ChatFolderWidget.fromJson(w); - } - return ChatFolderWidget.fromJson( - Map.from(w as Map), - ); - }).toList() ?? - [], - favorites: _parseIntList(json['favorites']), - filterSubjects: json['filterSubjects'] is Map - ? json['filterSubjects'] as Map - : (json['filterSubjects'] is Map - ? Map.from( - (json['filterSubjects'] as Map).cast(), - ) - : null), - options: _parseIntList(json['options']), + (json['widgets'] as List?) + ?.map(_parseMap) + .whereType>() + .map(ChatFolderWidget.fromJson) + .toList() ?? + const [], + filterSubjects: _parseMap(json['filterSubjects']), + updateTime: json['updateTime'] is int ? json['updateTime'] as int : 0, + sourceId: json['sourceId'] is int ? json['sourceId'] as int : null, ); } + ChatFolder copyWith({ + String? title, + String? emoji, + List? include, + List? filters, + List? options, + List? favorites, + int? updateTime, + }) => ChatFolder( + id: id, + title: title ?? this.title, + emoji: emoji ?? this.emoji, + include: include ?? this.include, + filters: filters ?? this.filters, + options: options ?? this.options, + favorites: favorites ?? this.favorites, + widgets: widgets, + filterSubjects: filterSubjects, + updateTime: updateTime ?? this.updateTime, + sourceId: sourceId, + ); + Map toJson() => { 'id': id, 'title': title, if (emoji != null) 'emoji': emoji, - if (include != null) 'include': include, + 'include': include, 'filters': filters, - 'hideEmpty': hideEmpty, + 'options': options, + 'favorites': favorites, 'widgets': widgets.map((w) => w.toJson()).toList(), - if (favorites != null) 'favorites': favorites, if (filterSubjects != null) 'filterSubjects': filterSubjects, - if (options != null) 'options': options, + 'updateTime': updateTime, + if (sourceId != null) 'sourceId': sourceId, }; } diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index a322a93..13b4161 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import '../api.dart'; +import '../../core/config/debug_test.dart'; import '../../core/config/komet_settings.dart'; import '../../core/protocol/chat_cache_fingerprint.dart'; import '../../core/protocol/opcode_map.dart'; @@ -10,6 +11,8 @@ import '../../core/storage/app_database.dart'; import '../../core/storage/spoofing_service.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; +import '../../models/login_info.dart'; +import 'banners.dart'; import 'chats.dart'; import 'complaints.dart'; import 'contacts.dart'; @@ -40,7 +43,9 @@ class AccountModule { late final PrivacyModule _privacy = PrivacyModule(_api); late final ProfileModule _profile = ProfileModule(_api); late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile); + late final BannersModule banners = BannersModule(_api); final _loginStatusController = StreamController.broadcast(); + final _noticeController = StreamController.broadcast(); bool _loggedIn = false; AccountModule(this._api) { @@ -51,6 +56,8 @@ class AccountModule { Stream get loginStatusStream => _loginStatusController.stream; + Stream get noticeStream => _noticeController.stream; + /// `true`, только когда сервер считает сессию ONLINE — после успешного /// login (opcode 19), а не просто после хэндшейка (opcode 6). bool get isLoggedIn => _loggedIn; @@ -327,6 +334,8 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); + ContactsModule.clearBlockedCache(); + banners.clear(); chats.resetForAccountSwitch(); logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); @@ -341,6 +350,8 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); + ContactsModule.clearBlockedCache(); + banners.clear(); chats.resetForAccountSwitch(); await _api.connect(); @@ -372,6 +383,8 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); + ContactsModule.clearBlockedCache(); + banners.clear(); chats.resetForAccountSwitch(); await ContactsModule.primeCacheFromDb(accountId); @@ -422,6 +435,8 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); ComplaintsModule.clear(); + ContactsModule.clearBlockedCache(); + banners.clear(); chats.resetForAccountSwitch(); } @@ -473,7 +488,7 @@ class AccountModule { final data = _requireMapPayload(packet, 'checkPassword'); if (data['error'] != null) { - throw Exception('checkPassword: неверный пароль'); + throw const WrongPasswordException(); } final tokenAttrs = data['tokenAttrs']; @@ -565,22 +580,16 @@ class AccountModule { ProfileData profile; final profileMap = data['profile']; - if (profileMap is Map) { - final contact = profileMap['contact']; - if (contact is! Map) { - throw Exception('login: отсутствует profile.contact в ответе'); - } + if (!DebugTest.berserk && + profileMap is Map && + profileMap['contact'] is Map) { profile = ProfileData.fromServerProfile( profileMap.cast(), ); - await AppDatabase.saveProfile(profile, isActive: true); } else { - final cachedProfile = await AppDatabase.loadProfile(accountId); - if (cachedProfile == null) { - throw Exception('login: отсутствует profile в ответе'); - } - profile = cachedProfile; + profile = await _resurrectProfile(accountId); } + await AppDatabase.saveProfile(profile, isActive: true); await AppDatabase.setActiveAccount(profile.id); await _saveSyncState(data, serverTime, profile.id); @@ -600,6 +609,7 @@ class AccountModule { profile.id, config.cast(), ); + await chats.applyFavorites(profile.id); final userConfig = config['user']; if (userConfig is Map) { await AppDatabase.savePrivacyConfig(profile.id, jsonEncode(userConfig)); @@ -610,6 +620,13 @@ class AccountModule { } catch (e) { logger.w('Папки чатов: $e'); } + await chats.applyFavorites(profile.id); + + try { + await banners.initFromLogin(profile.id, data); + } catch (e) { + logger.w('Баннеры: $e'); + } try { await _saveLoginInfo(data, profile.id); @@ -625,6 +642,31 @@ class AccountModule { ); } + Future _resurrectProfile(int accountId) async { + if (DebugTest.berserk) { + await AppDatabase.deleteAccount(accountId); + logger.w('login: [BERSERK] профиль удалён из БД, форсирую регенерацию (id=$accountId)'); + } else { + final cached = await AppDatabase.loadProfile(accountId); + if (cached != null) return cached; + } + + _noticeController.add(AccountNotice.resurrectingProfile); + + try { + final fetched = await ContactsModule.fetchSelfProfile(_api, accountId); + if (fetched != null) { + logger.i('login: профиль восстановлен через CONTACT_INFO (id=$accountId)'); + return fetched; + } + } catch (e) { + logger.w('login: восстановление профиля через CONTACT_INFO не удалось: $e'); + } + + logger.w('login: профиль недоступен, использую заглушку (id=$accountId)'); + return ProfileData.stub(accountId); + } + Future _saveSyncState( Map data, int serverTime, @@ -652,41 +694,12 @@ class AccountModule { } Future _saveLoginInfo(Map data, int accountId) async { - final contact = data['profile']?['contact'] as Map?; - final videoChatHistory = data['videoChatHistory']; - final chats = data['chats'] as List?; final config = data['config'] as Map?; final serverConfig = config?['server'] as Map?; - final userConfig = config?['user'] as Map?; if (serverConfig != null) { await _persistEntryBannerApps(accountId, serverConfig); } - final yMap = serverConfig?['y-map'] as Map?; - final whiteListLinks = serverConfig?['white-list-links'] as List?; - final fileUploadUnsupported = - serverConfig?['file-upload-unsupported-types'] as List?; - final time = data['time'] as int?; - - final info = { - 'registrationTime': contact?['registrationTime'], - 'country': contact?['country'], - 'videoChatHistory': videoChatHistory, - 'updateTime': contact?['updateTime'], - 'id': contact?['id'], - 'chatMarker': chats != null && chats.isNotEmpty - ? _extractChatMarker(chats.cast()) - : null, - 'time': time, - 'server': serverConfig != null - ? _extractServerInfo( - serverConfig, - yMap, - whiteListLinks, - fileUploadUnsupported, - ) - : null, - 'user': userConfig != null ? _extractUserConfig(userConfig) : null, - }; + final info = LoginInfo.fromPayload(data); await AppDatabase.saveLoginInfo(accountId, jsonEncode(info)); } @@ -719,86 +732,6 @@ class AccountModule { } } - Map _extractChatMarker(List chats) { - int? latestTime; - for (final chat in chats) { - final lastEventTime = chat['lastEventTime'] as int?; - if (lastEventTime != null && - (latestTime == null || lastEventTime > latestTime)) { - latestTime = lastEventTime; - } - } - return {'chatMarker': latestTime}; - } - - Map _extractServerInfo( - Map serverConfig, - Map? yMap, - List? whiteListLinks, - List? fileUploadUnsupported, - ) { - return { - 'account-removal-enabled': serverConfig['account-removal-enabled'], - 'image-size': serverConfig['image-size'], - 'gce': serverConfig['gce'], - 'gcce': serverConfig['gcce'], - 'max-msg-length': serverConfig['max-msg-length'], - 'quotes-enabled': serverConfig['quotes-enabled'], - 'calls-endpoint': serverConfig['calls-endpoint'], - 'send-location-enabled': serverConfig['send-location-enabled'], - 'lgce': serverConfig['lgce'], - 'wud': serverConfig['wud'], - 'video-msg-enabled': serverConfig['video-msg-enabled'], - 'grse': serverConfig['grse'], - 'edit-timeout': serverConfig['edit-timeout'], - 'image-quality': serverConfig['image-quality'], - 'unsafe-files-alert': serverConfig['unsafe-files-alert'], - 'account-nickname-enabled': serverConfig['account-nickname-enabled'], - 'mentions_entity_names_limit': - serverConfig['mentions_entity_names_limit'], - 'reactions-enabled': serverConfig['reactions-enabled'], - 'y-map': yMap != null - ? { - 'tile': yMap['tile'], - 'geocoder': yMap['geocoder'], - 'static': yMap['static'], - } - : null, - 'white-list-links': whiteListLinks, - 'file-upload-unsupported-types': fileUploadUnsupported, - }; - } - - Map _extractUserConfig(Map userConfig) { - return { - 'CHATS_PUSH_NOTIFICATION': userConfig['CHATS_PUSH_NOTIFICATION'], - 'PUSH_DETAILS': userConfig['PUSH_DETAILS'], - 'PUSH_SOUND': userConfig['PUSH_SOUND'], - 'PHONE_NUMBER_PRIVACY': userConfig['PHONE_NUMBER_PRIVACY'], - 'INACTIVE_TTL': userConfig['INACTIVE_TTL'], - 'SHOW_READ_MARK': userConfig['SHOW_READ_MARK'], - 'AUDIO_TRANSCRIPTION_ENABLED': userConfig['AUDIO_TRANSCRIPTION_ENABLED'], - 'SEARCH_BY_PHONE': userConfig['SEARCH_BY_PHONE'], - 'INCOMING_CALL': userConfig['INCOMING_CALL'], - 'DOUBLE_TAP_REACTION_DISABLED': - userConfig['DOUBLE_TAP_REACTION_DISABLED'], - 'SAFE_MODE_NO_PIN': userConfig['SAFE_MODE_NO_PIN'], - 'CHATS_PUSH_SOUND': userConfig['CHATS_PUSH_SOUND'], - 'DOUBLE_TAP_REACTION_VALUE': userConfig['DOUBLE_TAP_REACTION_VALUE'], - 'FAMILY_PROTECTION': userConfig['FAMILY_PROTECTION'], - 'HIDDEN': userConfig['HIDDEN'], - 'CHATS_INVITE': userConfig['CHATS_INVITE'], - 'PUSH_NEW_CONTACTS': userConfig['PUSH_NEW_CONTACTS'], - 'UNSAFE_FILES': userConfig['UNSAFE_FILES'], - 'DONT_DISTURB_UNTIL': userConfig['DONT_DISTURB_UNTIL'], - 'ALT_KEYBOARD': userConfig['ALT_KEYBOARD'], - 'CONTENT_LEVEL_ACCESS': userConfig['CONTENT_LEVEL_ACCESS'], - 'STICKERS_SUGGEST': userConfig['STICKERS_SUGGEST'], - 'SAFE_MODE': userConfig['SAFE_MODE'], - 'M_CALL_PUSH_NOTIFICATION': userConfig['M_CALL_PUSH_NOTIFICATION'], - }; - } - Future _requestCodeInternal( String phone, AuthRequestType type, diff --git a/lib/backend/modules/account/account_models.dart b/lib/backend/modules/account/account_models.dart index a5b2d47..540ff9e 100644 --- a/lib/backend/modules/account/account_models.dart +++ b/lib/backend/modules/account/account_models.dart @@ -282,12 +282,20 @@ enum AuthRequestType { enum LoginStatus { idle, loading, success, error } +enum AccountNotice { resurrectingProfile } + class WrongDeviceTokenException implements Exception { const WrongDeviceTokenException(); @override String toString() => 'WrongDeviceTokenException'; } +class WrongPasswordException implements Exception { + const WrongPasswordException(); + @override + String toString() => 'WrongPasswordException'; +} + class RequestCodeResult { final String token; diff --git a/lib/backend/modules/account/two_factor_module.dart b/lib/backend/modules/account/two_factor_module.dart index e6f7b47..eb57144 100644 --- a/lib/backend/modules/account/two_factor_module.dart +++ b/lib/backend/modules/account/two_factor_module.dart @@ -124,7 +124,7 @@ class TwoFactorModule extends AccountApiBase { checkPacketError(packet, 'check2faPassword'); final data = packet.payload; if (data is Map && data['error'] != null) { - throw Exception('Неверный пароль'); + throw const WrongPasswordException(); } } diff --git a/lib/backend/modules/animoji.dart b/lib/backend/modules/animoji.dart index ba541ab..faab6cb 100644 --- a/lib/backend/modules/animoji.dart +++ b/lib/backend/modules/animoji.dart @@ -2,6 +2,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/emoji_keyword_index.dart'; import '../../core/utils/logger.dart'; import '../../models/animoji.dart'; @@ -23,6 +24,7 @@ class AnimojiModule { static const int _maxRecents = 24; final Map _byId = {}; + final Map _byEmoji = {}; List _orderedIds = []; List _recentIds = []; bool _recentsLoaded = false; @@ -50,7 +52,7 @@ class AnimojiModule { Future noteUsed(Animoji animoji) async { await ensureRecentsLoaded(); - _byId[animoji.id] = animoji; + _remember(animoji); _recentIds.remove(animoji.id); _recentIds.insert(0, animoji.id); if (_recentIds.length > _maxRecents) { @@ -70,6 +72,28 @@ class AnimojiModule { return list.length <= 6 ? list : list.sublist(0, 6); } + Animoji? cached(int id) => _byId[id]; + + Animoji? findByEmoji(String emoji) => + _byEmoji[EmojiKeywordIndex.normalize(emoji)]; + + Future fetchById(int id) async { + final known = _byId[id]; + if (known != null) return known; + final map = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI', + 'ids': [id], + }); + final list = map?['animojis']; + if (list is! List) return null; + for (final e in list) { + if (e is! Map) continue; + final animoji = Animoji.fromMap(e); + if (animoji != null) _remember(animoji); + } + return _byId[id]; + } + Future ensureLoaded() { return _loading ??= _load().catchError((Object e) { _loading = null; @@ -133,7 +157,7 @@ class AnimojiModule { for (final e in list) { if (e is! Map) continue; final animoji = Animoji.fromMap(e); - if (animoji != null) _byId[animoji.id] = animoji; + if (animoji != null) _remember(animoji); } } @@ -141,6 +165,11 @@ class AnimojiModule { logger.i('Анимодзи: ${_orderedIds.length} доступно для реакций'); } + void _remember(Animoji animoji) { + _byId[animoji.id] = animoji; + _byEmoji[EmojiKeywordIndex.normalize(animoji.emoji)] = animoji; + } + List _dedup(List ids) { final seen = {}; final result = []; diff --git a/lib/backend/modules/banners.dart b/lib/backend/modules/banners.dart new file mode 100644 index 0000000..cc81d7e --- /dev/null +++ b/lib/backend/modules/banners.dart @@ -0,0 +1,337 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../models/informer_banner.dart'; + +class BannersModule { + static const _snapshotKey = 'informer_banners'; + static const _stateKey = 'informer_state'; + static const _enabledKey = 'informer_enabled'; + static const _serverFlag = 'informer-enabled'; + static const int _resyncMask = 1; + static const int _defaultShowTime = 86400000; + + final Api _api; + + BannersModule(this._api) { + _api.pushStream + .where((p) => p.opcode == Opcode.notifBanners) + .listen(_handlePush); + } + + final ValueNotifier activeBanner = ValueNotifier(null); + + int? _accountId; + bool _enabled = true; + int _updateTime = 0; + int _showTime = _defaultShowTime; + List _banners = const []; + final Map _showState = {}; + String? _lastShownId; + String? _pinnedId; + Future? _syncing; + + bool get isEnabled => _enabled; + int get showTime => _showTime; + List get banners => List.unmodifiable(_banners); + + BannerShowState stateOf(String bannerId) => + _showState[bannerId] ?? const BannerShowState(); + + Future initFromLogin( + int accountId, + Map loginData, + ) async { + await load(accountId); + + final config = loginData['config']; + final serverConfig = config is Map ? config['server'] : null; + if (serverConfig is Map && serverConfig.containsKey(_serverFlag)) { + await _setEnabled(accountId, _parseFlag(serverConfig[_serverFlag])); + } + if (!_enabled) return; + + var applied = false; + final inline = loginData['banners']; + if (inline is Map) { + applied = await applyPayload(accountId, inline.cast()); + } + + if (applied && !_needsResync(loginData['updates'])) return; + await syncFromServer(); + } + + Future load(int accountId) async { + _accountId = accountId; + _pinnedId = null; + await _restoreSnapshot(accountId); + await _restoreShowState(accountId); + final enabled = await AppDatabase.getSyncValue(accountId, _enabledKey); + _enabled = enabled != '0'; + _recompute(); + } + + Future syncFromServer() { + return _syncing ??= _sync().whenComplete(() => _syncing = null); + } + + Future applyPayload( + int accountId, + Map payload, + ) async { + final raw = payload['banners']; + if (raw is! List) return false; + + final updateTime = _int(payload['updateTime']); + if (_updateTime != 0 && updateTime != null && updateTime <= _updateTime) { + return true; + } + + final parsed = []; + for (final entry in raw) { + if (entry is! Map) continue; + final banner = InformerBanner.fromMap(entry); + if (banner != null) parsed.add(banner); + } + + _banners = parsed; + if (updateTime != null) _updateTime = updateTime; + final showTime = _int(payload['showTime']); + if (showTime != null && showTime > 0) _showTime = showTime; + + final known = parsed.map((b) => b.id).toSet(); + _showState.removeWhere((id, _) => !known.contains(id)); + if (_pinnedId != null && !known.contains(_pinnedId)) _pinnedId = null; + if (_lastShownId != null && !known.contains(_lastShownId)) { + _lastShownId = null; + } + + await _persistSnapshot(accountId); + await _persistShowState(accountId); + _recompute(); + return true; + } + + Future markShown(InformerBanner banner) async { + if (_pinnedId == banner.id) return; + final now = DateTime.now().millisecondsSinceEpoch; + final current = stateOf(banner.id); + _showState[banner.id] = current.copyWith( + showCounter: current.showCounter + 1, + showAt: current.showAt ?? now, + ); + _lastShownId = banner.id; + _pinnedId = banner.id; + await _persistShowState(_accountId); + } + + Future close(InformerBanner banner) async { + final now = DateTime.now().millisecondsSinceEpoch; + _showState[banner.id] = stateOf(banner.id).copyWith(closedAt: now); + if (_pinnedId == banner.id) _pinnedId = null; + await _persistShowState(_accountId); + _recompute(); + } + + Future markClicked(InformerBanner banner) async { + if (!banner.closesOnClick) return; + await close(banner); + } + + void refresh() { + _pinnedId = null; + _recompute(); + } + + void clear() { + _accountId = null; + _enabled = true; + _updateTime = 0; + _showTime = _defaultShowTime; + _banners = const []; + _showState.clear(); + _lastShownId = null; + _pinnedId = null; + activeBanner.value = null; + } + + Future _sync() async { + final accountId = _accountId; + if (accountId == null || !_enabled) return; + try { + final packet = await _api.sendRequest(Opcode.bannersSync, { + 'bannersSync': 0, + }); + throwIfPacketError(packet); + final payload = packet.payload; + if (payload is Map) { + await applyPayload(accountId, payload.cast()); + } + } catch (e) { + logger.w('Баннеры: синхронизация не удалась: $e'); + } + } + + void _handlePush(Packet packet) { + final accountId = _accountId; + if (accountId == null || !_enabled) return; + final payload = packet.payload; + if (payload is Map && payload['banners'] is List) { + unawaited(applyPayload(accountId, payload.cast())); + return; + } + unawaited(syncFromServer()); + } + + void _recompute() { + if (!_enabled) { + activeBanner.value = null; + return; + } + + final pinned = _pinnedId; + if (pinned != null) { + for (final banner in _banners) { + if (banner.id == pinned) { + activeBanner.value = banner; + return; + } + } + _pinnedId = null; + } + + final now = DateTime.now().millisecondsSinceEpoch; + var candidates = _banners.where((b) => _canShow(b, now)).toList(); + if (candidates.length > 1 && _lastShownId != null) { + final others = candidates.where((b) => b.id != _lastShownId).toList(); + if (others.isNotEmpty) candidates = others; + } + if (candidates.isEmpty) { + activeBanner.value = null; + return; + } + activeBanner.value = candidates.reduce( + (a, b) => b.priority > a.priority ? b : a, + ); + } + + bool _canShow(InformerBanner banner, int now) { + final state = stateOf(banner.id); + if (state.showCounter > 0 && state.showCounter >= banner.repeat) { + return false; + } + final showAt = state.showAt; + if (showAt != null && now - showAt > _showTime) return false; + final closedAt = state.closedAt; + if (closedAt != null) { + if (banner.rerun <= 0) return false; + if (now - closedAt <= banner.rerun) return false; + } + return true; + } + + bool _needsResync(dynamic updates) { + final mask = _int(updates); + return mask != null && mask & _resyncMask != 0; + } + + Future _setEnabled(int accountId, bool value) async { + _enabled = value; + await AppDatabase.setSyncValue(accountId, _enabledKey, value ? '1' : '0'); + if (!value) activeBanner.value = null; + } + + Future _restoreSnapshot(int accountId) async { + _banners = const []; + _updateTime = 0; + _showTime = _defaultShowTime; + final raw = await AppDatabase.getSyncValue(accountId, _snapshotKey); + if (raw == null || raw.isEmpty) return; + try { + final map = jsonDecode(raw) as Map; + final list = map['banners']; + final parsed = []; + if (list is List) { + for (final entry in list) { + if (entry is! Map) continue; + final banner = InformerBanner.fromMap(entry); + if (banner != null) parsed.add(banner); + } + } + _banners = parsed; + _updateTime = _int(map['updateTime']) ?? 0; + final showTime = _int(map['showTime']); + if (showTime != null && showTime > 0) _showTime = showTime; + } catch (_) {} + } + + Future _restoreShowState(int accountId) async { + _showState.clear(); + _lastShownId = null; + final raw = await AppDatabase.getSyncValue(accountId, _stateKey); + if (raw == null || raw.isEmpty) return; + try { + final map = jsonDecode(raw) as Map; + _lastShownId = map['lastShowedBannerId']?.toString(); + final entries = map['banners']; + if (entries is Map) { + entries.forEach((key, value) { + if (value is Map) { + _showState[key.toString()] = BannerShowState.fromJson(value); + } + }); + } + } catch (_) {} + } + + Future _persistSnapshot(int accountId) async { + await AppDatabase.setSyncValue( + accountId, + _snapshotKey, + jsonEncode({ + 'updateTime': _updateTime, + 'showTime': _showTime, + 'banners': _banners.map((b) => b.toJson()).toList(), + }), + ); + } + + Future _persistShowState(int? accountId) async { + if (accountId == null) return; + await AppDatabase.setSyncValue( + accountId, + _stateKey, + jsonEncode({ + 'lastShowedBannerId': _lastShownId, + 'banners': { + for (final entry in _showState.entries) + entry.key: entry.value.toJson(), + }, + }), + ); + } + + static bool _parseFlag(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + if (value is String) { + final v = value.trim().toLowerCase(); + return v == 'true' || v == '1' || v == 'yes' || v == 'on'; + } + return true; + } + + static int? _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 0b0299a..1e33413 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'contacts.dart'; import '../api.dart'; +import '../../core/calls/call_link.dart'; +import '../../core/calls/ws2_signaling.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/utils/ids.dart'; import '../../core/utils/logger.dart'; @@ -27,6 +29,22 @@ class OutgoingCallParams { }); } +class CreatedCall { + final String conversationId; + final String joinToken; + final String? callName; + final int? chatId; + + const CreatedCall({ + required this.conversationId, + required this.joinToken, + this.callName, + this.chatId, + }); + + String get url => CallLink.url(joinToken); +} + class CallLinkPreview { final String? conferenceId; final String? callName; @@ -83,24 +101,26 @@ class CallsModule { _CallerEndpoint _parseCallerEndpoint( Map payload, - String key, { + List keys, { required String context, }) { - final raw = payload[key]; - final parsed = raw is String - ? jsonDecode(raw) as Map - : const {}; + for (final key in keys) { + final raw = payload[key]; + final parsed = raw is String + ? jsonDecode(raw) as Map + : const {}; - final endpoint = parsed['endpoint'] as String?; - if (endpoint == null) { - throw _CallerEndpointMissingException('$context: no endpoint'); + final endpoint = parsed['endpoint'] as String?; + if (endpoint == null) continue; + + final id = parsed['id']; + final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; + final external = id is Map ? int.tryParse('${id['external']}') : null; + + return (endpoint: endpoint, callsUserId: callsUserId, external: external); } - final id = parsed['id']; - final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; - final external = id is Map ? int.tryParse('${id['external']}') : null; - - return (endpoint: endpoint, callsUserId: callsUserId, external: external); + throw _CallerEndpointMissingException('$context: no endpoint'); } Future initiateCall( @@ -120,11 +140,9 @@ class CallsModule { throw Exception('initiateCall: bad response'); } - final parsed = _parseCallerEndpoint( - payload, + final parsed = _parseCallerEndpoint(payload, const [ 'internalCallerParams', - context: 'initiateCall', - ); + ], context: 'initiateCall'); return OutgoingCallParams( conversationId: (payload['conversationId'] as String?) ?? conversationId, @@ -135,19 +153,64 @@ class CallsModule { ); } + Future createConference() async { + final conversationId = uuidV4(); + logger.i('[call] VIDEO_CHAT_START conv=$conversationId'); + + final payload = await _api.sendRequestMap(Opcode.videoChatStart, { + 'conversationId': conversationId, + }); + logger.i('[call] VIDEO_CHAT_START keys=${payload?.keys.toList()}'); + + if (payload == null) { + throw Exception('createConference: bad response'); + } + + final id = (payload['conversationId'] as String?) ?? conversationId; + final rawLink = + (payload['joinLink'] as String?) ?? await createJoinLink(id) ?? ''; + final token = CallLink.normalizeToken(rawLink); + if (token == null) { + throw Exception('createConference: no joinLink'); + } + + final name = (payload['callName'] as String?)?.trim(); + + return CreatedCall( + conversationId: id, + joinToken: token, + callName: (name?.isEmpty ?? true) ? null : name, + chatId: payload['chatId'] is int ? payload['chatId'] as int : null, + ); + } + + Future createJoinLink(String conversationId) async { + if (conversationId.isEmpty) return null; + + final payload = await _api.sendRequestMap(Opcode.videoChatCreateJoinLink, { + 'conversationId': conversationId, + }); + + final link = payload?['joinLink']; + return link is String && link.isNotEmpty ? link : null; + } + String _internalParams() => jsonEncode({ 'platform': 'ANDROID', - 'sdkVersion': '0.1.16.4', + 'sdkVersion': '0.2.1.3', 'clientAppKey': 'CGPGAGLGDIHBABABA', 'deviceId': _api.deviceId ?? '', 'protocolVersion': 5, 'onlyAdminCanRecord': false, - 'waitForAdmin': false, - 'capabilities': '3c03f', + 'isWaitForAdminEnabled': false, + 'hexCapability': Ws2Config.defaultCapabilities, }); Future resolveCallLink(String url) async { - final payload = await _api.sendRequestMap(Opcode.linkInfo, {'link': url}); + final token = CallLink.normalizeToken(url); + final payload = await _api.sendRequestMap(Opcode.linkInfo, { + 'link': token == null ? url : CallLink.path(token), + }); if (payload == null) return null; final vc = payload['videoConference']; @@ -165,21 +228,22 @@ class CallsModule { String token, { bool isVideo = false, }) async { + logger.i('[call] VIDEO_CHAT_JOIN link=$token isVideo=$isVideo'); final payload = await _api.sendRequestMap(Opcode.videoChatJoinByLink, { 'joinLink': token, 'internalParams': _internalParams(), 'isVideo': isVideo, }); + logger.i('[call] VIDEO_CHAT_JOIN keys=${payload?.keys.toList()}'); if (payload == null) { throw Exception('joinByLink: bad response'); } - final parsed = _parseCallerEndpoint( - payload, + final parsed = _parseCallerEndpoint(payload, const [ 'internalParams', - context: 'joinByLink', - ); + 'internalCallerParams', + ], context: 'joinByLink'); return OutgoingCallParams( conversationId: (payload['conversationId'] as String?) ?? '', diff --git a/lib/backend/modules/chat_parsing.dart b/lib/backend/modules/chat_parsing.dart index 06c0ee7..0cdcd55 100644 --- a/lib/backend/modules/chat_parsing.dart +++ b/lib/backend/modules/chat_parsing.dart @@ -40,10 +40,18 @@ CachedChat? parseChatRow( existing, ); final lastMessage = _resolveLastMessage(chat['lastMessage']); + final previous = existing[id]; + final sameLastMessage = + previous != null && + lastMessage.id != null && + previous.lastMsgId == lastMessage.id; final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing); final presence = _resolvePresence(type, otherId, presenceMap); final adminsOwner = _resolveAdmins(chat); final pinned = _resolvePinnedMessage(chat['pinnedMessage']); + final mentionId = int.tryParse( + chat['lastMentionMessageId']?.toString() ?? '', + ); return CachedChat( id: id, @@ -55,7 +63,11 @@ CachedChat? parseChatRow( lastMsgTime: lastMessage.time, lastMsgText: lastMessage.text, lastMsgElements: lastMessage.elements, - lastMsgSenderId: lastMessage.senderId, + lastMsgPreview: lastMessage.preview, + lastMsgSenderId: + lastMessage.senderId ?? + (sameLastMessage ? previous.lastMsgSenderId : null), + lastMsgStatus: sameLastMessage ? previous.lastMsgStatus : null, unreadCount: (chat['newMessages'] as int?) ?? 0, lastEventTime: (chat['lastEventTime'] as int?) ?? 0, cachedAt: cachedAt, @@ -71,6 +83,7 @@ CachedChat? parseChatRow( pinnedMsgText: pinned.text, pinnedMsgTime: pinned.time, pinnedMsgIsPreview: pinned.isPreview, + lastMentionMsgId: mentionId ?? existing[id]?.lastMentionMsgId, ); } catch (e) { logger.e("Ошибка при парсинге чата: $e"); @@ -121,20 +134,42 @@ CachedChat? parseChatRow( ); } -({int? id, int? time, String? text, String? elements, int? senderId}) +({ + int? id, + int? time, + String? text, + String? elements, + String? preview, + int? senderId, +}) _resolveLastMessage(dynamic lastMsg) { if (lastMsg is! Map) { - return (id: null, time: null, text: null, elements: null, senderId: null); + return ( + id: null, + time: null, + text: null, + elements: null, + preview: null, + senderId: null, + ); } return ( - id: lastMsg['id'] as int?, - time: lastMsg['time'] as int?, + id: _asIntOrNull(lastMsg['id']), + time: _asIntOrNull(lastMsg['time']), text: messagePreviewText(lastMsg), elements: messagePreviewElements(lastMsg), - senderId: lastMsg['sender'] as int?, + preview: messagePreviewMedia(lastMsg), + senderId: _asIntOrNull(lastMsg['sender']), ); } +int? _asIntOrNull(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + ({int? id, String? text, int? time, bool isPreview}) _resolvePinnedMessage( dynamic pinned, ) { @@ -159,13 +194,14 @@ _resolveLastMessage(dynamic lastMsg) { Map existing, ) { final config = chatsConfig[id.toString()] ?? chatsConfig[id]; + final ex = existing[id]; if (config is Map) { + final configFav = config['favIndex'] as int?; return ( - favIndex: config['favIndex'] as int?, + favIndex: (configFav != null && configFav > 0) ? configFav : ex?.favIndex, dontDisturbUntil: (config['dontDisturbUntil'] as int?) ?? 0, ); } - final ex = existing[id]; if (ex != null) { return (favIndex: ex.favIndex, dontDisturbUntil: ex.dontDisturbUntil); } @@ -303,6 +339,7 @@ bool sameChatContent(CachedChat a, CachedChat b) { if (a.lastMsgTime != b.lastMsgTime) return false; if (a.lastMsgText != b.lastMsgText) return false; if (a.lastMsgElements != b.lastMsgElements) return false; + if (a.lastMsgPreview != b.lastMsgPreview) return false; if (a.lastMsgSenderId != b.lastMsgSenderId) return false; if (a.unreadCount != b.unreadCount) return false; if (a.lastEventTime != b.lastEventTime) return false; diff --git a/lib/backend/modules/chat_preview.dart b/lib/backend/modules/chat_preview.dart index a3e3d2b..2888cfd 100644 --- a/lib/backend/modules/chat_preview.dart +++ b/lib/backend/modules/chat_preview.dart @@ -1,56 +1,121 @@ import 'dart:convert'; +import '../../models/attachment.dart'; +import '../../models/chat_preview_media.dart'; + +const int _maxPreviewThumbs = 3; +const int _maxThumbLength = 20000; + String? attachPreviewLabel(dynamic attaches) { + final parts = _attachPreviewParts(attaches); + if (parts == null) return null; + final detail = parts.detail; + return detail == null ? parts.label : '${parts.label}: $detail'; +} + +({String label, String? detail})? _attachPreviewParts(dynamic attaches) { final first = _firstPreviewAttach(attaches); if (first == null) return null; final type = (first['_type'] as String? ?? '').toUpperCase(); switch (type) { case 'PHOTO': - return 'Фото'; + return ( + label: _mediaAttachCount(attaches) > 1 ? 'Изображения' : 'Изображение', + detail: null, + ); case 'VIDEO': - return _isVideoNote(first) ? 'Видео-сообщение' : 'Видео'; + if (_isVideoNote(first)) return (label: 'Видео-сообщение', detail: null); + return (label: 'Видео', detail: null); case 'AUDIO': - return 'Голосовое сообщение'; + return (label: 'Голосовое сообщение', detail: null); case 'FILE': - final name = first['name']?.toString(); - return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл'; + return (label: 'Файл', detail: _nonEmpty(first['name'])); case 'STICKER': - return 'Стикер'; + return (label: 'Стикер', detail: null); case 'SHARE': - final title = first['title']?.toString(); - return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка'; + return (label: 'Ссылка', detail: _nonEmpty(first['title'])); case 'POLL': - final title = first['title']?.toString(); - return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос'; + return (label: 'Опрос', detail: _nonEmpty(first['title'])); case 'LOCATION': - return 'Геопозиция'; + return (label: 'Геопозиция', detail: null); case 'CONTACT': - return 'Контакт'; + return (label: 'Контакт', detail: null); case 'CONTROL': - return _controlPreviewLabel(first); + final label = _controlPreviewLabel(first); + return label == null ? null : (label: label, detail: null); case 'INLINE_KEYBOARD': return null; case 'CALL': - final video = first['callType']?.toString().toUpperCase() == 'VIDEO'; - final dur = (first['duration'] as num?)?.toInt() ?? 0; - final hangup = first['hangupType']?.toString(); - final failed = - dur == 0 || - hangup == 'CANCELED' || - hangup == 'REJECTED' || - hangup == 'MISSED'; - if (first['joinLink'] != null) { - return video ? 'Групповой видеозвонок' : 'Групповой звонок'; - } - if (failed) { - return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; - } - return video ? 'Видеозвонок' : 'Звонок'; + return (label: _callPreviewLabel(first), detail: null); default: - return 'Вложение'; + return (label: 'Вложение', detail: null); } } +ChatPreviewKind? _attachPreviewKind(Map attach) { + switch ((attach['_type'] as String? ?? '').toUpperCase()) { + case 'PHOTO': + return ChatPreviewKind.photo; + case 'VIDEO': + return _isVideoNote(attach) + ? ChatPreviewKind.videoNote + : ChatPreviewKind.video; + case 'AUDIO': + return ChatPreviewKind.audio; + case 'FILE': + return ChatPreviewKind.file; + case 'STICKER': + return ChatPreviewKind.sticker; + case 'SHARE': + return ChatPreviewKind.share; + case 'POLL': + return ChatPreviewKind.poll; + case 'LOCATION': + return ChatPreviewKind.location; + case 'CONTACT': + return ChatPreviewKind.contact; + case 'CONTROL': + return ChatPreviewKind.control; + case 'INLINE_KEYBOARD': + return null; + case 'CALL': + final video = attach['callType']?.toString().toUpperCase() == 'VIDEO'; + if (_isFailedCall(attach)) { + return video + ? ChatPreviewKind.missedVideoCall + : ChatPreviewKind.missedCall; + } + return video ? ChatPreviewKind.videoCall : ChatPreviewKind.call; + default: + return ChatPreviewKind.other; + } +} + +String _callPreviewLabel(Map attach) { + final video = attach['callType']?.toString().toUpperCase() == 'VIDEO'; + if (attach['joinLink'] != null) { + return video ? 'Групповой видеозвонок' : 'Групповой звонок'; + } + if (_isFailedCall(attach)) { + return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; + } + return video ? 'Видеозвонок' : 'Звонок'; +} + +bool _isFailedCall(Map attach) { + final duration = (attach['duration'] as num?)?.toInt() ?? 0; + final hangup = attach['hangupType']?.toString(); + return duration == 0 || + hangup == 'CANCELED' || + hangup == 'REJECTED' || + hangup == 'MISSED'; +} + +String? _nonEmpty(dynamic raw) { + final value = raw?.toString(); + return value != null && value.isNotEmpty ? value : null; +} + Map? _firstPreviewAttach(dynamic attaches) { if (attaches is! List || attaches.isEmpty) return null; for (final attach in attaches) { @@ -62,6 +127,16 @@ Map? _firstPreviewAttach(dynamic attaches) { return null; } +int _mediaAttachCount(dynamic attaches) { + if (attaches is! List) return 0; + var count = 0; + for (final attach in attaches.whereType()) { + final type = (attach['_type'] as String? ?? '').toUpperCase(); + if (type == 'PHOTO' || (type == 'VIDEO' && !_isVideoNote(attach))) count++; + } + return count; +} + bool _isVideoNote(Map attach) { final raw = attach['videoType']; if (raw is int) return raw == 1; @@ -95,9 +170,8 @@ String? _controlPreviewLabel(Map c) { } String? messagePreviewText(Map msg) { - final link = msg['link']; - if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { - final original = link['message']; + final original = _forwardOrigin(msg); + if (original != null) { final inner = original is Map ? _bodyPreviewText(original) : null; return inner != null && inner.isNotEmpty ? '↪ $inner' @@ -106,6 +180,62 @@ String? messagePreviewText(Map msg) { return _bodyPreviewText(msg); } +String? messagePreviewMedia(Map msg) { + final origin = _forwardOrigin(msg); + final body = origin ?? msg; + if (body is! Map) return null; + final first = _firstPreviewAttach(body['attaches']); + if (first == null) return null; + final kind = _attachPreviewKind(first); + if (kind == null) return null; + + final text = body['text']?.toString(); + final captioned = text != null && text.isNotEmpty; + final parts = captioned ? null : _attachPreviewParts(body['attaches']); + final label = parts == null + ? null + : (origin == null ? parts.label : '↪ ${parts.label}'); + + return ChatPreviewMedia( + kind: kind, + thumbs: _previewThumbs(body['attaches']), + label: label, + detail: parts?.detail, + ).encode(); +} + +dynamic _forwardOrigin(Map msg) { + final link = msg['link']; + if (link is! Map) return null; + if (link['type']?.toString().toUpperCase() != 'FORWARD') return null; + return link['message']; +} + +List _previewThumbs(dynamic attaches) { + if (attaches is! List) return const []; + final thumbs = []; + for (final attach in attaches.whereType()) { + if (thumbs.length >= _maxPreviewThumbs) break; + final type = (attach['_type'] as String? ?? '').toUpperCase(); + final isVideo = type == 'VIDEO'; + if (type != 'PHOTO' && !isVideo) continue; + final source = _thumbSource(attach, isVideo); + if (source == null) continue; + thumbs.add(ChatPreviewThumb(source: source, video: isVideo)); + } + return thumbs; +} + +String? _thumbSource(Map attach, bool isVideo) { + final data = decodeAttachPreview(attach['previewData']); + if (data != null && data.length <= _maxThumbLength) return data; + final url = isVideo + ? _nonEmpty(attach['thumbnail']) + : _nonEmpty(attach['baseUrl']); + if (url != null && url.startsWith('http')) return url; + return null; +} + ({String? text, bool isPreview}) pinnedMessagePreview(Map msg) { final link = msg['link']; if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index d071013..a03cf06 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -8,10 +8,14 @@ import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; 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'; +import '../../models/chat_preview_media.dart'; +import '../../models/contact_info.dart'; import '../api.dart'; import 'chat_parsing.dart'; import 'chat_preview.dart'; @@ -35,6 +39,20 @@ Map parseParticipants(dynamic raw) { return {}; } +/// Server message ids carry their timestamp in the high bits: the low 16 bits +/// are an intra-millisecond sequence number. +int messageIdToTime(int messageId) => messageId >> 16; + +bool messageMentionsUser(Map message, int userId) { + final elements = message['elements']; + if (elements is! List) return false; + for (final element in elements.whereType()) { + if (element['type']?.toString() != 'USER_MENTION') continue; + if (element['entityId'] == userId) return true; + } + return false; +} + class CachedChat { final int id; final int accountId; @@ -46,6 +64,7 @@ class CachedChat { final String? lastMsgText; final String? lastMsgTextOneLine; final String? lastMsgElements; + final String? lastMsgPreview; final int? lastMsgSenderId; final String? lastMsgStatus; final int unreadCount; @@ -63,6 +82,7 @@ class CachedChat { final String? pinnedMsgText; final int? pinnedMsgTime; final bool pinnedMsgIsPreview; + final int? lastMentionMsgId; CachedChat({ required this.id, @@ -74,6 +94,7 @@ class CachedChat { this.lastMsgTime, this.lastMsgText, this.lastMsgElements, + this.lastMsgPreview, this.lastMsgSenderId, this.lastMsgStatus, required this.unreadCount, @@ -91,12 +112,17 @@ class CachedChat { this.pinnedMsgText, this.pinnedMsgTime, this.pinnedMsgIsPreview = false, + this.lastMentionMsgId, }) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n') ? lastMsgText.replaceAll('\n', ' ') : lastMsgText; bool get isOfficial => options.contains('OFFICIAL'); + late final ChatPreviewMedia? lastMsgMedia = ChatPreviewMedia.decode( + lastMsgPreview, + ); + List get lastMsgFormatRanges { final raw = lastMsgElements; if (raw == null || raw.isEmpty) return const []; @@ -127,6 +153,12 @@ class CachedChat { return iAmAdmin(myId) || options.contains('ALL_CAN_PIN_MESSAGE'); } + bool get forwardDisabled => options.contains('DISABLE_FORWARD'); + + bool get copyDisabled => options.contains('MESSAGE_COPY_NOT_ALLOWED'); + + bool get confirmBeforeSend => options.contains('CONFIRM_BEFORE_SEND'); + bool get isMuted { if (dontDisturbUntil == ChatsModule.muteOff) return false; if (dontDisturbUntil < 0) return true; @@ -135,6 +167,12 @@ class CachedChat { bool get isLastMsgDeleted => lastMsgText == ChatsModule.lastMsgPlaceholder; + bool get hasUnreadMention { + final mentionId = lastMentionMsgId; + if (mentionId == null || mentionId <= 0) return false; + return messageIdToTime(mentionId) > (participants[accountId] ?? 0); + } + factory CachedChat.fromDbRow(Map row) => CachedChat( id: row['id'] as int, accountId: row['account_id'] as int, @@ -145,6 +183,7 @@ class CachedChat { lastMsgTime: row['last_msg_time'] as int?, lastMsgText: row['last_msg_text'] as String?, lastMsgElements: row['last_msg_elements'] as String?, + lastMsgPreview: row['last_msg_preview'] as String?, lastMsgSenderId: row['last_msg_sender'] as int?, lastMsgStatus: row['last_msg_status'] as String?, unreadCount: row['unread_count'] as int, @@ -162,6 +201,7 @@ class CachedChat { pinnedMsgText: row['pinned_msg_text'] as String?, pinnedMsgTime: row['pinned_msg_time'] as int?, pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1, + lastMentionMsgId: row['last_mention_msg_id'] as int?, ); static Set _decodeOptions(dynamic raw) { @@ -188,6 +228,7 @@ class CachedChat { 'last_msg_time': lastMsgTime, 'last_msg_text': lastMsgText, 'last_msg_elements': lastMsgElements, + 'last_msg_preview': lastMsgPreview, 'last_msg_sender': lastMsgSenderId, 'last_msg_status': lastMsgStatus, 'unread_count': unreadCount, @@ -207,6 +248,7 @@ class CachedChat { 'pinned_msg_text': pinnedMsgText, 'pinned_msg_time': pinnedMsgTime, 'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0, + 'last_mention_msg_id': lastMentionMsgId, }; static const Object _keep = Object(); @@ -219,6 +261,7 @@ class CachedChat { Object? lastMsgTime = _keep, Object? lastMsgText = _keep, Object? lastMsgElements = _keep, + Object? lastMsgPreview = _keep, Object? lastMsgSenderId = _keep, Object? lastMsgStatus = _keep, int? unreadCount, @@ -236,6 +279,7 @@ class CachedChat { Object? pinnedMsgText = _keep, Object? pinnedMsgTime = _keep, bool? pinnedMsgIsPreview, + Object? lastMentionMsgId = _keep, }) { return CachedChat( id: id, @@ -255,6 +299,9 @@ class CachedChat { lastMsgElements: identical(lastMsgElements, _keep) ? this.lastMsgElements : lastMsgElements as String?, + lastMsgPreview: identical(lastMsgPreview, _keep) + ? this.lastMsgPreview + : lastMsgPreview as String?, lastMsgSenderId: identical(lastMsgSenderId, _keep) ? this.lastMsgSenderId : lastMsgSenderId as int?, @@ -282,6 +329,9 @@ class CachedChat { ? this.pinnedMsgTime : pinnedMsgTime as int?, pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview, + lastMentionMsgId: identical(lastMentionMsgId, _keep) + ? this.lastMentionMsgId + : lastMentionMsgId as int?, ); } } @@ -302,6 +352,37 @@ class ChatSearchHit { }); } +class ChatMemberEntry { + final int id; + final String? name; + final String? fullName; + final String? avatarUrl; + final int? seenTime; + final int presenceStatus; + final bool blocked; + final bool isContact; + + const ChatMemberEntry({ + required this.id, + this.name, + this.fullName, + this.avatarUrl, + this.seenTime, + required this.presenceStatus, + this.blocked = false, + this.isContact = false, + }); + + bool get isOnline => presenceStatus == 1; +} + +class ChatMembersPage { + final List members; + final int marker; + + const ChatMembersPage({required this.members, required this.marker}); +} + class MessageSearchHit { final int chatId; final String? messageId; @@ -398,12 +479,16 @@ class ChatsModule { 'chatId': chatId, 'messageId': msgIdNum, 'mark': mark, - }); + }, silent: true); } catch (_) {} } - row['unread_count'] = 0; - await AppDatabase.saveChats([row]); + final cached = CachedChat.fromDbRow(row); + final currentMark = cached.participants[accountId] ?? 0; + final participants = Map.from(cached.participants) + ..[accountId] = mark > currentMark ? mark : currentMark; + final updated = cached.copyWith(unreadCount: 0, participants: participants); + await AppDatabase.saveChats([updated.toDbRow()]); _bump(); } @@ -423,7 +508,7 @@ class ChatsModule { 'chatId': chatId, 'messageId': msgIdNum, 'mark': mark, - }); + }, silent: true); } catch (_) {} } @@ -436,7 +521,10 @@ class ChatsModule { if (cached.unreadCount == next && nextMark == currentMark) return; final participants = Map.from(cached.participants) ..[accountId] = nextMark; - final updated = cached.copyWith(unreadCount: next, participants: participants); + final updated = cached.copyWith( + unreadCount: next, + participants: participants, + ); await AppDatabase.saveChats([updated.toDbRow()]); _bump(); } @@ -472,6 +560,7 @@ class ChatsModule { required String text, required String status, List>? elements, + String? preview, }) async { final thisId = int.tryParse(messageId); await _updateChat(accountId, chatId, (chat) { @@ -483,6 +572,7 @@ class ChatsModule { lastMsgElements: (elements != null && elements.isNotEmpty) ? jsonEncode(elements) : null, + lastMsgPreview: preview, lastMsgTime: time, lastEventTime: time, lastMsgSenderId: accountId, @@ -494,6 +584,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, @@ -560,6 +685,7 @@ class ChatsModule { ContactInfoFetch.clear(); PresenceFetch.clear(); ChatInfoFetch.clear(); + SharedContentModule.clearMediaIndex(); } void _enqueueGlobalPush(Packet packet) { @@ -637,6 +763,15 @@ class ChatsModule { final msg = payload['message']; if (msg is! Map) return; + final msgLink = msg['link']; + final linkPostId = (msgLink is Map) ? msgLink['postId'] : null; + final payloadPostId = payload['postId']; + final isCommentPush = + payloadPostId is String || + (linkPostId is String) || + (msg['postId'] is String); + if (isCommentPush) return; + final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; @@ -744,6 +879,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)); } } @@ -770,11 +906,19 @@ class ChatsModule { } newRow['last_msg_text'] = messagePreviewText(msg); newRow['last_msg_elements'] = messagePreviewElements(msg); + newRow['last_msg_preview'] = messagePreviewMedia(msg); if (senderId != null) newRow['last_msg_sender'] = senderId; newRow['last_msg_status'] = 'sent'; } if (unread != null) newRow['unread_count'] = unread; + if (msgIdInt != null && + status != 'REMOVED' && + senderId != accountId && + messageMentionsUser(msg, accountId)) { + newRow['last_mention_msg_id'] = msgIdInt; + } + final pinned = _extractPinnedMessage(msg); if (pinned != null) { newRow['pinned_msg_id'] = pinned.id; @@ -832,7 +976,9 @@ class ChatsModule { final rawText = m['text']?.toString(); String? previewText = rawText; String? elementsJson; + String? previewMedia; final payload = _decodePayload(m['payload']); + if (payload != null) previewMedia = messagePreviewMedia(payload); if (rawText == null || rawText.isEmpty) { if (payload != null) previewText = messagePreviewText(payload); } else { @@ -841,6 +987,7 @@ class ChatsModule { newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); newRow['last_msg_text'] = previewText ?? m['text']; newRow['last_msg_elements'] = elementsJson; + newRow['last_msg_preview'] = previewMedia; newRow['last_msg_time'] = m['time']; newRow['last_msg_sender'] = m['sender_id']; newRow['last_msg_status'] = m['status']; @@ -848,6 +995,7 @@ class ChatsModule { newRow['last_msg_id'] = null; newRow['last_msg_text'] = lastMsgPlaceholder; newRow['last_msg_elements'] = null; + newRow['last_msg_preview'] = null; newRow['last_msg_sender'] = null; newRow['last_msg_status'] = null; } @@ -1078,13 +1226,16 @@ class ChatsModule { }) async { final cachedAt = DateTime.now().millisecondsSinceEpoch; final id = chat['id']; + ChatMembersStore.instance.applyChatPayload(chat); Map existing = const {}; + Map? existingRow; if (preloadedExisting != null) { existing = preloadedExisting; } else if (id is int) { final rows = await AppDatabase.loadChat(accountId, id); if (rows.isNotEmpty) { - existing = {id: CachedChat.fromDbRow(rows.first)}; + existingRow = rows.first; + existing = {id: CachedChat.fromDbRow(existingRow)}; } } final parsed = parseChatRow( @@ -1102,11 +1253,14 @@ class ChatsModule { return null; } final ex = existing[parsed.id]; - if (ex != null && sameChatContent(ex, parsed)) { + final listState = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1); + final membershipUnchanged = + existingRow == null || existingRow['in_list'] == listState; + if (ex != null && sameChatContent(ex, parsed) && membershipUnchanged) { return parsed; } final row = parsed.toDbRow(); - row['in_list'] = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1); + row['in_list'] = listState; await AppDatabase.saveChats([row]); _bump(); return parsed; @@ -1173,6 +1327,7 @@ class ChatsModule { final rows = >[]; for (final c in chats.whereType()) { final map = c.cast(); + ChatMembersStore.instance.applyChatPayload(map); final parsed = parseChatRow( map, accountId, @@ -1226,17 +1381,23 @@ class ChatsModule { if (next is! int || next == marker || chats.length < count) break; marker = next; } + await applyFavorites(accountId); } catch (e) { _paginatedAccountId = null; logger.w('Пагинация чатов: $e'); } } + final Set _repairedSenders = {}; + Future> getChats( int accountId, { bool includeHidden = false, }) async { try { + if (_repairedSenders.add(accountId)) { + await AppDatabase.repairLastMessageSenders(accountId); + } final rows = await AppDatabase.loadChats( accountId, includeHidden: includeHidden, @@ -1272,7 +1433,21 @@ 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 { + try { + final info = await getChatInfo(api, chatId); + final fresh = parseParticipants(info?['participants']); + if (fresh.isNotEmpty) return fresh; + } catch (e) { + logger.w('Не удалось получить отметки прочтения для $chatId: $e'); + } + final rows = await getChat(accountId, chatId); + return rows.isEmpty ? const {} : rows.first.participants; } Future searchById(Api api, int userId) async { @@ -1334,12 +1509,38 @@ class ChatsModule { await api.sendRequest(Opcode.chatSubscribe, { 'chatId': chatId, 'subscribe': subscribe, - }); + }, silent: true); } catch (e) { logger.w('subscribeChat failed: $e'); } } + Future<({CachedChat chat, int? subscribersCount})> joinChannel( + Api api, + String link, + int accountId, + ) async { + final packet = await api.sendRequest(Opcode.chatJoin, { + 'link': link, + }, silent: true); + if (!packet.isOk) { + throw PacketError(messageFromErrorPayload(packet.payload)); + } + final payload = packet.payload; + final chatMap = payload is Map ? payload['chat'] : null; + if (chatMap is! Map) { + throw const PacketError('Не удалось подписаться'); + } + final cached = await cacheServerChat(chatMap, accountId); + if (cached == null) { + 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); + } + Future ensureChatCached(Api api, int accountId, int chatId) async { final rows = await AppDatabase.loadChat(accountId, chatId); if (rows.isNotEmpty) return true; @@ -1359,6 +1560,33 @@ class ChatsModule { required String title, required List userIds, bool notify = true, + }) => _createChat( + api, + chatType: 'CHAT', + title: title, + userIds: userIds, + notify: notify, + ); + + Future createChannel( + Api api, { + required String title, + List userIds = const [], + bool notify = true, + }) => _createChat( + api, + chatType: 'CHANNEL', + title: title, + userIds: userIds, + notify: notify, + ); + + Future _createChat( + Api api, { + required String chatType, + required String title, + required List userIds, + required bool notify, }) async { final payload = { 'message': { @@ -1367,7 +1595,7 @@ class ChatsModule { { '_type': 'CONTROL', 'event': 'new', - 'chatType': 'CHAT', + 'chatType': chatType, 'title': title, 'userIds': userIds, }, @@ -1377,22 +1605,24 @@ class ChatsModule { }; final packet = await api.sendRequest(Opcode.msgSend, payload); if (!packet.isOk) { - logger.w('createGroupChat: server error payload=${packet.payload}'); + logger.w( + '_createChat($chatType): server error payload=${packet.payload}', + ); return null; } final data = packet.payload; if (data is! Map) { - logger.w('createGroupChat: payload is not a Map: $data'); + logger.w('_createChat($chatType): payload is not a Map: $data'); return null; } final chat = data['chat']; if (chat is! Map) { - logger.w('createGroupChat: response has no chat field: $data'); + logger.w('_createChat($chatType): response has no chat field: $data'); return null; } final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) { - logger.w('createGroupChat: no active account id'); + logger.w('_createChat($chatType): no active account id'); return null; } return cacheServerChat(chat, accountId); @@ -1494,7 +1724,7 @@ class ChatsModule { : folders.first, ); - final favorites = List.from(allFolder.favorites ?? const []); + final favorites = List.from(allFolder.favorites); if (pin) { for (final id in chatIds) { if (!favorites.contains(id)) favorites.add(id); @@ -1538,6 +1768,40 @@ class ChatsModule { } } + Future applyFavorites(int accountId) async { + try { + final folders = await FoldersModule.loadFolders(accountId); + if (folders.isEmpty) return; + final allFolder = folders.firstWhere( + FoldersModule.isAllChatsFolder, + orElse: () => folders.first, + ); + final favorites = allFolder.favorites; + final favIndexById = {}; + for (var i = 0; i < favorites.length; i++) { + favIndexById[favorites[i]] = i + 1; + } + + final rows = await AppDatabase.loadChats(accountId, includeHidden: true); + final updates = >[]; + for (final row in rows) { + final id = row['id'] as int; + final current = (row['fav_index'] as int?) ?? 0; + final next = favIndexById[id] ?? 0; + if (current == next) continue; + final newRow = Map.from(row); + newRow['fav_index'] = next; + updates.add(newRow); + } + if (updates.isNotEmpty) { + await AppDatabase.saveChats(updates); + _bump(); + } + } catch (e) { + logger.w('applyFavorites: $e'); + } + } + Future setChatMute( Api api, { required int chatId, @@ -1639,6 +1903,127 @@ class ChatsModule { } } + Future addMembers( + Api api, { + required int chatId, + required List userIds, + bool showHistory = true, + }) async { + if (userIds.isEmpty) return false; + try { + final packet = await api.sendRequest(Opcode.chatMembersUpdate, { + 'chatId': chatId, + 'userIds': userIds, + 'showHistory': showHistory, + 'operation': 'add', + }); + if (!packet.isOk) { + logger.w( + 'addMembers $chatId: ${messageFromErrorPayload(packet.payload)}', + ); + return false; + } + final data = packet.payload; + final chat = data is Map ? data['chat'] : null; + if (chat is Map) { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + 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}'); + return false; + } catch (e) { + logger.w('addMembers $chatId: $e'); + return false; + } + } + + Future getChatMembers( + Api api, + int chatId, { + int marker = 0, + int count = 50, + }) async { + try { + final packet = await api.sendRequest(Opcode.chatMembers, { + 'type': 'MEMBER', + 'marker': marker, + 'chatId': chatId, + 'count': count, + }); + if (!packet.isOk) return null; + final payload = packet.payload; + if (payload is! Map) return null; + + final entries = []; + final presenceById = >{}; + final rawMembers = payload['members']; + if (rawMembers is List) { + for (final m in rawMembers.whereType()) { + final contact = m['contact']; + if (contact is! Map) continue; + final id = contact['id']; + if (id is! int) continue; + + final info = ContactInfo.fromMap(Map.from(contact)); + final name = info.displayName; + if (name != null && name.isNotEmpty) ContactCache.put(id, name); + final avatar = info.avatarUrl; + if (avatar != null && avatar.isNotEmpty) { + ContactCache.putAvatar(id, avatar); + } + final phone = contact['phone']; + if (phone is int && phone > 0) ContactCache.putPhone(id, phone); + ContactInfoFetch.putContact(id, contact.cast()); + + final presence = m['presence']; + var status = 0; + int? seen; + if (presence is Map) { + final p = Map.from(presence); + presenceById[id] = p; + status = (p['status'] as int?) ?? 0; + final s = p['seen']; + seen = s is int ? s : null; + } + + entries.add( + ChatMemberEntry( + id: id, + name: name, + fullName: info.fullName, + avatarUrl: avatar, + seenTime: seen, + presenceStatus: status, + blocked: info.isDeleted, + isContact: info.isSavedContact, + ), + ); + } + } + if (presenceById.isNotEmpty) PresenceFetch.primeAll(presenceById); + + final next = payload['marker']; + return ChatMembersPage( + members: entries, + marker: next is int ? next : marker, + ); + } on PacketError catch (e) { + logger.w('getChatMembers $chatId: ${e.message}'); + return null; + } catch (e) { + logger.w('getChatMembers $chatId: $e'); + return null; + } + } + Future> refreshChats(Api api, List chatIds) async { if (chatIds.isEmpty) return const []; try { diff --git a/lib/backend/modules/comments.dart b/lib/backend/modules/comments.dart new file mode 100644 index 0000000..dc9d94e --- /dev/null +++ b/lib/backend/modules/comments.dart @@ -0,0 +1,307 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../api.dart'; +import 'messages.dart' show CachedMessage; + +class CommentsInfo { + final String postId; + final int? totalCount; + final int? updatedAt; + const CommentsInfo({ + required this.postId, + this.totalCount, + this.updatedAt, + }); + + factory CommentsInfo.fromPayload(String postId, Map payload) { + final raw = payload['totalCount']; + int? count; + if (raw is int) { + count = raw; + } else if (raw is String) { + count = int.tryParse(raw); + } + return CommentsInfo( + postId: postId, + totalCount: count, + updatedAt: DateTime.now().millisecondsSinceEpoch, + ); + } +} + +class CommentAddedEvent { + final int chatId; + final String postId; + final CachedMessage comment; + const CommentAddedEvent(this.chatId, this.postId, this.comment); +} + +class CommentsModule { + final Api _api; + + CommentsModule(this._api); + + final ValueNotifier revision = ValueNotifier(0); + + final _infoController = + StreamController>.broadcast(); + Stream> get infoStream => _infoController.stream; + + final _commentController = StreamController.broadcast(); + Stream get commentStream => _commentController.stream; + + Map _info = {}; + Map get infoSnapshot => Map.unmodifiable(_info); + + CommentsInfo? infoFor(String postId) => _info[postId]; + + int _accountId = 0; + + void dispose() { + _pushSub?.cancel(); + _pushSub = null; + _infoController.close(); + _commentController.close(); + revision.dispose(); + } + + void attachPushHandlers(Api api) { + _pushSub?.cancel(); + _pushSub = api.pushStream.listen(_handlePush); + } + + StreamSubscription? _pushSub; + + void _handlePush(Packet packet) { + switch (packet.opcode) { + case Opcode.commentsInfo: + final payload = packet.payload; + if (payload is! Map) return; + final updates = payload['commentsInfoUpdates']; + if (updates is List) handleInfoUpdate(updates); + case Opcode.notifMessage: + _handleCommentPush(packet); + } + } + + void _handleCommentPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final msg = payload['message']; + if (msg is! Map) return; + + final link = msg['link']; + final postId = + (payload['postId'] ?? (link is Map ? link['postId'] : null) ?? + msg['postId']) + ?.toString(); + if (postId == null || postId.isEmpty) return; + + final comment = _parseComment( + msg.cast(), + _accountId, + chatId, + postId, + ); + if (comment == null) return; + _commentController.add(CommentAddedEvent(chatId, postId, comment)); + } + + void handleInfoUpdate(List updates) { + if (updates.isEmpty) return; + Map? next; + for (final raw in updates) { + if (raw is! Map) continue; + final postId = raw['postId']?.toString(); + final commentsInfo = raw['commentsInfo']; + if (postId == null || commentsInfo is! Map) continue; + final updated = CommentsInfo.fromPayload( + postId, + Map.from(commentsInfo.cast()), + ); + next ??= Map.from(_info); + next[postId] = updated; + } + if (next == null) return; + _info = next; + revision.value = revision.value + 1; + _infoController.add(Map.unmodifiable(_info)); + } + + Future> fetchInfo({ + required int accountId, + required int chatId, + required List postIds, + }) async { + _accountId = accountId; + if (postIds.isEmpty) return const {}; + final response = await _api.sendRequest(Opcode.commentsInfo, { + 'chatId': chatId, + 'postIds': postIds.map((id) => int.tryParse(id) ?? id).toList(), + }); + if (!response.isOk) return const {}; + final payload = response.payload; + if (payload is! Map) return const {}; + final updates = payload['commentsInfoUpdates']; + if (updates is! List) return const {}; + handleInfoUpdate(updates); + final byPost = {}; + for (final raw in updates.whereType()) { + final postId = raw['postId']?.toString(); + final commentsInfo = raw['commentsInfo']; + if (postId == null || commentsInfo is! Map) continue; + byPost[postId] = CommentsInfo.fromPayload( + postId, + Map.from(commentsInfo.cast()), + ); + } + return byPost; + } + + Future> fetchHistory( + int accountId, + int chatId, + String postId, { + required int fromTime, + int forward = 30, + int backward = 15, + }) async { + _accountId = accountId; + final payload = { + 'chatId': chatId, + 'postId': int.tryParse(postId) ?? postId, + 'from': fromTime, + 'forward': forward, + 'backward': backward, + 'getMessages': true, + }; + + final response = await _api.sendRequest(Opcode.chatHistory, payload); + if (!response.isOk) return const []; + final data = response.payload; + if (data is! Map) return const []; + + final messagesData = data['messages']; + if (messagesData is! List) return const []; + + final results = []; + for (var i = 0; i < messagesData.length; i++) { + final m = messagesData[i]; + if (m is! Map) continue; + final parsed = _parseComment( + m.cast(), + accountId, + chatId, + postId, + ); + if (parsed != null) results.add(parsed); + if (i > 0 && i % 20 == 0) await Future.delayed(Duration.zero); + } + + return results; + } + + Future sendComment( + int accountId, + int chatId, + String postId, + String text, { + bool notify = true, + int? replyToMessageId, + List> elements = const [], + }) async { + _accountId = accountId; + final Object postIdField = int.tryParse(postId) ?? postId; + final message = { + 'text': text, + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'elements': elements, + 'attaches': [], + }; + if (replyToMessageId != null) { + message['link'] = { + 'type': 'REPLY', + 'chatId': chatId, + 'postId': postIdField, + 'messageId': replyToMessageId, + }; + } + final payload = { + 'chatId': chatId, + 'postId': postIdField, + 'message': message, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) { + final raw = response.payload; + final msg = (raw is Map) + ? (raw['localizedMessage'] ?? raw['message'] ?? 'Ошибка отправки') + : 'Ошибка отправки'; + throw Exception(msg.toString()); + } + final data = response.payload; + if (data is Map) { + final msgMap = data['message']; + if (msgMap is Map) { + final id = msgMap['id']; + if (id != null) return id.toString(); + } + } + return ''; + } + + void sendTyping(int chatId, String postId, String type) { + unawaited(() async { + try { + await _api.sendRequest(Opcode.msgTyping, { + 'chatId': chatId, + 'postId': int.tryParse(postId) ?? postId, + 'type': type, + }); + } catch (_) {} + }()); + } + + CachedMessage? _parseComment( + Map m, + int accountId, + int chatId, + String postId, + ) { + final id = m['id']?.toString(); + if (id == null) return null; + + final full = Map.from(m.cast()); + full['postId'] = postId; + final parsed = CachedMessage.parseAttachments(full); + final senderId = _parseIntField(m['sender']); + + return CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: senderId, + text: m['text']?.toString(), + time: _parseIntField(m['time']), + status: m['status']?.toString(), + payload: full, + attachments: parsed.$1, + isControl: parsed.$2, + ); + } + + int _parseIntField(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + return int.tryParse(value.toString()) ?? 0; + } +} diff --git a/lib/backend/modules/complaints.dart b/lib/backend/modules/complaints.dart index 5ccbb21..6182e8b 100644 --- a/lib/backend/modules/complaints.dart +++ b/lib/backend/modules/complaints.dart @@ -1,5 +1,6 @@ import '../api.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; class ComplaintReason { final int reasonId; @@ -9,6 +10,8 @@ class ComplaintReason { } class ComplaintsModule { + static const int userTypeId = 6; + static Map>? _cache; static void clear() => _cache = null; @@ -17,10 +20,15 @@ class ComplaintsModule { final cached = _cache; if (cached != null) return cached; - final response = await api.sendRequest(Opcode.complainReasonsGet, { - 'complainSync': 0, - }); - if (!response.isOk) return cached ?? const {}; + final Packet response; + try { + response = await api.sendRequest(Opcode.complainReasonsGet, { + 'complainSync': 0, + }, silent: true); + } catch (_) { + return const {}; + } + if (!response.isOk) return const {}; final payload = response.payload; if (payload is! Map) return const {}; @@ -65,14 +73,19 @@ class ComplaintsModule { required int reasonId, required int typeId, required List ids, - required int parentId, + int? parentId, }) async { - final response = await api.sendRequest(Opcode.complain, { - 'reasonId': reasonId, - 'typeId': typeId, - 'ids': ids, - 'parentId': parentId, - }); + final Packet response; + try { + response = await api.sendRequest(Opcode.complain, { + 'reasonId': reasonId, + 'typeId': typeId, + 'ids': ids, + 'parentId': ?parentId, + }, silent: true); + } catch (_) { + return false; + } if (!response.isOk) return false; final payload = response.payload; return payload is Map && payload['success'] == true; diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 29b3c71..49ab0cd 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -1,9 +1,12 @@ import 'package:flutter/foundation.dart'; +import '../../core/cache/info_cache.dart'; import '../../core/config/debug_test.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; +import '../../models/contact_info.dart'; import '../api.dart'; import 'messages.dart'; @@ -18,6 +21,7 @@ class CachedContact { final String? baseRawUrl; final int updateTime; final Set options; + final int accountStatus; const CachedContact({ required this.id, @@ -30,12 +34,14 @@ class CachedContact { this.baseRawUrl, required this.updateTime, this.options = const {}, + this.accountStatus = 0, }); bool get isOfficial => options.contains('OFFICIAL'); bool get isBot => options.contains('BOT'); bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); bool get isVerified => isOfficial; + bool get isDeleted => accountStatus != 0; factory CachedContact.fromDbRow(Map row) => CachedContact( id: row['id'] as int, @@ -48,6 +54,7 @@ class CachedContact { baseRawUrl: row['base_raw_url'] as String?, updateTime: row['update_time'] as int, options: _decodeOptions(row['options']), + accountStatus: (row['account_status'] as int?) ?? 0, ); static Set _decodeOptions(dynamic raw) { @@ -60,8 +67,14 @@ class PhoneLookupResult { final int id; final String? name; final String? avatarUrl; + final int phone; - const PhoneLookupResult({required this.id, this.name, this.avatarUrl}); + const PhoneLookupResult({ + required this.id, + this.name, + this.avatarUrl, + this.phone = 0, + }); } class ContactPhotos { @@ -73,21 +86,47 @@ class ContactPhotos { static const empty = ContactPhotos(urls: [], total: 0); } +enum AddContactStatus { added, notFound, error } + +class AddContactResult { + final AddContactStatus status; + final CachedContact? contact; + + const AddContactResult(this.status, {this.contact}); +} + class ContactsModule { static final ValueNotifier revision = ValueNotifier(0); - static Future findByPhone(Api api, String phone) async { + static Future findByPhone( + Api api, + String phone, { + bool silent = false, + }) async { final normalized = _normalizePhone(phone); if (normalized == null) return null; - final packet = await api.sendRequest(Opcode.contactInfoByPhone, { - 'phone': normalized, - }); + final Packet packet; + try { + packet = await api.sendRequest(Opcode.contactInfoByPhone, { + 'phone': normalized, + }, silent: silent); + } on PacketError { + return null; + } if (packet.isError) return null; final contact = (packet.payload as Map?)?['contact']; if (contact is! Map) return null; final id = contact['id']; if (id is! int) return null; + primeContactCache(contact); + + final payloadPhone = contact['phone']; + final resolvedPhone = payloadPhone is int && payloadPhone > 0 + ? payloadPhone + : int.tryParse(normalized.substring(1)) ?? 0; + ContactCache.putPhone(id, resolvedPhone); + String? name; final names = contact['names']; if (names is List) { @@ -105,6 +144,7 @@ class ContactsModule { id: id, name: name, avatarUrl: contact['baseUrl'] as String?, + phone: resolvedPhone, ); } @@ -123,7 +163,7 @@ class ContactsModule { final resp = await api.sendRequest(Opcode.contactUpdate, { 'action': 'ADD', 'contactId': id, - 'firstName': firstName, + if (firstName.isNotEmpty) 'firstName': firstName, }); final profile = await AppDatabase.loadActiveProfile(); @@ -147,6 +187,7 @@ class ContactsModule { 'base_raw_url': null, 'update_time': 0, 'options': null, + 'account_status': 0, }; if (row == null) return null; @@ -154,24 +195,228 @@ class ContactsModule { row['phone'] = phone; } await AppDatabase.saveContacts([row]); - if (contact != null) _primeContactCache(contact); + if (contact != null) primeContactCache(contact); revision.value++; return CachedContact.fromDbRow(row); } + static Future addContactByPhone( + Api api, { + required String phone, + required String firstName, + String lastName = '', + }) async { + final normalized = _normalizePhone(phone); + if (normalized == null) { + return const AddContactResult(AddContactStatus.error); + } + + final Packet resp; + try { + resp = await api.sendRequest(Opcode.contactAddByPhone, { + 'phone': normalized, + 'firstName': firstName, + 'lastName': lastName, + }, silent: true); + } on PacketError catch (e) { + final key = e.errorKey ?? ''; + final notFound = + key == 'user.not.found' || + e.message.toLowerCase().contains('not found'); + return AddContactResult( + notFound ? AddContactStatus.notFound : AddContactStatus.error, + ); + } catch (_) { + return const AddContactResult(AddContactStatus.error); + } + + final data = resp.payload; + final contact = (data is Map && data['contact'] is Map) + ? (data['contact'] as Map).cast() + : null; + if (contact == null) { + return const AddContactResult(AddContactStatus.error); + } + + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null) { + return const AddContactResult(AddContactStatus.error); + } + + final row = _parseContact(contact, profile.id); + if (row == null) { + return const AddContactResult(AddContactStatus.error); + } + + await AppDatabase.saveContacts([row]); + primeContactCache(contact); + revision.value++; + return AddContactResult( + AddContactStatus.added, + contact: CachedContact.fromDbRow(row), + ); + } + + static Future updateContact( + Api api, { + required int contactId, + required String firstName, + required String lastName, + }) async { + final Packet resp; + try { + resp = await api.sendRequest(Opcode.contactUpdate, { + 'contactId': contactId, + 'action': 'UPDATE', + 'firstName': firstName, + 'lastName': lastName, + }); + } catch (_) { + return null; + } + + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null) return null; + + final data = resp.payload; + final contact = (data is Map && data['contact'] is Map) + ? (data['contact'] as Map).cast() + : null; + if (contact == null) return null; + + final row = _parseContact(contact, profile.id); + if (row == null) return null; + + await AppDatabase.saveContacts([row]); + primeContactCache(contact); + ContactInfoFetch.putContact(contactId, contact); + revision.value++; + return CachedContact.fromDbRow(row); + } + + static Future removeContact(Api api, int contactId) async { + try { + await api.sendRequest(Opcode.contactUpdate, { + 'contactId': contactId, + 'action': 'REMOVE', + }); + } catch (_) { + return false; + } + + final profile = await AppDatabase.loadActiveProfile(); + if (profile != null) { + await AppDatabase.deleteContact(profile.id, contactId); + } + + ContactCache.remove(contactId); + + ContactInfo? info = ContactInfoFetch.peek(contactId); + if (info == null) { + ContactInfoFetch.invalidate(contactId); + info = await ContactInfoFetch.get(contactId, forceRefresh: true); + } + + final rawNames = info?.raw['names']; + if (info != null && rawNames is List) { + final stripped = rawNames + .where((n) => !(n is Map && n['type'] == 'CUSTOM')) + .toList(); + final newRaw = Map.from(info.raw)..['names'] = stripped; + ContactInfoFetch.putContact(contactId, newRaw); + primeContactCache(newRaw); + } else { + ContactInfoFetch.invalidate(contactId); + } + + revision.value++; + return true; + } + + static final Set _blockedIds = {}; + static bool _blockedLoaded = false; + + static void clearBlockedCache() { + _blockedIds.clear(); + _blockedLoaded = false; + } + + static const int _blockedPageSize = 100; + static const int _blockedMaxPages = 20; + + static Future isBlocked(Api api, int contactId) async { + if (!_blockedLoaded) await _loadBlockedIds(api); + return _blockedIds.contains(contactId); + } + + static Future _loadBlockedIds(Api api) async { + final ids = {}; + try { + for (var page = 0; page < _blockedMaxPages; page++) { + final map = await api.sendRequestMap(Opcode.contactList, { + 'status': 'BLOCKED', + 'count': _blockedPageSize, + 'from': page * _blockedPageSize, + }); + final contacts = map?['contacts']; + if (contacts is! List) return; + ids.addAll( + contacts.whereType().map((c) => c['id']).whereType(), + ); + if (contacts.length < _blockedPageSize) break; + } + } catch (e) { + logger.w('Не удалось получить список заблокированных: $e'); + return; + } + _blockedIds + ..clear() + ..addAll(ids); + _blockedLoaded = true; + } + + static Future setBlocked(Api api, int contactId, bool blocked) async { + try { + final packet = await api.sendRequest(Opcode.contactUpdate, { + 'contactId': contactId, + 'action': blocked ? 'BLOCK' : 'UNBLOCK', + }); + if (packet.isError) return false; + } catch (e) { + logger.w('setBlocked $contactId: $e'); + return false; + } + + if (blocked) { + _blockedIds.add(contactId); + } else { + _blockedIds.remove(contactId); + } + ContactInfoFetch.invalidate(contactId); + revision.value++; + return true; + } + static Future syncFromLoginPayload( Map data, int accountId, ) async { final contacts = data['contacts']; - if (contacts is! List || contacts.isEmpty) return; + if (contacts is! List) { + logger.i('Контакты: сервер не прислал список (акк $accountId)'); + return; + } + if (contacts.isEmpty) { + logger.i('Контакты: сервер прислал пустой список (акк $accountId)'); + return; + } final rows = >[]; for (final raw in contacts.whereType()) { final contact = raw.cast(); final row = _parseContact(contact, accountId); if (row != null) rows.add(row); - _primeContactCache(contact); + primeContactCache(contact); } if (rows.isNotEmpty) { @@ -191,17 +436,32 @@ class ContactsModule { await syncFromLoginPayload(map.cast(), accountId); } - static void _primeContactCache(Map contact) { + static Future fetchSelfProfile(Api api, int accountId) async { + final map = await api.sendRequestMap(Opcode.contactInfo, { + 'contactIds': [accountId], + }); + final contacts = map?['contacts']; + if (contacts is! List) return null; + for (final raw in contacts.whereType()) { + if (raw['id'] != accountId) continue; + final contact = raw.cast(); + primeContactCache(contact); + return ProfileData.fromServerMap(contact); + } + return null; + } + + static void primeContactCache(Map contact) { final id = contact['id']; if (id is! int) return; + final phone = contact['phone']; + if (phone is int) ContactCache.putPhone(id, phone); + final names = contact['names']; if (names is List && names.isNotEmpty) { - final nameRaw = names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), - ); - if (nameRaw is Map) { + final nameRaw = _preferredNameEntry(names); + if (nameRaw != null) { final firstName = (nameRaw['firstName'] as String?) ?? ''; final lastName = nameRaw['lastName'] as String?; final fullName = (lastName != null && lastName.isNotEmpty) @@ -217,12 +477,17 @@ class ContactsModule { } } + static final Map _photosHead = {}; + + static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId]; + static Future fetchPhotos( Api api, int contactId, { int from = 0, int count = 25, }) async { + if (contactId <= 0) return ContactPhotos.empty; final map = await api.sendRequestMap(Opcode.contactPhotos, { 'contactId': contactId, 'from': from, @@ -234,23 +499,67 @@ class ContactsModule { ? rawUrls.whereType().toList() : []; final total = map['total'] is int ? map['total'] as int : urls.length; - return ContactPhotos(urls: urls, total: total); + final photos = ContactPhotos(urls: urls, total: total); + if (from == 0) _photosHead[contactId] = photos; + return photos; } - static Future> getContacts(int accountId) async { - final rows = await AppDatabase.loadContacts(accountId); + static Future> getContacts( + int accountId, { + bool includeDeleted = false, + }) async { + final rows = await AppDatabase.loadContacts( + accountId, + includeDeleted: includeDeleted, + ); return rows.map(CachedContact.fromDbRow).toList(); } + static Future getContact(int accountId, int id) async { + final row = await AppDatabase.loadContact(accountId, id); + return row == null ? null : CachedContact.fromDbRow(row); + } + static const List _debugFirstNames = [ - 'Алиса', 'Борис', 'Вера', 'Глеб', 'Дарья', 'Егор', 'Жанна', 'Захар', - 'Ирина', 'Кирилл', 'Лия', 'Максим', 'Нина', 'Олег', 'Полина', 'Роман', - 'София', 'Тимур', 'Ульяна', 'Фёдор', 'Ханна', 'Цветана', 'Чеслав', 'Шура', + 'Алиса', + 'Борис', + 'Вера', + 'Глеб', + 'Дарья', + 'Егор', + 'Жанна', + 'Захар', + 'Ирина', + 'Кирилл', + 'Лия', + 'Максим', + 'Нина', + 'Олег', + 'Полина', + 'Роман', + 'София', + 'Тимур', + 'Ульяна', + 'Фёдор', + 'Ханна', + 'Цветана', + 'Чеслав', + 'Шура', ]; static const List _debugLastNames = [ - 'Иванов', 'Петров', 'Сидоров', 'Кузнецов', 'Смирнов', 'Попов', 'Волков', - 'Соколов', 'Морозов', 'Новиков', 'Фёдоров', 'Козлов', + 'Иванов', + 'Петров', + 'Сидоров', + 'Кузнецов', + 'Смирнов', + 'Попов', + 'Волков', + 'Соколов', + 'Морозов', + 'Новиков', + 'Фёдоров', + 'Козлов', ]; static List debugContacts() { @@ -280,8 +589,9 @@ class ContactsModule { /// Прогревает in-memory ContactCache из локальных контактов. /// Нужно вызывать на cold start: иначе кэш пуст до следующего логина. static Future primeCacheFromDb(int accountId) async { - final contacts = await getContacts(accountId); + final contacts = await getContacts(accountId, includeDeleted: true); for (final c in contacts) { + ContactCache.putPhone(c.id, c.phone); final fullName = (c.lastName != null && c.lastName!.isNotEmpty) ? '${c.firstName} ${c.lastName}' : c.firstName; @@ -292,6 +602,19 @@ class ContactsModule { } } + static Map? _preferredNameEntry(List names) { + Map? oneme; + Map? any; + for (final n in names) { + if (n is! Map) continue; + any ??= n; + final type = n['type']; + if (type == 'CUSTOM') return n; + if (type == 'ONEME') oneme ??= n; + } + return oneme ?? any; + } + static Map? _parseContact( Map contact, int accountId, @@ -304,14 +627,10 @@ class ContactsModule { final names = contact['names']; if (names is List && names.isNotEmpty) { - final nameRaw = names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), - ); - if (nameRaw is! Map) return null; - final name = nameRaw; - firstName = (name['firstName'] as String?) ?? ''; - lastName = name['lastName'] as String?; + final nameRaw = _preferredNameEntry(names); + if (nameRaw == null) return null; + firstName = (nameRaw['firstName'] as String?) ?? ''; + lastName = nameRaw['lastName'] as String?; } final optionsRaw = contact['options']; @@ -331,6 +650,7 @@ class ContactsModule { 'base_raw_url': contact['baseRawUrl'] as String?, 'update_time': (contact['updateTime'] as int?) ?? 0, 'options': optionsStr, + 'account_status': (contact['accountStatus'] as int?) ?? 0, }; } } diff --git a/lib/backend/modules/digital_id.dart b/lib/backend/modules/digital_id.dart index 7285ac1..283f490 100644 --- a/lib/backend/modules/digital_id.dart +++ b/lib/backend/modules/digital_id.dart @@ -1,20 +1,25 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:device_info_plus/device_info_plus.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/spoofing_service.dart'; import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; import '../../models/digital_id.dart'; import 'webapp.dart'; class DigitalIdException implements Exception { final String code; final String message; + final int? statusCode; - const DigitalIdException(this.code, this.message); + const DigitalIdException(this.code, this.message, {this.statusCode}); bool get isUnauthorized => code == 'UNAUTHORIZED'; bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK'; @@ -61,10 +66,21 @@ class DigitalIdModule { ).firstMatch(fragment); final raw = match?.group(1); if (raw == null || raw.isEmpty) return null; - return Uri.decodeComponent(raw); + // Фрагмент несёт WebAppData полностью percent-encoded (hash%3D…%26…); + // сервер ждёт канонический initDataRaw (hash=…&…) — как шлёт web-страница + // после decodeURIComponent. Декодируем один раз (фолбэк — сырое значение). + try { + return Uri.decodeComponent(raw); + } catch (_) { + return raw; + } } Future deviceId() async { + // Тот же device_id, что уходит в handshake (опкод 6) — иначе сервер вернёт + // device_mismatch. Сессия обычно уже поднята к моменту открытия Цифрового ID. + final session = _webApp.sessionDeviceId; + if (session != null && session.isNotEmpty) return session; if (_deviceId != null) return _deviceId!; final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { @@ -144,6 +160,9 @@ class DigitalIdModule { } final response = await request.close(); final text = await response.transform(utf8.decoder).join(); + if (kDebugMode) { + logger.i('[DID-native] $method $path -> ${response.statusCode}'); + } if (response.statusCode == 401 && retry) { await _ensureWebAppData(forceRefresh: true); @@ -170,7 +189,7 @@ class DigitalIdModule { } } catch (_) {} if (statusCode == 401) code = 'UNAUTHORIZED'; - return DigitalIdException(code, message); + return DigitalIdException(code, message, statusCode: statusCode); } Map _unwrapData(dynamic decoded) { @@ -246,7 +265,7 @@ class DigitalIdModule { ); return _unwrapData(decoded)['shadow_mode'] == true; } on DigitalIdException catch (e) { - if (e.code == 'HTTP_404') return false; + if (e.statusCode == 404) return false; rethrow; } } @@ -377,7 +396,34 @@ class DigitalIdModule { return null; } + Future?> fetchMobileIdVerification(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme != 'https') return null; + try { + final request = await _http.getUrl(uri); + request.followRedirects = true; + final response = await request.close(); + final builder = BytesBuilder(copy: false); + await for (final chunk in response) { + builder.add(chunk); + } + final headers = {}; + response.headers.forEach((name, values) { + headers[name] = values.join(','); + }); + return { + 'statusCode': response.statusCode, + 'headers': headers, + 'data': base64Encode(builder.takeBytes()), + }; + } catch (_) { + return null; + } + } + void reset() { _webAppData = null; + _deviceId = null; + _realUserAgent = null; } } diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 6cb81de..8929d8a 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -3,11 +3,10 @@ import 'dart:convert' show jsonDecode, utf8; import 'dart:io'; import 'dart:typed_data'; +import 'package:kolibri/kolibri.dart' as kb; + import '../api.dart'; -import '../../core/config/proxy_config.dart'; import '../../core/protocol/opcode_map.dart'; -import '../../core/transport/proxy_connector.dart'; -import '../../core/transport/tls_config.dart'; import '../../core/utils/logger.dart'; import 'messages.dart'; @@ -27,12 +26,18 @@ class UploadDone extends UploadEvent { final String? url; final String filename; final int size; + + /// Server-assigned message id. Without it the optimistic message keeps its + /// local temp id and download URLs cannot be resolved until a restart. + final String? messageId; + const UploadDone({ required this.fileId, required this.filename, required this.size, this.token, this.url, + this.messageId, }); } @@ -41,10 +46,10 @@ class UploadError extends UploadEvent { const UploadError(this.message); } +/// Оркестратор медиа-загрузок: control-plane (URL, отправка сообщения) идёт +/// обычными опкодами, data-plane (заливка на CDN) — через Rust-ядро kolibri, +/// которое стримит файл с диска (не держит его целиком в памяти). class FileUploader { - static const String _userAgentHeader = - 'OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)'; - final Api api; final MessagesModule messages; @@ -62,13 +67,11 @@ class FileUploader { }) { final ctrl = StreamController(); var cancelled = false; - Socket? socket; + StreamSubscription? sub; ctrl.onCancel = () { cancelled = true; - try { - socket?.destroy(); - } catch (_) {} + sub?.cancel(); }; Future run() async { @@ -80,6 +83,12 @@ class FileUploader { return; } + final session = api.session; + if (session == null) { + ctrl.add(const UploadError('no_session')); + return; + } + unawaited(() async { try { await api.sendRequest(Opcode.msgTyping, { @@ -89,38 +98,61 @@ class FileUploader { } catch (_) {} }()); - final uri = Uri.parse(info.url); - final result = await _sendHttpRequest( - uri, - method: 'POST', - headers: _buildUploadHeaders(uri, filename, totalSize), - bodyStream: file.openRead(), - progressTotal: totalSize, - onProgress: (sent, total) { - if (!cancelled) ctrl.add(UploadProgress(sent: sent, total: total)); - }, - progressThrottle: progressThrottle, - autoForceAfter: autoForceAfter, - timeout: overallTimeout, - onSocketReady: (s) => socket = s, - shouldAbort: () => cancelled, - ); + var status = 0; + String? error; + final done = Completer(); + sub = + session + .uploadFilePath( + url: info.url, + path: file.path, + filename: filename, + connection: 'close', + ) + .listen( + (e) { + switch (e) { + case kb.UploadEvent_Progress(:final sent, :final total): + ctrl.add( + UploadProgress( + sent: sent.toInt(), + total: total.toInt(), + ), + ); + case kb.UploadEvent_Done(status: final s): + status = s; + case kb.UploadEvent_Error(:final message): + error = message; + } + }, + onError: (Object err) { + error = err.toString(); + if (!done.isCompleted) done.complete(); + }, + onDone: () { + if (!done.isCompleted) done.complete(); + }, + cancelOnError: true, + ); + await done.future; if (cancelled) return; - - final statusCode = result?.$1 ?? 0; - if (statusCode != 200 && statusCode != 0) { - ctrl.add(UploadError('http_$statusCode')); + if (error != null) { + ctrl.add(UploadError(error!)); + return; + } + if (status != 200 && status != 0) { + ctrl.add(UploadError('http_$status')); return; } - final ok = await messages.sendFileMessage( + final messageId = await messages.sendFileMessage( chatId, info.fileId, token: info.token, scheduledTime: scheduledTime, ); if (cancelled) return; - if (!ok) { + if (messageId == null) { ctrl.add(const UploadError('send_failed')); return; } @@ -132,14 +164,12 @@ class FileUploader { url: info.url, filename: filename, size: totalSize, + messageId: messageId, ), ); } catch (e) { if (!cancelled) ctrl.add(UploadError(e.toString())); } finally { - try { - socket?.destroy(); - } catch (_) {} await ctrl.close(); } } @@ -155,133 +185,59 @@ class FileUploader { Duration overallTimeout = const Duration(minutes: 5), Duration progressThrottle = const Duration(milliseconds: 16), }) async { + final session = api.session; + if (session == null) return false; try { - final total = await file.length(); - if (total <= 0) return false; - final filename = _syntheticFilename(); - - final result = await _sendHttpRequest( - uri, - method: 'POST', - headers: _buildUploadHeaders( - uri, - filename, - total, + final result = await _consume( + session.uploadFilePath( + url: uri.toString(), + path: file.path, + filename: _syntheticFilename(), contentType: 'application/octet-stream', connection: 'close', ), - bodyStream: file.openRead(), - progressTotal: total, onProgress: onProgress, - progressThrottle: progressThrottle, - timeout: overallTimeout, - ); - - final statusCode = result?.$1 ?? 0; - final respBody = result?.$2 ?? ''; - logger.w( - 'uploadMediaFile: status=$statusCode total=$total ' - 'host=${uri.host} body=${respBody.length > 200 ? respBody.substring(0, 200) : respBody}', ); + if (result.error != null) { + logger.w('uploadMediaFile: ${result.error}'); + return false; + } + final respBody = utf8.decode(result.body, allowMalformed: true); final hasError = respBody.contains('error_msg') || respBody.contains('error_code'); - return statusCode == 200 && !hasError; + if (result.status != 200 || hasError) { + logger.w( + 'uploadMediaFile rejected: status=${result.status} ' + 'body=${respBody.length > 500 ? respBody.substring(0, 500) : respBody}', + ); + } + return result.status == 200 && !hasError; } catch (e) { logger.w('uploadMediaFile: $e'); return false; } } - Future _openSocket(Uri uri) async { - final proxySettings = await ProxyConfig.load(); - final base = proxySettings.isEnabled - ? await ProxyConnector(proxySettings).connect(uri.host, uri.port) - : await Socket.connect(uri.host, uri.port); - if (uri.scheme != 'https') return base; - final allowInsecure = await TlsConfig.isInsecureAllowed(); - if (allowInsecure) { - logger.w( - 'TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM', - ); - return SecureSocket.secure( - base, - host: uri.host, - onBadCertificate: (_) => true, - ); - } - return SecureSocket.secure(base, host: uri.host); - } - - String _syntheticFilename() => - (DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString(); - - String _multipartBoundary() => - '----KometBoundary${DateTime.now().microsecondsSinceEpoch}'; - - Map _buildUploadHeaders( - Uri uri, - String filename, - int total, { - String contentType = 'application/x-binary; charset=x-user-defined', - String connection = 'keep-alive', - }) { - return { - 'Host': uri.host, - 'Content-Type': contentType, - 'Content-Disposition': 'attachment; filename=$filename', - 'Connection': connection, - 'User-Agent': Uri.encodeComponent(_userAgentHeader), - 'Content-Range': 'bytes 0-${total - 1}/$total', - 'Content-Length': '$total', - }; - } - Future uploadImage( Uri uri, Uint8List bytes, { String filename = 'avatar.jpg', }) async { + final session = api.session; + if (session == null) return null; try { - final boundary = _multipartBoundary(); - final preamble = utf8.encode( - '--$boundary\r\n' - 'Content-Disposition: form-data; name="file"; filename="$filename"\r\n' - 'Content-Type: ${_contentTypeForFilename(filename)}\r\n' - '\r\n', - ); - final epilogue = utf8.encode('\r\n--$boundary--\r\n'); - - final response = await _sendHttpRequest( - uri, - method: 'POST', - headers: _buildMultipartHeaders( - uri, - preamble.length + bytes.length + epilogue.length, - boundary: boundary, + final result = await _consume( + session.uploadPhoto( + url: uri.toString(), + data: bytes, + filename: filename, ), - prefixBytes: preamble, - bodyStream: Stream.value(bytes), - suffixBytes: epilogue, - timeout: const Duration(minutes: 2), ); - - if (response == null) { + if (result.error != null || result.status != 200) { + logger.w('uploadImage: status=${result.status} error=${result.error}'); return null; } - final (status, body) = response; - if (status != 200) { - logger.w( - 'uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}', - ); - return null; - } - final token = _parsePhotoToken(body); - if (token == null) { - logger.w( - 'uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}', - ); - } - return token; + return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true)); } catch (e) { logger.w('uploadImage: $e'); return null; @@ -295,41 +251,22 @@ class FileUploader { void Function(int sent, int total)? onProgress, Duration progressThrottle = const Duration(milliseconds: 16), }) async { + final session = api.session; + if (session == null) return null; try { - final fileLength = await file.length(); - final boundary = _multipartBoundary(); - final preamble = utf8.encode( - '--$boundary\r\n' - 'Content-Disposition: form-data; name="file"; filename="$filename"\r\n' - 'Content-Type: ${_contentTypeForFilename(filename)}\r\n' - '\r\n', - ); - final epilogue = utf8.encode('\r\n--$boundary--\r\n'); - - final response = await _sendHttpRequest( - uri, - method: 'POST', - headers: _buildMultipartHeaders( - uri, - preamble.length + fileLength + epilogue.length, - boundary: boundary, + final result = await _consume( + session.uploadPhotoPath( + url: uri.toString(), + path: file.path, + filename: filename, ), - prefixBytes: preamble, - bodyStream: file.openRead(), - suffixBytes: epilogue, - progressTotal: fileLength, onProgress: onProgress, - progressThrottle: progressThrottle, - timeout: const Duration(minutes: 2), ); - - if (response == null) return null; - final (status, responseBody) = response; - if (status != 200) { - logger.w('uploadPhoto: status=$status'); + if (result.error != null || result.status != 200) { + logger.w('uploadPhoto: status=${result.status} error=${result.error}'); return null; } - return _parsePhotoToken(responseBody); + return _parsePhotoToken(utf8.decode(result.body, allowMalformed: true)); } catch (e) { logger.w('uploadPhoto: $e'); return null; @@ -344,342 +281,129 @@ class FileUploader { int concurrency = 4, Duration overallTimeout = const Duration(minutes: 30), }) async { - final total = await file.length(); - if (total <= 0) return false; - - final fileName = _syntheticFilename(); - - final handshake = await _okCdnRequest( - uri, - method: 'GET', - fileName: fileName, - timeout: const Duration(seconds: 30), - ); - if (handshake == null || handshake.$1 != 200) return false; - - var startOffset = 0; - final resumed = int.tryParse(handshake.$2.trim()); - if (resumed != null && resumed > 0 && resumed <= total) { - startOffset = resumed; - } - - final ranges = <(int, int)>[]; - for (var o = startOffset; o < total; o += chunkSize) { - ranges.add((o, o + chunkSize < total ? o + chunkSize : total)); - } - if (ranges.isEmpty) return true; - - var nextIndex = 0; - var sent = startOffset; - var failed = false; - - Future worker() async { - while (!failed) { - final i = nextIndex++; - if (i >= ranges.length) return; - final (start, end) = ranges[i]; - final bytes = await _readRange(file, start, end); - - final resp = await _okCdnRequest( - uri, - method: 'POST', - fileName: fileName, - body: bytes, - contentRange: 'bytes $start-${end - 1}/$total', - timeout: overallTimeout, - ); - if (resp == null || (resp.$1 != 200 && resp.$1 != 201)) { - logger.w('uploadVideoFile: chunk status=${resp?.$1}'); - failed = true; - return; - } - - sent += end - start; - onProgress?.call(sent, total); - } - } - - final workerCount = concurrency < ranges.length - ? concurrency - : ranges.length; - await Future.wait(List.generate(workerCount, (_) => worker())); - return !failed; - } - - Future _readRange(File file, int start, int end) async { - final builder = BytesBuilder(copy: false); - await for (final chunk in file.openRead(start, end)) { - builder.add(chunk); - } - return builder.takeBytes(); - } - - Future<(int, String)?> _okCdnRequest( - Uri uri, { - required String method, - required String fileName, - Uint8List? body, - String? contentRange, - required Duration timeout, - }) async { + final session = api.session; + if (session == null) return false; try { - final headers = { - 'Host': uri.host, - 'Content-Type': 'application/x-binary; charset=x-user-defined', - 'Content-Disposition': 'attachment; fileName="$fileName"', - 'Content-Range': ?contentRange, - 'Content-Length': '${body?.length ?? 0}', - 'X-Uploading-Mode': 'parallel', - 'Connection': 'close', - }; - return await _sendHttpRequest( - uri, - method: method, - headers: headers, - prefixBytes: body, - timeout: timeout, + final result = await _consume( + session.uploadVideoPath( + url: uri.toString(), + path: file.path, + chunkSize: chunkSize, + concurrency: concurrency, + ), + onProgress: onProgress, ); + if (result.error != null || result.status != 200) { + logger.w( + 'uploadVideoFile: status=${result.status} error=${result.error}', + ); + } + return result.error == null && result.status == 200; } catch (e) { - logger.w('_okCdnRequest($method): $e'); - return null; + logger.w('uploadVideoFile: $e'); + return false; } } - Map _buildMultipartHeaders( + /// Загрузка видео для истории. В отличие от чата (чанковый `uploadVideoPath` + /// с GET-handshake), story-эндпоинт `su.oneme.ru/uploadVideo` ждёт **один POST + /// на весь файл** (как у оригинального клиента) и возвращает медиа-токен в теле + /// ответа — `[{"token":"..."}]`. Именно этот токен идёт в `media.token` + /// STORIES_SEND, а не токен из ответа VIDEO_UPLOAD. + Future<({bool ok, String? token})> uploadVideoWithToken( Uri uri, - int total, { - required String boundary, - }) { - return { - 'Host': uri.host, - 'Content-Type': 'multipart/form-data; boundary=$boundary', - 'Content-Length': '$total', - 'Connection': 'keep-alive', - 'User-Agent': Uri.encodeComponent(_userAgentHeader), - }; - } - - Future<(int, String)?> _sendHttpRequest( - Uri uri, { - required String method, - required Map headers, - List? prefixBytes, - Stream>? bodyStream, - List? suffixBytes, - int? progressTotal, + File file, { void Function(int sent, int total)? onProgress, - Duration progressThrottle = const Duration(milliseconds: 16), - Duration? autoForceAfter, - required Duration timeout, - void Function(Socket socket)? onSocketReady, - bool Function()? shouldAbort, }) async { - final socket = await _openSocket(uri); - onSocketReady?.call(socket); + final session = api.session; + if (session == null) return (ok: false, token: null); try { - if (shouldAbort?.call() ?? false) return null; - - _writeRequestHeaders(socket, uri, method, headers); - if (prefixBytes != null && prefixBytes.isNotEmpty) { - socket.add(prefixBytes); - } - if (bodyStream != null) { - final stream = (onProgress != null && progressTotal != null) - ? _withProgress( - bodyStream, - progressTotal, - onProgress, - throttle: progressThrottle, - ) - : bodyStream; - await socket.addStream(stream); - } - if (suffixBytes != null && suffixBytes.isNotEmpty) { - socket.add(suffixBytes); - } - await socket.flush(); - if (onProgress != null && progressTotal != null) { - onProgress(progressTotal, progressTotal); - } - if (shouldAbort?.call() ?? false) return null; - - if (autoForceAfter != null) { - final status = await _readResponse( - socket, - autoForceAfter: autoForceAfter, - overallTimeout: timeout, + final result = await _consume( + session.uploadFilePath( + url: uri.toString(), + path: file.path, + filename: _syntheticFilename(), + contentType: 'application/octet-stream', + connection: 'close', + ), + onProgress: onProgress, + ); + if (result.error != null || result.status != 200) { + logger.w( + 'uploadVideoWithToken: status=${result.status} error=${result.error}', ); - return (status, ''); + return (ok: false, token: null); } - return await _readFullResponse(socket, timeout: timeout); - } finally { - try { - socket.destroy(); - } catch (_) {} - } - } - - void _writeRequestHeaders( - Socket socket, - Uri uri, - String method, - Map headers, - ) { - final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; - final buffer = StringBuffer()..write('$method $path HTTP/1.1\r\n'); - for (final entry in headers.entries) { - buffer.write('${entry.key}: ${entry.value}\r\n'); - } - buffer.write('\r\n'); - socket.add(utf8.encode(buffer.toString())); - } - - Stream> _withProgress( - Stream> src, - int total, - void Function(int sent, int total) onProgress, { - Duration throttle = const Duration(milliseconds: 16), - }) { - final stopwatch = Stopwatch()..start(); - var sent = 0; - return src.map((chunk) { - sent += chunk.length; - if (stopwatch.elapsed >= throttle) { - onProgress(sent, total); - stopwatch.reset(); - } - return chunk; - }); - } - - String _contentTypeForFilename(String filename) { - final ext = filename.contains('.') - ? filename.split('.').last.toLowerCase() - : ''; - switch (ext) { - case 'png': - return 'image/png'; - case 'gif': - return 'image/gif'; - case 'webp': - return 'image/webp'; - case 'heic': - case 'heif': - return 'image/heic'; - case 'bmp': - return 'image/bmp'; - case 'jpg': - case 'jpeg': - default: - return 'image/jpeg'; - } - } - - Future<(int, String)?> _readFullResponse( - Socket socket, { - required Duration timeout, - }) { - final bytes = []; - final completer = Completer<(int, String)?>(); - Timer? timer; - StreamSubscription>? sub; - - void finishWith((int, String)? value) { - timer?.cancel(); - sub?.cancel(); - if (!completer.isCompleted) completer.complete(value); - } - - (int, String)? tryParse({required bool atClose}) { - final headerEnd = _findHeaderEnd(bytes); - if (headerEnd == -1) return null; - final headerStr = utf8.decode( - bytes.sublist(0, headerEnd), - allowMalformed: true, + final token = _parseVideoToken( + utf8.decode(result.body, allowMalformed: true), ); - final lines = headerStr.split('\r\n'); - final parts = lines.first.split(' '); - final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; - final headerLines = lines.skip(1); - final chunked = headerLines.any( - (l) => - l.toLowerCase().startsWith('transfer-encoding:') && - l.toLowerCase().contains('chunked'), - ); - int? contentLength; - for (final l in headerLines) { - if (l.toLowerCase().startsWith('content-length:')) { - contentLength = int.tryParse(l.split(':').last.trim()); + return (ok: true, token: token); + } catch (e) { + logger.w('uploadVideoWithToken: $e'); + return (ok: false, token: null); + } + } + + String? _parseVideoToken(String body) { + try { + final json = jsonDecode(body); + // Сервер отвечает массивом: [{"token":"..."}] + if (json is List) { + for (final v in json) { + if (v is Map) { + final token = v['token']; + if (token is String && token.isNotEmpty) return token; + } } } - final rawBody = utf8.decode( - bytes.sublist(headerEnd), - allowMalformed: true, - ); - if (chunked) { - if (!atClose && !rawBody.contains('\r\n0\r\n')) return null; - return (status, _decodeChunked(rawBody)); - } - if (contentLength != null && - !atClose && - bytes.length - headerEnd < contentLength) { - return null; - } - return (status, rawBody); - } - - sub = socket.listen( - (chunk) { - bytes.addAll(chunk); - final parsed = tryParse(atClose: false); - if (parsed != null) finishWith(parsed); - }, - onError: (e) { - logger.w('uploadImage: socket error after ${bytes.length} bytes: $e'); - finishWith(tryParse(atClose: true)); - }, - onDone: () { - final parsed = tryParse(atClose: true); - if (parsed == null) { - logger.w( - 'uploadImage: connection closed without HTTP response (${bytes.length} bytes)', - ); + if (json is Map) { + for (final key in const ['videos', 'video', 'photos']) { + final node = json[key]; + if (node is Map) { + for (final v in node.values) { + if (v is Map) { + final token = v['token']; + if (token is String && token.isNotEmpty) return token; + } + } + } } - finishWith(parsed); - }, - ); - timer = Timer(timeout, () { - logger.w('uploadImage: response timeout after ${bytes.length} bytes'); - finishWith(tryParse(atClose: true)); - }); - return completer.future; + for (final key in const ['token', 'videoToken', 'photoToken']) { + final t = json[key]; + if (t is String && t.isNotEmpty) return t; + } + } + } catch (e) { + logger.w('parseVideoToken: $e'); + } + return null; } - String _decodeChunked(String body) { - final out = StringBuffer(); - var i = 0; - while (i < body.length) { - final lineEnd = body.indexOf('\r\n', i); - if (lineEnd < 0) break; - final sizeStr = body.substring(i, lineEnd).split(';').first.trim(); - if (sizeStr.isEmpty) { - i = lineEnd + 2; - continue; - } - final size = int.tryParse(sizeStr, radix: 16); - if (size == null) break; - if (size == 0) break; - final dataStart = lineEnd + 2; - if (dataStart + size > body.length) break; - out.write(body.substring(dataStart, dataStart + size)); - i = dataStart + size; - if (i + 2 <= body.length && body.substring(i, i + 2) == '\r\n') { - i += 2; + /// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог. + Future<({int status, Uint8List body, String? error})> _consume( + Stream stream, { + void Function(int sent, int total)? onProgress, + }) async { + var status = 0; + var body = Uint8List(0); + String? error; + await for (final event in stream) { + switch (event) { + case kb.UploadEvent_Progress(:final sent, :final total): + onProgress?.call(sent.toInt(), total.toInt()); + case kb.UploadEvent_Done(status: final s, body: final b): + status = s; + body = b; + case kb.UploadEvent_Error(:final message): + error = message; } } - return out.toString(); + return (status: status, body: body, error: error); } + String _syntheticFilename() => + (DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString(); + String? _parsePhotoToken(String body) { try { final json = jsonDecode(body); @@ -701,79 +425,4 @@ class FileUploader { } return null; } - - Future _readResponse( - Socket socket, { - required Duration autoForceAfter, - required Duration overallTimeout, - }) { - final responseBytes = []; - final completer = Completer(); - Timer? force; - Timer? overall; - StreamSubscription>? sub; - - void finish(int code) { - if (completer.isCompleted) return; - force?.cancel(); - overall?.cancel(); - sub?.cancel(); - completer.complete(code); - } - - void fail(Object e) { - if (completer.isCompleted) return; - force?.cancel(); - overall?.cancel(); - sub?.cancel(); - completer.completeError(e); - } - - force = Timer(autoForceAfter, () => finish(0)); - - sub = socket.listen( - responseBytes.addAll, - onError: fail, - onDone: () { - final code = _parseHttpStatus(responseBytes); - if (code == null) { - fail(const SocketException('Не удалось прочитать заголовок ответа')); - } else { - finish(code); - } - }, - ); - - overall = Timer( - overallTimeout, - () => fail(TimeoutException('Тайм-аут загрузки')), - ); - - return completer.future; - } - - int? _parseHttpStatus(List bytes) { - final headerEnd = _findHeaderEnd(bytes); - if (headerEnd == -1) return null; - final headerStr = utf8.decode( - bytes.sublist(0, headerEnd), - allowMalformed: true, - ); - final statusLine = headerStr.split('\r\n').first; - final parts = statusLine.split(' '); - if (parts.length < 2) return null; - return int.tryParse(parts[1]); - } - - int _findHeaderEnd(List bytes) { - for (var i = 0; i < bytes.length - 3; i++) { - if (bytes[i] == 0x0D && - bytes[i + 1] == 0x0A && - bytes[i + 2] == 0x0D && - bytes[i + 3] == 0x0A) { - return i + 4; - } - } - return -1; - } } diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart index c179c75..74e6883 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -1,4 +1,8 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; import '../api.dart'; import '../models/chat_folder.dart'; @@ -6,11 +10,54 @@ import 'chats.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; + +class _FoldersSnapshot { + final List folders; + final List order; + final int folderSync; + + const _FoldersSnapshot({ + this.folders = const [], + this.order = const [], + this.folderSync = 0, + }); +} class FoldersModule { static const _syncKey = 'chat_folders_snapshot'; static const _listReadyKey = 'chat_folders_list_ready'; + static const String allChatsFolderId = 'all.chat.folder'; + static const int titleMaxLength = 20; + + static final ValueNotifier revision = ValueNotifier(0); + + static StreamSubscription? _pushSub; + static Future _pushQueue = Future.value(); + + static void attachGlobalPushHandlers(Api api) { + _pushSub?.cancel(); + _pushSub = api.pushStream + .where((p) => p.opcode == Opcode.notifFolders) + .listen(_enqueuePush); + } + + static void _enqueuePush(Packet packet) { + _pushQueue = _pushQueue + .then((_) => _handleFoldersPush(packet)) + .catchError((Object _) {}); + } + + static Future _handleFoldersPush(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + await applyPayload(accountId, payload.cast()); + await chats.applyFavorites(accountId); + } + static Future markFoldersListReady(int accountId) async { await AppDatabase.setSyncValue(accountId, _listReadyKey, '1'); } @@ -23,7 +70,7 @@ class FoldersModule { } static bool isAllChatsFolder(ChatFolder f) { - if (f.id == 'all.chat.folder') return true; + if (f.id == allChatsFolderId) return true; final t = f.title.trim().toLowerCase(); return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats'; } @@ -36,14 +83,21 @@ class FoldersModule { return folders.first.id; } - static void sortFoldersInPlace( - List folders, - List? foldersOrder, - ) { - if (foldersOrder == null || foldersOrder.isEmpty) return; + static String newFolderId() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; + } + + static void _sortInPlace(List folders, List order) { + if (order.isEmpty) return; final orderIndex = {}; - for (var i = 0; i < foldersOrder.length; i++) { - orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i); + for (var i = 0; i < order.length; i++) { + orderIndex.putIfAbsent(order[i], () => i); } folders.sort((a, b) { final aIndex = orderIndex[a.id] ?? -1; @@ -55,35 +109,58 @@ class FoldersModule { }); } - static const int filterUnread = 0; - static const int filterChannel = 2; - static const int filterGroup = 3; - static const int filterContact = 8; - static const int filterNotContact = 9; - static const int filterBot = 10; + static bool _matchesType( + int filter, + CachedChat chat, { + required int myId, + required Set contactIds, + }) { + final isDialog = chat.type == 'DIALOG'; + final isBot = isDialog && chat.options.contains('BOT'); + final peerId = isDialog ? chat.id ^ myId : null; + final isSelf = peerId != null && peerId == myId; + final isContact = + isDialog && !isSelf && peerId != null && contactIds.contains(peerId); - static int? _filterCode(dynamic raw) { - if (raw is int) return raw; - if (raw is String) { - final n = int.tryParse(raw); - if (n != null) return n; - switch (raw) { - case 'UNREAD': - return filterUnread; - case 'CHANNEL': - return filterChannel; - case 'GROUP': - case 'CHAT': - return filterGroup; - case 'CONTACT': - return filterContact; - case 'NOT_CONTACT': - return filterNotContact; - case 'BOT': - return filterBot; - } + switch (filter) { + case FolderFilter.channel: + return chat.type == 'CHANNEL'; + case FolderFilter.chat: + return chat.type == 'CHAT' || chat.type == 'GROUP'; + case FolderFilter.dialog: + return isDialog; + case FolderFilter.contact: + return isDialog && !isBot && isContact; + case FolderFilter.notContact: + return isDialog && !isBot && !isSelf && !isContact; + case FolderFilter.bot: + return isBot; } - return null; + return false; + } + + static bool _matchesRole(int filter, CachedChat chat, int myId) { + switch (filter) { + case FolderFilter.owner: + return chat.owner == myId; + case FolderFilter.admin: + return chat.owner == myId || chat.admins.contains(myId); + } + return false; + } + + static bool _matchesRestriction(int filter, CachedChat chat) { + switch (filter) { + case FolderFilter.unread: + return chat.unreadCount > 0; + case FolderFilter.read: + return chat.unreadCount == 0; + case FolderFilter.muted: + return chat.isMuted; + case FolderFilter.notMuted: + return !chat.isMuted; + } + return true; } static bool chatMatchesFolder( @@ -92,112 +169,167 @@ class FoldersModule { required int myId, required Set contactIds, }) { - if (folder.include != null && folder.include!.contains(chat.id)) { - return true; - } - if (folder.filters.isEmpty) return false; - - final isDialog = chat.type == 'DIALOG'; - final isBot = isDialog && chat.options.contains('BOT'); - final peerId = isDialog ? chat.id ^ myId : null; - final isSelf = peerId != null && peerId == myId; - final isContact = - isDialog && !isSelf && peerId != null && contactIds.contains(peerId); - - for (final raw in folder.filters) { - switch (_filterCode(raw)) { - case filterUnread: - if (chat.unreadCount > 0) return true; - case filterChannel: - if (chat.type == 'CHANNEL') return true; - case filterGroup: - if (chat.type == 'CHAT' || chat.type == 'GROUP') return true; - case filterContact: - if (isDialog && !isBot && isContact) return true; - case filterNotContact: - if (isDialog && !isBot && !isSelf && !isContact) return true; - case filterBot: - if (isBot) return true; - } - } - return false; - } - - static List _parseFolderList( - List json, { - bool lenient = true, - }) { - if (lenient) { - return json - .map((e) { - try { - final m = e is Map - ? e - : Map.from(e as Map); - return ChatFolder.fromJson(m); - } catch (_) { - return null; - } - }) - .whereType() + if (!folder.include.contains(chat.id)) { + final typeFilters = folder.filters + .where(FolderFilter.chatTypes.contains) .toList(); + if (typeFilters.isEmpty) return false; + final matchesType = typeFilters.any( + (f) => _matchesType(f, chat, myId: myId, contactIds: contactIds), + ); + if (!matchesType) return false; } - return json.map((e) { - final m = e is Map - ? e - : Map.from(e as Map); - return ChatFolder.fromJson(m); - }).toList(); + + final roleFilters = folder.filters.where(FolderFilter.roles.contains); + if (roleFilters.isNotEmpty && + !roleFilters.any((f) => _matchesRole(f, chat, myId))) { + return false; + } + + for (final f in folder.filters.where(FolderFilter.showOnly.contains)) { + if (!_matchesRestriction(f, chat)) return false; + } + return true; } - static Future> loadFolders(int accountId) async { + static List _parseFolderList(dynamic raw) { + if (raw is! List) return []; + return raw + .map((e) { + try { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + } + + static List? _parseOrder(dynamic raw) { + if (raw is! List) return null; + return raw.map((e) => e.toString()).toList(); + } + + static int? _parseSync(dynamic raw) => raw is int ? raw : null; + + static Future<_FoldersSnapshot> _loadSnapshot(int accountId) async { final raw = await AppDatabase.getSyncValue(accountId, _syncKey); - if (raw == null || raw.isEmpty) return []; + if (raw == null || raw.isEmpty) return const _FoldersSnapshot(); try { final map = jsonDecode(raw) as Map; - final foldersJson = map['folders'] as List?; - final folders = foldersJson == null - ? [] - : _parseFolderList(foldersJson, lenient: false); - final order = map['foldersOrder'] as List?; - sortFoldersInPlace(folders, order); - return folders; + final folders = _parseFolderList(map['folders']); + final order = _parseOrder(map['foldersOrder']) ?? const []; + _sortInPlace(folders, order); + return _FoldersSnapshot( + folders: folders, + order: order, + folderSync: _parseSync(map['folderSync']) ?? 0, + ); } catch (_) { - return []; + return const _FoldersSnapshot(); } } - static Future _persist( + static Future _saveSnapshot( int accountId, - List folders, - List? order, + _FoldersSnapshot snapshot, ) async { + final known = snapshot.folders.map((f) => f.id).toSet(); + final ordered = snapshot.order.where(known.contains).toList(); + final orderedSet = ordered.toSet(); + final order = [ + ...ordered, + ...known.where((id) => !orderedSet.contains(id)), + ]; await AppDatabase.setSyncValue( accountId, _syncKey, jsonEncode({ - 'folders': folders.map((f) => f.toJson()).toList(), + 'folders': snapshot.folders.map((f) => f.toJson()).toList(), 'foldersOrder': order, + 'folderSync': snapshot.folderSync, }), ); + revision.value++; + } + + static Future> loadFolders(int accountId) async { + return (await _loadSnapshot(accountId)).folders; + } + + static Future> loadFoldersOrder(int accountId) async { + return (await _loadSnapshot(accountId)).order; + } + + static Future loadFolderSync(int accountId) async { + return (await _loadSnapshot(accountId)).folderSync; } static Future applyPayload( int accountId, - Map payload, - ) async { - final foldersJson = payload['folders'] as List?; - final order = payload['foldersOrder'] as List?; - if (foldersJson == null && order == null) return; - - List folders; - if (foldersJson != null) { - folders = _parseFolderList(foldersJson); - } else { - folders = await loadFolders(accountId); + Map payload, { + bool replace = false, + }) async { + final foldersRaw = payload['folders']; + final folderRaw = payload['folder']; + final orderRaw = payload['foldersOrder']; + final syncRaw = payload['folderSync']; + if (foldersRaw == null && + folderRaw == null && + orderRaw == null && + syncRaw == null) { + return; } - sortFoldersInPlace(folders, order); - await _persist(accountId, folders, order); + + final incoming = [ + ..._parseFolderList(foldersRaw), + if (folderRaw is Map) + ChatFolder.fromJson(Map.from(folderRaw)), + ]; + + final current = await _loadSnapshot(accountId); + var folders = replace && foldersRaw is List + ? List.from(incoming) + : _merge(current.folders, incoming); + + final order = _parseOrder(orderRaw) ?? current.order; + if (orderRaw is List && order.isNotEmpty) { + final known = order.toSet(); + final fresh = incoming.map((f) => f.id).toSet(); + folders = folders + .where((f) => known.contains(f.id) || fresh.contains(f.id)) + .toList(); + } + + _sortInPlace(folders, order); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: folders, + order: order, + folderSync: _parseSync(syncRaw) ?? current.folderSync, + ), + ); + } + + static List _merge( + List current, + List incoming, + ) { + final merged = List.from(current); + for (final folder in incoming) { + final idx = merged.indexWhere((f) => f.id == folder.id); + if (idx >= 0) { + merged[idx] = folder; + } else { + merged.add(folder); + } + } + return merged; } static Future applyFromLoginConfig( @@ -206,57 +338,180 @@ class FoldersModule { ) async { final chatFolders = config['chatFolders']; if (chatFolders is! Map) return; - final foldersJson = chatFolders['FOLDERS'] as List?; - if (foldersJson == null) return; - final order = chatFolders['foldersOrder'] as List?; - final folders = _parseFolderList(foldersJson); - sortFoldersInPlace(folders, order); - await _persist(accountId, folders, order); + if (chatFolders['FOLDERS'] == null) return; + await applyPayload(accountId, { + 'folders': chatFolders['FOLDERS'], + 'foldersOrder': chatFolders['foldersOrder'], + 'folderSync': chatFolders['folderSync'], + }, replace: true); await markFoldersListReady(accountId); } - static Future setFolderFavorites( + static Future createFolder( + Api api, + int accountId, { + required String title, + List include = const [], + List filters = const [], + List options = const [], + List favorites = const [], + }) { + return _sendUpdate( + api, + accountId, + id: newFolderId(), + title: title, + include: include, + filters: filters, + options: options, + favorites: favorites, + ); + } + + static Future updateFolder( + Api api, + int accountId, + ChatFolder folder, { + String? title, + List? include, + List? filters, + List? options, + List? favorites, + }) { + return _sendUpdate( + api, + accountId, + id: folder.id, + title: title ?? folder.title, + include: include ?? folder.include, + filters: filters ?? folder.filters, + options: options ?? folder.options, + favorites: favorites ?? folder.favorites, + ); + } + + static Future _sendUpdate( + Api api, + int accountId, { + required String id, + required String title, + required List include, + required List filters, + required List options, + required List favorites, + }) async { + final packet = await api.sendRequest(Opcode.foldersUpdate, { + 'id': id, + 'title': title.trim(), + 'include': include, + 'filters': filters, + 'options': options, + 'favorites': favorites, + }); + throwIfPacketError(packet); + + final data = packet.payload; + final folderJson = data is Map ? data['folder'] : null; + if (folderJson is! Map) { + throw StateError('FOLDERS_UPDATE: сервер не вернул папку'); + } + await applyPayload(accountId, data.cast()); + return ChatFolder.fromJson(Map.from(folderJson)); + } + + static Future setFolderFavorites( Api api, int accountId, ChatFolder folder, List favorites, + ) { + return updateFolder(api, accountId, folder, favorites: favorites); + } + + static Future deleteFolders( + Api api, + int accountId, + List folderIds, ) async { - final packet = await api.sendRequest(Opcode.foldersUpdate, { - 'id': folder.id, - 'title': folder.title, - 'include': folder.include ?? const [], - 'favorites': favorites, - 'filters': folder.filters, - 'options': folder.options ?? const [], + if (folderIds.isEmpty) return; + final packet = await api.sendRequest(Opcode.foldersDelete, { + 'folderIds': folderIds, }); throwIfPacketError(packet); - final data = packet.payload; - if (data is! Map) return null; - final folderJson = data['folder']; - if (folderJson is! Map) return null; - final updated = ChatFolder.fromJson( - folderJson is Map - ? folderJson - : Map.from(folderJson), + + final removed = folderIds.toSet(); + final current = await _loadSnapshot(accountId); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: current.folders.where((f) => !removed.contains(f.id)).toList(), + order: current.order, + folderSync: current.folderSync, + ), ); - final currentRaw = await AppDatabase.getSyncValue(accountId, _syncKey); - final snapshot = (currentRaw != null && currentRaw.isNotEmpty) - ? jsonDecode(currentRaw) as Map - : {}; - final existingRaw = snapshot['folders'] as List?; - final existing = existingRaw == null - ? [] - : _parseFolderList(existingRaw, lenient: false); - final idx = existing.indexWhere((f) => f.id == updated.id); - if (idx >= 0) { - existing[idx] = updated; - } else { - existing.add(updated); + final data = packet.payload; + if (data is Map) { + await applyPayload(accountId, data.cast()); } - final order = snapshot['foldersOrder'] as List?; - await _persist(accountId, existing, order); - return updated; + } + + static Future reorderFolders( + Api api, + int accountId, + List order, + ) async { + if (order.isEmpty) return; + final packet = await api.sendRequest(Opcode.foldersReorder, { + 'foldersOrder': order, + }); + throwIfPacketError(packet); + + final current = await _loadSnapshot(accountId); + final folders = List.from(current.folders); + _sortInPlace(folders, order); + final data = packet.payload; + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: folders, + order: order, + folderSync: + (data is Map ? _parseSync(data['folderSync']) : null) ?? + current.folderSync, + ), + ); + } + + static Future> fetchFoldersByIds( + Api api, + int accountId, + List folderIds, + ) async { + if (folderIds.isEmpty) return const []; + final packet = await api.sendRequest(Opcode.foldersGetById, { + 'folderIds': folderIds, + }); + throwIfPacketError(packet); + + final data = packet.payload; + if (data is! Map) return const []; + final folders = _parseFolderList(data['folders']); + + final missing = folderIds.toSet()..removeAll(folders.map((f) => f.id)); + final current = await _loadSnapshot(accountId); + await _saveSnapshot( + accountId, + _FoldersSnapshot( + folders: _merge( + current.folders.where((f) => !missing.contains(f.id)).toList(), + folders, + ), + order: current.order, + folderSync: _parseSync(data['folderSync']) ?? current.folderSync, + ), + ); + return folders; } static Future syncFromServer(Api api, int accountId) async { @@ -267,7 +522,11 @@ class FoldersModule { throwIfPacketError(packet); final data = packet.payload; if (data is Map) { - await applyPayload(accountId, data.cast()); + await applyPayload( + accountId, + data.cast(), + replace: true, + ); } } finally { await markFoldersListReady(accountId); diff --git a/lib/backend/modules/links.dart b/lib/backend/modules/links.dart index 4edfe9b..cb3ed56 100644 --- a/lib/backend/modules/links.dart +++ b/lib/backend/modules/links.dart @@ -31,7 +31,9 @@ abstract class LinkModule { static Future resolve(Api api, String url) async { final Packet response; try { - response = await api.sendRequest(Opcode.linkInfo, {'link': url}); + response = await api.sendRequest(Opcode.linkInfo, { + 'link': url, + }, silent: true); } on TimeoutException { return const ResolvedLinkError('Превышено время ожидания'); } on PacketError catch (e) { diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 6ef434b..14c01d0 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../api.dart'; import '../../core/config/komet_settings.dart'; +import '../../core/contacts/device_contacts_service.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; @@ -17,6 +18,7 @@ class ContactCache { static final Map _nameCache = {}; static final Map _avatarCache = {}; static final Map> _optionsCache = {}; + static final Map _phoneCache = {}; static const _prefsKey = 'contact_cache_v1'; static Timer? _saveTimer; @@ -61,16 +63,37 @@ class ContactCache { _scheduleSave(); } - static String? get(int id) => _nameCache[id]; + static void putPhone(int id, int phone) { + if (phone > 0) _phoneCache[id] = phone; + } + + static String? get(int id) { + final phone = _phoneCache[id]; + if (phone != null) { + final book = DeviceContactsService.nameForPhone(phone); + if (book != null && book.isNotEmpty) return book; + } + return _nameCache[id]; + } + static String? getAvatar(int id) => _avatarCache[id]; static Set? getOptions(int id) => _optionsCache[id]; static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; + static void remove(int id) { + _nameCache.remove(id); + _avatarCache.remove(id); + _optionsCache.remove(id); + _phoneCache.remove(id); + _scheduleSave(); + } + static void clear() { _nameCache.clear(); _avatarCache.clear(); _optionsCache.clear(); + _phoneCache.clear(); _saveTimer?.cancel(); _saveTimer = null; unawaited(_wipePersisted()); @@ -126,16 +149,92 @@ class TranscriptionResult { class TranscriptionCache { static final Map _cache = {}; + static final Map> _listeners = {}; + static final Set _expanded = {}; - static void put(String messageId, TranscriptionResult result) { + static void put( + String messageId, + TranscriptionResult result, { + bool expanded = false, + }) { _cache[messageId] = result; + if (expanded) _expanded.add(messageId); + final listeners = _listeners[messageId]; + if (listeners == null) return; + for (final listener in listeners.toList()) { + listener(); + } } static TranscriptionResult? get(String messageId) => _cache[messageId]; static bool has(String messageId) => _cache.containsKey(messageId); - static void clear() => _cache.clear(); + static bool isExpanded(String messageId) => _expanded.contains(messageId); + + static void setExpanded(String messageId, bool value) { + if (value) { + _expanded.add(messageId); + } else { + _expanded.remove(messageId); + } + } + + static void listen(String messageId, VoidCallback listener) { + _listeners.putIfAbsent(messageId, () => {}).add(listener); + } + + static void unlisten(String messageId, VoidCallback listener) { + final listeners = _listeners[messageId]; + if (listeners == null) return; + listeners.remove(listener); + if (listeners.isEmpty) _listeners.remove(messageId); + } + + static void clear() { + _cache.clear(); + _expanded.clear(); + } +} + +class TranscriptionPushHandler { + static StreamSubscription? _sub; + + static void attach(Api api) { + _sub?.cancel(); + _sub = api.pushStream + .where((p) => p.opcode == Opcode.transcriptionResult) + .listen(_onPush); + } + + static void _onPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final source = payload['message'] is Map + ? payload['message'] as Map + : payload; + + final messageId = (source['messageId'] ?? source['msgId'])?.toString(); + if (messageId == null || messageId.isEmpty) return; + + final status = source['transcriptionStatus'] as int? ?? 1; + final rawText = source['transcription'] as String?; + if (status != 1) return; + + TranscriptionCache.put( + messageId, + TranscriptionResult( + status: 1, + text: (rawText == null || rawText.isEmpty) + ? 'не удалось распознать текст' + : rawText, + messageId: messageId, + chatId: source['chatId'] as int?, + mediaId: source['mediaId'] as int?, + ), + expanded: true, + ); + } } class FileHistoryEntry { @@ -274,6 +373,8 @@ class ReplyInfo { this.attachments, }); + bool get missing => previewText().isEmpty; + static ReplyInfo? fromPayload(Map? payload) { if (payload == null) return null; final link = payload['link']; @@ -389,6 +490,30 @@ class CachedMessage { this.editHistory, }); + ControlAttachment? get controlAttachment => + attachments?.whereType().firstOrNull; + + ForwardedMessageAttachment? get forwardedAttachment => + attachments?.whereType().firstOrNull; + + String? get selectableText { + final own = text; + if (own != null && own.isNotEmpty) return own; + final forwarded = forwardedAttachment?.originalText; + if (forwarded != null && forwarded.isNotEmpty) return forwarded; + return null; + } + + String? get botStartPayload { + final control = controlAttachment; + if (control == null || !control.isBotStart) return null; + final payload = (control.startPayload ?? text)?.trim(); + return (payload == null || payload.isEmpty) ? null : payload; + } + + bool get isSilentBotStart => + (controlAttachment?.isBotStart ?? false) && botStartPayload == null; + CachedMessage copyWith({ String? status, bool? deleted, @@ -742,6 +867,7 @@ class MessagesModule { bool notify = true, int? scheduledTime, int? replyToMessageId, + int? replySourceChatId, List> elements = const [], }) async { final message = { @@ -753,7 +879,7 @@ class MessagesModule { if (replyToMessageId != null) { message['link'] = { 'type': 'REPLY', - 'chatId': chatId, + 'chatId': replySourceChatId ?? chatId, 'messageId': replyToMessageId, }; } @@ -768,6 +894,43 @@ class MessagesModule { return _sendAndExtractMessageId(payload, 'Ошибка отправки'); } + Future sendControlMessage( + int chatId, + Map control, { + bool notify = true, + }) { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'text': '', + 'attaches': [control], + }, + 'notify': notify, + }; + return _api.sendRequest(Opcode.msgSend, payload); + } + + Future?> sendBotStart( + int chatId, + String startPayload, + ) async { + final response = await _api.sendRequest(Opcode.msgSend, { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + { + '_type': 'CONTROL', + 'event': ControlAttachment.botStartedEvent, + 'startPayload': startPayload, + }, + ], + }, + }); + return _sentMessageMap(response); + } + Future _sendAndExtractMessageId( Map payload, String defaultError, @@ -862,23 +1025,41 @@ class MessagesModule { required String tempId, required int time, required String status, + String? sourceChatName, + String? sourceChatIconUrl, + String? sourceChatType, }) { final srcPayload = source.payload; final srcLink = srcPayload?['link']; + final isForwardedSource = + srcLink is Map && + srcLink['type']?.toString().toUpperCase() == 'FORWARD' && + srcLink['message'] is Map; Map originalMsg; - if (srcLink is Map && - srcLink['type'] == 'FORWARD' && - srcLink['message'] is Map) { + if (isForwardedSource) { originalMsg = Map.from(srcLink['message'] as Map); } else { + final originalType = srcPayload?['type']?.toString() ?? sourceChatType; originalMsg = { 'id': int.tryParse(source.id) ?? source.id, + 'type': ?originalType, 'sender': source.senderId, 'time': source.time, 'text': source.text, 'attaches': (srcPayload?['attaches'] as List?) ?? const [], + 'elements': (srcPayload?['elements'] as List?) ?? const [], }; } + final isChannelSource = + originalMsg['type']?.toString().toUpperCase() == 'CHANNEL'; + final rawChannelName = isForwardedSource + ? srcLink['chatName'] + : sourceChatName; + final rawChannelIconUrl = isForwardedSource + ? srcLink['chatIconUrl'] + : sourceChatIconUrl; + final channelName = rawChannelName?.toString().trim(); + final channelIconUrl = rawChannelIconUrl?.toString().trim(); final payload = { 'elements': const [], 'attaches': const [], @@ -887,6 +1068,12 @@ class MessagesModule { 'chatId': sourceChatId, 'messageId': int.tryParse(source.id) ?? source.id, 'message': originalMsg, + if (isChannelSource && channelName != null && channelName.isNotEmpty) + 'chatName': channelName, + if (isChannelSource && + channelIconUrl != null && + channelIconUrl.isNotEmpty) + 'chatIconUrl': channelIconUrl, }, }; return CachedMessage( @@ -1085,6 +1272,42 @@ class MessagesModule { return _applyReactionResponse(chatId, messageId, response); } + Future> getDetailedReactions( + int chatId, + String messageId, { + int count = 100, + }) async { + final id = int.tryParse(messageId); + if (id == null) return const {}; + if (_api.state != SessionState.online) return const {}; + try { + final response = await _api.sendRequest(Opcode.msgGetDetailedReactions, { + 'chatId': chatId, + 'messageId': id, + 'count': count, + }); + if (!response.isOk) return const {}; + final payload = response.payload; + if (payload is! Map) return const {}; + return _parseDetailedReactions(payload['reactions']); + } catch (e) { + logger.e('getDetailedReactions error: $e'); + return const {}; + } + } + + static Map _parseDetailedReactions(dynamic raw) { + if (raw is! List) return const {}; + final result = {}; + for (final entry in raw.whereType()) { + final userId = entry['userId']; + final reaction = entry['reaction']; + if (userId is! int || reaction is! String || reaction.isEmpty) continue; + result[userId] = reaction; + } + return result; + } + Future<({bool ok, Map? info})> _applyReactionResponse( int chatId, String messageId, @@ -1132,7 +1355,11 @@ class MessagesModule { ) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; - final existing = await AppDatabase.loadMessage(accountId, chatId, messageId); + final existing = await AppDatabase.loadMessage( + accountId, + chatId, + messageId, + ); if (existing == null) return; Map payloadMap; @@ -1233,7 +1460,10 @@ class MessagesModule { ); } - Future sendFileMessage( + /// Returns the server-assigned message id, or null on failure. The id is + /// required to later resolve a download URL — a message still carrying its + /// local temp id resolves to messageId 0 and the server rejects it. + Future sendFileMessage( int chatId, int fileId, { String? token, @@ -1262,17 +1492,20 @@ class MessagesModule { } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; - return _sendWithNotReadyRetry( + return _sendWithNotReadyRetry( payload: payload, maxAttempts: maxAttempts, retryDelay: retryDelay, - onResult: (response) => response.isOk, - onExhausted: false, + onResult: (response) => _sentMessageMap(response)?['id']?.toString(), + onExhausted: null, ); } - Future requestPhotoUploadUrl() async { - final response = await _api.sendRequest(Opcode.photoUpload, {'count': 1}); + Future requestPhotoUploadUrl({int? type}) async { + final response = await _api.sendRequest(Opcode.photoUpload, { + 'type': ?type, + 'count': 1, + }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; @@ -1313,10 +1546,10 @@ class MessagesModule { ); } - Future requestVideoUploadUrl() async { + Future requestVideoUploadUrl({int type = 0}) async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 0, - 'type': 0, + 'type': type, 'count': 1, }); if (!response.isOk) return null; @@ -1526,6 +1759,26 @@ class MessagesModule { return _sentMessageMap(response); } + Future?> sendContactMessage( + int chatId, + int contactId, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + {'_type': 'CONTACT', 'contactId': contactId}, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return _sentMessageMap(response); + } + static const int _pollAnonymousFlag = 4; static const int _pollMultipleFlag = 1; @@ -1736,9 +1989,10 @@ class MessagesModule { }) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { - 'messageId': int.tryParse(messageId) ?? 0, - 'chatId': chatId, 'fileId': fileId, + 'chatId': chatId, + 'messageId': int.tryParse(messageId) ?? 0, + 'itemType': 'REGULAR', }); if (!response.isOk) return null; diff --git a/lib/backend/modules/outbox.dart b/lib/backend/modules/outbox.dart index ea2712e..64ff389 100644 --- a/lib/backend/modules/outbox.dart +++ b/lib/backend/modules/outbox.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; @@ -47,6 +48,7 @@ class OutboxService { final payload = pending.payload; final replyToMessageId = _replyIdFromPayload(payload); + final replySourceChatId = _replySourceChatIdFromPayload(payload); final elements = _elementsFromPayload(payload); try { @@ -55,6 +57,7 @@ class OutboxService { pending.chatId, text, replyToMessageId: replyToMessageId, + replySourceChatId: replySourceChatId, elements: elements, ); final sent = CachedMessage( @@ -86,8 +89,23 @@ class OutboxService { elements: elements.isEmpty ? null : elements, ); } catch (e) { - logger.w('Outbox: отправка ${pending.id} не удалась: $e'); - continue; + if (!isPermanentSendFailure(e)) { + logger.w('Outbox: отправка ${pending.id} не удалась: $e'); + continue; + } + logger.w('Outbox: ${pending.id} отклонено сервером: $e'); + final failed = pending.copyWith(status: 'error'); + await AppDatabase.saveMessages([failed.toDbRow()]); + chats.emitMessageSent(pending.chatId, pending.id, failed); + await chats.applyOutgoing( + accountId, + pending.chatId, + messageId: failed.id, + time: failed.time, + text: text, + status: 'error', + elements: elements.isEmpty ? null : elements, + ); } } } catch (e) { @@ -114,6 +132,17 @@ class OutboxService { return null; } + int? _replySourceChatIdFromPayload(Map? payload) { + if (payload == null) return null; + final link = payload['link']; + if (link is! Map) return null; + if ((link['type'] as String?)?.toUpperCase() != 'REPLY') return null; + final chatId = link['chatId']; + if (chatId is int) return chatId; + if (chatId != null) return int.tryParse(chatId.toString()); + return null; + } + List> _elementsFromPayload( Map? payload, ) { diff --git a/lib/backend/modules/share_sender.dart b/lib/backend/modules/share_sender.dart new file mode 100644 index 0000000..e9a1f56 --- /dev/null +++ b/lib/backend/modules/share_sender.dart @@ -0,0 +1,547 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../core/media/gallery_source.dart'; +import '../../core/media/share_thumbnail.dart'; +import '../../core/media/video_transcoder.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../models/attachment.dart'; +import '../../models/chat_preview_media.dart'; +import '../../models/shared_payload.dart'; +import '../../main.dart'; +import 'chats.dart'; +import 'messages.dart'; +import 'upload_service.dart'; + +class PreparedShareFile { + final SharedFile source; + final String? thumbDataUri; + final int? width; + final int? height; + final int? durationMs; + + const PreparedShareFile({ + required this.source, + this.thumbDataUri, + this.width, + this.height, + this.durationMs, + }); + + SharedFileKind get kind => source.kind; + File get file => source.file; +} + +class PreparedShare { + final List files; + final String? text; + + const PreparedShare({required this.files, this.text}); + + List get photos => + files.where((f) => f.kind == SharedFileKind.photo).toList(); + + List get videos => + files.where((f) => f.kind == SharedFileKind.video).toList(); + + List get documents => + files.where((f) => f.kind == SharedFileKind.file).toList(); + + bool get isTextOnly => files.isEmpty; + + static Future prepare(SharedPayload payload) async { + final prepared = []; + for (final source in payload.files) { + prepared.add(await _prepareOne(source)); + } + return PreparedShare(files: prepared, text: payload.text); + } + + static Future _prepareOne(SharedFile source) async { + final thumb = await sharedThumbnailDataUri(source); + switch (source.kind) { + case SharedFileKind.photo: + final dim = await imageFileDimensions(source.file); + return PreparedShareFile( + source: source, + thumbDataUri: thumb, + width: dim?.$1, + height: dim?.$2, + ); + case SharedFileKind.video: + VideoInfo? info; + try { + info = await VideoTranscoder.probe(source.path); + } catch (e) { + logger.w('Поделиться: probe ${source.path}: $e'); + } + return PreparedShareFile( + source: source, + thumbDataUri: thumb, + width: (info?.width ?? 0) > 0 ? info!.width : null, + height: (info?.height ?? 0) > 0 ? info!.height : null, + durationMs: (info?.durationMs ?? 0) > 0 ? info!.durationMs : null, + ); + case SharedFileKind.file: + return PreparedShareFile(source: source); + } + } +} + +class ShareSendResult { + final int chatCount; + final int messageCount; + + const ShareSendResult({required this.chatCount, required this.messageCount}); +} + +class ShareSender { + ShareSender._(); + + static final Map< + String, + ({int accountId, int chatId, String text, String? preview, int time}) + > + _tracked = {}; + + static StreamSubscription? _sub; + + static void _track( + String tempId, { + required int accountId, + required int chatId, + required String text, + required String? preview, + required int time, + }) { + _sub ??= UploadService.instance.events.listen(_onUploadEvent); + _tracked[tempId] = ( + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: time, + ); + } + + static void _onUploadEvent(UploadJobEvent event) { + final entry = _tracked.remove(event.tempId); + if (entry == null) return; + final status = event is UploadJobDone ? 'sent' : 'error'; + final messageId = event is UploadJobDone + ? (event.message?.id ?? event.tempId) + : event.tempId; + unawaited( + chats + .applyOutgoing( + entry.accountId, + entry.chatId, + messageId: messageId, + time: entry.time, + text: entry.text, + status: status, + preview: entry.preview, + ) + .catchError((Object e) { + logger.w('Поделиться: не обновить превью чата ${entry.chatId}: $e'); + }), + ); + } + + static Future send({ + required int accountId, + required List chatIds, + required PreparedShare share, + required String caption, + }) async { + var messages = 0; + for (final chatId in chatIds) { + messages += await _sendToChat( + accountId: accountId, + chatId: chatId, + share: share, + caption: caption, + ); + } + logger.i( + 'Поделиться: отправлено $messages сообщений в ${chatIds.length} чатов', + ); + return ShareSendResult(chatCount: chatIds.length, messageCount: messages); + } + + static Future _sendToChat({ + required int accountId, + required int chatId, + required PreparedShare share, + required String caption, + }) async { + if (share.isTextOnly) { + final text = caption.trim().isNotEmpty + ? caption + : (share.text ?? '').trim(); + if (text.isEmpty) return 0; + await _sendText(accountId: accountId, chatId: chatId, text: text); + return 1; + } + + final photos = share.photos; + final videos = share.videos; + final documents = share.documents; + + var used = false; + String take() { + if (used || caption.isEmpty) return ''; + used = true; + return caption; + } + + var count = 0; + if (photos.isNotEmpty) { + await _sendPhotos( + accountId: accountId, + chatId: chatId, + photos: photos, + caption: take(), + ); + count++; + } + for (final video in videos) { + await _sendVideo( + accountId: accountId, + chatId: chatId, + video: video, + caption: take(), + ); + count++; + } + for (final document in documents) { + await _sendDocument( + accountId: accountId, + chatId: chatId, + document: document, + caption: take(), + ); + count++; + } + return count; + } + + static Future _sendText({ + required int accountId, + required int chatId, + required String text, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: text, + time: now, + status: 'sending', + ); + await _persist(placeholder); + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: null, + status: 'sending', + ); + + String realId = tempId; + var status = 'sent'; + try { + final sent = await messagesModule.sendMessage(accountId, chatId, text); + if (sent.isNotEmpty) realId = sent; + } catch (e) { + logger.w('Поделиться: текст в $chatId не ушёл: $e'); + status = 'error'; + } + final settled = CachedMessage( + id: realId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: text, + time: now, + status: status, + ); + await _persist(settled, removeId: realId == tempId ? null : tempId); + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: realId, + time: now, + text: text, + preview: null, + status: status, + ); + } + + static Future _sendPhotos({ + required int accountId, + required int chatId, + required List photos, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + + final jobs = <({File file, GalleryItem? item})>[]; + final attachments = []; + for (final photo in photos) { + jobs.add((file: photo.file, item: null)); + attachments.add( + PhotoAttachment( + localPath: photo.file.path, + previewData: photo.thumbDataUri, + width: photo.width, + height: photo.height, + ), + ); + } + if (jobs.isEmpty) return; + + final label = photos.length > 1 ? 'Изображения' : 'Изображение'; + final preview = _preview( + kind: ChatPreviewKind.photo, + files: photos, + label: caption.isEmpty ? label : null, + ); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: attachments, + ); + + await _persist(placeholder); + final text = caption.isEmpty ? label : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + UploadService.instance.sendPhotos( + accountId: accountId, + chatId: chatId, + tempId: tempId, + jobs: jobs, + caption: caption, + placeholder: placeholder, + ), + ); + } + + static Future _sendVideo({ + required int accountId, + required int chatId, + required PreparedShareFile video, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + + final preview = _preview( + kind: ChatPreviewKind.video, + files: [video], + label: caption.isEmpty ? 'Видео' : null, + ); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: [ + VideoAttachment( + localPath: video.file.path, + previewData: video.thumbDataUri, + width: video.width, + height: video.height, + duration: video.durationMs, + ), + ], + ); + + await _persist(placeholder); + final text = caption.isEmpty ? 'Видео' : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + UploadService.instance.sendVideo( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: video.file, + caption: caption, + placeholder: placeholder, + ), + ); + } + + static Future _sendDocument({ + required int accountId, + required int chatId, + required PreparedShareFile document, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + final name = document.source.name; + final size = document.source.size; + + final preview = ChatPreviewMedia( + kind: ChatPreviewKind.file, + label: caption.isEmpty ? 'Файл' : null, + detail: caption.isEmpty ? name : null, + ).encode(); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: [FileAttachment(name: name, size: size)], + ); + + await _persist(placeholder); + final text = caption.isEmpty ? 'Файл: $name' : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + UploadService.instance.sendFile( + accountId: accountId, + chatId: chatId, + tempId: tempId, + source: document.file, + filename: name, + size: size, + placeholder: placeholder, + ), + ); + } + + static String? _preview({ + required ChatPreviewKind kind, + required List files, + String? label, + }) { + final thumbs = []; + for (final file in files) { + final data = file.thumbDataUri; + if (data == null) continue; + thumbs.add( + ChatPreviewThumb( + source: data, + video: file.kind == SharedFileKind.video, + ), + ); + if (thumbs.length >= 3) break; + } + if (thumbs.isEmpty && label == null) return null; + return ChatPreviewMedia(kind: kind, thumbs: thumbs, label: label).encode(); + } + + static Future _persist( + CachedMessage message, { + String? removeId, + }) async { + try { + if (removeId != null && removeId != message.id) { + await AppDatabase.deleteMessage( + message.accountId, + message.chatId, + removeId, + ); + } + await AppDatabase.saveMessages([message.toDbRow()]); + } catch (e) { + logger.w('Поделиться: не сохранить плейсхолдер: $e'); + } + } + + static Future _bumpChat({ + required int accountId, + required int chatId, + required String messageId, + required int time, + required String text, + required String? preview, + required String status, + }) async { + try { + await chats.applyOutgoing( + accountId, + chatId, + messageId: messageId, + time: time, + text: text, + status: status, + preview: preview, + ); + } catch (e) { + logger.w('Поделиться: не обновить строку чата $chatId: $e'); + } + } +} diff --git a/lib/backend/modules/shared_content.dart b/lib/backend/modules/shared_content.dart index d33c7a6..809921a 100644 --- a/lib/backend/modules/shared_content.dart +++ b/lib/backend/modules/shared_content.dart @@ -17,6 +17,7 @@ class SharedMediaItem { final int senderId; final int time; final MessageAttachment attachment; + final String? text; const SharedMediaItem({ required this.messageId, @@ -24,6 +25,7 @@ class SharedMediaItem { required this.senderId, required this.time, required this.attachment, + this.text, }); String get dedupKey { @@ -93,11 +95,144 @@ class CommonChatEntry { } } +class ChatMediaFeed { + final List items; + final int total; + final bool reachedEnd; + + const ChatMediaFeed({ + required this.items, + required this.total, + required this.reachedEnd, + }); +} + +class _ChatMediaIndex { + final List items = []; + final Set seen = {}; + int total = 0; + bool reachedEnd = false; + bool started = false; + Future? inFlight; +} + +String mediaDedupKey(String messageId, MessageAttachment attachment) { + if (attachment is PhotoAttachment) { + return '$messageId:p${attachment.photoId ?? attachment.baseUrl}'; + } + if (attachment is VideoAttachment) { + return '$messageId:v${attachment.videoId ?? attachment.baseUrl}'; + } + return '$messageId:${attachment.hashCode}'; +} + class SharedContentModule { + static const int _mediaIndexPageSize = 60; + static const int _mediaIndexMaxPages = 40; + + static final Map _mediaIndexes = {}; + final Api _api; SharedContentModule(this._api); + static void clearMediaIndex() => _mediaIndexes.clear(); + + Future mediaFeedFor({ + required int chatId, + required String mediaKey, + required Future Function() resolveAnchor, + }) async { + final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new); + + for (var page = 0; page < _mediaIndexMaxPages; page++) { + if (index.seen.contains(mediaKey)) return _snapshot(index); + if (index.reachedEnd) return null; + await _nextMediaPage(chatId, index, resolveAnchor); + } + return null; + } + + Future loadMoreMedia({ + required int chatId, + required Future Function() resolveAnchor, + }) async { + final index = _mediaIndexes.putIfAbsent(chatId, _ChatMediaIndex.new); + if (!index.reachedEnd) { + await _nextMediaPage(chatId, index, resolveAnchor); + } + return _snapshot(index); + } + + ChatMediaFeed _snapshot(_ChatMediaIndex index) { + final counted = index.items.length; + final total = index.reachedEnd + ? counted + : (index.total > counted ? index.total : counted); + return ChatMediaFeed( + items: List.unmodifiable(index.items), + total: total, + reachedEnd: index.reachedEnd, + ); + } + + Future _nextMediaPage( + int chatId, + _ChatMediaIndex index, + Future Function() resolveAnchor, + ) async { + final pending = index.inFlight; + if (pending != null) { + await pending; + return; + } + final task = _loadMediaPage(chatId, index, resolveAnchor); + index.inFlight = task; + try { + await task; + } finally { + index.inFlight = null; + } + } + + Future _loadMediaPage( + int chatId, + _ChatMediaIndex index, + Future Function() resolveAnchor, + ) async { + final initial = !index.started; + final anchor = initial ? await resolveAnchor() : index.items.last.messageId; + if (anchor == null || anchor.isEmpty) { + index.reachedEnd = true; + return; + } + + final page = await fetchMedia( + chatId: chatId, + anchorMessageId: anchor, + attachTypes: const ['PHOTO', 'VIDEO'], + forward: initial ? _mediaIndexPageSize : 0, + backward: _mediaIndexPageSize, + ); + index.started = true; + if (page.total > index.total) index.total = page.total; + + final fresh = []; + for (final item in page.items) { + if (index.seen.add(item.dedupKey)) fresh.add(item); + } + if (fresh.isEmpty) { + index.reachedEnd = true; + return; + } + + final oldest = index.items.isEmpty ? null : index.items.last; + index.items.addAll(fresh); + if (oldest != null && fresh.first.time > oldest.time) { + index.items.sort((a, b) => b.time.compareTo(a.time)); + } + } + Future fetchMedia({ required int chatId, required String anchorMessageId, @@ -134,6 +269,7 @@ class SharedContentModule { if (id == null) continue; final sender = (map['sender'] as num?)?.toInt() ?? 0; final time = (map['time'] as num?)?.toInt() ?? 0; + final text = map['text'] as String?; final attaches = map['attaches']; if (attaches is! List) continue; for (final a in attaches) { @@ -147,6 +283,7 @@ class SharedContentModule { senderId: sender, time: time, attachment: att, + text: text, ), ); } diff --git a/lib/backend/modules/stories.dart b/lib/backend/modules/stories.dart index 9e21706..18d321a 100644 --- a/lib/backend/modules/stories.dart +++ b/lib/backend/modules/stories.dart @@ -123,6 +123,7 @@ class StoriesModule { if (acc == null) return; final map = {}; _peerStories.forEach((ownerId, stories) { + if (!_previews.containsKey(ownerId)) return; map['$ownerId'] = stories.map((s) => s.toJson()).toList(); }); await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map)); @@ -147,6 +148,11 @@ class StoriesModule { int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId]; + void clearLastViewed(int ownerId) { + if (_lastViewed.remove(ownerId) == null) return; + unawaited(_persistProgress()); + } + /// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени. List get previews { final list = _previews.values.where((p) => !p.isEmpty).toList(); @@ -161,6 +167,83 @@ class StoriesModule { StoryPreview? previewFor(int ownerId) => _previews[ownerId]; + final Map _peerPreviews = {}; + final Set _requestedOwners = {}; + + static const int _ownersChunk = 20; + + StoryPreview? previewOf(int ownerId) { + final feed = _previews[ownerId]; + if (feed != null) return feed.isEmpty ? null : feed; + final peer = _peerPreviews[ownerId]; + return (peer == null || peer.isEmpty) ? null : peer; + } + + Future loadOwnersPreviews(List ownerIds) async { + if (_api.state != SessionState.online) return; + final missing = ownerIds + .where((id) => id > 0 && !_requestedOwners.contains(id)) + .toSet() + .toList(); + if (missing.isEmpty) return; + _requestedOwners.addAll(missing); + + var changed = false; + for (var i = 0; i < missing.length; i += _ownersChunk) { + final end = i + _ownersChunk > missing.length + ? missing.length + : i + _ownersChunk; + final chunk = missing.sublist(i, end); + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [ + for (final id in chunk) StoryOwner(ownerId: id).toMap(), + ], + }, silent: true); + if (packet.isError) continue; + if (_applyOwnerPayload(packet.payload, chunk)) changed = true; + } catch (e) { + logger.w('StoriesModule.loadOwnersPreviews: $e'); + } + } + if (changed) _bump(); + } + + bool _applyOwnerPayload(Object? data, List requested) { + if (data is! Map) return false; + var changed = false; + final seen = {}; + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview == null) continue; + final id = preview.owner.ownerId; + seen.add(id); + if (preview.isEmpty) { + _peerPreviews.remove(id); + } else { + _peerPreviews[id] = preview; + } + _refreshPreview(preview); + changed = true; + } + } + for (final id in requested) { + if (!seen.contains(id) && _peerPreviews.remove(id) != null) changed = true; + } + final rawPeers = data['peerStories']; + if (rawPeers is List) { + for (final raw in rawPeers) { + final peer = PeerStories.fromMap(raw); + if (peer == null) continue; + _peerStories[peer.owner.ownerId] = peer.stories; + changed = true; + } + } + return changed; + } + List? cachedStories(int ownerId) => _peerStories[ownerId]; /// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE). @@ -180,6 +263,11 @@ class StoriesModule { unawaited(_persistPreviews()); } + void _refreshPreview(StoryPreview preview) { + if (!_previews.containsKey(preview.owner.ownerId)) return; + _applyPreview(preview); + } + void _applyPreview(StoryPreview preview) { if (preview.isEmpty) { _previews.remove(preview.owner.ownerId); @@ -234,7 +322,7 @@ class StoriesModule { if (rawPreviews is List) { for (final raw in rawPreviews) { final preview = StoryPreview.fromMap(raw); - if (preview != null) _applyPreview(preview); + if (preview != null) _refreshPreview(preview); } } @@ -258,6 +346,31 @@ class StoriesModule { } } + Future loadOwnerPreview(StoryOwner owner) async { + final cached = _previews[owner.ownerId]; + if (_api.state != SessionState.online) return cached; + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [owner.toMap()], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return cached; + + _applyOwnerPayload(data, [owner.ownerId]); + _requestedOwners.add(owner.ownerId); + final own = previewOf(owner.ownerId); + + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + return own; + } catch (e) { + logger.w('StoriesModule.loadOwnerPreview: $e'); + return cached; + } + } + /// Отметить историю просмотренной. Оптимистично поднимает readCount кольца. Future mark(StoryOwner owner, int storyId) async { if (_api.state != SessionState.online) return false; @@ -283,52 +396,46 @@ class StoriesModule { unawaited(_persistPreviews()); } - /// Поставить ([reaction] != null) или снять (null) реакцию на историю. - Future react( - StoryOwner owner, - int storyId, - StoryReaction? reaction, - ) async { - if (_api.state != SessionState.online) return false; - try { - final ok = await _api.sendRequestOk(Opcode.storiesReact, { - 'owner': owner.toMap(), - 'storyId': storyId, - if (reaction != null) 'reaction': reaction.toMap(), - }); - if (ok) _applyReactionLocally(owner.ownerId, storyId, reaction); - return ok; - } catch (e) { - logger.w('StoriesModule.react: $e'); - return false; - } - } - - void _applyReactionLocally( - int ownerId, - int storyId, - StoryReaction? reaction, - ) { - final stories = _peerStories[ownerId]; - if (stories == null) return; - final idx = stories.indexWhere((s) => s.id == storyId); - if (idx < 0) return; - stories[idx] = stories[idx].copyWith( - reaction: reaction, - clearReaction: reaction == null, - ); - _bump(); - unawaited(_persistPeers()); - } - /// Публикация фото-истории. [photoToken] — токен уже загруженного фото. - /// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, сек. + /// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, мс. /// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI /// показал реальную причину, а не общее «не удалось». Future publishPhoto({ required String photoToken, int settings = 1, - int expiration = 86400, + int expiration = 86400000, + }) { + return _publishMedia( + media: {'_type': 'PHOTO', 'photoToken': photoToken}, + settings: settings, + expiration: expiration, + ); + } + + /// Публикация видео-истории. [videoToken] — токен уже загруженного видео + /// (`VideoUploadInfo.token`), [durationMs] — длительность ролика в мс. + Future publishVideo({ + required String videoToken, + int? durationMs, + int settings = 1, + int expiration = 86400000, + }) { + return _publishMedia( + media: { + '_type': 'VIDEO', + 'videoType': 2, + 'token': videoToken, + 'duration': ?durationMs, + }, + settings: settings, + expiration: expiration, + ); + } + + Future _publishMedia({ + required Map media, + required int settings, + required int expiration, }) async { if (_api.state != SessionState.online) { throw const PacketError('Нет соединения с сервером'); @@ -339,7 +446,7 @@ class StoriesModule { { 'cid': cid, 'settings': settings, - 'media': {'_type': 'PHOTO', 'photoToken': photoToken}, + 'media': media, 'expiration': expiration, }, ], 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..19240a0 100644 --- a/lib/backend/modules/upload_notification_service.dart +++ b/lib/backend/modules/upload_notification_service.dart @@ -1,31 +1,209 @@ -import 'dart:io'; +import 'dart:async'; + +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, videoNote, voice, 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.videoNote => l10n.uploadNotificationVideoNote, + UploadKind.voice => l10n.uploadNotificationVoice, + 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 const Duration _startDelay = Duration(milliseconds: 700); + + static final Map _jobs = {}; + static Timer? _startTimer; + 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, + ); + if (_running) { + _push(force: true); + return; + } + _startTimer ??= Timer(_startDelay, () { + _startTimer = null; + _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() { + _startTimer?.cancel(); + _startTimer = null; + final wasRunning = _running; + _running = false; + _lastTitle = null; + _lastBody = null; + _lastPercent = -1; + _lastPushAt = 0; + if (wasRunning) _invoke('stop', const {}); + } + + static void _push({bool force = false}) { + if (_jobs.isEmpty) return; + if (!_running && _startTimer != null) 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..893cf09 --- /dev/null +++ b/lib/backend/modules/upload_service.dart @@ -0,0 +1,690 @@ +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 sendVoice({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required int durationMs, + required Uint8List wave, + CachedMessage? placeholder, + }) { + return _sendRecording( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: file, + kind: UploadKind.voice, + placeholder: placeholder, + requestUpload: () async { + final info = await messagesModule.requestAudioUploadUrl(); + return info == null ? null : (url: info.url, token: info.token); + }, + send: (token) => messagesModule.sendAudioMessage( + chatId, + token, + duration: durationMs, + wave: wave, + ), + ); + } + + Future sendVideoNote({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required int durationMs, + CachedMessage? placeholder, + }) { + return _sendRecording( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: file, + kind: UploadKind.videoNote, + placeholder: placeholder, + requestUpload: () async { + final info = await messagesModule.requestVideoNoteUploadUrl(); + return info == null ? null : (url: info.url, token: info.token); + }, + send: (token) => messagesModule.sendVideoNoteMessage( + chatId, + token, + duration: durationMs, + ), + ); + } + + Future _sendRecording({ + required int accountId, + required int chatId, + required String tempId, + required File file, + required UploadKind kind, + required Future<({String url, String token})?> Function() requestUpload, + required Future?> Function(String token) send, + CachedMessage? placeholder, + }) { + return _run( + UploadJob._( + id: tempId, + accountId: accountId, + chatId: chatId, + kind: kind, + slots: 1, + placeholder: placeholder, + ), + (job) async { + try { + final info = await requestUpload(); + if (info == null || info.url.isEmpty) { + throw const UploadFailure('no_upload_url'); + } + final ok = await fileUploader.uploadMediaFile( + 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 send(info.token); + if (sent == null) return null; + return CachedMessage.fromPushPayload(accountId, chatId, sent); + } finally { + try { + await file.delete(); + } catch (_) {} + } + }, + ); + } + + 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/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index 1189f82..b1a36a7 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -13,10 +13,64 @@ abstract class EntryBannerApps { }; } +const Set kMiniAppOptions = {'HAS_WEBAPP', 'HAS_WEB_APP', 'WEBAPP'}; + +bool hasMiniAppOption(Set? options) => + options != null && options.any(kMiniAppOptions.contains); + class WebAppLaunch { final String url; + final String? queryId; + final int botId; - const WebAppLaunch({required this.url}); + const WebAppLaunch({required this.url, required this.botId, this.queryId}); +} + +class WebAppPhone { + final String phone; + final String hash; + final int authDate; + + const WebAppPhone({ + required this.phone, + required this.hash, + required this.authDate, + }); +} + +class ExternalCallbackResult { + final int botId; + final String? startParam; + + const ExternalCallbackResult({required this.botId, this.startParam}); + + static ExternalCallbackResult? fromPayload(dynamic payload) { + final data = _findResponseMap(payload); + if (data == null) return null; + final botId = _asInt(data['botId'] ?? data['bot_id']); + if (botId == null) return null; + final startParam = data['startParam'] ?? data['start_param']; + return ExternalCallbackResult( + botId: botId, + startParam: startParam?.toString(), + ); + } + + static Map? _findResponseMap(dynamic value) { + if (value is! Map) return null; + if (value.containsKey('botId') || value.containsKey('bot_id')) return value; + for (final key in const ['data', 'result', 'response']) { + final nested = _findResponseMap(value[key]); + if (nested != null) return nested; + } + return null; + } +} + +int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); } class WebAppModule { @@ -24,6 +78,10 @@ class WebAppModule { WebAppModule(this._api); + /// device_id сессии (опкод 6 sessionInit, с учётом спуфинга) — тот же id, + /// к которому сервер привязывает Цифровой ID. + String? get sessionDeviceId => _api.deviceId; + Future fetchLaunch( int botId, { String? startParam, @@ -32,9 +90,11 @@ class WebAppModule { if (_api.state != SessionState.online) { throw const WebAppUnavailable('Нет соединения с сервером'); } + final normalizedStartParam = + (startParam != null && startParam.trim().isNotEmpty) ? startParam : null; final packet = await _api.sendRequest(Opcode.webAppInitData, { 'botId': botId, - 'startParam': ?startParam, + 'startParam': ?normalizedStartParam, 'chatId': ?chatId, }); if (!packet.isOk) { @@ -45,7 +105,33 @@ class WebAppModule { if (url == null || url.isEmpty) { throw const WebAppUnavailable('Сервер не вернул адрес приложения'); } - return WebAppLaunch(url: url); + final queryId = (data is Map) ? data['query_id']?.toString() : null; + return WebAppLaunch(url: url, botId: botId, queryId: queryId); + } + + Future requestPhone(int botId) async { + if (_api.state != SessionState.online) { + throw const WebAppUnavailable('Нет соединения с сервером'); + } + final packet = await _api.sendRequest(Opcode.phoneWebappShare, { + 'botId': botId, + }); + final data = packet.payload; + if (!packet.isOk || data is! Map) { + throw const WebAppUnavailable('Не удалось передать номер телефона'); + } + final phone = data['phone']?.toString(); + final hash = data['hash']?.toString(); + final authDate = _asInt(data['authDate'] ?? data['auth_date']); + if (phone == null || + phone.isEmpty || + hash == null || + hash.isEmpty || + authDate == null || + authDate == 0) { + throw const WebAppUnavailable('Сервер не вернул номер телефона'); + } + return WebAppPhone(phone: phone, hash: hash, authDate: authDate); } Future fetchSferum() async { @@ -68,6 +154,26 @@ class WebAppModule { return fetchLaunch(botId); } + Future handleExternalCallback(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || uri.queryParameters['externalCallback'] != '1') { + throw const WebAppUnavailable('Некорректный callback Цифрового ID'); + } + if (_api.state != SessionState.online) { + throw const WebAppUnavailable('Нет соединения с сервером'); + } + final packet = await _api.sendRequest(Opcode.externalCallback, { + 'url': url, + }); + final result = ExternalCallbackResult.fromPayload(packet.payload); + if (result == null) { + throw const WebAppUnavailable( + 'Сервер не вернул данные для завершения Цифрового ID', + ); + } + return fetchLaunch(result.botId, startParam: result.startParam); + } + Future _resolveEntryApp(String key) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return null; diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart index 52eb7b4..cde97b0 100644 --- a/lib/core/cache/info_cache.dart +++ b/lib/core/cache/info_cache.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../backend/api.dart'; +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; @@ -109,6 +111,53 @@ class ContactInfoFetch { static void invalidate(int id) => _cache.invalidate(id); static void clear() => _cache.clear(); + static void putContact(int id, Map contact) { + _cache.putValue( + id, + ContactInfo.fromMap(Map.from(contact)), + ); + } + + static Future> getMany( + List ids, { + bool forceRefresh = false, + }) async { + final result = {}; + final missing = []; + for (final id in ids) { + if (!forceRefresh) { + final cached = _cache.peek(id); + if (cached != null) { + result[id] = cached; + continue; + } + } + missing.add(id); + } + if (missing.isEmpty) return result; + + final api = _api; + if (api == null || api.state != SessionState.online) return result; + try { + final resp = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': missing, + }); + final data = resp.payload; + final contacts = data is Map ? data['contacts'] : null; + if (contacts is List) { + final now = DateTime.now(); + for (final c in contacts.whereType()) { + final id = c['id']; + if (id is! int) continue; + final info = ContactInfo.fromMap(Map.from(c)); + _cache.putValue(id, info, at: now); + result[id] = info; + } + } + } catch (_) {} + return result; + } + static Future _fetch(int id) async { final api = _api; if (api == null || api.state != SessionState.online) return null; @@ -197,19 +246,43 @@ class PresenceFetch { if (missing.isNotEmpty) { final fetched = await _fetchBatch(missing); final now = DateTime.now(); + var changed = false; for (final id in missing) { final value = fetched[id]; if (value != null) { _cache.putValue(id, value, at: now); + _live[id] = value; result[id] = value; + changed = true; } else { _cache.markFailed(id, at: now); } } + if (changed) revision.value++; } return result; } + static const _batchSize = 100; + + static Future ensureFor(Iterable ids) async { + final wanted = {}; + for (final id in ids) { + if (id <= 0) continue; + if (_cache.peek(id) != null) continue; + wanted.add(id); + } + if (wanted.isEmpty) return; + final list = wanted.toList(); + for (var i = 0; i < list.length; i += _batchSize) { + final chunk = list.sublist( + i, + i + _batchSize > list.length ? list.length : i + _batchSize, + ); + await getMany(chunk); + } + } + static Future>> _fetchBatch( List ids, ) async { @@ -235,6 +308,36 @@ class PresenceFetch { } } +class BotInfoFetch { + static final _cache = InfoCache( + ttl: const Duration(minutes: 30), + fetcher: _fetch, + ); + + static Future get(int botId, {bool forceRefresh = false}) => + _cache.get(botId, forceRefresh: forceRefresh); + + static BotInfo? peek(int botId) => _cache.peek(botId); + + static List commandsOf(int botId) => + _cache.peek(botId)?.commands ?? const []; + + static void invalidate(int botId) => _cache.invalidate(botId); + static void clear() => _cache.clear(); + + static Future _fetch(int botId) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.botInfo, {'botId': botId}); + final data = resp.payload; + if (data is! Map) return null; + final info = BotInfo.fromPayload(botId, Map.from(data)); + final contact = info.contact; + if (contact != null) ContactInfoFetch.putContact(botId, contact.raw); + return info; + } +} + class ChatInfoFetch { static final _cache = InfoCache( ttl: const Duration(minutes: 5), @@ -261,6 +364,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/calls/active_call.dart b/lib/core/calls/active_call.dart new file mode 100644 index 0000000..1705aeb --- /dev/null +++ b/lib/core/calls/active_call.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; + +import 'call_session.dart'; + +class ActiveCallPresentation { + const ActiveCallPresentation({ + required this.session, + required this.name, + required this.avatarUrl, + required this.isGroup, + }); + + final CallSession session; + final String name; + final String? avatarUrl; + final bool isGroup; + + bool sameAs(ActiveCallPresentation other) => + identical(session, other.session) && + name == other.name && + avatarUrl == other.avatarUrl && + isGroup == other.isGroup; +} + +class ActiveCall { + ActiveCall._(); + + static final ActiveCall instance = ActiveCall._(); + + final ValueNotifier current = ValueNotifier(null); + + final ValueNotifier screenVisible = ValueNotifier(false); + + int _openScreens = 0; + StreamSubscription? _stateSub; + + void attach({ + required CallSession session, + required String name, + String? avatarUrl, + bool isGroup = false, + }) { + if (session.currentState == CallSessionState.ended) return; + final next = ActiveCallPresentation( + session: session, + name: name, + avatarUrl: avatarUrl, + isGroup: isGroup, + ); + final previous = current.value; + if (previous != null && previous.sameAs(next)) return; + if (previous == null || !identical(previous.session, session)) { + _stateSub?.cancel(); + _stateSub = session.stateStream.listen((state) { + if (state == CallSessionState.ended) detach(session); + }); + } + _publish(current, next); + } + + void detach([CallSession? session]) { + final active = current.value; + if (active == null) return; + if (session != null && !identical(active.session, session)) return; + _stateSub?.cancel(); + _stateSub = null; + _publish(current, null); + } + + void enterScreen() { + _openScreens++; + _publish(screenVisible, true); + } + + void leaveScreen() { + if (_openScreens > 0) _openScreens--; + _publish(screenVisible, _openScreens > 0); + } + + void _publish(ValueNotifier notifier, T value) { + if (SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance.addPostFrameCallback((_) { + notifier.value = value; + }); + return; + } + notifier.value = value; + } +} diff --git a/lib/core/calls/audio_devices.dart b/lib/core/calls/audio_devices.dart new file mode 100644 index 0000000..35b0451 --- /dev/null +++ b/lib/core/calls/audio_devices.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import '../utils/logger.dart'; + +class AudioInputDevice { + const AudioInputDevice({required this.id, required this.label}); + + final String id; + final String label; +} + +class AudioDevices { + AudioDevices._(); + + static Future> microphones() async { + try { + final devices = await navigator.mediaDevices.enumerateDevices(); + final mics = []; + final seen = {}; + for (final device in devices) { + if (device.kind != 'audioinput') continue; + if (device.deviceId.isEmpty || !seen.add(device.deviceId)) continue; + mics.add( + AudioInputDevice(id: device.deviceId, label: device.label.trim()), + ); + } + return mics; + } catch (e) { + logger.w('[call] enumerateDevices: $e'); + return const []; + } + } + + static bool get switchesInsideEngine => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS); + + static Future selectInput(String deviceId) async { + try { + await Helper.selectAudioInput(deviceId); + } catch (e) { + logger.w('[call] selectAudioInput($deviceId): $e'); + } + } + + static Object micConstraints( + String? deviceId, { + bool monitorCapture = false, + }) { + final constraints = {}; + final hasDevice = deviceId != null && deviceId.isNotEmpty; + if (hasDevice && !switchesInsideEngine) { + if (kIsWeb) { + constraints['deviceId'] = deviceId; + } else { + constraints['optional'] = [ + {'sourceId': deviceId}, + ]; + } + } + if (monitorCapture) { + constraints['echoCancellation'] = true; + constraints['noiseSuppression'] = false; + constraints['autoGainControl'] = false; + constraints['highpassFilter'] = false; + } + return constraints.isEmpty ? true : constraints; + } + + static Future findDevice(String token, {int attempts = 1}) async { + for (var attempt = 0; attempt < attempts; attempt++) { + for (final device in await microphones()) { + if (device.id == token || + device.id.contains(token) || + device.label.contains(token)) { + return device.id; + } + } + if (attempt + 1 < attempts) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + return null; + } +} diff --git a/lib/core/calls/call_admin.dart b/lib/core/calls/call_admin.dart new file mode 100644 index 0000000..da9a51a --- /dev/null +++ b/lib/core/calls/call_admin.dart @@ -0,0 +1,298 @@ +import 'ws2_signaling.dart'; + +enum CallMedia { + audio('AUDIO'), + video('VIDEO'), + screenShare('SCREEN_SHARING'), + movieShare('MOVIE_SHARING'); + + const CallMedia(this.wire); + final String wire; +} + +enum CallMuteState { + unmute('UNMUTE'), + mute('MUTE'), + mutePermanent('MUTE_PERMANENT'); + + const CallMuteState(this.wire); + final String wire; +} + +enum CallRoleName { + creator('CREATOR'), + admin('ADMIN'), + speaker('SPEAKER'); + + const CallRoleName(this.wire); + final String wire; +} + +enum CallOption { + requireAuthToJoin('REQUIRE_AUTH_TO_JOIN'), + waitingHall('WAITING_HALL'), + recurring('RECURRING'), + feedback('FEEDBACK'), + audienceMode('AUDIENCE_MODE'), + asr('ASR'), + waitForAdmin('WAIT_FOR_ADMIN'), + adminIsHere('ADMIN_IS_HERE'); + + const CallOption(this.wire); + final String wire; +} + +enum CallFeature { + addParticipant('ADD_PARTICIPANT'), + admin('ADMIN'), + asr('ASR'), + movieShare('MOVIE_SHARE'), + record('RECORD'), + speaker('SPEAKER'); + + const CallFeature(this.wire); + final String wire; +} + +enum CallListType { + grid('GRID'), + side('SIDE'); + + const CallListType(this.wire); + final String wire; +} + +class CallParticipantRef { + final int id; + final int deviceIdx; + final bool isGroup; + + const CallParticipantRef(this.id, {this.deviceIdx = 0, this.isGroup = false}); + + String get wire => '${isGroup ? 'g' : 'u'}$id:d$deviceIdx'; +} + +class CallAdmin { + final Ws2Signaling _signaling; + + const CallAdmin(this._signaling); + + Future requestMedia( + Set media, { + CallParticipantRef? participant, + String? roomId, + }) { + return _signaling.sendCommand( + 'mute-participant', + extra: { + 'participantId': ?participant?.wire, + 'requestedMedia': media.map((m) => m.wire).toList(), + 'roomId': ?roomId, + }, + ); + } + + Future setMuteStates( + Map states, { + CallParticipantRef? participant, + String? roomId, + }) { + return _signaling.sendCommand( + 'mute-participant', + extra: { + 'participantId': ?participant?.wire, + 'muteStates': { + for (final media in CallMedia.values) media.wire: states[media]?.wire, + }, + 'roomId': ?roomId, + }, + ); + } + + Future muteMicrophone( + CallParticipantRef participant, { + bool muted = true, + }) { + return _signaling.sendCommand( + 'switch-micro', + extra: {'eId': participant.wire, 'muteTarget': muted}, + ); + } + + Future muteEveryone() { + return _signaling.sendCommand( + 'switch-micro', + extra: const {'all': true, 'muteTarget': true}, + ); + } + + Future setPromoted(CallParticipantRef participant, bool promoted) { + return _signaling.sendCommand( + 'promote-participant', + extra: {'participantId': participant.wire, 'demote': !promoted}, + ); + } + + Future setRoles( + CallParticipantRef participant, + List roles, { + bool revoke = false, + }) { + return _signaling.sendCommand( + 'grant-roles', + extra: { + 'participantId': participant.wire, + 'roles': roles.map((r) => r.wire).toList(), + 'revoke': revoke, + }, + ); + } + + Future removeParticipant(CallParticipantRef participant) { + return _signaling.sendCommand( + 'remove-participant', + extra: {'participantId': participant.wire}, + ); + } + + Future setPinned( + CallParticipantRef participant, + bool pinned, { + String? roomId, + }) { + return _signaling.sendCommand( + 'pin-participant', + extra: { + 'participantId': participant.wire, + 'unpin': !pinned, + 'roomId': ?roomId, + }, + ); + } + + Future setOptions(Map options) { + return _signaling.sendCommand( + 'change-options', + extra: { + 'options': { + for (final entry in options.entries) entry.key.wire: entry.value, + }, + }, + ); + } + + Future enableFeatureForRoles( + CallFeature feature, + List roles, + ) { + return _signaling.sendCommand( + 'enable-feature-for-roles', + extra: { + 'feature': feature.wire, + 'roles': roles.map((r) => r.wire).toList(), + }, + ); + } + + Future lowerAllHands() => _signaling.sendCommand('put-hands-down'); + + Future setHandRaised(bool raised, {CallParticipantRef? participant}) { + return _setState({'hand': raised ? '1' : '0'}, participant: participant); + } + + Future setAssistanceRequested( + bool requested, { + CallParticipantRef? participant, + }) { + return _setState({'drat': requested ? '1' : '0'}, participant: participant); + } + + Future _setState( + Map state, { + CallParticipantRef? participant, + }) { + return _signaling.sendCommand( + 'change-participant-state', + extra: { + 'participantState': {'state': state}, + 'participantId': ?participant?.wire, + }, + ); + } + + Future addParticipants( + List externalIds, { + bool? unban, + bool showChatHistory = false, + }) { + return _signaling.sendCommand( + 'add-participant', + extra: { + 'externalIds': externalIds, + if (unban == true) 'unban': true, + if (showChatHistory) 'payload': '{"show_chat_history":true}', + }, + ); + } + + Future addParticipantByLink(String link) { + return _signaling.sendCommand( + 'add-participant', + extra: {'participantIdAsQRCodeLink': link}, + ); + } + + Future> startRecord({ + int? movieId, + String? name, + String? description, + String? privacy, + int? groupId, + String? albumId, + bool streamMovie = false, + String? roomId, + }) { + return _signaling.sendCommand( + 'record-start', + extra: { + 'movieId': movieId, + 'name': name, + 'description': description, + 'privacy': privacy, + 'groupId': groupId, + 'albumId': albumId, + 'streamMovie': streamMovie, + 'roomId': ?roomId, + }, + ); + } + + Future stopRecord({bool remove = false, String? roomId}) { + return _signaling.sendCommand( + 'record-stop', + extra: {if (remove) 'remove': true, 'roomId': ?roomId}, + ); + } + + Future> participantChunk({ + int count = 50, + CallListType listType = CallListType.grid, + String? roomId, + }) { + return _signaling.sendCommand( + 'get-participant-list-chunk', + extra: {'count': count, 'listType': listType.wire, 'roomId': ?roomId}, + ); + } + + Future> waitingHall({ + int count = 50, + String? fromId, + bool backward = false, + }) { + return _signaling.sendCommand( + 'get-waiting-hall', + extra: {'count': count, 'fromId': ?fromId, 'backward': backward}, + ); + } +} diff --git a/lib/core/calls/call_bridge.dart b/lib/core/calls/call_bridge.dart index 9de9731..30a55ba 100644 --- a/lib/core/calls/call_bridge.dart +++ b/lib/core/calls/call_bridge.dart @@ -78,6 +78,36 @@ class CallBridge { } } + Future ensureOngoing({String? caller}) async { + if (!_android) return; + try { + await _method.invokeMethod('ensureOngoing', {'caller': caller}); + } catch (e) { + logger.w('CallBridge.ensureOngoing: $e'); + } + } + + Future setScreenShare(bool enabled, {String? caller}) async { + if (!_android) return; + try { + await _method.invokeMethod('setScreenShare', { + 'enabled': enabled, + 'caller': caller, + }); + } catch (e) { + logger.w('CallBridge.setScreenShare: enabled=$enabled $e'); + } + } + + Future dropOngoing() async { + if (!_android) return; + try { + await _method.invokeMethod('dropOngoing'); + } catch (e) { + logger.w('CallBridge.dropOngoing: $e'); + } + } + Future notifyEnded() async { if (!_android) return; try { diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index cbe2e37..92d07da 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -3,6 +3,7 @@ import 'dart:async'; import '../../backend/api.dart'; import '../../backend/modules/calls.dart'; import '../protocol/opcode_map.dart'; +import '../push/fkm_controller.dart'; import '../protocol/packet.dart'; import '../utils/parse.dart'; import 'call_bridge.dart'; @@ -56,6 +57,7 @@ class CallController { Stream get incomingCanceled => _canceled.stream; CallSession? _active; + StreamSubscription? _activeSub; CallSession? get activeSession => _active; IncomingCall? _pending; @@ -72,7 +74,6 @@ class CallController { void _onPush(Packet packet) { if (packet.opcode != Opcode.notifCallStart) return; - if (!appResumed) return; final payload = packet.payload; if (payload is! Map) return; @@ -81,6 +82,13 @@ class CallController { final callerId = payload['callerId'] as int?; if (vcp == null || conversationId == null || callerId == null) return; + // Приложение свёрнуто — звонок показывает FKM отдельным уведомлением, + // приём оттуда вернётся через injectFromNative. + if (!appResumed) { + unawaited(FkmController.instance.showIncomingCall(payload)); + return; + } + final params = ConversationParams.decode(vcp); if (params == null) return; @@ -150,12 +158,16 @@ class CallController { final config = Ws2Config.fromEndpoint( out.endpoint, userId: out.callsUserId, + device: _api?.callsDevice, + osVersion: _api?.callsOsVersion, ); final session = CallSession(ws2Config: config, role: CallRole.caller); - _bind(session); - await session.start(); - CallBridge.instance.notifyAccepted(); - return session; + return _launch(session, session.start); + } + + Future createConference() async { + if (_active != null) throw StateError('уже идёт звонок'); + return _calls!.createConference(); } Future previewCallLink(String url) => @@ -167,12 +179,15 @@ class CallController { final config = Ws2Config.fromEndpoint( params.endpoint, userId: params.callsUserId, + device: _api?.callsDevice, + osVersion: _api?.callsOsVersion, ); - final session = CallSession(ws2Config: config, role: CallRole.joiner); - _bind(session); - await session.start(); - CallBridge.instance.notifyAccepted(); - return session; + final session = CallSession( + ws2Config: config, + role: CallRole.joiner, + isGroup: true, + ); + return _launch(session, session.start); } Future acceptIncoming(IncomingCall call) async { @@ -181,17 +196,22 @@ class CallController { final config = Ws2Config.fromVcp( call.params, conversationId: call.conversationId, + device: _api?.callsDevice, + osVersion: _api?.callsOsVersion, ); final session = CallSession( ws2Config: config, params: call.params, role: CallRole.callee, ); - _bind(session); - await session.start(); - await session.accept(); - CallBridge.instance.notifyAccepted(caller: call.callerName); - return session; + return _launch( + session, + () async { + await session.start(); + await session.accept(); + }, + caller: call.callerName, + ); } Future rejectIncoming(IncomingCall call) async { @@ -200,6 +220,8 @@ class CallController { final config = Ws2Config.fromVcp( call.params, conversationId: call.conversationId, + device: _api?.callsDevice, + osVersion: _api?.callsOsVersion, ); final signaling = Ws2Signaling(config); try { @@ -220,18 +242,45 @@ class CallController { return true; } + Future _launch( + CallSession session, + Future Function() open, { + String? caller, + }) async { + _bind(session); + try { + await open(); + } catch (_) { + await _release(session); + try { + await session.hangup(); + } catch (_) {} + rethrow; + } + CallBridge.instance.notifyAccepted(caller: caller); + return session; + } + void _bind(CallSession session) { + unawaited(_activeSub?.cancel()); _active = session; - session.stateStream.listen((state) { - if (state == CallSessionState.ended && _active == session) { - _active = null; - CallBridge.instance.notifyEnded(); - _ended.add(null); - } + _activeSub = session.stateStream.listen((state) { + if (state != CallSessionState.ended) return; + unawaited(_release(session)); }); } + Future _release(CallSession session) async { + if (!identical(_active, session)) return; + _active = null; + await _activeSub?.cancel(); + _activeSub = null; + CallBridge.instance.notifyEnded(); + _ended.add(null); + } + void dispose() { + _activeSub?.cancel(); _pushSub?.cancel(); _incoming.close(); _ended.close(); diff --git a/lib/core/calls/call_info.dart b/lib/core/calls/call_info.dart index 5b940cb..bd7ea3e 100644 --- a/lib/core/calls/call_info.dart +++ b/lib/core/calls/call_info.dart @@ -61,7 +61,8 @@ class CallParse { for (final line in const LineSplitter().convert(sdp)) { if (!line.startsWith('o=')) continue; final l = line.toLowerCase(); - if (l.contains('mozilla') || l.contains('sdparta')) return 'Firefox (web)'; + if (l.contains('mozilla') || l.contains('sdparta')) + return 'Firefox (web)'; if (l.contains('gstreamer')) return 'GStreamer'; return 'нативный libwebrtc'; } diff --git a/lib/core/calls/call_link.dart b/lib/core/calls/call_link.dart index 4bccb5e..fe939db 100644 --- a/lib/core/calls/call_link.dart +++ b/lib/core/calls/call_link.dart @@ -1,11 +1,26 @@ class CallLink { + static const String base = 'https://max.ru/joincall/'; + static final RegExp _pattern = RegExp( r'^https?://(?:[^/\s]+\.)?max\.ru/joincall/([A-Za-z0-9_-]+)', caseSensitive: false, ); + static final RegExp _rawPattern = RegExp( + r'^(?:joincall/)?([A-Za-z0-9_-]+)$', + caseSensitive: false, + ); + static bool isCallLink(String url) => token(url) != null; - static String? token(String url) => - _pattern.firstMatch(url.trim())?.group(1); + static String? token(String url) => _pattern.firstMatch(url.trim())?.group(1); + + static String? normalizeToken(String raw) { + final value = raw.trim(); + return token(value) ?? _rawPattern.firstMatch(value)?.group(1); + } + + static String url(String token) => '$base$token'; + + static String path(String token) => 'joincall/$token'; } diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index 2c91f91..3b0f559 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -5,10 +5,18 @@ import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; import 'package:flutter_webrtc/flutter_webrtc.dart'; +import '../config/app_microphone.dart'; +import '../config/app_pulse_source.dart'; +import '../config/call_no_mute.dart'; import '../utils/logger.dart'; import '../utils/parse.dart'; +import 'audio_devices.dart'; +import 'call_admin.dart'; +import 'call_bridge.dart'; import 'call_info.dart'; import 'conversation_params.dart'; +import 'pulse_audio.dart'; +import 'sfu_data_channel.dart'; import 'ws2_signaling.dart'; enum CallRole { caller, callee, joiner } @@ -24,6 +32,7 @@ class CallParticipant { bool videoEnabled; bool screenSharing; bool handRaised; + List roles; CallParticipant({ required this.id, @@ -34,7 +43,12 @@ class CallParticipant { this.videoEnabled = false, this.screenSharing = false, this.handRaised = false, + this.roles = const [], }); + + bool get isAdmin => roles.contains('ADMIN') || roles.contains('CREATOR'); + bool get isCreator => roles.contains('CREATOR'); + bool get isSpeaker => roles.contains('SPEAKER'); } class CallChatMessage { @@ -50,12 +64,19 @@ class CallSession { final ConversationParams? params; final CallRole role; + final bool isGroup; - CallSession({required this.ws2Config, required this.role, this.params}); + CallSession({ + required this.ws2Config, + required this.role, + this.params, + this.isGroup = false, + }); Ws2Signaling? _signaling; RTCPeerConnection? _pc; MediaStream? _localStream; + MediaStream? _micStream; MediaStream? _remoteStreamRef; int? _peerId; @@ -63,6 +84,7 @@ class CallSession { int _peerDeviceIdx = 0; bool _muted = false; + bool _speakerOn = false; bool _accepted = false; bool _peerMuted = false; bool _peerVideo = false; @@ -73,6 +95,8 @@ class CallSession { Future _tail = Future.value(); final Map _participants = {}; + final Map _participantStreams = {}; + final _participantStreamUpdates = StreamController.broadcast(); String? _topology; List _iceServers = const []; @@ -81,8 +105,26 @@ class CallSession { bool _localVideo = false; bool _localScreen = false; - MediaStream? _localVideoStream; + MediaStream? _cameraStream; + MediaStream? _screenStream; + RTCRtpSender? _audioSender; RTCRtpSender? _videoSender; + RTCRtpSender? _screenSender; + String? _micDeviceId = AppMicrophone.deviceId; + String? _pulseSource = AppPulseSource.name; + bool _monitorCapture = false; + + Completer? _gatherDone; + bool _gotConnection = false; + + bool _reconnecting = false; + bool _iceRestarting = false; + int _iceRestarts = 0; + static const int _maxIceRestarts = 6; + static const int _maxReconnectAttempts = 12; + static const Duration _maxReconnectDelay = Duration(seconds: 20); + + bool get isReconnecting => _reconnecting; Timer? _levelTimer; final Map _speakHold = {}; @@ -93,6 +135,27 @@ class CallSession { RTCDataChannel? _probeChannel; bool _peerIsKomet = false; + final List _sfuChannels = []; + SfuCommandChannel? _sfuCommands; + StreamSubscription>? _sfuSlotSub; + StreamSubscription>? _sfuLevelSub; + final Map _slotParticipant = {}; + Timer? _layoutDebounce; + Timer? _videoStatsTimer; + List _lastLayout = const []; + bool _layoutSent = false; + + static const int _maxVideoSlots = 10; + static const int _sfuSpeakLevel = 50; + static const Duration _levelTtl = Duration(seconds: 6); + final Map _levelState = {}; + + static const List _sfuChannelLabels = [ + 'producerCommand', + 'producerNotification', + ]; + + static const bool _kometProbeEnabled = false; static const String _probeQuestion = 'AreYouKomet?'; static const String _probeAnswer = 'YesImKomet😎'; @@ -109,11 +172,27 @@ class CallSession { bool get localVideo => _localVideo; bool get localScreen => _localScreen; - MediaStream? get localVideoStream => _localVideoStream; + MediaStream? get localVideoStream => + _localScreen ? _screenStream : _cameraStream; + MediaStream? get localCameraStream => _cameraStream; + MediaStream? get localScreenStream => _screenStream; + + CallAdmin? get admin { + final signaling = _signaling; + return signaling == null ? null : CallAdmin(signaling); + } List get participants => _participants.values.toList(growable: false); + Map get participantStreams => + Map.unmodifiable(_participantStreams); + + Stream get participantStreamUpdates => _participantStreamUpdates.stream; + + MediaStream? streamOf(int participantId) => + _participantStreams[participantId]; + int get participantCount => _participants.length; bool isSpeaking(int id) => _speaking.contains(id); @@ -139,6 +218,10 @@ class CallSession { bool get peerIsKomet => _peerIsKomet; bool get isMuted => _muted; + bool get audioTransmitting => !_muted || CallNoMute.enabled; + String? get micDeviceId => _micDeviceId; + String? get pulseSource => _pulseSource; + bool get isSpeaker => _speakerOn; bool get peerMuted => _peerMuted; bool get peerVideo => _peerVideo; bool get mediaConnected => _mediaConnected; @@ -161,25 +244,195 @@ class CallSession { void _notifyInfo() { if (!_info.isClosed) _info.add(null); + if (_topology == 'SERVER') _scheduleDisplayLayout(); } Future start() async { _setState(CallSessionState.connecting); info.region = ws2Config.uri.host; - final signaling = Ws2Signaling(ws2Config); - _signaling = signaling; - signaling.notifications.listen(_enqueue, onError: (_) => _end()); - signaling.done.then((_) => _end()); - await signaling.connect(); + await _openSignaling(); _levelTimer = Timer.periodic( const Duration(milliseconds: 300), (_) => unawaited(_sampleLevels()), ); } + Future _openSignaling() async { + final signaling = Ws2Signaling(ws2Config); + _signaling = signaling; + signaling.notifications.listen( + _enqueue, + onError: (_) => _onSignalingLost(), + ); + signaling.done.then((_) => _onSignalingLost()); + await signaling.connect(); + logger.i('[call] signaling connected to ${ws2Config.uri.host}'); + logger.i('[call] ws2 url ${_maskedUrl()}'); + unawaited(_wakeSignalingIfSilent(signaling)); + Timer(const Duration(seconds: 10), () { + if (_ended || _gotConnection) return; + logger.w( + '[call] ws2 молчит 10 с: нотификация "connection" не пришла — ' + 'конференция закрыта или токен протух', + ); + }); + } + + String _maskedUrl() { + final token = ws2Config.uri.queryParameters['token']; + if (token == null || token.length < 12) return ws2Config.uri.toString(); + final masked = + '${token.substring(0, 4)}…${token.substring(token.length - 6)}'; + return ws2Config.uri.toString().replaceAll( + Uri.encodeQueryComponent(token), + masked, + ); + } + + /// Кадры ws2 приходят в broadcast-канал Rust-ядра, а подписка на него + /// создаётся уже после того, как сокет открыт: нотификацию `connection`, + /// присланную сразу после хэндшейка, ядро выбрасывает. Если её нет — толкаем + /// сервер командой (ответы идут по sequence и гонке не подвержены). + Future _wakeSignalingIfSilent(Ws2Signaling signaling) async { + await Future.delayed(const Duration(milliseconds: 1200)); + if (_ended || _gotConnection || _signaling != signaling) return; + + logger.w('[call] "connection" не пришла за 1.2 с — бужу ws2'); + try { + final response = await signaling.sendCommand( + 'change-media-settings', + extra: { + 'mediaSettings': { + 'isVideoEnabled': _localVideo, + 'isAudioEnabled': !_muted, + 'isScreenSharingEnabled': _localScreen, + 'isAnimojiEnabled': false, + }, + }, + ); + logger.i('[call] ws2 wake ok: $response'); + } catch (e) { + logger.w('[call] ws2 wake failed: $e'); + return; + } + + await Future.delayed(const Duration(milliseconds: 1200)); + if (_ended || _gotConnection || _signaling != signaling) return; + + logger.w('[call] всё ещё тихо — шлю accept-call вслепую'); + try { + await accept(activate: false); + } catch (e) { + logger.w('[call] accept-call failed: $e'); + } + } + + void _onSignalingLost() { + if (_ended || _reconnecting) return; + logger.w('[call] signaling lost, reconnecting'); + unawaited(_reconnect()); + } + + Future _reconnect() async { + _reconnecting = true; + _setState(CallSessionState.connecting); + _notifyInfo(); + + for (var attempt = 1; attempt <= _maxReconnectAttempts; attempt++) { + final backoff = Duration(seconds: 1 << (attempt - 1)); + final delay = backoff > _maxReconnectDelay ? _maxReconnectDelay : backoff; + await Future.delayed(delay); + if (_ended) break; + + logger.i('[call] reconnect attempt $attempt/$_maxReconnectAttempts'); + try { + await _resetForReconnect(); + await _openSignaling(); + _reconnecting = false; + return; + } catch (e) { + logger.w('[call] reconnect attempt $attempt failed: $e'); + } + } + + _reconnecting = false; + if (!_ended) { + logger.w('[call] reconnect gave up'); + _end(); + } + } + + Future _restartIce() async { + if (_ended || _iceRestarting || _topology == 'SERVER') return; + if (_iceRestarts >= _maxIceRestarts) { + logger.w('[call] ice restart budget exhausted, ending call'); + _end(); + return; + } + _iceRestarting = true; + _iceRestarts++; + _setState(CallSessionState.connecting); + _notifyInfo(); + logger.i('[call] ice restart $_iceRestarts/$_maxIceRestarts'); + try { + _pendingCandidates.clear(); + await _createAndSendOffer(iceRestart: true); + } catch (e) { + logger.w('[call] ice restart failed: $e'); + } finally { + _iceRestarting = false; + } + } + + Future _resetForReconnect() async { + try { + await _signaling?.close(); + } catch (_) {} + _signaling = null; + + try { + await _probeChannel?.close(); + } catch (_) {} + _probeChannel = null; + await _closeSfuChannels(); + + try { + await _pc?.close(); + } catch (_) {} + _pc = null; + + _audioSender = null; + _videoSender = null; + _screenSender = null; + _remoteDescSet = false; + _pendingCandidates.clear(); + _accepted = false; + _mediaConnected = false; + _sfuSessionId = null; + await _clearParticipantStreams(); + + for (final track in _localStream?.getTracks() ?? []) { + try { + await track.stop(); + } catch (_) {} + } + try { + await _localStream?.dispose(); + } catch (_) {} + _localStream = null; + await _disposeMicStream(); + + await _disposeStream(_cameraStream); + await _disposeStream(_screenStream); + _cameraStream = null; + _screenStream = null; + _localVideo = false; + _localScreen = false; + } + Future _sampleLevels() async { final pc = _pc; - if (pc == null || _ended) return; + if (pc == null || _ended || _topology == 'SERVER') return; if (!_mediaConnected || _current != CallSessionState.active) return; var local = 0.0; @@ -202,7 +455,7 @@ class CallSession { } final loud = {}; - if (!_muted && local > _speakLevelOn) loud.add(ws2Config.userId); + if (audioTransmitting && local > _speakLevelOn) loud.add(ws2Config.userId); final others = _participants.values.where((p) => !p.isSelf).toList(); if (others.length == 1 && remote > _speakLevelOn) loud.add(others.first.id); @@ -220,10 +473,17 @@ class CallSession { } void _enqueue(Map msg) { - _tail = _tail.then((_) => _onNotification(msg)).catchError((_) {}); + _tail = _tail.then((_) => _onNotification(msg)).catchError(( + Object e, + StackTrace st, + ) { + logger.w('[call] handler failed for ${msg['notification']}: $e\n$st'); + }); } Future _onNotification(Map msg) async { + final name = msg['notification'] ?? msg['response'] ?? msg['type']; + logger.i('[call] ws2 <- $name'); if (msg['type'] == 'error') { _onWs2Error(msg); return; @@ -243,12 +503,18 @@ class CallSession { _applyRegisteredPeer(msg); break; case 'participant-joined': + case 'participant-added': + _onParticipantJoined(msg); + break; case 'media-settings-changed': _onParticipantMedia(msg); break; case 'participant-state-changed': _onParticipantStateChanged(msg); break; + case 'roles-changed': + _onRolesChanged(msg); + break; case 'participants-state-changed': _onParticipantsStateChanged(msg); break; @@ -283,7 +549,7 @@ class CallSession { void _onWs2Error(Map msg) { final err = msg['error']; - logger.t('[call] ws2 error: $err'); + logger.w('[call] ws2 error: $err raw=$msg'); if (err == 'conversation-ended') _end(); } @@ -373,6 +639,7 @@ class CallSession { state: p['state'] as String?, mediaSettings: p['mediaSettings'], muteStates: p['muteStates'], + roles: p['roles'], ); } _participants.removeWhere((key, _) => !seen.contains(key)); @@ -386,6 +653,7 @@ class CallSession { Object? mediaSettings, Object? muteStates, bool? handRaised, + Object? roles, }) { final p = _participants.putIfAbsent( id, @@ -394,22 +662,22 @@ class CallSession { if (externalId != null) p.externalId = externalId; if (state != null) p.state = state; if (mediaSettings is Map) { - final a = mediaSettings['isAudioEnabled']; - final v = mediaSettings['isVideoEnabled']; - final s = mediaSettings['isScreenSharingEnabled']; - if (a is bool) p.audioEnabled = a; - if (v is bool) p.videoEnabled = v; - if (s is bool) p.screenSharing = s; + p.audioEnabled = mediaSettings['isAudioEnabled'] == true; + p.videoEnabled = mediaSettings['isVideoEnabled'] == true; + p.screenSharing = mediaSettings['isScreenSharingEnabled'] == true; } if (muteStates is Map) { final a = muteStates['AUDIO']; final v = muteStates['VIDEO']; final s = muteStates['SCREEN_SHARING']; - if (a is String) p.audioEnabled = a == 'UNMUTE'; - if (v is String) p.videoEnabled = v == 'UNMUTE'; - if (s is String) p.screenSharing = s == 'UNMUTE'; + if (a is String && a != 'UNMUTE') p.audioEnabled = false; + if (v is String && v != 'UNMUTE') p.videoEnabled = false; + if (s is String && s != 'UNMUTE') p.screenSharing = false; } if (handRaised != null) p.handRaised = handRaised; + if (roles is List) { + p.roles = roles.whereType().toList(growable: false); + } return p; } @@ -428,30 +696,60 @@ class CallSession { void _onParticipantMedia(Map msg) { final id = _participantIdFrom(msg['participantId']); if (id == null) return; - _upsertParticipant( + final p = _upsertParticipant( id, externalId: _externalId(msg['externalId']), mediaSettings: msg['mediaSettings'], muteStates: msg['muteStates'], ); - _maybeAdoptPeer(msg); + logger.i( + '[call] media $id video=${p.videoEnabled} audio=${p.audioEnabled} ' + 'screen=${p.screenSharing} raw=${msg['mediaSettings']}', + ); + _maybeAdoptPeer(id, msg); _notifyInfo(); } - void _maybeAdoptPeer(Map msg) { + void _onParticipantJoined(Map msg) { + final nested = msg['participant']; + final p = nested is Map ? nested : msg; + final id = _participantIdFrom( + p['id'] ?? p['participantId'] ?? msg['participantId'], + ); + if (id == null) return; + _upsertParticipant( + id, + externalId: _externalId(p['externalId']), + state: p['state'] as String?, + mediaSettings: p['mediaSettings'], + muteStates: p['muteStates'], + handRaised: _handFrom(p['participantState']), + roles: p['roles'], + ); + _maybeAdoptPeer(id, p); + _notifyInfo(); + } + + void _maybeAdoptPeer(int id, Map source) { if (role != CallRole.joiner || _peerId != null || _pc == null) return; if (_topology == 'SERVER') return; - final id = msg['participantId']; - if (id is! int || id == ws2Config.userId) return; + if (id == ws2Config.userId) return; _peerId = id; - final type = msg['participantType']; + final type = source['participantType'] ?? source['idType']; if (type is String && type.isNotEmpty) _peerType = type; - final deviceIdx = msg['deviceIdx']; + final deviceIdx = source['deviceIdx']; if (deviceIdx is int) _peerDeviceIdx = deviceIdx; logger.t('[call] adopting peer $_peerId on join'); unawaited(_createAndSendOffer()); } + void _onRolesChanged(Map msg) { + final id = _participantIdFrom(msg['participantId']); + if (id == null) return; + _upsertParticipant(id, roles: msg['roles']); + _notifyInfo(); + } + void _onParticipantStateChanged(Map msg) { final id = msg['participantId']; if (id is! int) return; @@ -472,6 +770,7 @@ class CallSession { mediaSettings: p['mediaSettings'], muteStates: p['muteStates'], handRaised: _handFrom(p['participantState']), + roles: p['roles'], ); } _notifyInfo(); @@ -484,6 +783,8 @@ class CallSession { } Future _onConnection(Map msg) async { + _gotConnection = true; + logger.i('[call] connection notification received'); final convParams = msg['conversationParams']; final conversation = msg['conversation']; @@ -496,9 +797,10 @@ class CallSession { _topology = (conversation is Map ? conversation['topology']?.toString() : null) ?? _topology; - logger.t('[call] connection role=$role peer=$_peerId topology=$_topology'); + logger.i('[call] connection role=$role peer=$_peerId topology=$_topology'); if (_topology == 'SERVER') { + await accept(activate: role != CallRole.caller); await _setupSfu(); return; } @@ -522,6 +824,7 @@ class CallSession { } else if (role == CallRole.joiner) { await _createAndSendOffer(); } + await accept(activate: role != CallRole.caller); } Future _createPc(List ice) async { @@ -530,47 +833,427 @@ class CallSession { 'sdpSemantics': 'unified-plan', 'bundlePolicy': 'max-bundle', 'rtcpMuxPolicy': 'require', + 'tcpCandidatePolicy': 'enabled', + 'continualGatheringPolicy': 'gather_continually', + 'audioJitterBufferMaxPackets': 200, }); pc.onIceCandidate = _onLocalCandidate; + pc.onIceGatheringState = (s) { + logger.i('[call] ice gathering $s'); + if (s != RTCIceGatheringState.RTCIceGatheringStateComplete) return; + final done = _gatherDone; + if (done != null && !done.isCompleted) done.complete(); + }; pc.onTrack = (event) => unawaited(_onRemoteTrack(event)); - pc.onDataChannel = (channel) => _bindProbeChannel(channel, ask: false); - pc.onIceConnectionState = (s) => logger.t('[call] ice $s'); + pc.onDataChannel = (channel) { + if (!_kometProbeEnabled) return; + _bindProbeChannel(channel, ask: false); + }; + pc.onIceConnectionState = (s) { + logger.i('[call] ice $s'); + if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return; + if (_topology != 'SERVER' || _ended) return; + unawaited(_dumpIceStats(pc)); + logger.w('[call][sfu] ice failed, request-realloc'); + unawaited( + _signaling?.requestRealloc().catchError( + (e) => logger.w('[call] request-realloc failed: $e'), + ) ?? + Future.value(), + ); + }; pc.onConnectionState = (s) { - logger.t('[call] pc $s'); + logger.i('[call] pc $s'); final connected = s == RTCPeerConnectionState.RTCPeerConnectionStateConnected; if (connected != _mediaConnected) { _mediaConnected = connected; _notifyInfo(); if (connected) { + _iceRestarts = 0; if (role == CallRole.joiner || _topology == 'SERVER') { _setState(CallSessionState.active); } + unawaited(applyAudioRoute()); unawaited(_resolvePath()); unawaited(_collectReceivers()); } } - if ((s == RTCPeerConnectionState.RTCPeerConnectionStateFailed || - s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) && - _topology != 'SERVER') { + if (_topology == 'SERVER') return; + if (s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) { _end(); + return; + } + if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed) { + unawaited(_restartIce()); } }; return pc; } Future _addLocalMedia(RTCPeerConnection pc) async { + await _prepareAudioSession(); + await _disposeMicStream(); + try { + await _prepareMicRoute(); + } catch (e) { + logger.w( + '[call][pulse] маршрут недоступен, беру устройство по умолчанию: $e', + ); + await _resetMicRoute(); + } + await _selectMicInsideEngine(); _localStream = await navigator.mediaDevices.getUserMedia({ - 'audio': true, + 'audio': AudioDevices.micConstraints( + _micDeviceId, + monitorCapture: _monitorCapture, + ), 'video': _wantVideo, }); for (final track in _localStream!.getTracks()) { - await pc.addTrack(track, _localStream!); + final sender = await pc.addTrack(track, _localStream!); + if (track.kind == 'audio') _audioSender = sender; + } + _applyAudioTracks(); + await applyAudioRoute(); + } + + Future _selectMicInsideEngine() async { + final deviceId = _micDeviceId; + if (deviceId == null || !AudioDevices.switchesInsideEngine) return; + await AudioDevices.selectInput(deviceId); + } + + Future _disposeMicStream() async { + final stream = _micStream; + _micStream = null; + await _disposeStream(stream); + } + + List get _audioTracks => + _micStream?.getAudioTracks() ?? + _localStream?.getAudioTracks() ?? + const []; + + void _applyAudioTracks() { + for (final track in _audioTracks) { + track.enabled = audioTransmitting; + } + } + + Future setPulseSource(String? sourceName) async { + final previous = _pulseSource; + final next = (sourceName == null || sourceName.isEmpty) ? null : sourceName; + _pulseSource = next; + if (next == null) await _resetMicRoute(); + try { + await _replaceMicTrack(); + } catch (e) { + _pulseSource = previous; + await _resetMicRoute(); + rethrow; + } + await AppPulseSource.save(next ?? ''); + _notifyInfo(); + } + + Future _resetMicRoute() async { + _pulseSource = null; + _monitorCapture = false; + _micDeviceId = AppMicrophone.deviceId; + await PulseAudio.closeBridge(); + } + + Future _prepareMicRoute() async { + final wanted = _pulseSource; + if (!PulseAudio.supported || wanted == null) { + _monitorCapture = false; + await PulseAudio.closeBridge(); + return; + } + final source = await PulseAudio.find(wanted); + if (source == null) { + logger.w('[call][pulse] источник $wanted пропал'); + await _resetMicRoute(); + return; + } + _monitorCapture = source.isMonitor; + if (!source.isMonitor) { + final direct = await AudioDevices.findDevice(source.name); + if (direct != null) { + _micDeviceId = direct; + await PulseAudio.closeBridge(); + return; + } + } + final bridge = await PulseAudio.openBridge(source.name); + final device = bridge == null + ? null + : await AudioDevices.findDevice(bridge, attempts: 8); + if (device == null) { + await PulseAudio.closeBridge(); + throw PulseRouteException(source.label); + } + _micDeviceId = device; + } + + Future setMicrophone(String? deviceId) async { + final next = (deviceId == null || deviceId.isEmpty) ? null : deviceId; + _micDeviceId = next; + _pulseSource = null; + _monitorCapture = false; + await AppMicrophone.save(next ?? ''); + await AppPulseSource.save(''); + await PulseAudio.closeBridge(); + if (AudioDevices.switchesInsideEngine) { + await _selectMicInsideEngine(); + } else { + await _replaceMicTrack(); + } + _notifyInfo(); + } + + Future _replaceMicTrack() async { + await _prepareMicRoute(); + final sender = _audioSender; + if (sender == null) return; + final stream = await navigator.mediaDevices.getUserMedia({ + 'audio': AudioDevices.micConstraints( + _micDeviceId, + monitorCapture: _monitorCapture, + ), + 'video': false, + }); + final tracks = stream.getAudioTracks(); + if (tracks.isEmpty) { + await _disposeStream(stream); + return; + } + final track = tracks.first; + track.enabled = audioTransmitting; + await sender.replaceTrack(track); + final previous = _micStream; + _micStream = stream; + if (previous != null) { + await _disposeStream(previous); + } else { + for (final old + in _localStream?.getAudioTracks() ?? const []) { + try { + await old.stop(); + } catch (_) {} + } + } + } + + Future setSpeaker(bool on) async { + if (_speakerOn == on) return; + _speakerOn = on; + await applyAudioRoute(); + _notifyInfo(); + } + + Future _prepareAudioSession() async { + if (!_canRouteAudio) return; + if (defaultTargetPlatform == TargetPlatform.android) { + try { + await Helper.setAndroidAudioConfiguration( + AndroidAudioConfiguration.communication, + ); + } catch (e) { + logger.w('[call] setAndroidAudioConfiguration: $e'); + } + } + await applyAudioRoute(); + } + + Future applyAudioRoute() async { + if (!_canRouteAudio) return; + try { + await Helper.setSpeakerphoneOn(_speakerOn); + } catch (e) { + logger.w('[call] setSpeakerphoneOn($_speakerOn) недоступен: $e'); + } + } + + static bool get _canRouteAudio => + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + + Future _openSfuChannels(RTCPeerConnection pc) async { + await _closeSfuChannels(); + final commands = SfuCommandChannel(); + _sfuCommands = commands; + _sfuSlotSub = commands.slotUpdates.listen(_onSfuSlots); + _sfuLevelSub = commands.audioLevels.listen(_onSfuLevels); + for (final label in _sfuChannelLabels) { + try { + final channel = await pc.createDataChannel( + label, + RTCDataChannelInit() + ..ordered = true + ..maxRetransmitTime = 10000000, + ); + channel.onDataChannelState = (state) { + logger.i('[call][sfu] data channel $label $state'); + if (state == RTCDataChannelState.RTCDataChannelOpen) { + _scheduleDisplayLayout(); + } + }; + commands.bind(channel); + _sfuChannels.add(channel); + } catch (e) { + logger.w('[call][sfu] data channel $label failed: $e'); + } + } + } + + void _onSfuLevels(Map levels) { + final now = DateTime.now(); + levels.forEach((key, level) { + final id = _participantIdFrom(key.split(':').first); + if (id != null) _levelState[id] = (level: level, at: now); + }); + _levelState.removeWhere((_, v) => now.difference(v.at) > _levelTtl); + + final loud = _levelState.entries + .where((e) => e.value.level >= _sfuSpeakLevel) + .map((e) => e.key) + .toSet(); + logger.i('[call][sfu] levels: $levels speaking=$loud'); + if (loud.length == _speaking.length && loud.containsAll(_speaking)) return; + _speaking = loud; + _notifyInfo(); + } + + void _onSfuSlots(Map slots) { + if (slots.isEmpty) return; + _slotParticipant.clear(); + slots.forEach((key, slot) { + if (slot < 0) return; + final id = _participantIdFrom(key.split(':').first); + if (id != null) _slotParticipant[slot] = id; + }); + unawaited(_rebindSlotTracks()); + } + + Future _rebindSlotTracks() async { + await _clearParticipantStreams(); + await _collectReceivers(); + _notifyInfo(); + } + + void _scheduleDisplayLayout() { + _layoutDebounce?.cancel(); + _layoutDebounce = Timer( + const Duration(milliseconds: 300), + () => unawaited(_publishDisplayLayout()), + ); + } + + Future _publishDisplayLayout({bool force = false}) async { + final commands = _sfuCommands; + if (commands == null || _topology != 'SERVER' || _ended) return; + final items = []; + for (final p in _participants.values) { + if (p.isSelf || items.length >= _maxVideoSlots) continue; + if (!p.videoEnabled && !p.screenSharing) continue; + items.add( + SfuLayoutItem( + trackKey: 'u${p.id}:${p.screenSharing ? 'sSCREEN' : 'sCAMERA'}', + ), + ); + } + final keys = items.map((i) => i.trackKey).toList(growable: false); + if (!force && + _layoutSent && + keys.length == _lastLayout.length && + keys.every(_lastLayout.contains)) { + return; + } + if (!await commands.sendDisplayLayout(items)) return; + _lastLayout = keys; + _layoutSent = true; + } + + Set _videoSlotMids(String sdp) { + final mids = {}; + String? kind; + String? mid; + var recvOnly = false; + + void flush() { + final id = mid; + if (kind == 'video' && recvOnly && id != null) mids.add(id); + } + + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + flush(); + kind = line.substring(2).split(' ').first; + mid = null; + recvOnly = false; + } else if (line.startsWith('a=mid:')) { + mid = line.substring(6); + } else if (line == 'a=recvonly') { + recvOnly = true; + } + } + flush(); + return mids; + } + + Future _prepareVideoSlot(RTCPeerConnection pc, String offerSdp) async { + final mids = _videoSlotMids(offerSdp); + if (mids.isEmpty) return; + for (final transceiver in await pc.getTransceivers()) { + final mid = transceiver.mid; + if (!mids.contains(mid)) continue; + final tracks = + _cameraStream?.getVideoTracks() ?? const []; + if (tracks.isNotEmpty) { + try { + await transceiver.sender.replaceTrack(tracks.first); + } catch (e) { + logger.w('[call][sfu] video slot $mid replaceTrack failed: $e'); + } + } + try { + await transceiver.setDirection(TransceiverDirection.SendOnly); + } catch (e) { + logger.w('[call][sfu] video slot $mid setDirection failed: $e'); + continue; + } + _videoSender = transceiver.sender; + logger.i('[call][sfu] video slot mid=$mid -> sendonly'); + return; + } + } + + Future _closeSfuChannels() async { + _layoutDebounce?.cancel(); + _layoutDebounce = null; + await _sfuSlotSub?.cancel(); + _sfuSlotSub = null; + await _sfuLevelSub?.cancel(); + _sfuLevelSub = null; + await _sfuCommands?.dispose(); + _sfuCommands = null; + _slotParticipant.clear(); + _lastLayout = const []; + _layoutSent = false; + final channels = List.from(_sfuChannels); + _sfuChannels.clear(); + for (final channel in channels) { + try { + await channel.close(); + } catch (_) {} } } Future _setupKometProbe(RTCPeerConnection pc) async { - if (_topology == 'SERVER') return; + if (!_kometProbeEnabled || _topology == 'SERVER') return; try { final channel = await pc.createDataChannel( 'komet', @@ -669,6 +1352,7 @@ class CallSession { Future _setupSfu() async { if (_pc != null) { + await _closeSfuChannels(); await _pc!.close(); _pc = null; _probeChannel = null; @@ -679,23 +1363,79 @@ class CallSession { } await _localStream?.dispose(); _localStream = null; + await _disposeMicStream(); + _audioSender = null; _videoSender = null; - await _disposeLocalVideoStream(); - _localVideo = false; - _localScreen = false; + _screenSender = null; } _setState(CallSessionState.connecting); final pc = await _createPc(_iceServers); _pc = pc; await _addLocalMedia(pc); - logger.t('[call][sfu] allocate-consumer'); - await _signaling?.allocateConsumer(); + await _republishVideo(pc); + await _openSfuChannels(pc); + logger.i( + '[call][sfu] allocate-consumer camera=$_localVideo screen=$_localScreen', + ); + try { + final reply = await _signaling?.allocateConsumer(); + logger.i('[call][sfu] allocate-consumer reply: $reply'); + } catch (e) { + logger.w('[call][sfu] allocate-consumer failed: $e'); + } + } + + Future _rebuildSfuPc() async { + await _closeSfuChannels(); + try { + await _pc?.close(); + } catch (_) {} + _pc = null; + _audioSender = null; + _videoSender = null; + _screenSender = null; + _remoteDescSet = false; + _pendingCandidates.clear(); + await _clearParticipantStreams(); + + for (final track in _localStream?.getTracks() ?? []) { + try { + await track.stop(); + } catch (_) {} + } + try { + await _localStream?.dispose(); + } catch (_) {} + _localStream = null; + + final pc = await _createPc(_iceServers); + _pc = pc; + await _addLocalMedia(pc); + await _republishVideo(pc); + await _openSfuChannels(pc); + } + + Future _republishVideo(RTCPeerConnection pc) async { + final camera = _cameraStream; + if (camera != null) { + final tracks = camera.getVideoTracks(); + if (tracks.isNotEmpty) { + _videoSender = await pc.addTrack(tracks.first, camera); + } + } + final screen = _screenStream; + if (screen != null) { + final tracks = screen.getVideoTracks(); + if (tracks.isNotEmpty) { + _screenSender = await pc.addTrack(tracks.first, screen); + } + } } Future _onTopologyChanged(Map msg) async { final topo = msg['topology']?.toString(); if (topo == null) return; - logger.t('[call] topology-changed -> $topo'); + logger.i('[call] topology-changed -> $topo'); info.topology = topo; final switchingToSfu = topo == 'SERVER' && _topology != 'SERVER'; _topology = topo; @@ -704,11 +1444,22 @@ class CallSession { } Future _onProducerUpdated(Map msg) async { - final pc = _pc; - if (pc == null) return; + logger.i( + '[call][sfu] producer-updated fields=${msg.keys.toList()} ' + 'sessionId=${msg['sessionId']}', + ); + if (_pc == null) return; final session = msg['sessionId']; + final previous = _sfuSessionId; if (session != null) _sfuSessionId = session; + if (previous != null && session != null && session != previous) { + logger.i('[call][sfu] session changed, recreating peer connection'); + await _rebuildSfuPc(); + } + + final pc = _pc; + if (pc == null) return; final description = msg['description']; String? sdp; @@ -720,84 +1471,339 @@ class CallSession { sdp = description; } if (sdp == null) { - logger.t('[call][sfu] producer-updated without sdp: $msg'); + logger.w('[call][sfu] producer-updated without sdp: $msg'); return; } - logger.t('[call][sfu] producer offer: ${_mLines(sdp)} m-lines'); + final ssrcs = _extractSsrcs(sdp); + logger.i( + '[call][sfu] producer offer: ${_mLines(sdp)} m-lines, ' + 'ssrcs=${ssrcs.length}, candidates=${_countCandidates(sdp)} ' + '(${_candidateTypes(sdp)}), ${_sdpSummary(sdp)}, ice=${_iceServerUrls()}', + ); + logger.i('[call][sfu] producer m-lines: ${_mLineDetails(sdp)}'); + logger.i('[call][sfu] producer video codecs: ${_videoCodecs(sdp)}'); await pc.setRemoteDescription(RTCSessionDescription(sdp, type)); _remoteDescSet = true; await _flushCandidates(); + await _addRemoteCandidatesFromSdp(pc, sdp); + await _prepareVideoSlot(pc, sdp); final answer = await pc.createAnswer({}); + if (_pc != pc) return; await pc.setLocalDescription(answer); - await _waitIceGathering(pc, const Duration(seconds: 3)); + if (_pc != pc) { + logger.w('[call][sfu] peer connection replaced, dropping answer'); + return; + } - final local = await pc.getLocalDescription(); + await _awaitIceGathering(pc); + if (_pc != pc) { + logger.w('[call][sfu] peer connection replaced while gathering'); + return; + } + + RTCSessionDescription? local; + try { + local = await pc.getLocalDescription(); + } catch (e) { + logger.w('[call][sfu] getLocalDescription failed: $e'); + } final answerSdp = local?.sdp ?? answer.sdp ?? ''; - final ssrcs = _extractSsrcs(answerSdp); - logger.t( + if (answerSdp.isEmpty) return; + logger.i( '[call][sfu] answer: ${_mLines(answerSdp)} m-lines, ' - 'ssrcs=${ssrcs.length}', + 'candidates=${_countCandidates(answerSdp)} ' + '(${_candidateTypes(answerSdp)}), ${_sdpSummary(answerSdp)}, ' + 'gathering=${pc.iceGatheringState}', ); - - await _signaling?.acceptProducer( - description: answerSdp, - ssrcs: ssrcs, - sessionId: _sfuSessionId, + logger.i('[call][sfu] answer m-lines: ${_mLineDetails(answerSdp)}'); + logger.i('[call][sfu] answer video codecs: ${_videoCodecs(answerSdp)}'); + logger.i( + '[call][sfu] video feedback: offer=[${_videoFeedback(sdp)}] ' + 'answer=[${_videoFeedback(answerSdp)}]', ); + await _logSenders(); - if (_wantVideo) await _publishCamera(); + try { + logger.i('[call][sfu] accept-producer ssrcs=$ssrcs'); + final reply = await _signaling?.acceptProducer( + description: _labelLocalTracks(answerSdp), + ssrcs: ssrcs, + sessionId: _sfuSessionId, + ); + logger.i('[call][sfu] accept-producer reply: $reply'); + } catch (e) { + logger.w('[call][sfu] accept-producer failed: $e'); + } + + Timer(const Duration(seconds: 5), () { + if (_pc == pc && !_ended) unawaited(_dumpIceStats(pc)); + }); + _videoStatsTimer?.cancel(); + _videoStatsTimer = Timer.periodic(const Duration(seconds: 5), (t) { + if (_pc != pc || _ended) { + t.cancel(); + return; + } + unawaited(_dumpVideoStats(pc)); + }); + + if (_accepted) await _sendMediaSettings(); unawaited(_collectReceivers()); + unawaited(_publishDisplayLayout(force: true)); } - Future _publishCamera() async { + int _countCandidates(String sdp) => + RegExp(r'^a=candidate:', multiLine: true).allMatches(sdp).length; + + String _candidateTypes(String sdp) { + final counts = {}; + for (final m in RegExp( + r'^a=candidate:.* typ (\w+)', + multiLine: true, + ).allMatches(sdp)) { + final type = m.group(1) ?? '?'; + counts[type] = (counts[type] ?? 0) + 1; + } + return counts.isEmpty + ? 'none' + : counts.entries.map((e) => '${e.key}=${e.value}').join(' '); + } + + Future _addRemoteCandidatesFromSdp( + RTCPeerConnection pc, + String sdp, + ) async { + final mid = RegExp(r'^a=mid:(\S+)', multiLine: true).firstMatch(sdp); + if (mid == null) return; + final seen = {}; + var added = 0; + for (final m in RegExp( + r'^a=(candidate:\S.*)$', + multiLine: true, + ).allMatches(sdp)) { + final line = m.group(1)!.trim(); + if (!seen.add(line)) continue; + try { + await pc.addCandidate(RTCIceCandidate(line, mid.group(1), 0)); + added++; + } catch (_) {} + } + logger.i( + '[call][sfu] remote candidates added=$added: ' + '${seen.map((c) => c.split(' ').take(6).join(' ')).join(' | ')}', + ); + } + + Future _dumpVideoStats(RTCPeerConnection pc) async { try { - await _signaling?.changeSimulcast( - mediaSource: 'CAMERA', - layers: const [ - { - 'rid': 'h', - 'width': 1280, - 'height': 720, - 'fps': 30, - 'bitrateKbps': 2000, - }, - ], + final rows = []; + for (final r in await pc.getStats()) { + if (r.type != 'inbound-rtp') continue; + final v = r.values; + if (v['kind'] != 'video' && v['mediaType'] != 'video') continue; + rows.add( + '[ssrc=${v['ssrc']} bytes=${v['bytesReceived']} ' + 'packets=${v['packetsReceived']} decoded=${v['framesDecoded']} ' + '${v['frameWidth']}x${v['frameHeight']}]', + ); + } + var transportBytes = 0; + var audioBytes = 0; + for (final r in await pc.getStats()) { + final v = r.values; + if (r.type == 'transport') { + final b = v['bytesReceived']; + if (b is num) transportBytes += b.toInt(); + } else if (r.type == 'inbound-rtp' && + (v['kind'] == 'audio' || v['mediaType'] == 'audio')) { + final b = v['bytesReceived']; + if (b is num) audioBytes += b.toInt(); + } + } + logger.i( + '[call][sfu] inbound video: ${rows.join(' ')} ' + '| transport=$transportBytes audio=$audioBytes', ); - } catch (_) {} + } catch (e) { + logger.w('[call][sfu] video stats failed: $e'); + } + } + + Future _dumpIceStats(RTCPeerConnection pc) async { + try { + final reports = await pc.getStats(); + final candidates = {}; + for (final r in reports) { + if (r.type != 'local-candidate' && r.type != 'remote-candidate') { + continue; + } + final v = r.values; + candidates[r.id] = + '${v['candidateType']}/${v['protocol']} ' + '${v['ip'] ?? v['address']}:${v['port']}'; + } + + for (final r in reports) { + if (r.type != 'candidate-pair' && r.type != 'googCandidatePair') { + continue; + } + final v = r.values; + final from = candidates[v['localCandidateId']] ?? '?'; + final to = candidates[v['remoteCandidateId']] ?? '?'; + logger.w( + '[call][sfu] pair ${v['state'] ?? v['googState']}: $from -> $to ' + 'sent=${v['requestsSent']} recv=${v['responsesReceived']} ' + 'inRecv=${v['requestsReceived']} nominated=${v['nominated']}', + ); + } + } catch (e) { + logger.w('[call][sfu] ice stats failed: $e'); + } + } + + String _iceServerUrls() => + _iceServers.whereType().map((s) => '${s['urls']}').join(' | '); + + String _sdpSummary(String sdp) { + final bundle = RegExp( + r'^a=group:BUNDLE (.*)$', + multiLine: true, + ).firstMatch(sdp); + final mids = bundle == null + ? 'none' + : '${bundle.group(1)!.trim().split(RegExp(r'\s+')).length}'; + final ufrags = RegExp( + r'^a=ice-ufrag:(\S+)', + multiLine: true, + ).allMatches(sdp).map((m) => m.group(1)).toSet().length; + var active = 0; + var total = 0; + for (final m in RegExp(r'^m=\S+ (\d+)', multiLine: true).allMatches(sdp)) { + total++; + if (m.group(1) != '0') active++; + } + final setup = RegExp( + r'^a=setup:(\S+)', + multiLine: true, + ).allMatches(sdp).map((m) => m.group(1)).toSet().join(','); + final lite = sdp.contains('a=ice-lite') ? ' ice-lite' : ''; + return 'bundle=$mids ufrags=$ufrags active=$active/$total ' + 'setup=$setup$lite'; + } + + String _videoFeedback(String sdp) { + final fb = {}; + var inVideo = false; + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + inVideo = line.startsWith('m=video'); + } else if (inVideo && line.startsWith('a=rtcp-fb:')) { + final idx = line.indexOf(' '); + if (idx > 0) fb.add(line.substring(idx + 1)); + } else if (inVideo && line.startsWith('a=extmap:')) { + if (line.contains('transport-wide-cc')) fb.add('extmap:transport-cc'); + } + } + return fb.isEmpty ? 'нет' : fb.join(', '); + } + + String _videoCodecs(String sdp) { + final codecs = {}; + var inVideo = false; + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + inVideo = line.startsWith('m=video'); + } else if (inVideo && line.startsWith('a=rtpmap:')) { + final m = RegExp(r'^a=rtpmap:\d+ ([^/]+)/').firstMatch(line); + if (m != null) codecs.add(m.group(1)!); + } + } + return codecs.isEmpty ? 'нет' : codecs.join(','); } int _mLines(String sdp) => RegExp(r'^m=', multiLine: true).allMatches(sdp).length; - List _extractSsrcs(String sdp) { - final set = {}; - for (final m in RegExp(r'^a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) { - final v = int.tryParse(m.group(1) ?? ''); + String _mLineDetails(String sdp) { + final rows = []; + String? kind; + String? port; + String? mid; + String? dir; + String? msid; + + void flush() { + if (kind == null) return; + rows.add( + '[$kind:$port mid=${mid ?? '?'} ${dir ?? '?'} msid=${msid ?? '-'}]', + ); + } + + for (var line in sdp.split('\n')) { + line = line.trim(); + if (line.startsWith('m=')) { + flush(); + final parts = line.substring(2).split(' '); + kind = parts.isEmpty ? '?' : parts.first; + port = parts.length > 1 ? parts[1] : '?'; + mid = null; + dir = null; + msid = null; + } else if (line.startsWith('a=mid:')) { + mid = line.substring(6); + } else if (line == 'a=sendrecv' || + line == 'a=recvonly' || + line == 'a=sendonly' || + line == 'a=inactive') { + dir = line.substring(2); + } else if (line.startsWith('a=msid:')) { + msid = line.substring(7); + } + } + flush(); + return rows.join(' '); + } + + List _extractSsrcs(String sdp) { + final set = {}; + for (final m in RegExp(r'a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) { + final v = m.group(1); if (v != null) set.add(v); } return set.toList(); } - Future _waitIceGathering(RTCPeerConnection pc, Duration timeout) async { - if (pc.iceGatheringState == - RTCIceGatheringState.RTCIceGatheringStateComplete) { - return; - } - final completer = Completer(); - Timer? timer; - void finish() { - if (!completer.isCompleted) completer.complete(); - } + String _labelLocalTracks(String sdp) { + final self = 'u${ws2Config.userId}'; + final names = {}; + final camera = _videoSender?.track?.id; + final screen = _screenSender?.track?.id; + if (camera != null && camera.isNotEmpty) names[camera] = '$self:sCAMERA'; + if (screen != null && screen.isNotEmpty) names[screen] = '$self:sSCREEN'; + if (names.isEmpty) return sdp; - pc.onIceGatheringState = (state) { - if (state == RTCIceGatheringState.RTCIceGatheringStateComplete) finish(); - }; - timer = Timer(timeout, finish); - await completer.future; - timer.cancel(); - pc.onIceGatheringState = null; + var out = sdp; + for (final entry in names.entries) { + final id = RegExp.escape(entry.key); + final name = entry.value; + out = out.replaceAllMapped( + RegExp('^a=msid:(\\S+) $id\\s*\$', multiLine: true), + (m) => 'a=msid:${m[1]} $name', + ); + out = out.replaceAllMapped( + RegExp('^(a=ssrc:\\d+ msid:\\S+) $id\\s*\$', multiLine: true), + (m) => '${m[1]} $name', + ); + out = out.replaceAllMapped( + RegExp('^(a=ssrc:\\d+ label:)$id\\s*\$', multiLine: true), + (m) => '${m[1]}$name', + ); + } + return out; } String _videoDir(String sdp) { @@ -822,8 +1828,10 @@ class CallSession { Future _onRemoteTrack(RTCTrackEvent event) async { logger.t( - '[call] remote track: ${event.track.kind} streams=${event.streams.length}', + '[call] remote track: ${event.track.kind} id=${event.track.id} ' + 'streams=${event.streams.length}', ); + await _bindParticipantTrack(event.track); if (event.streams.isNotEmpty) { _remoteStreamRef = event.streams.first; _remoteStream.add(event.streams.first); @@ -832,6 +1840,39 @@ class CallSession { } } + int? _participantFromTrackId(String? trackId) { + if (trackId == null) return null; + final slot = RegExp(r'^video-pat-(\d+)$').firstMatch(trackId); + if (slot != null) { + return _slotParticipant[int.parse(slot.group(1)!)]; + } + for (final prefix in const ['video-', 'audio-']) { + if (trackId.length > prefix.length && trackId.startsWith(prefix)) { + final parsed = _participantIdFrom(trackId.substring(prefix.length)); + if (parsed != null) return parsed; + } + } + return null; + } + + Future _bindParticipantTrack(MediaStreamTrack track) async { + final id = _participantFromTrackId(track.id); + if (id == null || id == ws2Config.userId) return; + var stream = _participantStreams[id]; + if (stream == null) { + stream = await createLocalMediaStream('komet_p$id'); + _participantStreams[id] = stream; + } + if (stream.getTracks().any((t) => t.id == track.id)) return; + try { + await stream.addTrack(track); + } catch (_) { + return; + } + logger.t('[call] track ${track.id} -> participant $id'); + if (!_participantStreamUpdates.isClosed) _participantStreamUpdates.add(id); + } + Future _pushRemoteTrack(MediaStreamTrack track) async { var stream = _remoteStreamRef; if (stream == null) { @@ -847,6 +1888,26 @@ class CallSession { _remoteStream.add(stream); } + Future _logSenders() async { + final pc = _pc; + if (pc == null) return; + try { + final rows = []; + for (final tr in await pc.getTransceivers()) { + final sent = tr.sender.track; + final received = tr.receiver.track; + rows.add( + '[mid=${tr.mid} dir=${await tr.getCurrentDirection()} ' + 'send=${sent == null ? '-' : '${sent.kind}:${sent.id}'} ' + 'recv=${received == null ? '-' : '${received.kind}:${received.id}'}]', + ); + } + logger.i('[call][sfu] transceivers: ${rows.join(' ')}'); + } catch (e) { + logger.w('[call][sfu] transceiver dump failed: $e'); + } + } + Future _collectReceivers() async { final pc = _pc; if (pc == null) return; @@ -854,19 +1915,20 @@ class CallSession { for (final tr in await pc.getTransceivers()) { final track = tr.receiver.track; if (track != null) { - logger.t('[call] receiver track: ${track.kind}'); + logger.t('[call] receiver track: ${track.kind} id=${track.id}'); + await _bindParticipantTrack(track); await _pushRemoteTrack(track); } } } catch (_) {} } - Future _createAndSendOffer() async { + Future _createAndSendOffer({bool iceRestart = false}) async { final pc = _pc; final peerId = _peerId; if (pc == null || peerId == null) return; - final offer = await pc.createOffer({}); + final offer = await pc.createOffer(iceRestart ? {'iceRestart': true} : {}); final sdp = offer.sdp ?? ''; await pc.setLocalDescription(RTCSessionDescription(sdp, offer.type)); logger.t('[call] our offer video: ${_videoDir(sdp)}'); @@ -875,7 +1937,7 @@ class CallSession { participantType: _peerType, deviceIdx: _peerDeviceIdx, type: offer.type!, - sdp: sdp, + sdp: _labelLocalTracks(sdp), ); } @@ -928,6 +1990,13 @@ class CallSession { return; } + if (type == 'offer' && + pc.signalingState == + RTCSignalingState.RTCSignalingStateHaveLocalOffer) { + logger.w('[call] offer glare, rolling back local offer'); + await pc.setLocalDescription(RTCSessionDescription(null, 'rollback')); + } + await pc.setRemoteDescription(RTCSessionDescription(desc, type)); _remoteDescSet = true; await _flushCandidates(); @@ -943,7 +2012,7 @@ class CallSession { participantType: _peerType, deviceIdx: _peerDeviceIdx, type: answer.type!, - sdp: answer.sdp!, + sdp: _labelLocalTracks(answer.sdp!), ); } if (_current == CallSessionState.connecting) { @@ -984,7 +2053,32 @@ class CallSession { } } + Future _awaitIceGathering( + RTCPeerConnection pc, { + Duration timeout = const Duration(seconds: 5), + }) async { + if (pc.iceGatheringState == + RTCIceGatheringState.RTCIceGatheringStateComplete) { + return; + } + final done = Completer(); + _gatherDone = done; + try { + await done.future.timeout(timeout); + logger.i('[call][sfu] relay candidate gathered'); + } catch (_) { + logger.w('[call][sfu] no relay candidate within $timeout'); + } finally { + _gatherDone = null; + } + } + void _onLocalCandidate(RTCIceCandidate candidate) { + final line = candidate.candidate; + if (line != null && line.contains(' typ relay')) { + final done = _gatherDone; + if (done != null && !done.isCompleted) done.complete(); + } if (_topology == 'SERVER') return; final peerId = _peerId; if (peerId == null || candidate.candidate == null) return; @@ -998,13 +2092,16 @@ class CallSession { ); } - Future accept() async { + Future accept({bool activate = true}) async { if (_accepted) return; _accepted = true; - logger.t('[call] accepted'); - await _signaling?.acceptCall(); - await _sendMediaSettings(); - _setState(CallSessionState.active); + logger.i('[call] accept-call sent (activate=$activate)'); + await _signaling?.acceptCall( + isAudioEnabled: !_muted, + isVideoEnabled: _localVideo, + isScreenSharingEnabled: _localScreen, + ); + if (activate) _setState(CallSessionState.active); } Future sendAudioEnabledSignal(bool enabled) async { @@ -1017,10 +2114,7 @@ class CallSession { Future _applyMuted(bool muted, {bool announce = false}) async { _muted = muted; - for (final track - in _localStream?.getAudioTracks() ?? []) { - track.enabled = !muted; - } + _applyAudioTracks(); _notifyInfo(); if (announce) await _sendMediaSettings(); } @@ -1033,34 +2127,31 @@ class CallSession { ); } - Future setVideoEnabled(bool on) => - on ? _startLocalVideo(screen: false) : _stopLocalVideo(); + Future setVideoEnabled(bool on) => on ? _startCamera() : _stopCamera(); Future setScreenSharing(bool on) => - on ? _startLocalVideo(screen: true) : _stopLocalVideo(); + on ? _startScreenShare() : _stopScreenShare(); - Future _startLocalVideo({required bool screen}) async { + Future switchToServerTopology({bool force = false}) async { + if (_topology == 'SERVER') return; + try { + await _signaling?.switchTopology(force: force); + } catch (e) { + logger.w('[call] switch-topology failed: $e'); + } + } + + Future _startCamera() async { final pc = _pc; if (pc == null) return; - MediaStream stream; - try { - stream = screen - ? await navigator.mediaDevices.getDisplayMedia({ - 'video': true, - 'audio': false, - }) - : await navigator.mediaDevices.getUserMedia({ - 'video': true, - 'audio': false, - }); - } catch (e) { - logger.t('[call] video capture failed: $e'); - return; - } + final stream = await navigator.mediaDevices.getUserMedia({ + 'video': true, + 'audio': false, + }); - await _disposeLocalVideoStream(); - _localVideoStream = stream; + await _disposeStream(_cameraStream); + _cameraStream = stream; final tracks = stream.getVideoTracks(); final track = tracks.isEmpty ? null : tracks.first; @@ -1072,28 +2163,125 @@ class CallSession { } } - _localVideo = !screen; - _localScreen = screen; - - if (_topology != 'SERVER') await _createAndSendOffer(); + _localVideo = true; + await _renegotiate(); await _sendMediaSettings(); _notifyInfo(); } - Future _stopLocalVideo() async { + Future _stopCamera() async { try { await _videoSender?.replaceTrack(null); } catch (_) {} - await _disposeLocalVideoStream(); + await _disposeStream(_cameraStream); + _cameraStream = null; _localVideo = false; - _localScreen = false; await _sendMediaSettings(); _notifyInfo(); } - Future _disposeLocalVideoStream() async { - final stream = _localVideoStream; - _localVideoStream = null; + Future _startScreenShare() async { + if (_pc == null) return; + + await CallBridge.instance.setScreenShare(true); + + _localScreen = true; + await _sendMediaSettings(); + _notifyInfo(); + + final MediaStream stream; + try { + stream = await _captureScreen(); + } catch (e) { + _localScreen = false; + await CallBridge.instance.setScreenShare(false); + await _sendMediaSettings(); + _notifyInfo(); + rethrow; + } + logger.i('[call] screen captured, topology=$_topology'); + + await _disposeStream(_screenStream); + _screenStream = stream; + + final pc = _pc; + if (pc == null) return; + + final tracks = stream.getVideoTracks(); + final track = tracks.isEmpty ? null : tracks.first; + if (track != null) { + if (_screenSender == null) { + _screenSender = await pc.addTrack(track, stream); + } else { + await _screenSender!.replaceTrack(track); + } + } + + logger.i('[call] screen share published, topology=$_topology'); + await _renegotiate(); + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _captureScreen() async { + if (!_isDesktop) { + return navigator.mediaDevices.getDisplayMedia({ + 'video': true, + 'audio': false, + }); + } + final sources = await desktopCapturer.getSources( + types: [SourceType.Screen], + ); + if (sources.isEmpty) { + throw StateError('нет доступных экранов для захвата'); + } + return navigator.mediaDevices.getDisplayMedia({ + 'video': { + 'deviceId': {'exact': sources.first.id}, + 'mandatory': {'frameRate': 30.0}, + }, + 'audio': false, + }); + } + + Future _stopScreenShare() async { + try { + await _screenSender?.replaceTrack(null); + } catch (_) {} + await _disposeStream(_screenStream); + _screenStream = null; + _localScreen = false; + await CallBridge.instance.setScreenShare(false); + await _sendMediaSettings(); + _notifyInfo(); + } + + Future _renegotiate() async { + if (_topology == 'SERVER') return; + try { + await _createAndSendOffer(); + } catch (e) { + logger.w('[call] renegotiation offer failed: $e'); + } + } + + Future _clearParticipantStreams() async { + final entries = Map.from(_participantStreams); + _participantStreams.clear(); + for (final id in entries.keys) { + if (!_participantStreamUpdates.isClosed) { + _participantStreamUpdates.add(id); + } + } + for (final stream in entries.values) { + try { + await stream.dispose(); + } catch (_) {} + } + } + + Future _disposeStream(MediaStream? stream) async { if (stream == null) return; for (final track in stream.getTracks()) { try { @@ -1131,22 +2319,33 @@ class CallSession { Future _dispose() async { _levelTimer?.cancel(); + _videoStatsTimer?.cancel(); try { await _probeChannel?.close(); } catch (_) {} _probeChannel = null; + await _closeSfuChannels(); for (final track in _localStream?.getTracks() ?? []) { await track.stop(); } await _localStream?.dispose(); - await _disposeLocalVideoStream(); + await _disposeMicStream(); + await PulseAudio.closeBridge(); + await _disposeStream(_cameraStream); + await _disposeStream(_screenStream); + _cameraStream = null; + _screenStream = null; await _pc?.close(); if (_ownRemoteStream) { try { await _remoteStreamRef?.dispose(); } catch (_) {} } + await _clearParticipantStreams(); await _signaling?.close(); + if (!_participantStreamUpdates.isClosed) { + await _participantStreamUpdates.close(); + } if (!_state.isClosed) await _state.close(); if (!_remoteStream.isClosed) await _remoteStream.close(); if (!_info.isClosed) await _info.close(); diff --git a/lib/core/calls/conversation_params.dart b/lib/core/calls/conversation_params.dart index 64a9e2a..8000c77 100644 --- a/lib/core/calls/conversation_params.dart +++ b/lib/core/calls/conversation_params.dart @@ -1,7 +1,4 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import '../protocol/lz4_block.dart'; +import 'package:kolibri/kolibri.dart' as kb; /// Параметры подключения к звонку (`vcp`), которые сервер присылает в пуше /// входящего звонка (opcode 137) и в ответе на инициацию исходящего. @@ -79,73 +76,19 @@ class ConversationParams { return nowSec >= expiresAt! - 5; } - static List _splitTurn(Object? value) { - if (value is! String || value.isEmpty) return const []; - return value - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(); - } - - static List _stringList(Object? value) { - if (value is! List) return const []; - return value.whereType().toList(); - } - - /// Распаковывает и парсит строку `vcp`. Возвращает `null`, если формат - /// не распознан. + /// Распаковывает и парсит строку `vcp` через Rust-ядро (kolibri). Возвращает + /// `null`, если формат не распознан. Требует инициализации `initKolibri()`. static ConversationParams? decode(String vcp) { - final sep = vcp.indexOf(':'); - if (sep <= 0) return null; - - final rawLen = int.tryParse(vcp.substring(0, sep)); - if (rawLen == null || rawLen <= 0) return null; - - final Uint8List compressed; - try { - compressed = base64.decode(vcp.substring(sep + 1)); - } catch (_) { - return null; - } - - final Uint8List bytes; - try { - final decompressed = lz4BlockDecompress(compressed, rawLen); - bytes = decompressed.length > rawLen - ? Uint8List.sublistView(decompressed, 0, rawLen) - : decompressed; - } catch (_) { - return null; - } - - final Object? json; - try { - json = jsonDecode(utf8.decode(bytes)); - } catch (_) { - return null; - } - if (json is! Map) return null; - - final token = json['tkn']; - final wse = json['wse']; - if (token is! String || wse is! String) return null; - + final kb.CallParams? p = kb.decodeVcp(vcp: vcp, conversationId: ''); + if (p == null) return null; return ConversationParams( - token: token, - wsEndpoint: wse, - wsIps: _stringList(json['wsip']), - wtEndpoint: json['wte'] as String?, - wtIps: _stringList(json['wtip']), - callsApiEndpoint: json['vcae'] as String?, - callsApiIps: _stringList(json['vcaip']), - clientType: json['srcp'] as String?, - expiresAt: json['et'] is int ? json['et'] as int : null, - stun: json['stne'] as String?, - turn: _splitTurn(json['trne']), - turnUser: json['trnu'] as String?, - turnPassword: json['trnp'] as String?, - isVideo: json['iv'] == true, + token: p.token, + wsEndpoint: p.wsEndpoint, + stun: p.stun, + turn: p.turn, + turnUser: p.turnUser, + turnPassword: p.turnPassword, + isVideo: p.isVideo, ); } } diff --git a/lib/core/calls/pulse_audio.dart b/lib/core/calls/pulse_audio.dart new file mode 100644 index 0000000..0c3b468 --- /dev/null +++ b/lib/core/calls/pulse_audio.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart' show kIsWeb; + +import '../utils/logger.dart'; + +class PulseSource { + const PulseSource({ + required this.name, + required this.label, + required this.isMonitor, + }); + + final String name; + final String label; + final bool isMonitor; +} + +class PulseRouteException implements Exception { + const PulseRouteException(this.source); + + final String source; + + @override + String toString() => source; +} + +class PulseAudio { + PulseAudio._(); + + static const String bridgePrefix = 'komet_capture_'; + + static String? _bridgeModule; + static String? _bridgeMaster; + static String? _bridgeSource; + + static bool get supported => !kIsWeb && Platform.isLinux; + + static String get _bridgeName => '$bridgePrefix$pid'; + + static Future _pactl(List args) async { + if (!supported) return null; + try { + return await Process.run( + 'pactl', + args, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + } on ProcessException catch (e) { + logger.w('[call][pulse] pactl ${args.first}: ${e.message}'); + return null; + } + } + + static Future isAvailable() async => + (await _pactl(const ['info']))?.exitCode == 0; + + static Future> sources() async { + final result = await _pactl(const ['-f', 'json', 'list', 'sources']); + if (result == null || result.exitCode != 0) return const []; + return parseSources(result.stdout as String); + } + + static List parseSources(String json) { + final List entries; + try { + entries = jsonDecode(json) as List; + } catch (e) { + logger.w('[call][pulse] список источников: $e'); + return const []; + } + final sources = []; + for (final entry in entries.whereType>()) { + final name = entry['name']; + if (name is! String || name.isEmpty) continue; + if (name.startsWith(bridgePrefix)) continue; + final monitorOf = entry['monitor_source']; + final isMonitor = monitorOf is String && monitorOf.isNotEmpty; + sources.add( + PulseSource( + name: name, + label: _labelOf(entry, name, isMonitor), + isMonitor: isMonitor, + ), + ); + } + return sources; + } + + static Future find(String name) async { + for (final source in await sources()) { + if (source.name == name) return source; + } + return null; + } + + static Future openBridge(String master) async { + if (_bridgeMaster == master && _bridgeSource != null) return _bridgeSource; + await closeBridge(); + await _dropStaleBridges(); + final name = _bridgeName; + final result = await _pactl([ + 'load-module', + 'module-remap-source', + 'master=$master', + 'source_name=$name', + 'source_properties=device.description=$name', + ]); + if (result == null || result.exitCode != 0) { + logger.w('[call][pulse] remap-source($master): ${result?.stderr}'); + return null; + } + final module = (result.stdout as String).trim(); + if (module.isEmpty) return null; + _bridgeModule = module; + _bridgeMaster = master; + _bridgeSource = name; + logger.i('[call][pulse] мост $name ← $master (модуль $module)'); + return name; + } + + static Future closeBridge() async { + final module = _bridgeModule; + _bridgeModule = null; + _bridgeMaster = null; + _bridgeSource = null; + if (module == null) return; + await _pactl(['unload-module', module]); + } + + static Future _dropStaleBridges() async { + final result = await _pactl(const ['list', 'modules', 'short']); + if (result == null || result.exitCode != 0) return; + for (final line in const LineSplitter().convert(result.stdout as String)) { + final columns = line.split('\t'); + if (columns.length < 3) continue; + final owner = _bridgeOwnerPid(columns[2]); + if (owner == null || Directory('/proc/$owner').existsSync()) continue; + logger.i('[call][pulse] снимаю зависший мост процесса $owner'); + await _pactl(['unload-module', columns[0]]); + } + } + + static int? _bridgeOwnerPid(String argument) { + final match = RegExp('$bridgePrefix([0-9]+)').firstMatch(argument); + return match == null ? null : int.tryParse(match.group(1)!); + } + + static String _labelOf( + Map entry, + String name, + bool isMonitor, + ) { + final description = entry['description']; + if (_usable(description)) return description as String; + final properties = entry['properties']; + final device = properties is Map ? properties['device.description'] : null; + if (_usable(device)) { + return isMonitor ? 'Monitor of $device' : device as String; + } + return name; + } + + static bool _usable(Object? value) => + value is String && value.isNotEmpty && value != '(null)'; +} diff --git a/lib/core/calls/sfu_data_channel.dart b/lib/core/calls/sfu_data_channel.dart new file mode 100644 index 0000000..48ccb5f --- /dev/null +++ b/lib/core/calls/sfu_data_channel.dart @@ -0,0 +1,346 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import '../utils/logger.dart'; + +class MsgpackWriter { + final BytesBuilder _out = BytesBuilder(); + + Uint8List takeBytes() => _out.takeBytes(); + + void raw(int byte) => _out.addByte(byte); + + void nil() => raw(0xC0); + + void boolean(bool value) => raw(value ? 0xC3 : 0xC2); + + void integer(int value) { + if (value >= 0) { + if (value < 0x80) return raw(value); + if (value <= 0xFF) { + raw(0xCC); + return raw(value); + } + if (value <= 0xFFFF) { + raw(0xCD); + return _uint(value, 2); + } + if (value <= 0xFFFFFFFF) { + raw(0xCE); + return _uint(value, 4); + } + raw(0xCF); + return _uint(value, 8); + } + if (value >= -32) return raw(0xE0 | (value + 32)); + if (value >= -128) { + raw(0xD0); + return _uint(value & 0xFF, 1); + } + if (value >= -32768) { + raw(0xD1); + return _uint(value & 0xFFFF, 2); + } + if (value >= -2147483648) { + raw(0xD2); + return _uint(value & 0xFFFFFFFF, 4); + } + raw(0xD3); + _uint(value, 8); + } + + void string(String value) { + final bytes = utf8.encode(value); + final length = bytes.length; + if (length < 32) { + raw(0xA0 | length); + } else if (length <= 0xFF) { + raw(0xD9); + raw(length); + } else if (length <= 0xFFFF) { + raw(0xDA); + _uint(length, 2); + } else { + raw(0xDB); + _uint(length, 4); + } + _out.add(bytes); + } + + void arrayHeader(int length) { + if (length < 16) return raw(0x90 | length); + if (length <= 0xFFFF) { + raw(0xDC); + return _uint(length, 2); + } + raw(0xDD); + _uint(length, 4); + } + + void _uint(int value, int bytes) { + for (var shift = (bytes - 1) * 8; shift >= 0; shift -= 8) { + raw((value >> shift) & 0xFF); + } + } +} + +class MsgpackReader { + final Uint8List _data; + int _pos = 0; + + MsgpackReader(this._data); + + bool get exhausted => _pos >= _data.length; + + bool get nextIsString { + final b = _data[_pos]; + return (b & 0xE0) == 0xA0 || b == 0xD9 || b == 0xDA || b == 0xDB; + } + + int readInt() { + final b = _data[_pos++]; + if (b < 0x80) return b; + if (b >= 0xE0) return b - 256; + switch (b) { + case 0xCC: + return _uint(1); + case 0xCD: + return _uint(2); + case 0xCE: + return _uint(4); + case 0xCF: + return _uint(8); + case 0xD0: + final v = _uint(1); + return v >= 0x80 ? v - 0x100 : v; + case 0xD1: + final v = _uint(2); + return v >= 0x8000 ? v - 0x10000 : v; + case 0xD2: + final v = _uint(4); + return v >= 0x80000000 ? v - 0x100000000 : v; + case 0xD3: + return _uint(8); + } + throw FormatException('не целое: 0x${b.toRadixString(16)}'); + } + + String readString() { + final b = _data[_pos++]; + int length; + if ((b & 0xE0) == 0xA0) { + length = b & 0x1F; + } else if (b == 0xD9) { + length = _uint(1); + } else if (b == 0xDA) { + length = _uint(2); + } else if (b == 0xDB) { + length = _uint(4); + } else { + throw FormatException('не строка: 0x${b.toRadixString(16)}'); + } + final value = utf8.decode(_data.sublist(_pos, _pos + length)); + _pos += length; + return value; + } + + int readMapHeader() { + final b = _data[_pos++]; + if ((b & 0xF0) == 0x80) return b & 0x0F; + if (b == 0xDE) return _uint(2); + if (b == 0xDF) return _uint(4); + throw FormatException('не map: 0x${b.toRadixString(16)}'); + } + + int readArrayHeader() { + final b = _data[_pos++]; + if ((b & 0xF0) == 0x90) return b & 0x0F; + if (b == 0xDC) return _uint(2); + if (b == 0xDD) return _uint(4); + throw FormatException('не array: 0x${b.toRadixString(16)}'); + } + + int _uint(int bytes) { + var value = 0; + for (var i = 0; i < bytes; i++) { + value = (value << 8) | _data[_pos++]; + } + return value; + } +} + +class SfuLayoutItem { + final String trackKey; + final int width; + final int height; + + const SfuLayoutItem({ + required this.trackKey, + this.width = 640, + this.height = 360, + }); +} + +class SfuCommandChannel { + static const int _commandDisplayLayout = 0; + static const int _fitMode = 0; + + static const int _notifyAliases = 1; + static const int _notifySlots = 2; + static const int _notifyAudioLevels = 6; + + RTCDataChannel? _command; + int _sequence = 1; + + final Map _aliases = {}; + final _slots = StreamController>.broadcast(); + final _levels = StreamController>.broadcast(); + + Stream> get slotUpdates => _slots.stream; + Stream> get audioLevels => _levels.stream; + + void bind(RTCDataChannel channel) { + if (channel.label == 'producerCommand') { + _command = channel; + channel.onMessage = _onCommandReply; + return; + } + if (channel.label != 'producerNotification') return; + channel.onMessage = _onNotification; + } + + bool get ready => _command?.state == RTCDataChannelState.RTCDataChannelOpen; + + Future sendDisplayLayout( + List items, { + bool snapshot = true, + }) async { + final channel = _command; + if (channel == null) return false; + if (channel.state != RTCDataChannelState.RTCDataChannelOpen) { + logger.w('[call][sfu] producerCommand не открыт, слои не отправлены'); + return false; + } + + final writer = MsgpackWriter() + ..integer(_commandDisplayLayout) + ..integer(0) + ..integer(_sequence++) + ..boolean(snapshot); + + if (items.isEmpty) { + writer.nil(); + } else { + writer.arrayHeader(items.length * 2); + for (final item in items) { + writer + ..string(item.trackKey) + ..integer(0) + ..nil() + ..integer(item.width) + ..integer(item.height) + ..integer(_fitMode); + } + } + writer.nil(); + + final payload = writer.takeBytes(); + try { + await channel.send(RTCDataChannelMessage.fromBinary(payload)); + logger.i( + '[call][sfu] update-display-layout: ' + '${items.map((i) => i.trackKey).join(', ')} ' + 'raw=${payload.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); + return true; + } catch (e) { + logger.w('[call][sfu] update-display-layout failed: $e'); + return false; + } + } + + void _onCommandReply(RTCDataChannelMessage message) { + if (!message.isBinary) return; + final bytes = message.binary; + final head = bytes.length > 32 ? bytes.sublist(0, 32) : bytes; + final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); + try { + final reader = MsgpackReader(bytes); + final type = reader.readInt(); + final version = reader.readInt(); + final error = reader.readInt(); + if (error != 0) { + logger.w( + '[call][sfu] command reply type=$type version=$version ' + 'ERROR=$error raw=$hex', + ); + return; + } + logger.i('[call][sfu] command reply type=$type ok raw=$hex'); + } catch (e) { + logger.w('[call][sfu] command reply parse failed: $e raw=$hex'); + } + } + + int _dumped = 0; + + void _onNotification(RTCDataChannelMessage message) { + if (!message.isBinary) return; + if (_dumped < 12) { + _dumped++; + final bytes = message.binary; + final head = bytes.length > 64 ? bytes.sublist(0, 64) : bytes; + logger.i( + '[call][sfu] notify raw len=${bytes.length} ' + '${head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); + } + final bytes = message.binary; + if (bytes.isEmpty) return; + final type = bytes[0]; + final reader = MsgpackReader(Uint8List.sublistView(bytes, 1)); + try { + switch (type) { + case _notifyAliases: + final count = reader.readMapHeader(); + for (var i = 0; i < count; i++) { + final key = reader.readString(); + _aliases[reader.readInt()] = key; + } + break; + case _notifySlots: + final count = reader.readArrayHeader(); + final slots = {}; + for (var i = 0; i < count; i++) { + final key = _aliases[reader.readInt()]; + if (key != null) slots[key] = i; + } + logger.i('[call][sfu] slots: $slots'); + if (!_slots.isClosed) _slots.add(slots); + break; + case _notifyAudioLevels: + final count = reader.readMapHeader(); + final levels = {}; + for (var i = 0; i < count; i++) { + final key = _aliases[reader.readInt()]; + final level = reader.readInt(); + if (key != null) levels[key] = level; + } + if (!_levels.isClosed) _levels.add(levels); + break; + } + } catch (e) { + logger.w('[call][sfu] notify type=$type parse failed: $e'); + } + } + + Future dispose() async { + _command = null; + _aliases.clear(); + if (!_slots.isClosed) await _slots.close(); + if (!_levels.isClosed) await _levels.close(); + } +} diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart index 5f0f242..baa9ac5 100644 --- a/lib/core/calls/ws2_signaling.dart +++ b/lib/core/calls/ws2_signaling.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import '../utils/logger.dart'; +import 'package:kolibri/kolibri.dart' as kb; + import 'conversation_params.dart'; /// Параметры подключения к сигналинг-сокету ws2. @@ -20,55 +20,63 @@ class Ws2Config { const Ws2Config({required this.uri, required this.userId}); - static const _defaultCapabilities = '3c03f'; - static const _appVersion = 'sdk-0.1.16.4'; + static const defaultCapabilities = '3c02f'; + static const _appVersion = 'sdk-0.2.1.3'; + static const defaultDevice = 'Android/Unknown'; + static const defaultOsVersion = '34'; /// Входящий звонок: из распакованных параметров [ConversationParams]. /// `userId` — часть после `:` в [ConversationParams.turnUser]. factory Ws2Config.fromVcp( ConversationParams params, { required String conversationId, - String capabilities = _defaultCapabilities, - String device = 'Komet', - String osVersion = '36', + String capabilities = defaultCapabilities, + String? device, + String? osVersion, }) { - final userId = - int.tryParse((params.turnUser ?? '').split(':').last) ?? 0; - final uri = Uri.parse(params.wsEndpoint).replace(queryParameters: { - 'userId': '$userId', - 'entityType': 'USER', - 'conversationId': conversationId, - 'token': params.token, - 'version': '5', - 'capabilities': capabilities, - 'device': device, - 'platform': 'ANDROID', - 'clientType': 'ONE_ME', - 'appVersion': _appVersion, - 'osVersion': osVersion, - }); + final userId = int.tryParse((params.turnUser ?? '').split(':').last) ?? 0; + final uri = Uri.parse(params.wsEndpoint).replace( + queryParameters: { + 'userId': '$userId', + 'token': params.token, + 'conversationId': conversationId, + 'version': '5', + 'capabilities': capabilities, + 'device': device ?? defaultDevice, + 'platform': 'ANDROID', + 'clientType': 'ONE_ME', + 'appVersion': _appVersion, + 'osVersion': osVersion ?? defaultOsVersion, + }, + ); return Ws2Config(uri: uri, userId: userId); } - /// Исходящий звонок: `endpoint` из ответа opcode 78 уже содержит токен и - /// conversationId/userId в query — дописываем клиентские параметры. + /// Исходящий звонок или вход в конференцию: `endpoint` из ответа opcode 78 / + /// 166 уже содержит токен и conversationId/userId в query — дописываем + /// клиентские параметры. Без `tgt=start` медиасервер принимает сокет, но не + /// поднимает разговор и не шлёт нотификацию `connection`. factory Ws2Config.fromEndpoint( String endpoint, { required int userId, - String capabilities = _defaultCapabilities, - String device = 'Komet', + String capabilities = defaultCapabilities, + String? device, + String? osVersion, }) { final base = Uri.parse(endpoint); - final uri = base.replace(queryParameters: { - ...base.queryParameters, - 'platform': 'ANDROID', - 'version': '5', - 'capabilities': capabilities, - 'clientType': 'ONE_ME', - 'appVersion': _appVersion, - 'device': device, - 'tgt': 'start', - }); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'version': '5', + 'capabilities': capabilities, + 'device': device ?? defaultDevice, + 'platform': 'ANDROID', + 'clientType': 'ONE_ME', + 'appVersion': _appVersion, + 'osVersion': osVersion ?? defaultOsVersion, + 'tgt': 'start', + }, + ); return Ws2Config(uri: uri, userId: userId); } } @@ -84,17 +92,19 @@ class Ws2CommandException implements Exception { /// Клиент сигналинга звонка поверх WebSocket `ws2`. /// -/// Конверт сообщений (подтверждено захватом `docs/ws2_capture.log`): +/// Тонкий адаптер над Rust-ядром (kolibri [kb.CallSignaling]): ядро держит +/// WebSocket, корреляцию `sequence`/`response`, keepalive `ping`→`pong` и +/// разбор кадров; здесь — прежний Dart-интерфейс для [call_session]. +/// +/// Конверт сообщений: /// - запрос: `{"command": ..., ..., "sequence": N}` /// - ответ: `{"sequence": N, "response": "", "type": "response"}` /// - пуш: `{..., "notification": "", "type": "notification"}` -/// - keepalive: текстовый кадр `ping` → ответ `pong`. class Ws2Signaling { final Ws2Config config; - WebSocket? _socket; - int _sequence = 0; - final Map>> _pending = {}; + kb.CallSignaling? _call; + StreamSubscription? _notifSub; final _notifications = StreamController>.broadcast(); final _closed = Completer(); @@ -104,106 +114,58 @@ class Ws2Signaling { /// Пуши сервера (`type == "notification"`). Фильтруй по полю `notification`. Stream> get notifications => _notifications.stream; - /// Завершается, когда сокет закрыт (значение — причина закрытия, если была). + /// Завершается, когда сокет закрыт. Future get done => _closed.future; - bool get isConnected => _socket != null; + bool get isConnected => _call?.isConnected() ?? false; Future connect() async { - final socket = await WebSocket.connect( - config.uri.toString(), - headers: {'User-Agent': 'okhttp/4.12.0'}, + final call = await kb.connectCallSignaling( + url: config.uri.toString(), + userAgent: 'okhttp/4.12.0', ); - _socket = socket; - socket.listen( - _onFrame, - onError: _onDone, + _call = call; + _notifSub = call.notifications().listen( + (json) { + Object? decoded; + try { + decoded = jsonDecode(json); + } catch (_) { + return; + } + if (decoded is Map) _notifications.add(decoded); + }, + onError: (_) => _onDone(null), onDone: () => _onDone(null), cancelOnError: false, ); } - void _onFrame(dynamic frame) { - if (frame is String && frame == 'ping') { - _socket?.add('pong'); - return; - } - - final String text; - if (frame is String) { - text = frame; - } else if (frame is List) { - text = utf8.decode(frame); - } else { - return; - } - - Object? decoded; - try { - decoded = jsonDecode(text); - } catch (_) { - return; - } - if (decoded is! Map) return; - - final label = - decoded['notification'] ?? decoded['response'] ?? decoded['type']; - final dump = jsonEncode(decoded); - logger.t('[ws2] ← $label'); - logger.t(dump.length > 1500 - ? '${dump.substring(0, 1500)}… (${dump.length}b)' - : dump); - - final type = decoded['type']; - if (type == 'response' || type == 'error') { - final seq = decoded['sequence']; - if (seq is int) { - final completer = _pending.remove(seq); - if (completer != null && !completer.isCompleted) { - completer.complete(decoded); - } - } - if (type == 'error') _notifications.add(decoded); - return; - } - - if (type == 'notification' || decoded.containsKey('notification')) { - _notifications.add(decoded); - } - } - void _onDone(Object? error) { - for (final c in _pending.values) { - if (!c.isCompleted) c.completeError(error ?? const SocketException('ws2 closed')); - } - _pending.clear(); if (!_closed.isCompleted) _closed.complete(error); if (!_notifications.isClosed) _notifications.close(); } /// Отправляет команду и ждёт ответ сервера. Бросает [Ws2CommandException], - /// если в ответе есть поле `error`. + /// если сервер вернул ошибку. Future> sendCommand( String command, { Map extra = const {}, Duration timeout = const Duration(seconds: 15), - }) { - final socket = _socket; - if (socket == null) { + }) async { + final call = _call; + if (call == null) { return Future.error(StateError('ws2 не подключён')); } - - final seq = ++_sequence; - final completer = Completer>(); - _pending[seq] = completer; - - socket.add(jsonEncode({'command': command, ...extra, 'sequence': seq})); - - return completer.future.timeout(timeout).then((response) { - final error = response['error']; - if (error != null) throw Ws2CommandException(command, error); - return response; - }); + try { + final response = await call + .sendCommand(command: command, extraJson: jsonEncode(extra)) + .timeout(timeout); + final decoded = jsonDecode(response); + return decoded is Map ? decoded : {}; + } catch (e) { + throw Ws2CommandException(command, e); + } } /// Передаёт SDP (offer/answer) другому участнику. @@ -213,7 +175,7 @@ class Ws2Signaling { required String sdp, String participantType = 'USER', int deviceIdx = 0, - String capabilities = '1', + String capabilities = Ws2Config.defaultCapabilities, }) { return sendCommand( 'transmit-data', @@ -260,9 +222,45 @@ class Ws2Signaling { bool isVideoEnabled = false, bool isScreenSharingEnabled = false, bool isAnimojiEnabled = false, + bool? isFastScreenSharingEnabled, + bool? isAudioSharingEnabled, }) { return sendCommand( 'change-media-settings', + extra: { + 'mediaSettings': { + 'isVideoEnabled': isVideoEnabled, + 'isAudioEnabled': isAudioEnabled, + 'isScreenSharingEnabled': isScreenSharingEnabled, + 'isAnimojiEnabled': isAnimojiEnabled, + 'isFastScreenSharingEnabled': ?isFastScreenSharingEnabled, + 'isAudioSharingEnabled': ?isAudioSharingEnabled, + }, + }, + ); + } + + Future switchTopology({ + String topology = 'SERVER', + bool force = false, + }) { + return sendCommand( + 'switch-topology', + extra: {'topology': topology, 'force': force}, + ); + } + + Future requestRealloc() => sendCommand('request-realloc'); + + /// Принять входящий звонок (сторона вызываемого). + Future acceptCall({ + bool isAudioEnabled = true, + bool isVideoEnabled = false, + bool isScreenSharingEnabled = false, + bool isAnimojiEnabled = false, + }) { + return sendCommand( + 'accept-call', extra: { 'mediaSettings': { 'isVideoEnabled': isVideoEnabled, @@ -274,62 +272,47 @@ class Ws2Signaling { ); } - /// Принять входящий звонок (сторона вызываемого). - Future acceptCall() => sendCommand('accept-call'); - Future hangup({String reason = 'HUNGUP'}) => sendCommand('hangup', extra: {'reason': reason}); - Future allocateConsumer() => sendCommand( - 'allocate-consumer', - extra: const { - 'capabilities': { - 'maxH264Decoders': 10, - 'producerNotificationDataChannelVersion': 7, - 'producerCommandDataChannelVersion': 2, - 'audioMix': true, - 'consumerUpdate': true, - 'onDemandTracks': true, - 'singleSession': true, - 'unifiedPlan': true, - 'fastScreenShare': true, - 'producerScreenDataChannelVersion': 1, - 'consumerScreenDataChannelVersion': 1, - 'animojiDataChannelVersion': 2, - 'animojiBackendRender': true, - 'asrDataChannelVersion': 1, - 'consumerFastScreenShare': true, - 'consumerFastScreenShareQualityOnDemand': true, - 'audioShare': true, - 'simulcast': true, - 'simulcastNativeOrder': true, - 'red': true, - 'videoTracksCount': 10, - 'csrcAccessible': true, - }, - }, - ); + Future> allocateConsumer() => sendCommand( + 'allocate-consumer', + extra: const { + 'capabilities': { + 'maxH264Decoders': 10, + 'producerNotificationDataChannelVersion': 7, + 'producerCommandDataChannelVersion': 2, + 'audioMix': true, + 'consumerUpdate': true, + 'onDemandTracks': true, + 'singleSession': true, + 'unifiedPlan': true, + 'fastScreenShare': true, + 'consumerFastScreenShareQualityOnDemand': true, + 'red': true, + 'videoTracksCount': 10, + 'csrcAccessible': true, + }, + }, + ); - Future acceptProducer({ + Future> acceptProducer({ required String description, - required List ssrcs, + required List ssrcs, Object? sessionId, - }) => - sendCommand('accept-producer', extra: { - 'description': description, - 'ssrcs': ssrcs, - 'sessionId': ?sessionId, - }); - - Future changeSimulcast({ - String mediaSource = 'CAMERA', - required List> layers, - }) => - sendCommand('change-simulcast', - extra: {'mediaSource': mediaSource, 'layers': layers}); + }) => sendCommand( + 'accept-producer', + extra: { + 'description': description, + if (ssrcs.isNotEmpty) 'ssrcs': ssrcs, + 'sessionId': ?sessionId, + }, + ); Future close() async { - await _socket?.close(); - _socket = null; + await _notifSub?.cancel(); + _notifSub = null; + _call?.close(); + _call = null; } } diff --git a/lib/core/config/app_amoled.dart b/lib/core/config/app_amoled.dart index e4a8c77..e66b830 100644 --- a/lib/core/config/app_amoled.dart +++ b/lib/core/config/app_amoled.dart @@ -5,10 +5,13 @@ import 'persisted_setting.dart'; class AppAmoled { static const prefKey = 'app_amoled'; + static bool get systemIsDark => + PlatformDispatcher.instance.platformBrightness == Brightness.dark; + static final _setting = PersistedSetting( prefKey: prefKey, - defaultValue: false, - read: (prefs, key) => prefs.getBool(key), + defaultValue: systemIsDark, + read: (prefs, key) => prefs.getBool(key) ?? systemIsDark, write: (prefs, key, value) async { await prefs.setBool(key, value); }, diff --git a/lib/core/config/app_animations.dart b/lib/core/config/app_animations.dart index 17a88b3..6d4abab 100644 --- a/lib/core/config/app_animations.dart +++ b/lib/core/config/app_animations.dart @@ -6,4 +6,7 @@ class AppAnimations { static const String clock = '$_dir/ic_clock.json'; static const String search = '$_dir/ic_search.json'; static const String settings = '$_dir/ic_settings.json'; + static const String chat = '$_dir/ic_chat.json'; + static const String call = '$_dir/ic_call.json'; + static const String contacts = '$_dir/ic_contacts.json'; } diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart index 85f8b53..677380c 100644 --- a/lib/core/config/app_chat_chrome.dart +++ b/lib/core/config/app_chat_chrome.dart @@ -1,8 +1,14 @@ import 'package:flutter/foundation.dart'; +import '../../frontend/widgets/liquid_glass.dart'; import 'persisted_setting.dart'; -enum ChatChromeStyle { color, blur, none, transparent } +enum ChatChromeStyle { color, blur, none, transparent, liquidGlass } + +class ChatChromeMaterial { + static bool isLiquid(ChatChromeStyle style) => + style == ChatChromeStyle.liquidGlass && LiquidGlass.isSupported; +} class AppChatChrome { static const prefKey = 'app_chat_chrome'; diff --git a/lib/core/config/app_colors.dart b/lib/core/config/app_colors.dart index 6ee6bfe..a92d723 100644 --- a/lib/core/config/app_colors.dart +++ b/lib/core/config/app_colors.dart @@ -6,6 +6,25 @@ extension AppColorTokens on ColorScheme { const int kAvatarThumbSize = 144; +const Color kSuccessGreen = Color(0xFF2EC36B); +const Color kDangerRed = Color(0xFFE5484D); const Color kReadReceiptBlue = Color(0xFF4FC3F7); -const Color kOnlineGreen = Color(0xFF34C759); -const Color kEditorAccent = Color(0xFF2F8FFF); + +class MediaAccent { + static Color? _seed; + static ColorScheme? _scheme; + + static ColorScheme schemeOf(BuildContext context) { + final seed = Theme.of(context).colorScheme.primary; + if (_seed != seed || _scheme == null) { + _seed = seed; + _scheme = ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.dark, + ); + } + return _scheme!; + } + + static Color of(BuildContext context) => schemeOf(context).primary; +} diff --git a/lib/core/config/app_composer_background.dart b/lib/core/config/app_composer_background.dart new file mode 100644 index 0000000..b48f89f --- /dev/null +++ b/lib/core/config/app_composer_background.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; + +import '../../frontend/widgets/liquid_glass.dart'; +import 'persisted_setting.dart'; + +enum ComposerBackground { standard, frostBlur, liquidGlass } + +class ComposerMaterial { + static bool isLiquid(ComposerBackground value) => + value == ComposerBackground.liquidGlass && LiquidGlass.isSupported; + + static bool isFrost(ComposerBackground value) => + value == ComposerBackground.frostBlur || + (value == ComposerBackground.liquidGlass && !LiquidGlass.isSupported); +} + +class AppComposerBackground { + static const prefKey = 'app_composer_background'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: ComposerBackground.standard, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(ComposerBackground value) => _setting.save(value); + + static ComposerBackground _parse(String? val) => enumFromName( + ComposerBackground.values, + val, + ComposerBackground.standard, + ); +} diff --git a/lib/core/config/app_composer_style.dart b/lib/core/config/app_composer_style.dart new file mode 100644 index 0000000..39ed03c --- /dev/null +++ b/lib/core/config/app_composer_style.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; + +import 'app_visual_style.dart'; +import 'persisted_setting.dart'; + +enum ComposerStyle { auto, glossy, materialYou } + +class ComposerChrome { + static bool isGlossy(ComposerStyle style) => switch (style) { + ComposerStyle.auto => AppVisualStyle.current.value.glossyChrome, + ComposerStyle.glossy => true, + ComposerStyle.materialYou => false, + }; +} + +class AppComposerStyle { + static const prefKey = 'app_composer_style'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: ComposerStyle.glossy, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(ComposerStyle value) => _setting.save(value); + + static ComposerStyle _parse(String? val) => + enumFromName(ComposerStyle.values, val, ComposerStyle.glossy); +} diff --git a/lib/core/config/app_fonts.dart b/lib/core/config/app_fonts.dart index 8fe9b9d..624468f 100644 --- a/lib/core/config/app_fonts.dart +++ b/lib/core/config/app_fonts.dart @@ -1,11 +1,40 @@ import 'package:flutter/material.dart'; +import 'custom_font_service.dart'; + +const String kDisplayFontFamily = 'Outfit'; + +@immutable +class AppDisplayFont extends ThemeExtension { + final String? family; + + const AppDisplayFont(this.family); + + @override + AppDisplayFont copyWith({String? family}) => + AppDisplayFont(family ?? this.family); + + @override + AppDisplayFont lerp(ThemeExtension? other, double t) => + t < 0.5 ? this : (other as AppDisplayFont? ?? this); +} + +String? displayFontOf(BuildContext context) => + Theme.of(context).extension()?.family ?? kDisplayFontFamily; + class AppFont { final String id; final String label; final String? fontFamily; - const AppFont({required this.id, required this.label, this.fontFamily}); + final double metricScale; + + const AppFont({ + required this.id, + required this.label, + this.fontFamily, + this.metricScale = 1.0, + }); bool get isSystem => fontFamily == null; bool get isCustom => id.startsWith(AppFonts.customPrefix); @@ -22,8 +51,18 @@ class AppFonts { static const List builtIn = [ AppFont(id: 'system', label: 'Системный'), - AppFont(id: 'inter', label: 'Inter', fontFamily: 'Inter'), - AppFont(id: 'unbounded', label: 'Unbounded', fontFamily: 'Unbounded'), + AppFont( + id: 'inter', + label: 'Inter', + fontFamily: 'Inter', + metricScale: 0.967, + ), + AppFont( + id: 'unbounded', + label: 'Unbounded', + fontFamily: 'Unbounded', + metricScale: 0.933, + ), ]; static AppFont get fallback => builtIn.first; @@ -33,11 +72,24 @@ class AppFonts { static AppFont resolve(String id) { if (id.startsWith(customPrefix)) { final family = id.substring(customPrefix.length); - return AppFont(id: id, label: family, fontFamily: family); + return AppFont( + id: id, + label: family, + fontFamily: family, + metricScale: CustomFontService.metricScaleFor(family), + ); } return builtIn.firstWhere((f) => f.id == id, orElse: () => fallback); } + static double effectiveScale(String id, double userScale) => + userScale * resolve(id).metricScale; + + static String? displayFamily(String id) { + final font = resolve(id); + return font.isSystem ? kDisplayFontFamily : font.fontFamily; + } + static TextTheme textTheme(String id, TextTheme base) { final family = resolve(id).fontFamily; if (family == null) return base; diff --git a/lib/core/config/app_frost.dart b/lib/core/config/app_frost.dart new file mode 100644 index 0000000..91b7de2 --- /dev/null +++ b/lib/core/config/app_frost.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class AppFrost { + static const double sigma = 34; + static const double panelSigma = 24; + static const double overlaySigma = 18; + static const double mediaBackdropSigma = 30; + static const double glassAlpha = 0.28; + static const double blurPanelAlpha = 0.55; + static const double scrimAlpha = 0.4; + + static Color glassTint(ColorScheme cs, [double alpha = glassAlpha]) => + cs.surfaceContainerHigh.withValues(alpha: alpha); + + static Color blurPanelTint(ColorScheme cs) => glassTint(cs, blurPanelAlpha); + + static Color scrim([double alpha = scrimAlpha]) => + Colors.black.withValues(alpha: alpha); + + static BorderSide hairline(ColorScheme cs) => + BorderSide(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5); +} diff --git a/lib/core/config/app_icon.dart b/lib/core/config/app_icon.dart index 8c503fa..bd360bd 100644 --- a/lib/core/config/app_icon.dart +++ b/lib/core/config/app_icon.dart @@ -5,15 +5,34 @@ import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; enum AppIcon { - defaultIcon('default', 'Default', 'assets/komet_icon.png', 'MainActivity'), - minimal('minimal', 'Minimal', 'assets/meteor_icon.png', 'MinimalIcon'); + defaultIcon( + 'default', + 'Default', + 'assets/komet_icon.png', + 'MainActivity', + null, + ), + minimal( + 'minimal', + 'Minimal', + 'assets/meteor_icon.png', + 'MinimalIcon', + 'MinimalIcon', + ); final String id; final String title; final String previewAsset; - final String platformName; + final String androidAlias; + final String? iosAlternateName; - const AppIcon(this.id, this.title, this.previewAsset, this.platformName); + const AppIcon( + this.id, + this.title, + this.previewAsset, + this.androidAlias, + this.iosAlternateName, + ); } class AppIconConfig { @@ -29,21 +48,37 @@ class AppIconConfig { static Future load() async { if (!isSupported) return; final prefs = await SharedPreferences.getInstance(); - final id = prefs.getString(prefKey); - current.value = _parse(id); + var icon = _parse(prefs.getString(prefKey)); + final applied = await _appliedIcon(); + if (applied != null && applied != icon) { + icon = applied; + await prefs.setString(prefKey, icon.id); + } + current.value = icon; } static Future apply(AppIcon icon) async { if (!isSupported) return; if (current.value == icon) return; await _channel.invokeMethod('setAppIcon', { - 'name': icon.platformName, + 'name': Platform.isIOS ? icon.iosAlternateName : icon.androidAlias, }); final prefs = await SharedPreferences.getInstance(); await prefs.setString(prefKey, icon.id); current.value = icon; } + static Future _appliedIcon() async { + if (!Platform.isIOS) return null; + try { + final name = await _channel.invokeMethod('getAppIcon'); + for (final icon in AppIcon.values) { + if (icon.iosAlternateName == name) return icon; + } + } catch (_) {} + return null; + } + static AppIcon _parse(String? val) { for (final icon in AppIcon.values) { if (icon.id == val) return icon; diff --git a/lib/core/config/app_liquid_glass.dart b/lib/core/config/app_liquid_glass.dart new file mode 100644 index 0000000..ad8fcd6 --- /dev/null +++ b/lib/core/config/app_liquid_glass.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class AppLiquidGlass { + static const bool enabled = false; + + static const double blurSigma = 0; + static const double spread = 1; + static const double refraction = 34; + static const double chroma = 0; + static const double specular = 0.45; + static const double rimWidth = 8; + static const Offset light = Offset(-0.4, -1); + static const double tintFeather = 44; + + static Color navTint(ColorScheme cs) => + cs.surfaceContainerHigh.withValues(alpha: 0); + + static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.24); +} diff --git a/lib/core/config/app_message_actions_style.dart b/lib/core/config/app_message_actions_style.dart index 9375175..a21e6d8 100644 --- a/lib/core/config/app_message_actions_style.dart +++ b/lib/core/config/app_message_actions_style.dart @@ -9,7 +9,7 @@ class AppMessageActionsStyle { static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: MessageActionsStyle.radial, + defaultValue: MessageActionsStyle.list, encode: (value) => value.name, decode: _parse, ); @@ -21,7 +21,7 @@ class AppMessageActionsStyle { static Future save(MessageActionsStyle style) => _setting.save(style); static MessageActionsStyle _parse(String? val) => - enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.radial); + enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.list); static String label(MessageActionsStyle style) { switch (style) { diff --git a/lib/core/config/app_microphone.dart b/lib/core/config/app_microphone.dart new file mode 100644 index 0000000..ad696da --- /dev/null +++ b/lib/core/config/app_microphone.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppMicrophone { + static const prefKey = 'call_microphone_id'; + static const String defaultValue = ''; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getString(key), + write: (prefs, key, value) async { + await prefs.setString(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static String? get deviceId => + _setting.current.value.isEmpty ? null : _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(String value) => _setting.save(value); +} diff --git a/lib/core/config/app_nav_pill_style.dart b/lib/core/config/app_nav_pill_style.dart new file mode 100644 index 0000000..4423e13 --- /dev/null +++ b/lib/core/config/app_nav_pill_style.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; + +import '../../frontend/widgets/liquid_glass.dart'; +import 'app_visual_style.dart'; +import 'persisted_setting.dart'; + +enum NavPillStyle { auto, glossy, frostBlur, liquidGlass } + +class NavPillMaterial { + static NavPillStyle resolve(NavPillStyle style) { + if (style != NavPillStyle.auto) return style; + return AppVisualStyle.current.value == VisualStyle.liquidGlass + ? NavPillStyle.liquidGlass + : NavPillStyle.glossy; + } + + static bool isLiquid(NavPillStyle style) => + resolve(style) == NavPillStyle.liquidGlass && LiquidGlass.isSupported; + + static bool isFrost(NavPillStyle style) { + final resolved = resolve(style); + return resolved == NavPillStyle.frostBlur || + (resolved == NavPillStyle.liquidGlass && !LiquidGlass.isSupported); + } +} + +class AppNavPillStyle { + static const prefKey = 'app_nav_pill_style'; + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: NavPillStyle.glossy, + encode: (value) => value.name, + decode: _parse, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(NavPillStyle value) => _setting.save(value); + + static NavPillStyle _parse(String? val) => + enumFromName(NavPillStyle.values, val, NavPillStyle.glossy); +} diff --git a/lib/core/config/app_phonebook_names.dart b/lib/core/config/app_phonebook_names.dart new file mode 100644 index 0000000..f36d954 --- /dev/null +++ b/lib/core/config/app_phonebook_names.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppPhonebookNames { + static const prefKey = 'dev_phonebook_names'; + static const bool defaultValue = true; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_pill_gradient.dart b/lib/core/config/app_pill_gradient.dart index ff5e98b..d0b2967 100644 --- a/lib/core/config/app_pill_gradient.dart +++ b/lib/core/config/app_pill_gradient.dart @@ -7,7 +7,7 @@ class AppPillGradient { static final _setting = PersistedSetting( prefKey: prefKey, - defaultValue: true, + defaultValue: false, read: (prefs, key) => prefs.getBool(key), write: (prefs, key, value) async { await prefs.setBool(key, value); diff --git a/lib/core/config/app_pulse_source.dart b/lib/core/config/app_pulse_source.dart new file mode 100644 index 0000000..e4a349c --- /dev/null +++ b/lib/core/config/app_pulse_source.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppPulseSource { + static const prefKey = 'call_pulse_source'; + static const String defaultValue = ''; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getString(key), + write: (prefs, key, value) async { + await prefs.setString(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static String? get name => + _setting.current.value.isEmpty ? null : _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(String value) => _setting.save(value); +} diff --git a/lib/core/config/app_shape.dart b/lib/core/config/app_shape.dart new file mode 100644 index 0000000..dfce8fc --- /dev/null +++ b/lib/core/config/app_shape.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class AppShape { + static const double card = 20; + static const double button = 14; + static const double sheet = 24; + static const double dialog = 24; + static const double pill = 100; + + static const BorderRadius cardRadius = BorderRadius.all( + Radius.circular(card), + ); + static const BorderRadius buttonRadius = BorderRadius.all( + Radius.circular(button), + ); + static const BorderRadius pillRadius = BorderRadius.all( + Radius.circular(pill), + ); + + static const RoundedRectangleBorder buttonBorder = RoundedRectangleBorder( + borderRadius: buttonRadius, + ); + static const RoundedRectangleBorder dialogBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(dialog)), + ); + static const RoundedRectangleBorder sheetBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(sheet)), + ); +} diff --git a/lib/core/config/app_spectrum_background.dart b/lib/core/config/app_spectrum_background.dart new file mode 100644 index 0000000..b1b47be --- /dev/null +++ b/lib/core/config/app_spectrum_background.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppSpectrumBackground { + static const prefKey = 'app_spectrum_background'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static bool get isEnabled => _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/config/app_stories.dart b/lib/core/config/app_stories.dart index e375584..169d317 100644 --- a/lib/core/config/app_stories.dart +++ b/lib/core/config/app_stories.dart @@ -4,7 +4,7 @@ import 'persisted_setting.dart'; class AppStories { static const prefKey = 'dev_stories'; - static const bool defaultValue = false; + static const bool defaultValue = true; static final _setting = PersistedSetting( prefKey: prefKey, diff --git a/lib/core/config/app_video_note_quality.dart b/lib/core/config/app_video_note_quality.dart new file mode 100644 index 0000000..b582607 --- /dev/null +++ b/lib/core/config/app_video_note_quality.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppVideoNoteResolution { + static const prefKey = 'dev_video_note_resolution'; + static const int defaultValue = 480; + static const List presets = [480, 720, 1080]; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getInt(key), + write: (prefs, key, value) async { + await prefs.setInt(key, value); + }, + sanitize: (value) => presets.contains(value) ? value : defaultValue, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(int value) => _setting.save(value); +} + +class AppVideoNoteRearCamera { + static const prefKey = 'dev_video_note_rear_camera'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} + +class AppVideoNoteFps { + static const prefKey = 'dev_video_note_fps'; + static const int defaultValue = 30; + static const List presets = [30, 60]; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getInt(key), + write: (prefs, key, value) async { + await prefs.setInt(key, value); + }, + sanitize: (value) => presets.contains(value) ? value : defaultValue, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(int value) => _setting.save(value); +} diff --git a/lib/core/config/app_visual_style.dart b/lib/core/config/app_visual_style.dart index ece7066..8c8c786 100644 --- a/lib/core/config/app_visual_style.dart +++ b/lib/core/config/app_visual_style.dart @@ -2,7 +2,11 @@ import 'package:flutter/foundation.dart'; import 'persisted_setting.dart'; -enum VisualStyle { materialYou, glossy } +enum VisualStyle { materialYou, glossy, liquidGlass } + +extension VisualStyleChrome on VisualStyle { + bool get glossyChrome => this != VisualStyle.materialYou; +} class AppVisualStyle { static const prefKey = 'app_visual_style'; diff --git a/lib/core/config/call_no_mute.dart b/lib/core/config/call_no_mute.dart new file mode 100644 index 0000000..4a5f414 --- /dev/null +++ b/lib/core/config/call_no_mute.dart @@ -0,0 +1,11 @@ +class CallNoMute { + CallNoMute._(); + + static const String flag = '--no-mute'; + + static bool enabled = const bool.fromEnvironment('NO_MUTE'); + + static void parse(List args) { + if (args.contains(flag)) enabled = true; + } +} diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart index 40d09e6..ed57789 100644 --- a/lib/core/config/config.dart +++ b/lib/core/config/config.dart @@ -1,15 +1,18 @@ import 'package:shared_preferences/shared_preferences.dart'; abstract class ServerConfig { - static const String defaultHost = 'api.oneme.ru'; + static const String defaultHost = 'api2.oneme.ru'; static const int defaultPort = 443; + static const bool defaultTrustMincifryCa = true; static const String prefHostKey = 'server_host_override'; static const String prefPortKey = 'server_port_override'; + static const String prefTrustMincifryKey = 'server_trust_mincifry_ca'; static const Duration pingInterval = Duration(seconds: 10); static const Duration requestTimeout = Duration(seconds: 30); static const int maxReconnectAttempts = 50; - static Future<({String host, int port})> loadEndpoint() async { + static Future<({String host, int port, bool trustMincifryCa})> + loadEndpoint() async { final prefs = await SharedPreferences.getInstance(); final rawHost = prefs.getString(prefHostKey); final rawPort = prefs.getInt(prefPortKey); @@ -20,6 +23,11 @@ abstract class ServerConfig { if (rawPort != null && rawPort >= 1 && rawPort <= 65535) { port = rawPort; } - return (host: host, port: port); + return ( + host: host, + port: port, + trustMincifryCa: + prefs.getBool(prefTrustMincifryKey) ?? defaultTrustMincifryCa, + ); } } diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index b8a3ed0..666f58d 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -39,6 +39,25 @@ final Map countriesByCode = { for (final country in allCountries) country.code: country, }; +const Map primaryCountryByPhoneCode = {'+7': 'RU', '+1': 'US'}; + +bool isPrimaryForPhoneCode(CountryName country) => + primaryCountryByPhoneCode[country.phoneCode] == country.code; + +List sortedByDisplayName( + Iterable countries, + String languageCode, +) { + final list = countries.toList(); + list.sort( + (a, b) => a + .displayName(languageCode) + .toLowerCase() + .compareTo(b.displayName(languageCode).toLowerCase()), + ); + return list; +} + List countriesInServerOrder(Iterable codes) { final out = []; for (final raw in codes) { diff --git a/lib/core/config/custom_font_service.dart b/lib/core/config/custom_font_service.dart index bc34bd2..c7b7c39 100644 --- a/lib/core/config/custom_font_service.dart +++ b/lib/core/config/custom_font_service.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/logger.dart'; +import 'font_metrics.dart'; class CustomFontService { static const String prefKey = 'app_custom_fonts'; @@ -15,6 +16,9 @@ class CustomFontService { 'AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'; static final Set _loaded = {}; + static final Map _metricScales = {}; + + static double metricScaleFor(String family) => _metricScales[family] ?? 1.0; static Future> families() async { final prefs = await SharedPreferences.getInstance(); @@ -85,6 +89,10 @@ class CustomFontService { ..addFont(Future.value(ByteData.sublistView(bytes))); await loader.load(); _loaded.add(family); + final xHeight = FontMetrics.xHeightRatio(bytes); + if (xHeight != null) { + _metricScales[family] = FontMetrics.scaleForXHeight(xHeight); + } } static bool _isSfnt(Uint8List b) { diff --git a/lib/core/config/debug_test.dart b/lib/core/config/debug_test.dart index e0176b9..fd30cd7 100644 --- a/lib/core/config/debug_test.dart +++ b/lib/core/config/debug_test.dart @@ -1,20 +1,24 @@ class DebugTest { static bool enabled = false; static int contactCount = 0; + static bool berserk = false; static const int debugAccountId = -424242; static const bool _envEnabled = bool.fromEnvironment('DEBUG_TEST'); + static const bool _envBerserk = bool.fromEnvironment('BERSERK'); static const int _envContacts = int.fromEnvironment( 'DEBUG_CONTACTS', defaultValue: -1, ); static const String _flag = '--debug-test'; + static const String _berserkFlag = '--berserk'; static const String _contactsFlag = '--contacts'; static void parse(List args) { if (_envEnabled) enabled = true; + if (_envBerserk) berserk = true; if (_envContacts >= 0) { enabled = true; contactCount = _envContacts; @@ -24,6 +28,8 @@ class DebugTest { final arg = args[i]; if (arg == _flag) { enabled = true; + } else if (arg == _berserkFlag) { + berserk = true; } else if (arg.startsWith('$_contactsFlag=')) { enabled = true; contactCount = diff --git a/lib/core/config/font_metrics.dart b/lib/core/config/font_metrics.dart new file mode 100644 index 0000000..abf5bec --- /dev/null +++ b/lib/core/config/font_metrics.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; + +abstract class FontMetrics { + static const double referenceXHeight = 0.528; + static const double minScale = 0.85; + static const double maxScale = 1.15; + + static double scaleForXHeight(double xHeightRatio) { + if (xHeightRatio <= 0) return 1.0; + return (referenceXHeight / xHeightRatio) + .clamp(minScale, maxScale) + .toDouble(); + } + + static double? xHeightRatio(Uint8List bytes) { + try { + if (bytes.length < 12) return null; + final data = ByteData.sublistView(bytes); + if (data.getUint32(0) == 0x74746366) return null; + final numTables = data.getUint16(4); + int? headOffset; + int? os2Offset; + for (var i = 0; i < numTables; i++) { + final record = 12 + i * 16; + if (record + 16 > bytes.length) return null; + final tag = String.fromCharCodes(bytes, record, record + 4); + if (tag == 'head') headOffset = data.getUint32(record + 8); + if (tag == 'OS/2') os2Offset = data.getUint32(record + 8); + } + if (headOffset == null || os2Offset == null) return null; + if (headOffset + 20 > bytes.length || os2Offset + 88 > bytes.length) { + return null; + } + final unitsPerEm = data.getUint16(headOffset + 18); + if (unitsPerEm == 0) return null; + if (data.getUint16(os2Offset) < 2) return null; + final xHeight = data.getInt16(os2Offset + 86); + if (xHeight <= 0) return null; + return xHeight / unitsPerEm; + } catch (_) { + return null; + } + } +} diff --git a/lib/core/contacts/device_contacts_service.dart b/lib/core/contacts/device_contacts_service.dart new file mode 100644 index 0000000..643136a --- /dev/null +++ b/lib/core/contacts/device_contacts_service.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/app_phonebook_names.dart'; +import '../utils/logger.dart'; + +class DeviceContactsService { + DeviceContactsService._(); + + static const _deniedKey = 'phonebook_denied'; + + static final Map _byLast10 = {}; + static bool _loaded = false; + + static bool get _supported => Platform.isAndroid || Platform.isIOS; + + static bool get isLoaded => _loaded; + + static int get knownNumbers => _byLast10.length; + + static String? _last10(String raw) { + final digits = raw.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length < 10) return null; + return digits.substring(digits.length - 10); + } + + static String? nameForPhone(int phone) { + if (!AppPhonebookNames.current.value) return null; + if (_byLast10.isEmpty) return null; + final key = _last10(phone.toString()); + if (key == null) return null; + final name = _byLast10[key]; + if (name == null || name.trim().isEmpty) return null; + return name.trim(); + } + + static Future hasPermission() async { + if (!_supported) return false; + try { + return await Permission.contacts.isGranted; + } catch (e) { + logger.w('Телефонная книга: не удалось прочитать статус разрешения: $e'); + return false; + } + } + + static Future loadFromStartup() async { + if (_loaded || !_supported) return; + if (!AppPhonebookNames.current.value) return; + if (!await hasPermission()) return; + await _forgetDenial(); + await _readBook(); + } + + static Future ensureLoadedInteractive({bool force = false}) async { + if (!_supported) return false; + if (!AppPhonebookNames.current.value) return false; + if (_loaded && !force) return false; + + if (await hasPermission()) { + await _forgetDenial(); + return _readBook(); + } + + final prefs = await SharedPreferences.getInstance(); + if (!force && prefs.getBool(_deniedKey) == true) return false; + + final granted = await FlutterContacts.requestPermission(readonly: true); + if (!granted) { + await prefs.setBool(_deniedKey, true); + return false; + } + await prefs.remove(_deniedKey); + return _readBook(); + } + + static Future reload() async { + _loaded = false; + _byLast10.clear(); + return ensureLoadedInteractive(force: true); + } + + static Future _forgetDenial() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_deniedKey) == true) await prefs.remove(_deniedKey); + } + + static Future _readBook() async { + try { + FlutterContacts.config.includeNonVisibleOnAndroid = true; + final contacts = await FlutterContacts.getContacts(withProperties: true); + _byLast10.clear(); + for (final contact in contacts) { + final name = contact.displayName.trim(); + if (name.isEmpty) continue; + for (final phone in contact.phones) { + final key = _last10(phone.number); + if (key != null) { + _byLast10.putIfAbsent(key, () => name); + } + } + } + _loaded = true; + logger.i( + 'Телефонная книга: прочитано ${contacts.length} записей, ' + '${_byLast10.length} номеров', + ); + return true; + } catch (e) { + logger.w('Телефонная книга: не удалось прочитать: $e'); + return false; + } + } +} diff --git a/lib/core/crypto/chat_crypto_service.dart b/lib/core/crypto/chat_crypto_service.dart new file mode 100644 index 0000000..5b745ac --- /dev/null +++ b/lib/core/crypto/chat_crypto_service.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:komet_crypto/komet_crypto.dart' as kc; + +import '../storage/chat_encryption_store.dart'; +import '../utils/logger.dart'; + +const int kMaxEncryptedMessageLength = 1000; + +enum CryptoFailure { noKey, wrongKey, notEncrypted, malformed, unavailable } + +class CryptoResult { + final String? text; + final CryptoFailure? failure; + + const CryptoResult.ok(String this.text) : failure = null; + const CryptoResult.failed(CryptoFailure this.failure) : text = null; + + bool get isOk => text != null; +} + +class ChatCryptoService { + ChatCryptoService._() { + ChatEncryptionStore.instance.revision.addListener(clearKeys); + } + + static final ChatCryptoService instance = ChatCryptoService._(); + + final Map _keys = {}; + final Map> _pending = {}; + Future? _init; + bool _unavailable = false; + + String _cacheKey(int accountId, int chatId) => '$accountId/$chatId'; + + void clearKeys() { + _keys.clear(); + _pending.clear(); + } + + Future _ensureInitialized() async { + if (_unavailable) return false; + try { + await (_init ??= kc.RustLib.init()); + return true; + } catch (e) { + _init = null; + _unavailable = true; + logger.w('komet_crypto init failed: $e'); + return false; + } + } + + Future _keyFor(int accountId, int chatId) { + final cacheKey = _cacheKey(accountId, chatId); + final cached = _keys[cacheKey]; + if (cached != null) return Future.value(cached); + return _pending[cacheKey] ??= _deriveKey(accountId, chatId, cacheKey); + } + + Future _deriveKey( + int accountId, + int chatId, + String cacheKey, + ) async { + try { + if (!await _ensureInitialized()) return null; + final password = await ChatEncryptionStore.instance.readKey( + accountId, + chatId, + ); + if (password == null || password.isEmpty) return null; + final key = await kc.deriveKey(password: password); + _keys[cacheKey] = key; + return key; + } catch (e) { + logger.w('derive key for chat $chatId: $e'); + return null; + } finally { + _pending.remove(cacheKey); + } + } + + bool isEnabled(int accountId, int chatId) => + ChatEncryptionStore.instance.isEnabled(accountId, chatId); + + Future warmKey(int accountId, int chatId) => _keyFor(accountId, chatId); + + Future encrypt( + int accountId, + int chatId, + String plaintext, + ) async { + final key = await _keyFor(accountId, chatId); + if (key == null) { + return CryptoResult.failed( + _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey, + ); + } + try { + return CryptoResult.ok( + await kc.encryptMessage(plaintext: plaintext, key: key), + ); + } catch (e) { + logger.w('encrypt for chat $chatId: $e'); + return const CryptoResult.failed(CryptoFailure.unavailable); + } + } + + Future decrypt(int accountId, int chatId, String text) async { + final key = await _keyFor(accountId, chatId); + if (key == null) { + return CryptoResult.failed( + _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey, + ); + } + try { + return CryptoResult.ok(await kc.decryptMessage(text: text, key: key)); + } catch (e) { + return CryptoResult.failed(_failureFromCode(e.toString())); + } + } + + Future encryptImageFile( + int accountId, + int chatId, + String sourcePath, + String destPath, + ) => _imageOp( + accountId, + chatId, + () => kc.encryptImageFile( + sourcePath: sourcePath, + destPath: destPath, + key: _keys[_cacheKey(accountId, chatId)]!, + ), + ); + + Future decryptImageFile( + int accountId, + int chatId, + String sourcePath, + String destPath, + ) => _imageOp( + accountId, + chatId, + () => kc.decryptImageFile( + sourcePath: sourcePath, + destPath: destPath, + key: _keys[_cacheKey(accountId, chatId)]!, + ), + ); + + Future _imageOp( + int accountId, + int chatId, + Future Function() run, + ) async { + final key = await _keyFor(accountId, chatId); + if (key == null) { + return _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey; + } + try { + await run(); + return null; + } catch (e) { + logger.w('image crypto for chat $chatId: $e'); + return _failureFromCode(e.toString()); + } + } + + Future looksEncryptedImage(String path) async { + if (!await _ensureInitialized()) return false; + try { + return await kc.looksEncryptedImageFile(path: path); + } catch (_) { + return false; + } + } + + Future looksEncrypted(String text) async { + if (!await _ensureInitialized()) return false; + try { + return await kc.looksEncrypted(text: text); + } catch (_) { + return false; + } + } + + CryptoFailure _failureFromCode(String message) { + if (message.contains('wrong_key')) return CryptoFailure.wrongKey; + if (message.contains('not_encrypted')) return CryptoFailure.notEncrypted; + if (message.contains('malformed')) return CryptoFailure.malformed; + return CryptoFailure.unavailable; + } +} diff --git a/lib/core/crypto/encrypted_photo.dart b/lib/core/crypto/encrypted_photo.dart new file mode 100644 index 0000000..aaa0c2c --- /dev/null +++ b/lib/core/crypto/encrypted_photo.dart @@ -0,0 +1,105 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:path_provider/path_provider.dart'; + +import '../utils/logger.dart'; +import '../utils/media_cache.dart'; +import 'chat_crypto_service.dart'; + +const String kEncryptedPhotoExtension = '.png'; + +String decryptedCacheName(String cacheName) => 'decrypted_$cacheName'; + +class EncryptedPhotoResult { + final File? file; + final CryptoFailure? failure; + + const EncryptedPhotoResult.ok(File this.file) : failure = null; + const EncryptedPhotoResult.failed(CryptoFailure this.failure) : file = null; + + bool get isOk => file != null; +} + +/// Re-encodes an arbitrary image into lossless PNG. Encryption needs a format +/// that survives byte-for-byte; a re-encoded JPEG would not. +Future reencodeAsPng(File source, String destPath) async { + try { + final bytes = await source.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final data = await frame.image.toByteData(format: ui.ImageByteFormat.png); + frame.image.dispose(); + codec.dispose(); + if (data == null) return null; + final dest = File(destPath); + await dest.writeAsBytes(data.buffer.asUint8List(), flush: true); + return dest; + } catch (e) { + logger.w('png re-encode failed: $e'); + return null; + } +} + +Future _scratchDir() async { + final dir = Directory('${(await getTemporaryDirectory()).path}/komet_enc'); + if (!await dir.exists()) await dir.create(recursive: true); + return dir; +} + +/// Picked image → PNG → encrypted noise PNG, ready to upload as a file. +Future prepareEncryptedPhoto({ + required int accountId, + required int chatId, + required File source, + required String stamp, +}) async { + final dir = await _scratchDir(); + final pngPath = '${dir.path}/plain_$stamp.png'; + final encPath = '${dir.path}/enc_$stamp.png'; + + final png = await reencodeAsPng(source, pngPath); + if (png == null) { + return const EncryptedPhotoResult.failed(CryptoFailure.malformed); + } + + final failure = await ChatCryptoService.instance.encryptImageFile( + accountId, + chatId, + png.path, + encPath, + ); + await _quietDelete(png); + if (failure != null) return EncryptedPhotoResult.failed(failure); + return EncryptedPhotoResult.ok(File(encPath)); +} + +/// Downloaded noise PNG → original photo, cached for the viewer. +Future openEncryptedPhoto({ + required int accountId, + required int chatId, + required File encrypted, + required String cacheName, +}) async { + final target = await MediaCache.fileFor(decryptedCacheName(cacheName)); + if (await target.exists() && await target.length() > 0) { + return EncryptedPhotoResult.ok(target); + } + final failure = await ChatCryptoService.instance.decryptImageFile( + accountId, + chatId, + encrypted.path, + target.path, + ); + if (failure != null) { + await _quietDelete(target); + return EncryptedPhotoResult.failed(failure); + } + return EncryptedPhotoResult.ok(target); +} + +Future _quietDelete(File file) async { + try { + if (await file.exists()) await file.delete(); + } catch (_) {} +} diff --git a/lib/core/crypto/encrypted_photo_cache.dart b/lib/core/crypto/encrypted_photo_cache.dart new file mode 100644 index 0000000..eb11f25 --- /dev/null +++ b/lib/core/crypto/encrypted_photo_cache.dart @@ -0,0 +1,246 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../storage/chat_encryption_store.dart'; +import '../utils/download_progress.dart'; +import '../utils/logger.dart'; +import '../utils/media_cache.dart'; +import 'chat_crypto_service.dart'; +import 'encrypted_photo.dart'; + +typedef EncryptedPhotoUrlLoader = Future Function(); + +enum EncryptedPhotoStatus { plain, decrypted, wrongKey, locked } + +@immutable +class EncryptedPhotoView { + final File? file; + final EncryptedPhotoStatus status; + + const EncryptedPhotoView.plain() + : file = null, + status = EncryptedPhotoStatus.plain; + + const EncryptedPhotoView.decrypted(File this.file) + : status = EncryptedPhotoStatus.decrypted; + + const EncryptedPhotoView.wrongKey() + : file = null, + status = EncryptedPhotoStatus.wrongKey; + + const EncryptedPhotoView.locked() + : file = null, + status = EncryptedPhotoStatus.locked; + + bool get isDecrypted => status == EncryptedPhotoStatus.decrypted; +} + +class _AutoRequest { + final int accountId; + final int chatId; + final String cacheName; + final EncryptedPhotoUrlLoader urlLoader; + final int size; + + const _AutoRequest({ + required this.accountId, + required this.chatId, + required this.cacheName, + required this.urlLoader, + required this.size, + }); +} + +class EncryptedPhotoCache { + EncryptedPhotoCache._() { + ChatEncryptionStore.instance.revision.addListener(clear); + } + + static final EncryptedPhotoCache instance = EncryptedPhotoCache._(); + + static const int _maxEntries = 200; + static const int _maxAutoBytes = 32 * 1024 * 1024; + static const int _maxConcurrentAuto = 3; + + final LinkedHashMap> _entries = + LinkedHashMap(); + final Map> _inFlight = {}; + final Queue<_AutoRequest> _queue = Queue(); + final Set _queued = {}; + int _running = 0; + + ValueListenable listenableFor(String cacheName) => + _entryFor(cacheName); + + ValueNotifier _entryFor(String cacheName) => + _entries[cacheName] ??= ValueNotifier(null); + + void _evictStale(String keep) { + while (_entries.length > _maxEntries) { + final oldest = _entries.keys.first; + if (oldest == keep) break; + _entries.remove(oldest); + } + } + + void request({ + required int accountId, + required int chatId, + required String cacheName, + required EncryptedPhotoUrlLoader urlLoader, + required int size, + }) { + if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return; + if (_entryFor(cacheName).value != null) return; + if (_inFlight.containsKey(cacheName)) return; + if (!_queued.add(cacheName)) return; + _evictStale(cacheName); + _queue.add( + _AutoRequest( + accountId: accountId, + chatId: chatId, + cacheName: cacheName, + urlLoader: urlLoader, + size: size, + ), + ); + _pump(); + } + + Future resolve({ + required int accountId, + required int chatId, + required String cacheName, + required EncryptedPhotoUrlLoader urlLoader, + }) { + final known = _entryFor(cacheName).value; + if (known != null && known.status != EncryptedPhotoStatus.locked) { + return Future.value(known); + } + _dequeue(cacheName); + return _start(accountId, chatId, cacheName, urlLoader, null); + } + + void clear() { + _queue.clear(); + _queued.clear(); + for (final entry in _entries.values) { + entry.value = null; + } + } + + void _dequeue(String cacheName) { + if (!_queued.remove(cacheName)) return; + _queue.removeWhere((request) => request.cacheName == cacheName); + } + + void _pump() { + while (_running < _maxConcurrentAuto && _queue.isNotEmpty) { + final next = _queue.removeLast(); + _queued.remove(next.cacheName); + _running++; + final done = _start( + next.accountId, + next.chatId, + next.cacheName, + next.urlLoader, + next.size, + ); + unawaited( + done.whenComplete(() { + _running--; + _pump(); + }), + ); + } + } + + Future _start( + int accountId, + int chatId, + String cacheName, + EncryptedPhotoUrlLoader urlLoader, + int? autoSize, + ) { + final running = _inFlight[cacheName]; + if (running != null) return running; + final future = _resolve(accountId, chatId, cacheName, urlLoader, autoSize); + _inFlight[cacheName] = future; + unawaited(future.whenComplete(() => _inFlight.remove(cacheName))); + return future; + } + + Future _resolve( + int accountId, + int chatId, + String cacheName, + EncryptedPhotoUrlLoader urlLoader, + int? autoSize, + ) async { + EncryptedPhotoView view; + try { + view = await _decrypt(accountId, chatId, cacheName, urlLoader, autoSize); + } catch (e) { + logger.w('encrypted preview $cacheName: $e'); + view = const EncryptedPhotoView.locked(); + } + _entryFor(cacheName).value = view; + return view; + } + + Future _decrypt( + int accountId, + int chatId, + String cacheName, + EncryptedPhotoUrlLoader urlLoader, + int? autoSize, + ) async { + final ready = await MediaCache.existing(decryptedCacheName(cacheName)); + if (ready != null) return EncryptedPhotoView.decrypted(ready); + + var encrypted = await MediaCache.existing(cacheName); + if (encrypted == null) { + if (autoSize != null && autoSize > _maxAutoBytes) { + return const EncryptedPhotoView.locked(); + } + encrypted = await _download(cacheName, urlLoader); + } + if (encrypted == null) return const EncryptedPhotoView.locked(); + + if (!await ChatCryptoService.instance.looksEncryptedImage(encrypted.path)) { + return const EncryptedPhotoView.plain(); + } + + final result = await openEncryptedPhoto( + accountId: accountId, + chatId: chatId, + encrypted: encrypted, + cacheName: cacheName, + ); + if (result.isOk) return EncryptedPhotoView.decrypted(result.file!); + return result.failure == CryptoFailure.unavailable + ? const EncryptedPhotoView.locked() + : const EncryptedPhotoView.wrongKey(); + } + + Future _download( + String cacheName, + EncryptedPhotoUrlLoader urlLoader, + ) async { + final url = await urlLoader(); + if (url == null || url.isEmpty) return null; + MediaDownloadProgress.set(cacheName, 0); + try { + return await MediaCache.getOrDownload( + cacheName, + url, + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + ); + } finally { + MediaDownloadProgress.set(cacheName, null); + } + } +} diff --git a/lib/core/crypto/message_decryption_cache.dart b/lib/core/crypto/message_decryption_cache.dart new file mode 100644 index 0000000..40ef89b --- /dev/null +++ b/lib/core/crypto/message_decryption_cache.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +import '../storage/chat_encryption_store.dart'; +import 'chat_crypto_service.dart'; + +enum MessageDecryptionState { decrypted, wrongKey } + +@immutable +class MessageDecryption { + final String? plaintext; + final MessageDecryptionState state; + + const MessageDecryption.decrypted(String this.plaintext) + : state = MessageDecryptionState.decrypted; + + const MessageDecryption.wrongKey() + : plaintext = null, + state = MessageDecryptionState.wrongKey; + + bool get isDecrypted => state == MessageDecryptionState.decrypted; +} + +class MessageDecryptionCache { + MessageDecryptionCache._() { + ChatEncryptionStore.instance.revision.addListener(clear); + } + + static final MessageDecryptionCache instance = MessageDecryptionCache._(); + + static const int _maxEntries = 1000; + + final LinkedHashMap> _entries = + LinkedHashMap(); + final Set _inFlight = {}; + + ValueListenable listenableFor(String messageId) => + _entryFor(messageId); + + ValueNotifier _entryFor(String messageId) => + _entries[messageId] ??= ValueNotifier(null); + + void _evictStale(String keep) { + while (_entries.length > _maxEntries) { + final oldest = _entries.keys.first; + if (oldest == keep) break; + _entries.remove(oldest); + } + } + + void seed(String messageId, String plaintext) { + _entryFor(messageId).value = MessageDecryption.decrypted(plaintext); + } + + void adopt(String fromMessageId, String toMessageId) { + final value = _entries[fromMessageId]?.value; + if (value != null) _entryFor(toMessageId).value = value; + } + + void request({ + required int accountId, + required int chatId, + required String messageId, + required String cipherText, + }) { + if (cipherText.isEmpty) return; + if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return; + if (_entryFor(messageId).value != null) return; + if (!_inFlight.add(messageId)) return; + _evictStale(messageId); + unawaited(_resolve(accountId, chatId, messageId, cipherText)); + } + + Future _resolve( + int accountId, + int chatId, + String messageId, + String cipherText, + ) async { + try { + final crypto = ChatCryptoService.instance; + final result = await crypto.decrypt(accountId, chatId, cipherText); + if (result.isOk) { + _entryFor(messageId).value = MessageDecryption.decrypted(result.text!); + return; + } + switch (result.failure) { + case CryptoFailure.wrongKey: + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + case CryptoFailure.noKey: + if (await crypto.looksEncrypted(cipherText)) { + _entryFor(messageId).value = const MessageDecryption.wrongKey(); + } + case CryptoFailure.notEncrypted: + case CryptoFailure.malformed: + case CryptoFailure.unavailable: + case null: + break; + } + } finally { + _inFlight.remove(messageId); + } + } + + void clear() { + _entries.clear(); + _inFlight.clear(); + } +} diff --git a/lib/core/links/deep_link_service.dart b/lib/core/links/deep_link_service.dart index 133aecc..7eb90b8 100644 --- a/lib/core/links/deep_link_service.dart +++ b/lib/core/links/deep_link_service.dart @@ -1,11 +1,22 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:app_links/app_links.dart'; +import 'package:flutter/widgets.dart'; + +import '../../l10n/app_localizations.dart'; import '../../backend/api.dart'; +import '../../frontend/debug/log_export.dart'; +import '../../frontend/screens/digital_id/digital_id_web_screen.dart'; +import '../../frontend/widgets/custom_notification.dart'; import '../../frontend/widgets/max_link_handler.dart'; +import '../../frontend/widgets/swipe_route.dart'; import '../../main.dart'; +import '../webpush/max_web_socket.dart'; +import '../webpush/web_push_service.dart'; import 'desktop_url_scheme.dart'; +import 'max_link.dart'; class DeepLinkService { DeepLinkService._(); @@ -16,6 +27,13 @@ class DeepLinkService { StreamSubscription? _sub; StreamSubscription? _stateSub; String? _pending; + bool _pendingLogExport = false; + String? _pendingExternalCallback; + WebPushSubscription? _pendingWebPush; + String? _lastExternalCallback; + Timer? _externalCallbackRetry; + Timer? _webPushRetry; + Timer? _logExportRetry; bool _ready = false; bool _started = false; @@ -41,7 +59,28 @@ class DeepLinkService { _flushPending(); } + void handle(Uri uri) => _onUri(uri); + void _onUri(Uri uri) { + if (_isLogExportLink(uri)) { + _pendingLogExport = true; + _flushPending(); + return; + } + final webPush = _parseWebPushLink(uri); + if (webPush != null) { + _pendingWebPush = webPush; + _flushPending(); + return; + } + if (_isExternalCallback(uri)) { + final callbackUrl = uri.toString(); + if (callbackUrl == _lastExternalCallback) return; + _lastExternalCallback = callbackUrl; + _pendingExternalCallback = callbackUrl; + _flushPending(); + return; + } final url = _normalize(uri); if (url == null) return; _pending = url; @@ -49,16 +88,149 @@ class DeepLinkService { } void _flushPending() { - final pending = _pending; - if (pending == null || !_ready) return; - if (api.state != SessionState.online) return; final context = KometApp.navigatorKey.currentContext; - if (context == null) return; + if (_pendingLogExport) { + if (context == null) { + _logExportRetry ??= Timer(const Duration(milliseconds: 300), () { + _logExportRetry = null; + _flushPending(); + }); + } else { + _pendingLogExport = false; + exportDebugLog(context); + } + } + + if (_pendingExternalCallback != null) { + if (context == null || api.state != SessionState.online) { + _externalCallbackRetry ??= Timer(const Duration(milliseconds: 300), () { + _externalCallbackRetry = null; + _flushPending(); + }); + } else { + final url = _pendingExternalCallback!; + _pendingExternalCallback = null; + _handleExternalCallback(context, url); + } + } + + if (_pendingWebPush != null) { + if (context == null) { + _webPushRetry ??= Timer(const Duration(milliseconds: 300), () { + _webPushRetry = null; + _flushPending(); + }); + } else { + final subscription = _pendingWebPush!; + _pendingWebPush = null; + _handleWebPush(context, subscription); + } + } + + if (!_ready || context == null) return; + final pending = _pending; + if (pending == null) return; + final needsConnection = MaxLink.parse(pending)?.needsConnection ?? true; + if (needsConnection && api.state != SessionState.online) return; _pending = null; tryHandleMaxLink(context, pending); } + bool _isExternalCallback(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'https' && scheme != 'http' && scheme != 'max') return false; + final host = uri.host.toLowerCase(); + if (host != 'max.ru' && host != 'www.max.ru') return false; + return uri.queryParameters['externalCallback'] == '1'; + } + + Future _handleExternalCallback(BuildContext context, String url) async { + try { + final launch = await webAppModule.handleExternalCallback(url); + if (!context.mounted) return; + await pushSwipeable( + context, + (_) => DigitalIdWebScreen(initialLaunch: launch), + ); + } catch (e) { + if (context.mounted) { + showCustomNotification(context, 'Не удалось завершить Цифровой ID: $e'); + } + } + } + + WebPushSubscription? _parseWebPushLink(Uri uri) { + if (!Platform.isIOS) return null; + if (uri.scheme.toLowerCase() != 'komet') return null; + + final segments = [ + if (uri.host.isNotEmpty) uri.host, + ...uri.pathSegments, + ].where((s) => s.isNotEmpty).toList(); + if (segments.length != 1 || segments.first != 'webpush') return null; + + final endpoint = uri.queryParameters['endpoint'] ?? ''; + final publicKey = uri.queryParameters['p256dh'] ?? ''; + final authKey = uri.queryParameters['auth'] ?? ''; + if (endpoint.isEmpty || publicKey.isEmpty || authKey.isEmpty) return null; + if (Uri.tryParse(endpoint)?.isScheme('https') != true) return null; + + return WebPushSubscription( + endpoint: endpoint, + publicKey: publicKey, + authKey: authKey, + ); + } + + Future _handleWebPush( + BuildContext context, + WebPushSubscription subscription, + ) async { + final l10n = AppLocalizations.of(context)!; + + if (!await WebPushService.instance.isAuthorized()) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushNotAuthorized); + } + return; + } + + try { + await WebPushService.instance.registerSubscription(subscription); + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinked); + } + } on MaxWebException catch (e) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinkFailed(e.message)); + } + } catch (e) { + if (context.mounted) { + showCustomNotification(context, l10n.webPushLinkFailed('$e')); + } + } + } + + bool _isLogExportLink(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + final host = uri.host.toLowerCase(); + final segments = [ + if (scheme == 'komet' && host.isNotEmpty) host, + ...uri.pathSegments, + ].where((s) => s.isNotEmpty).toList(); + + if (scheme == 'komet') { + return segments.length == 1 && segments.first == 'export-logs'; + } + if (scheme == 'https' || scheme == 'http') { + return (host == 'komet.pw' || host == 'www.komet.pw') && + segments.length == 1 && + segments.first == 'export-logs'; + } + return false; + } + String? _normalize(Uri uri) { final scheme = uri.scheme.toLowerCase(); @@ -82,6 +254,10 @@ class DeepLinkService { } void dispose() { + _logExportRetry?.cancel(); + _logExportRetry = null; + _webPushRetry?.cancel(); + _webPushRetry = null; _sub?.cancel(); _sub = null; _stateSub?.cancel(); diff --git a/lib/core/links/max_link.dart b/lib/core/links/max_link.dart index 01f9168..e0f1a55 100644 --- a/lib/core/links/max_link.dart +++ b/lib/core/links/max_link.dart @@ -1,23 +1,18 @@ -enum MaxLinkKind { call, invite, user, content, public, auth, stickerSet } +enum MaxContentKind { public, invite, user, content } -class MaxLink { - final MaxLinkKind kind; - final String url; +sealed class MaxLink { + const MaxLink(); - const MaxLink(this.kind, this.url); + bool get needsConnection => false; - static final RegExp _host = RegExp( - r'^https?://(?:www\.)?max\.ru/(.+)$', + static final RegExp _schemeless = RegExp( + r'^(?:www\.)?max\.ru(?:[/?#]|$)', caseSensitive: false, ); static final RegExp _segment = RegExp(r'^[A-Za-z0-9_]+$'); static const Set _reserved = { - 'join', - 'joincall', - 'u', - 'c', 'login', 'ps', 'tos', @@ -29,36 +24,266 @@ class MaxLink { static bool isMaxLink(String url) => parse(url) != null; static MaxLink? parse(String input) { - final url = input.trim(); - final match = _host.firstMatch(url); - if (match == null) return null; + final uri = _canonical(input); + if (uri == null) return null; - final path = match.group(1)!.split('?').first.split('#').first; - final segments = path - .split('/') + final segments = uri.pathSegments .where((s) => s.isNotEmpty) .toList(growable: false); - if (segments.isEmpty) return null; + final params = uri.queryParameters; + final url = uri.replace(scheme: 'https', host: 'max.ru').toString(); - switch (segments.first.toLowerCase()) { + if (segments.isEmpty) return _parseQueryOnly(params); + if (segments.first.startsWith(':')) { + return _parseRoute(segments, params, url); + } + return _parseContent(segments, params, url); + } + + static Uri? _canonical(String input) { + var value = input.trim(); + if (value.isEmpty) return null; + if (!value.contains('://')) { + if (!_schemeless.hasMatch(value)) return null; + value = 'https://$value'; + } + final uri = Uri.tryParse(value); + if (uri == null) return null; + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'https' && scheme != 'http' && scheme != 'max') return null; + final host = uri.host.toLowerCase(); + if (host != 'max.ru' && host != 'www.max.ru') return null; + return uri; + } + + static MaxLink _parseQueryOnly(Map params) { + final userId = _idParam(params, 'uid'); + if (userId != null) return MaxContactIdLink(userId); + final chatId = _idParam(params, 'cid'); + if (chatId != null) return MaxChatIdLink(chatId); + return const MaxRootLink(); + } + + static MaxLink _parseRoute( + List segments, + Map params, + String url, + ) { + final route = segments.join('/').toLowerCase(); + switch (route) { case ':auth': - return segments.length >= 2 ? MaxLink(MaxLinkKind.auth, url) : null; - case 'joincall': - return segments.length >= 2 ? MaxLink(MaxLinkKind.call, url) : null; - case 'join': - return segments.length >= 2 ? MaxLink(MaxLinkKind.invite, url) : null; - case 'u': - return segments.length >= 2 ? MaxLink(MaxLinkKind.user, url) : null; - case 'c': - return segments.length >= 3 ? MaxLink(MaxLinkKind.content, url) : null; - case 'stickerset': - return segments.length >= 2 - ? MaxLink(MaxLinkKind.stickerSet, url) - : null; + return MaxAuthLink(url); + case ':current': + return const MaxCurrentLink(); + case ':share-self-out': + return const MaxShareSelfLink(); + case ':share': + return MaxShareTextLink(params['text']?.trim() ?? ''); + case ':folder': + final id = params['id']?.trim(); + if (id != null && id.isNotEmpty) return MaxFolderLink(id); + } + if (segments.length > 1 && segments.first.toLowerCase() == ':auth') { + return MaxAuthLink(url); + } + return MaxRouteLink(route, params); + } + + static MaxLink? _parseContent( + List segments, + Map params, + String url, + ) { + final first = segments.first; + final lower = first.toLowerCase(); + + if (segments.length == 1) { + final name = _publicName(first); + if (name == null) return null; + final startApp = params['startapp'] ?? params['startApp']; + if (startApp != null && startApp.isNotEmpty) { + return MaxWebAppLink('https://max.ru/$name', startApp.split('&').first); + } + return MaxContentLink( + kind: MaxContentKind.public, + url: url, + baseUrl: 'https://max.ru/$name', + startPayload: _startPayload(params), + ); } - if (_reserved.contains(segments.first.toLowerCase())) return null; - if (!_segment.hasMatch(segments.first)) return null; - return MaxLink(MaxLinkKind.public, url); + if (segments.length == 2) { + switch (lower) { + case 'stickerset': + return MaxStickerSetLink('$lower/${segments[1]}'); + case 'joincall': + return MaxCallLink(url); + case 'join': + return MaxContentLink( + kind: MaxContentKind.invite, + url: url, + baseUrl: url, + ); + case 'u': + return MaxContentLink( + kind: MaxContentKind.user, + url: url, + baseUrl: url, + ); + } + final messageId = int.tryParse(segments[1]); + final name = _publicName(first); + if (messageId == null || name == null) return null; + return MaxContentLink( + kind: MaxContentKind.public, + url: url, + baseUrl: 'https://max.ru/$name', + messageId: messageId, + ); + } + + if (lower == 'c' && segments.length == 3) { + final chatId = int.tryParse(segments[1]); + final messageId = int.tryParse(segments[2]); + if (chatId == null || messageId == null) return null; + return MaxContentLink( + kind: MaxContentKind.content, + url: url, + baseUrl: 'https://max.ru/c/$chatId', + messageId: messageId, + ); + } + + if (lower == 'join') { + return MaxContentLink( + kind: MaxContentKind.invite, + url: url, + baseUrl: url, + ); + } + return null; + } + + static String? _publicName(String segment) { + final name = segment.startsWith('@') ? segment.substring(1) : segment; + if (name.isEmpty) return null; + if (_reserved.contains(name.toLowerCase())) return null; + if (!_segment.hasMatch(name)) return null; + return name; + } + + static String? _startPayload(Map params) { + final value = params['start']?.trim(); + return (value == null || value.isEmpty) ? null : value; + } + + static int? _idParam(Map params, String key) { + final raw = params[key]?.trim(); + if (raw == null || raw.isEmpty) return null; + final value = int.tryParse(raw); + return (value == null || value <= 0) ? null : value; } } + +class MaxRootLink extends MaxLink { + const MaxRootLink(); +} + +class MaxCurrentLink extends MaxLink { + const MaxCurrentLink(); +} + +class MaxShareSelfLink extends MaxLink { + const MaxShareSelfLink(); +} + +class MaxAuthLink extends MaxLink { + final String url; + + const MaxAuthLink(this.url); + + @override + bool get needsConnection => true; +} + +class MaxCallLink extends MaxLink { + final String url; + + const MaxCallLink(this.url); + + @override + bool get needsConnection => true; +} + +class MaxStickerSetLink extends MaxLink { + final String path; + + const MaxStickerSetLink(this.path); + + @override + bool get needsConnection => true; +} + +class MaxShareTextLink extends MaxLink { + final String text; + + const MaxShareTextLink(this.text); +} + +class MaxFolderLink extends MaxLink { + final String folderId; + + const MaxFolderLink(this.folderId); +} + +class MaxRouteLink extends MaxLink { + final String route; + final Map params; + + const MaxRouteLink(this.route, this.params); +} + +class MaxContactIdLink extends MaxLink { + final int userId; + + const MaxContactIdLink(this.userId); + + @override + bool get needsConnection => true; +} + +class MaxChatIdLink extends MaxLink { + final int chatId; + final int? messageId; + + const MaxChatIdLink(this.chatId, {this.messageId}); +} + +class MaxWebAppLink extends MaxLink { + final String url; + final String startApp; + + const MaxWebAppLink(this.url, this.startApp); + + @override + bool get needsConnection => true; +} + +class MaxContentLink extends MaxLink { + final MaxContentKind kind; + final String url; + final String baseUrl; + final String? startPayload; + final int? messageId; + + const MaxContentLink({ + required this.kind, + required this.url, + required this.baseUrl, + this.startPayload, + this.messageId, + }); + + @override + bool get needsConnection => true; +} diff --git a/lib/core/media/desktop_video_probe.dart b/lib/core/media/desktop_video_probe.dart new file mode 100644 index 0000000..d16f7b4 --- /dev/null +++ b/lib/core/media/desktop_video_probe.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'video_transcoder.dart' show VideoInfo; + +class DesktopVideoProbe { + static const Duration _timeout = Duration(seconds: 6); + static const int _maxCache = 60; + + static bool get supported => + !Platform.isAndroid && !Platform.isIOS && !Platform.isFuchsia; + + static bool? _hasTools; + static final Map _durations = {}; + static final Map _thumbs = {}; + + static Future toolsAvailable() => _toolsAvailable(); + + static Future _toolsAvailable() async { + if (_hasTools != null) return _hasTools!; + if (!supported) return _hasTools = false; + try { + final probe = await Process.run('ffprobe', const [ + '-version', + ]).timeout(_timeout); + _hasTools = probe.exitCode == 0; + } catch (_) { + _hasTools = false; + } + return _hasTools!; + } + + static Future duration(String path) async { + if (_durations.containsKey(path)) return _durations[path]; + if (!await _toolsAvailable()) return null; + Duration? result; + try { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + path, + ]).timeout(_timeout); + final seconds = double.tryParse('${out.stdout}'.trim()); + if (seconds != null && seconds > 0) { + result = Duration(milliseconds: (seconds * 1000).round()); + } + } catch (_) {} + _remember(_durations, path, result); + return result; + } + + static final Map _sizes = {}; + + static Future<(int, int)?> dimensions(String path) async { + if (_sizes.containsKey(path)) return _sizes[path]; + if (!await _toolsAvailable()) return null; + (int, int)? result; + try { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-select_streams', + 'v:0', + '-show_entries', + 'stream=width,height', + '-of', + 'csv=s=x:p=0', + path, + ]).timeout(_timeout); + final parts = '${out.stdout}'.trim().split('x'); + if (parts.length >= 2) { + final w = int.tryParse(parts[0].trim()); + final h = int.tryParse(parts[1].trim()); + if (w != null && h != null && w > 0 && h > 0) result = (w, h); + } + } catch (_) {} + _remember(_sizes, path, result); + return result; + } + + static Future info(String path) async { + if (!await _toolsAvailable()) return null; + try { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-show_entries', + 'stream=codec_type,width,height,r_frame_rate:format=duration', + '-of', + 'json', + path, + ]).timeout(_timeout); + final root = jsonDecode('${out.stdout}') as Map; + final streams = (root['streams'] as List?) ?? const []; + Map? video; + var hasAudio = false; + for (final raw in streams) { + final s = raw as Map; + if (s['codec_type'] == 'video') { + video ??= s; + } else if (s['codec_type'] == 'audio') { + hasAudio = true; + } + } + if (video == null) return null; + final seconds = + double.tryParse('${(root['format'] as Map?)?['duration']}') ?? 0; + return VideoInfo( + width: (video['width'] as num?)?.toInt() ?? 0, + height: (video['height'] as num?)?.toInt() ?? 0, + durationMs: (seconds * 1000).round(), + fps: _parseRate('${video['r_frame_rate']}'), + hasAudio: hasAudio, + ); + } catch (_) { + return null; + } + } + + static double _parseRate(String value) { + final parts = value.split('/'); + if (parts.length == 2) { + final num = double.tryParse(parts[0]); + final den = double.tryParse(parts[1]); + if (num != null && den != null && den > 0) return num / den; + } + return double.tryParse(value) ?? 30; + } + + static Future frameAt(String path, int timeMs, int size) async { + if (!await _toolsAvailable()) return null; + return _grabFrame(path, size, (timeMs / 1000).toStringAsFixed(3)); + } + + static Future thumbnail(String path, int size) async { + final key = '$path@$size'; + if (_thumbs.containsKey(key)) return _thumbs[key]; + if (!await _toolsAvailable()) return null; + var bytes = await _grabFrame(path, size, '1'); + bytes ??= await _grabFrame(path, size, '0'); + _remember(_thumbs, key, bytes); + return bytes; + } + + static Future _grabFrame( + String path, + int size, + String seek, + ) async { + try { + final out = await Process.run('ffmpeg', [ + '-v', + 'error', + '-ss', + seek, + '-i', + path, + '-frames:v', + '1', + '-vf', + 'scale=$size:-2:force_original_aspect_ratio=decrease', + '-f', + 'image2', + '-vcodec', + 'mjpeg', + 'pipe:1', + ], stdoutEncoding: null).timeout(_timeout); + final data = out.stdout; + if (data is List && data.isNotEmpty) { + return Uint8List.fromList(data); + } + } catch (_) {} + return null; + } + + static void _remember(Map cache, String key, T value) { + if (cache.length > _maxCache) cache.clear(); + cache[key] = value; + } +} diff --git a/lib/core/media/dominant_color.dart b/lib/core/media/dominant_color.dart new file mode 100644 index 0000000..d17a1a0 --- /dev/null +++ b/lib/core/media/dominant_color.dart @@ -0,0 +1,142 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +class DominantColorCache { + DominantColorCache._(); + + static final DominantColorCache instance = DominantColorCache._(); + + static const int _sampleExtent = 8; + static const int _maxEntries = 256; + static const int _minAlpha = 8; + static const double _achromaticFloor = 0.15; + static const Duration _missingFileCooldown = Duration(seconds: 3); + + final Map _resolved = {}; + final Set _inFlight = {}; + final Set _rejected = {}; + final Map _retryAfter = {}; + + Color? lookup(String url) => _resolved[url]; + + void request(String url, VoidCallback onResolved) { + if (_resolved.containsKey(url) || + _inFlight.contains(url) || + _rejected.contains(url)) { + return; + } + final retryAt = _retryAfter[url]; + if (retryAt != null && DateTime.now().isBefore(retryAt)) return; + + _inFlight.add(url); + _extract(url).then((outcome) { + _inFlight.remove(url); + switch (outcome) { + case _ExtractionMissing(): + _retryAfter[url] = DateTime.now().add(_missingFileCooldown); + case _ExtractionRejected(): + _rejected.add(url); + _retryAfter.remove(url); + case _ExtractionResolved(color: final color): + _retryAfter.remove(url); + _store(url, color); + onResolved(); + } + }); + } + + void _store(String url, Color color) { + if (_resolved.length >= _maxEntries) { + _resolved.remove(_resolved.keys.first); + } + _resolved[url] = color; + } + + Future<_ExtractionOutcome> _extract(String url) async { + ui.Codec? codec; + ui.Image? image; + try { + final cached = await DefaultCacheManager().getFileFromCache(url); + if (cached == null) return const _ExtractionMissing(); + + final bytes = await cached.file.readAsBytes(); + codec = await ui.instantiateImageCodec( + bytes, + targetWidth: _sampleExtent, + targetHeight: _sampleExtent, + ); + final frame = await codec.getNextFrame(); + image = frame.image; + final raw = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (raw == null) return const _ExtractionRejected(); + + final color = _average(raw.buffer.asUint8List()); + if (color == null) return const _ExtractionRejected(); + return _ExtractionResolved(color); + } catch (_) { + return const _ExtractionRejected(); + } finally { + image?.dispose(); + codec?.dispose(); + } + } + + Color? _average(Uint8List pixels) { + var accumulatedRed = 0.0; + var accumulatedGreen = 0.0; + var accumulatedBlue = 0.0; + var accumulatedWeight = 0.0; + + for (var offset = 0; offset + 3 < pixels.length; offset += 4) { + final alpha = pixels[offset + 3]; + if (alpha < _minAlpha) continue; + + final red = pixels[offset].toDouble(); + final green = pixels[offset + 1].toDouble(); + final blue = pixels[offset + 2].toDouble(); + + final brightest = red > green + ? (red > blue ? red : blue) + : (green > blue ? green : blue); + final darkest = red < green + ? (red < blue ? red : blue) + : (green < blue ? green : blue); + final saturation = brightest <= 0 ? 0.0 : (brightest - darkest) / brightest; + + final weight = (alpha / 255) * (_achromaticFloor + saturation); + accumulatedRed += red * weight; + accumulatedGreen += green * weight; + accumulatedBlue += blue * weight; + accumulatedWeight += weight; + } + + if (accumulatedWeight <= 0) return null; + return Color.fromARGB( + 255, + (accumulatedRed / accumulatedWeight).round().clamp(0, 255), + (accumulatedGreen / accumulatedWeight).round().clamp(0, 255), + (accumulatedBlue / accumulatedWeight).round().clamp(0, 255), + ); + } +} + +sealed class _ExtractionOutcome { + const _ExtractionOutcome(); +} + +class _ExtractionMissing extends _ExtractionOutcome { + const _ExtractionMissing(); +} + +class _ExtractionRejected extends _ExtractionOutcome { + const _ExtractionRejected(); +} + +class _ExtractionResolved extends _ExtractionOutcome { + const _ExtractionResolved(this.color); + + final Color color; +} diff --git a/lib/core/media/gallery_source.dart b/lib/core/media/gallery_source.dart index 6ff60fa..f9ae88f 100644 --- a/lib/core/media/gallery_source.dart +++ b/lib/core/media/gallery_source.dart @@ -1,9 +1,12 @@ +import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:photo_manager/photo_manager.dart'; +import 'desktop_video_probe.dart'; + enum GalleryPermission { granted, limited, denied } abstract class GalleryItem { @@ -14,6 +17,21 @@ abstract class GalleryItem { Future thumbnail(int size); Future originFile(); Future<(int, int)?> dimensions(); + Future encodeForUpload({ + required int maxDimension, + required int quality, + }); + + static GalleryItem fromFile(File file) => _FileGalleryItem(file); +} + +class GalleryPage { + const GalleryPage({required this.items, required this.hasMore}); + + static const empty = GalleryPage(items: [], hasMore: false); + + final List items; + final bool hasMore; } class PickedPhoto { @@ -40,8 +58,10 @@ Future<(int, int)?> imageFileDimensions(File file) async { } abstract class GallerySource { + static const int pageSize = 120; + Future ensurePermission(); - Future> load({int limit}); + Future load({int offset, int limit}); Future openSettings(); Future manageAccess(); @@ -62,20 +82,51 @@ class _PhotoManagerSource implements GallerySource { return GalleryPermission.denied; } - @override - Future> load({int limit = 120}) async { - final paths = await PhotoManager.getAssetPathList( - type: RequestType.common, - onlyAll: true, - filterOption: FilterOptionGroup( - orders: const [ - OrderOption(type: OrderOptionType.createDate, asc: false), - ], + AssetPathEntity? _album; + int _total = 0; + + static FilterOptionGroup _filter() => FilterOptionGroup( + imageOption: const FilterOption( + sizeConstraint: SizeConstraint(ignoreSize: true), + ), + videoOption: const FilterOption( + sizeConstraint: SizeConstraint(ignoreSize: true), + durationConstraint: DurationConstraint( + max: Duration(days: 365), + allowNullable: true, ), + ), + createTimeCond: DateTimeCond.def().copyWith(ignore: true), + orders: const [OrderOption(type: OrderOptionType.createDate, asc: false)], + ); + + @override + Future load({ + int offset = 0, + int limit = GallerySource.pageSize, + }) async { + if (offset == 0 || _album == null) { + final paths = await PhotoManager.getAssetPathList( + type: RequestType.common, + onlyAll: true, + filterOption: _filter(), + ); + if (paths.isEmpty) { + _album = null; + _total = 0; + return GalleryPage.empty; + } + _album = paths.first; + _total = await paths.first.assetCountAsync; + } + final album = _album; + if (album == null || offset >= _total) return GalleryPage.empty; + final end = offset + limit < _total ? offset + limit : _total; + final assets = await album.getAssetListRange(start: offset, end: end); + return GalleryPage( + items: assets.map((a) => _AssetGalleryItem(a)).toList(), + hasMore: end < _total, ); - if (paths.isEmpty) return const []; - final assets = await paths.first.getAssetListRange(start: 0, end: limit); - return assets.map((a) => _AssetGalleryItem(a)).toList(); } @override @@ -109,6 +160,16 @@ class _AssetGalleryItem implements GalleryItem { @override Future originFile() => asset.file; + @override + Future encodeForUpload({ + required int maxDimension, + required int quality, + }) => asset.thumbnailDataWithSize( + ThumbnailSize(maxDimension, maxDimension), + format: ThumbnailFormat.jpeg, + quality: quality, + ); + @override Future<(int, int)?> dimensions() async { if (asset.width > 0 && asset.height > 0) { @@ -118,36 +179,81 @@ class _AssetGalleryItem implements GalleryItem { } } -class _DesktopGallerySource implements GallerySource { - static const _imageExtensions = { - '.jpg', - '.jpeg', - '.png', - '.gif', - '.webp', - '.bmp', - '.heic', - '.heif', - }; +const Set kGalleryImageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.heic', + '.heif', +}; +const Set kGalleryVideoExtensions = { + '.mp4', + '.mov', + '.m4v', + '.mkv', + '.webm', + '.avi', + '.3gp', +}; + +String _fileExtension(String path) { + final dot = path.lastIndexOf('.'); + if (dot < 0) return ''; + return path.substring(dot).toLowerCase(); +} + +bool isVideoPath(String path) => + kGalleryVideoExtensions.contains(_fileExtension(path)); + +class _DesktopGallerySource implements GallerySource { @override Future ensurePermission() async => GalleryPermission.granted; + List<_FileGalleryItem> _all = const []; + @override - Future> load({int limit = 120}) async { + Future load({ + int offset = 0, + int limit = GallerySource.pageSize, + }) async { + if (offset == 0 || _all.isEmpty) _all = _scan(); + if (offset >= _all.length) return GalleryPage.empty; + final end = offset + limit < _all.length ? offset + limit : _all.length; + final items = _all.sublist(offset, end); + const batch = 8; + const eager = 24; + + Future probeRange(int from, int to) async { + for (var i = from; i < to; i += batch) { + final stop = i + batch > to ? to : i + batch; + await Future.wait(items.sublist(i, stop).map((it) => it.probe())); + } + } + + final head = items.length < eager ? items.length : eager; + await probeRange(0, head); + if (head < items.length) unawaited(probeRange(head, items.length)); + return GalleryPage(items: items, hasMore: end < _all.length); + } + + List<_FileGalleryItem> _scan() { final entries = <({File file, DateTime modified})>[]; for (final dir in _candidateDirs()) { if (!dir.existsSync()) continue; try { for (final entity in dir.listSync(followLinks: false)) { - if (entity is! File || !_isImage(entity.path)) continue; + if (entity is! File || !_isMedia(entity.path)) continue; entries.add((file: entity, modified: entity.statSync().modified)); } } catch (_) {} } entries.sort((a, b) => b.modified.compareTo(a.modified)); - return entries.take(limit).map((e) => _FileGalleryItem(e.file)).toList(); + return entries.map((e) => _FileGalleryItem(e.file)).toList(); } @override @@ -164,39 +270,57 @@ class _DesktopGallerySource implements GallerySource { Directory('$home/Pictures'), Directory('$home/Изображения'), Directory('$home/Images'), + Directory('$home/Videos'), + Directory('$home/Видео'), + Directory('$home/Movies'), ]; } - bool _isImage(String path) { - final dot = path.lastIndexOf('.'); - if (dot < 0) return false; - return _imageExtensions.contains(path.substring(dot).toLowerCase()); + bool _isMedia(String path) { + final ext = _fileExtension(path); + return kGalleryImageExtensions.contains(ext) || + kGalleryVideoExtensions.contains(ext); } } class _FileGalleryItem implements GalleryItem { final File file; + Duration? _duration; - _FileGalleryItem(this.file); + _FileGalleryItem(this.file, {Duration? duration}) : _duration = duration; + + Future probe() async { + if (!isVideo || _duration != null) return; + _duration = await DesktopVideoProbe.duration(file.path); + } @override String get id => file.path; @override - bool get isVideo => false; + bool get isVideo => isVideoPath(file.path); @override - Duration? get duration => null; + Duration? get duration => _duration; @override File? get localFile => file; @override - Future thumbnail(int size) async => null; + Future thumbnail(int size) async => + isVideo ? DesktopVideoProbe.thumbnail(file.path, size) : null; @override Future originFile() async => file; @override - Future<(int, int)?> dimensions() => imageFileDimensions(file); + Future encodeForUpload({ + required int maxDimension, + required int quality, + }) async => null; + + @override + Future<(int, int)?> dimensions() => isVideo + ? DesktopVideoProbe.dimensions(file.path) + : imageFileDimensions(file); } diff --git a/lib/core/media/image_optimizer.dart b/lib/core/media/image_optimizer.dart new file mode 100644 index 0000000..6243218 --- /dev/null +++ b/lib/core/media/image_optimizer.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'gallery_source.dart'; + +const int kPhotoUploadLimitBytes = 5 * 1024 * 1024; + +const int _photoMaxDimension = 2560; +const int _photoJpegQuality = 85; +const int _photoTargetBytes = 4700 * 1024; + +const Set _heicExtensions = {'.heic', '.heif'}; + +bool isHeicPath(String path) { + final dot = path.lastIndexOf('.'); + if (dot < 0) return false; + return _heicExtensions.contains(path.substring(dot).toLowerCase()); +} + +Future optimizePhotoForUpload(File source, {GalleryItem? item}) async { + final heic = isHeicPath(source.path); + var length = 0; + try { + length = await source.length(); + } catch (_) {} + + if (!heic && length > 0 && length <= kPhotoUploadLimitBytes) { + return source; + } + + if (item != null) { + final native = await item.encodeForUpload( + maxDimension: _photoMaxDimension, + quality: _photoJpegQuality, + ); + if (native != null) return writePhotoJpeg(native); + } + + final fallback = await _encodeWithImagePackage(source); + return fallback ?? source; +} + +Future _encodeWithImagePackage(File source) async { + Uint8List bytes; + try { + bytes = await source.readAsBytes(); + } catch (_) { + return null; + } + final jpeg = await compute(_encodePhotoIsolate, ( + bytes, + _photoMaxDimension, + _photoJpegQuality, + _photoTargetBytes, + )); + if (jpeg == null) return null; + return writePhotoJpeg(jpeg); +} + +Uint8List? _encodePhotoIsolate((Uint8List, int, int, int) args) { + final (bytes, maxDim, quality, target) = args; + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final scaled = oriented.width > maxDim || oriented.height > maxDim + ? img.copyResize( + oriented, + width: oriented.width >= oriented.height ? maxDim : null, + height: oriented.height > oriented.width ? maxDim : null, + interpolation: img.Interpolation.average, + ) + : oriented; + var quality0 = quality; + var out = img.encodeJpg(scaled, quality: quality0); + while (out.lengthInBytes > target && quality0 > 40) { + quality0 -= 10; + out = img.encodeJpg(scaled, quality: quality0); + } + return out; +} + +Future writePhotoJpeg(Uint8List bytes) async { + final dir = await getTemporaryDirectory(); + final file = File( + p.join( + dir.path, + 'komet_photo_${DateTime.now().microsecondsSinceEpoch}.jpg', + ), + ); + await file.writeAsBytes(bytes, flush: true); + return file; +} diff --git a/lib/core/media/media_playback.dart b/lib/core/media/media_playback.dart new file mode 100644 index 0000000..b5bcbb7 --- /dev/null +++ b/lib/core/media/media_playback.dart @@ -0,0 +1,198 @@ +import 'package:flutter/foundation.dart'; +import 'package:video_player/video_player.dart'; + +import 'voice_audio_controller.dart'; + +enum PlaybackKind { voice, videoNote } + +class VoiceTrack { + const VoiceTrack({ + required this.cacheName, + required this.chatId, + required this.messageId, + required this.senderId, + required this.isMe, + required this.time, + required this.audio, + }); + + final String cacheName; + final int chatId; + final String messageId; + final int senderId; + final bool isMe; + final int time; + final VoiceAudioController audio; +} + +class VideoNoteTrack { + const VideoNoteTrack({ + required this.cacheName, + required this.chatId, + required this.messageId, + required this.senderId, + required this.isMe, + required this.time, + required this.controller, + required this.preview, + }); + + final String cacheName; + final int chatId; + final String messageId; + final int senderId; + final bool isMe; + final int time; + final VideoPlayerController controller; + final Uint8List? preview; +} + +class MediaPlayback { + MediaPlayback._(); + + static final MediaPlayback instance = MediaPlayback._(); + + static const List speeds = [1.0, 1.5, 2.0]; + + final ValueNotifier primary = ValueNotifier(null); + + final ValueNotifier visibleChatId = ValueNotifier(null); + + void enterChat(int chatId) => visibleChatId.value = chatId; + + void leaveChat(int chatId) { + if (visibleChatId.value == chatId) visibleChatId.value = null; + } + + final ValueNotifier voice = ValueNotifier(null); + final ValueNotifier voiceSpeed = ValueNotifier(speeds.first); + + final Set _heldVoice = {}; + + VoiceAudioController acquireVoice({ + required String cacheName, + required Future Function() resolveUrl, + required Duration fallbackDuration, + }) { + final active = voice.value; + if (active != null && active.cacheName == cacheName) { + _heldVoice.add(active.audio); + return active.audio; + } + final created = VoiceAudioController( + cacheName: cacheName, + resolveUrl: resolveUrl, + fallbackDuration: fallbackDuration, + ); + _heldVoice.add(created); + return created; + } + + void releaseVoice(VoiceAudioController audio) { + _heldVoice.remove(audio); + _disposeVoiceIfIdle(audio); + } + + void activateVoice(VoiceTrack track) { + _clearVideoNote(); + final previous = voice.value; + if (previous != null && previous.audio != track.audio) { + previous.audio.pause(); + voice.value = null; + _disposeVoiceIfIdle(previous.audio); + } + voice.value = track; + primary.value = PlaybackKind.voice; + track.audio.setSpeed(voiceSpeed.value); + } + + void cycleVoiceSpeed() { + final next = speeds[(speeds.indexOf(voiceSpeed.value) + 1) % speeds.length]; + voiceSpeed.value = next; + voice.value?.audio.setSpeed(next); + } + + void closeVoice() { + if (!_clearVoice()) return; + primary.value = videoNote.value == null ? null : PlaybackKind.videoNote; + } + + bool _clearVoice() { + final track = voice.value; + if (track == null) return false; + voice.value = null; + track.audio.stopAndReset(); + _disposeVoiceIfIdle(track.audio); + return true; + } + + void _disposeVoiceIfIdle(VoiceAudioController audio) { + if (_heldVoice.contains(audio)) return; + if (voice.value?.audio == audio) return; + audio.dispose(); + } + + final ValueNotifier videoNote = ValueNotifier(null); + final ValueNotifier videoNoteSpeed = ValueNotifier(speeds.first); + + final Set _heldNotes = {}; + + VideoPlayerController? liveVideoNote(String cacheName) { + final active = videoNote.value; + if (active == null || active.cacheName != cacheName) return null; + _heldNotes.add(active.controller); + return active.controller; + } + + void holdVideoNote(VideoPlayerController controller) => + _heldNotes.add(controller); + + bool isActiveVideoNote(VideoPlayerController controller) => + videoNote.value?.controller == controller; + + void releaseVideoNote(VideoPlayerController controller) { + _heldNotes.remove(controller); + _disposeNoteIfIdle(controller); + } + + void activateVideoNote(VideoNoteTrack track) { + _clearVoice(); + final previous = videoNote.value; + if (previous != null && previous.controller != track.controller) { + previous.controller.pause(); + videoNote.value = null; + _disposeNoteIfIdle(previous.controller); + } + videoNote.value = track; + primary.value = PlaybackKind.videoNote; + track.controller.setPlaybackSpeed(videoNoteSpeed.value); + } + + void cycleVideoNoteSpeed() { + final index = speeds.indexOf(videoNoteSpeed.value); + final next = speeds[(index + 1) % speeds.length]; + videoNoteSpeed.value = next; + videoNote.value?.controller.setPlaybackSpeed(next); + } + + void closeVideoNote() { + if (!_clearVideoNote()) return; + primary.value = voice.value == null ? null : PlaybackKind.voice; + } + + bool _clearVideoNote() { + final track = videoNote.value; + if (track == null) return false; + videoNote.value = null; + track.controller.pause(); + track.controller.seekTo(Duration.zero); + _disposeNoteIfIdle(track.controller); + return true; + } + + void _disposeNoteIfIdle(VideoPlayerController controller) { + if (_heldNotes.contains(controller)) return; + if (videoNote.value?.controller == controller) return; + controller.dispose(); + } +} diff --git a/lib/core/media/native_video_note_recorder.dart b/lib/core/media/native_video_note_recorder.dart index 6b80f4b..ffe1e67 100644 --- a/lib/core/media/native_video_note_recorder.dart +++ b/lib/core/media/native_video_note_recorder.dart @@ -4,49 +4,84 @@ import 'package:flutter/services.dart'; import '../utils/logger.dart'; -/// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет -/// квадрат 480×480 сразу при съёмке — как официальный клиент. Превью отдаётся -/// через Flutter [Texture] по [textureId]. media3-перекод не используется -/// (серверный валидатор принимает только нативно записанный MP4). +class VideoNoteAccess { + const VideoNoteAccess({required this.camera, required this.microphone}); + + static const denied = VideoNoteAccess(camera: false, microphone: false); + + final bool camera; + final bool microphone; + + bool get granted => camera && microphone; +} + class NativeVideoNoteRecorder { static const _channel = MethodChannel('ru.komet.app/video_note'); int? textureId; - bool get isAvailable => Platform.isAndroid; + bool hasFlash = false; + bool get isAvailable => Platform.isAndroid || Platform.isIOS; - Future init({bool front = true}) async { + Future requestAccess() async { + if (!isAvailable) return VideoNoteAccess.denied; + try { + final res = await _channel.invokeMapMethod('permission'); + return VideoNoteAccess( + camera: res?['camera'] as bool? ?? false, + microphone: res?['microphone'] as bool? ?? false, + ); + } catch (e) { + logger.w('NativeVideoNoteRecorder.requestAccess: $e'); + return VideoNoteAccess.denied; + } + } + + Future init({bool front = true, int size = 480, int fps = 30}) async { + if (!isAvailable) return false; + final res = await _channel.invokeMapMethod('init', { + 'front': front, + 'size': size, + 'fps': fps, + }); + textureId = res?['textureId'] as int?; + hasFlash = res?['hasFlash'] as bool? ?? false; + return textureId != null; + } + + Future switchCamera() async { if (!isAvailable) return false; try { - final res = await _channel.invokeMapMethod('init', { - 'front': front, - }); - textureId = res?['textureId'] as int?; - return textureId != null; + await _channel.invokeMethod('switch'); + return true; } catch (e) { - logger.w('NativeVideoNoteRecorder.init: $e'); + logger.w('NativeVideoNoteRecorder.switchCamera: $e'); return false; } } - Future start() async { - if (!isAvailable) return false; + Future setTorch(bool on) async { + if (!isAvailable || !hasFlash) return false; try { - await _channel.invokeMethod('start'); - return true; + return await _channel.invokeMethod('torch', {'on': on}) ?? false; } catch (e) { - logger.w('NativeVideoNoteRecorder.start: $e'); + logger.w('NativeVideoNoteRecorder.setTorch: $e'); return false; } } + Future start() async { + if (!isAvailable) { + throw PlatformException( + code: 'UNSUPPORTED', + message: 'video notes are not supported on this platform', + ); + } + await _channel.invokeMethod('start'); + } + Future stop() async { if (!isAvailable) return null; - try { - return await _channel.invokeMethod('stop'); - } catch (e) { - logger.w('NativeVideoNoteRecorder.stop: $e'); - return null; - } + return _channel.invokeMethod('stop'); } Future dispose() async { @@ -55,5 +90,6 @@ class NativeVideoNoteRecorder { await _channel.invokeMethod('dispose'); } catch (_) {} textureId = null; + hasFlash = false; } } diff --git a/lib/core/media/ogg_page_writer.dart b/lib/core/media/ogg_page_writer.dart new file mode 100644 index 0000000..624cc8a --- /dev/null +++ b/lib/core/media/ogg_page_writer.dart @@ -0,0 +1,156 @@ +import 'dart:typed_data'; + +class OggPageWriter { + static const int maxSegmentsPerPage = 255; + static const int continuedPacket = 0x01; + static const int beginningOfStream = 0x02; + static const int endOfStream = 0x04; + + static const int headerSize = 27; + static const int _granuleOffset = 6; + static const int _serialOffset = 14; + static const int _sequenceOffset = 18; + static const int _crcOffset = 22; + static const int _segmentCountOffset = 26; + + static int segmentsFor(int length) => (length ~/ 255) + 1; + + static int lengthFor(List packets) { + var segments = 0; + var body = 0; + for (final packet in packets) { + segments += segmentsFor(packet.length); + body += packet.length; + } + return headerSize + segments + body; + } + + static Uint8List page({ + required int headerType, + required int granulePos, + required int serial, + required int sequence, + required List packets, + }) { + final out = Uint8List(lengthFor(packets)); + writeInto( + out, + 0, + headerType: headerType, + granulePos: granulePos, + serial: serial, + sequence: sequence, + packets: packets, + ); + return out; + } + + static int writeInto( + Uint8List out, + int offset, { + required int headerType, + required int granulePos, + required int serial, + required int sequence, + required List packets, + }) { + final view = ByteData.sublistView(out); + out[offset] = 0x4f; + out[offset + 1] = 0x67; + out[offset + 2] = 0x67; + out[offset + 3] = 0x53; + view.setUint8(offset + 4, 0); + view.setUint8(offset + 5, headerType); + view.setInt64(offset + _granuleOffset, granulePos, Endian.little); + view.setUint32(offset + _serialOffset, serial, Endian.little); + view.setUint32(offset + _sequenceOffset, sequence, Endian.little); + view.setUint32(offset + _crcOffset, 0, Endian.little); + + var table = offset + headerSize; + for (final packet in packets) { + var remaining = packet.length; + while (remaining >= 255) { + out[table++] = 255; + remaining -= 255; + } + out[table++] = remaining; + } + view.setUint8(offset + _segmentCountOffset, table - offset - headerSize); + + var cursor = table; + for (final packet in packets) { + out.setRange(cursor, cursor + packet.length, packet); + cursor += packet.length; + } + + view.setUint32( + offset + _crcOffset, + crc32(out, offset, cursor), + Endian.little, + ); + return cursor; + } + + static final List _crcTables = _buildCrcTables(); + + static List _buildCrcTables() { + final base = Uint32List(256); + for (var i = 0; i < 256; i++) { + var r = (i << 24) & 0xffffffff; + for (var j = 0; j < 8; j++) { + if ((r & 0x80000000) != 0) { + r = ((r << 1) ^ 0x04c11db7) & 0xffffffff; + } else { + r = (r << 1) & 0xffffffff; + } + } + base[i] = r; + } + + final tables = [base]; + for (var slice = 1; slice < 4; slice++) { + final previous = tables[slice - 1]; + final next = Uint32List(256); + for (var i = 0; i < 256; i++) { + next[i] = + (((previous[i] << 8) & 0xffffffff) ^ + base[(previous[i] >> 24) & 0xff]) & + 0xffffffff; + } + tables.add(next); + } + return tables; + } + + static int crc32(Uint8List data, [int start = 0, int? end]) { + final stop = end ?? data.length; + final t0 = _crcTables[0]; + final t1 = _crcTables[1]; + final t2 = _crcTables[2]; + final t3 = _crcTables[3]; + + var crc = 0; + var i = start; + final wordEnd = stop - ((stop - start) & 3); + while (i < wordEnd) { + crc ^= + (data[i] << 24) | + (data[i + 1] << 16) | + (data[i + 2] << 8) | + data[i + 3]; + crc = + t3[(crc >> 24) & 0xff] ^ + t2[(crc >> 16) & 0xff] ^ + t1[(crc >> 8) & 0xff] ^ + t0[crc & 0xff]; + i += 4; + } + while (i < stop) { + crc = + (((crc << 8) & 0xffffffff) ^ t0[((crc >> 24) & 0xff) ^ data[i]]) & + 0xffffffff; + i++; + } + return crc & 0xffffffff; + } +} diff --git a/lib/core/media/opus_ogg_encoder.dart b/lib/core/media/opus_ogg_encoder.dart index 5104f08..e16ab41 100644 --- a/lib/core/media/opus_ogg_encoder.dart +++ b/lib/core/media/opus_ogg_encoder.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:opus_dart/opus_dart.dart'; import '../utils/logger.dart'; +import 'ogg_page_writer.dart'; /// Кодирует PCM в Ogg/Opus через libopus (FFI) на платформах, где у системы нет /// своего Opus-энкодера (Windows). Сырые Opus-пакеты выдаёт [opus_dart], а @@ -25,22 +26,24 @@ class OpusOggEncoder { /// Лениво загружает libopus и инициализирует opus_dart: на Windows — /// вендоренную `opus.dll` рядом с exe, на Android — через - /// `opus_flutter_android`. Возвращает `false`, если кодек недоступен. + /// `opus_flutter_android`, на iOS/macOS — статически слинкованную + /// `ogg_opus_player` (см. `-force_load` в ios/Podfile). + /// Возвращает `false`, если кодек недоступен. static Future ensureAvailable() async { if (_initialized) return _available; _initialized = true; try { - // libopus.so на Android бандлится плагином opus_flutter_android, - // opus.dll — вендоренная рядом с exe на Windows. - final String libName; - if (Platform.isWindows) { - libName = 'opus.dll'; + final DynamicLibrary lib; + if (Platform.isIOS || Platform.isMacOS) { + lib = DynamicLibrary.process(); + } else if (Platform.isWindows) { + lib = DynamicLibrary.open('opus.dll'); } else if (Platform.isAndroid) { - libName = 'libopus.so'; + lib = DynamicLibrary.open('libopus.so'); } else { return false; } - initOpus(DynamicLibrary.open(libName) as dynamic); + initOpus(lib as dynamic); _available = true; } catch (e) { logger.w('OpusOggEncoder: libopus недоступна: $e'); @@ -176,71 +179,16 @@ class OpusOggEncoder { required int granulePos, required int seq, required List packets, - }) { - final segs = []; - for (final p in packets) { - var len = p.length; - while (len >= 255) { - segs.add(255); - len -= 255; - } - segs.add(len); - } - - final header = Uint8List(27 + segs.length); - final hd = ByteData.sublistView(header); - header.setRange(0, 4, _ascii('OggS')); - hd.setUint8(4, 0); // stream structure version - hd.setUint8(5, headerType); - hd.setUint64(6, granulePos, Endian.little); - hd.setUint32(14, _serial, Endian.little); - hd.setUint32(18, seq, Endian.little); - hd.setUint32(22, 0, Endian.little); // CRC placeholder - hd.setUint8(26, segs.length); - header.setRange(27, 27 + segs.length, segs); - - final body = BytesBuilder(); - body.add(header); - for (final p in packets) { - body.add(p); - } - final page = body.toBytes(); - - final crc = _crc32(page); - ByteData.sublistView(page).setUint32(22, crc, Endian.little); - return page; - } + }) => OggPageWriter.page( + headerType: headerType, + granulePos: granulePos, + serial: _serial, + sequence: seq, + packets: packets, + ); static Uint8List _ascii(String s) => Uint8List.fromList(s.codeUnits); - static final Uint32List _crcTable = _buildCrcTable(); - - static Uint32List _buildCrcTable() { - final t = Uint32List(256); - for (var i = 0; i < 256; i++) { - var r = (i << 24) & 0xffffffff; - for (var j = 0; j < 8; j++) { - if ((r & 0x80000000) != 0) { - r = ((r << 1) ^ 0x04c11db7) & 0xffffffff; - } else { - r = (r << 1) & 0xffffffff; - } - } - t[i] = r; - } - return t; - } - - static int _crc32(Uint8List data) { - var crc = 0; - for (final b in data) { - crc = - (((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) & - 0xffffffff; - } - return crc & 0xffffffff; - } - static Int16List? _pcmFromWav(Uint8List bytes) { if (bytes.length < 12) return null; if (String.fromCharCodes(bytes, 0, 4) != 'RIFF' || diff --git a/lib/core/media/opus_ogg_index.dart b/lib/core/media/opus_ogg_index.dart new file mode 100644 index 0000000..50ce929 --- /dev/null +++ b/lib/core/media/opus_ogg_index.dart @@ -0,0 +1,362 @@ +import 'dart:typed_data'; + +import 'ogg_page_writer.dart'; + +class OpusOggIndex { + static const int sampleRate = 48000; + + static const int _prerollSamples = 3840; + static const int _maxPreSkip = 65535; + static const int _maxPacketSamples = 5760; + static const int _preSkipOffset = 10; + static const int _opusHeadMinLength = 19; + static const int _pageHeaderSize = 27; + + OpusOggIndex._({ + required Uint8List head, + required Uint8List tags, + required List packets, + required List packetStarts, + required int preSkip, + required int serial, + required int endGranule, + }) : _head = head, + _tags = tags, + _packets = packets, + _packetStarts = packetStarts, + _preSkip = preSkip, + _serial = serial, + _endGranule = endGranule; + + final Uint8List _head; + final Uint8List _tags; + final List _packets; + final List _packetStarts; + final int _preSkip; + final int _serial; + final int _endGranule; + + double get duration { + final playable = _endGranule - _preSkip; + return playable <= 0 ? 0 : playable / sampleRate; + } + + static OpusOggIndex? parse(Uint8List bytes) { + Uint8List? head; + Uint8List? tags; + int? serial; + var lastGranule = 0; + final packets = []; + final pendingParts = []; + var pendingStart = -1; + var pendingLength = 0; + var pendingContiguous = true; + var offset = 0; + + while (offset + _pageHeaderSize <= bytes.length) { + if (!_hasCapture(bytes, offset)) { + final resync = _findCapture(bytes, offset + 1); + if (resync < 0) break; + offset = resync; + continue; + } + + final view = ByteData.sublistView(bytes, offset); + final headerType = bytes[offset + 5]; + final pageSerial = view.getUint32(14, Endian.little); + final segmentCount = bytes[offset + 26]; + final tableStart = offset + _pageHeaderSize; + final bodyStart = tableStart + segmentCount; + if (bodyStart > bytes.length) break; + + var bodyLength = 0; + for (var i = 0; i < segmentCount; i++) { + bodyLength += bytes[tableStart + i]; + } + final bodyEnd = bodyStart + bodyLength; + if (bodyEnd > bytes.length) break; + + serial ??= pageSerial; + if (pageSerial != serial) { + offset = bodyEnd; + continue; + } + + final granule = view.getInt64(6, Endian.little); + if (granule > lastGranule) lastGranule = granule; + + if ((headerType & OggPageWriter.continuedPacket) == 0) { + pendingParts.clear(); + pendingStart = -1; + pendingLength = 0; + pendingContiguous = true; + } else if (pendingLength > 0 && pendingContiguous) { + pendingParts.add( + Uint8List.sublistView(bytes, pendingStart, pendingStart + pendingLength), + ); + pendingContiguous = false; + } + + var cursor = bodyStart; + for (var i = 0; i < segmentCount; i++) { + final length = bytes[tableStart + i]; + if (length > 0) { + if (pendingContiguous) { + if (pendingLength == 0) pendingStart = cursor; + } else { + pendingParts.add( + Uint8List.sublistView(bytes, cursor, cursor + length), + ); + } + pendingLength += length; + } + cursor += length; + if (length == 255) continue; + + final Uint8List packet; + if (pendingContiguous) { + packet = pendingLength == 0 + ? _empty + : Uint8List.sublistView( + bytes, + pendingStart, + pendingStart + pendingLength, + ); + } else { + packet = _join(pendingParts); + } + pendingParts.clear(); + pendingStart = -1; + pendingLength = 0; + pendingContiguous = true; + if (packet.isEmpty) continue; + if (head == null) { + if (!_startsWith(packet, 'OpusHead')) return null; + head = packet; + } else if (tags == null) { + tags = packet; + } else { + packets.add(packet); + } + } + offset = bodyEnd; + } + + if (head == null || tags == null || packets.isEmpty || serial == null) { + return null; + } + if (head.length < _opusHeadMinLength) return null; + + final starts = []; + var total = 0; + for (final packet in packets) { + final samples = _packetDuration(packet); + if (samples <= 0) return null; + starts.add(total); + total += samples; + } + + final preSkip = ByteData.sublistView( + head, + ).getUint16(_preSkipOffset, Endian.little); + if (preSkip >= total) return null; + + final endGranule = lastGranule > preSkip && lastGranule <= total + ? lastGranule + : total; + + return OpusOggIndex._( + head: head, + tags: tags, + packets: packets, + packetStarts: starts, + preSkip: preSkip, + serial: serial, + endGranule: endGranule, + ); + } + + Uint8List? sliceFrom(double seconds) { + if (seconds <= 0) return null; + final target = (seconds * sampleRate).round() + _preSkip; + if (target >= _endGranule) return null; + + final floor = target - _prerollSamples; + var first = 0; + for (var i = 0; i < _packetStarts.length; i++) { + if (_packetStarts[i] > floor) break; + first = i; + } + + final base = _packetStarts[first]; + final preSkip = target - base; + if (preSkip < 0 || preSkip > _maxPreSkip) return null; + + final head = _headWithPreSkip(preSkip); + final plans = <_PagePlan>[]; + var pageStart = first; + var pageSegments = 0; + var pageBytes = 0; + + for (var i = first; i < _packets.length; i++) { + final packet = _packets[i]; + final segments = OggPageWriter.segmentsFor(packet.length); + if (segments > OggPageWriter.maxSegmentsPerPage) return null; + if (i > pageStart && + pageSegments + segments > OggPageWriter.maxSegmentsPerPage) { + plans.add( + _PagePlan( + start: pageStart, + end: i, + granulePos: _packetStarts[i] - base, + bytes: OggPageWriter.headerSize + pageSegments + pageBytes, + ), + ); + pageStart = i; + pageSegments = 0; + pageBytes = 0; + } + pageSegments += segments; + pageBytes += packet.length; + } + plans.add( + _PagePlan( + start: pageStart, + end: _packets.length, + granulePos: _endGranule - base, + bytes: OggPageWriter.headerSize + pageSegments + pageBytes, + last: true, + ), + ); + + var total = + OggPageWriter.lengthFor([head]) + OggPageWriter.lengthFor([_tags]); + for (final plan in plans) { + total += plan.bytes; + } + + final out = Uint8List(total); + var sequence = 0; + var offset = OggPageWriter.writeInto( + out, + 0, + headerType: OggPageWriter.beginningOfStream, + granulePos: 0, + serial: _serial, + sequence: sequence++, + packets: [head], + ); + offset = OggPageWriter.writeInto( + out, + offset, + headerType: 0, + granulePos: 0, + serial: _serial, + sequence: sequence++, + packets: [_tags], + ); + for (final plan in plans) { + offset = OggPageWriter.writeInto( + out, + offset, + headerType: plan.last ? OggPageWriter.endOfStream : 0, + granulePos: plan.granulePos, + serial: _serial, + sequence: sequence++, + packets: _packets.sublist(plan.start, plan.end), + ); + } + return out; + } + + Uint8List _headWithPreSkip(int preSkip) { + final head = Uint8List.fromList(_head); + ByteData.sublistView( + head, + ).setUint16(_preSkipOffset, preSkip, Endian.little); + return head; + } + + static bool _hasCapture(Uint8List bytes, int offset) => + bytes[offset] == 0x4f && + bytes[offset + 1] == 0x67 && + bytes[offset + 2] == 0x67 && + bytes[offset + 3] == 0x53; + + static int _findCapture(Uint8List bytes, int from) { + for (var i = from; i + 4 <= bytes.length; i++) { + if (_hasCapture(bytes, i)) return i; + } + return -1; + } + + static final Uint8List _empty = Uint8List(0); + + static Uint8List _join(List parts) { + if (parts.isEmpty) return _empty; + if (parts.length == 1) return parts.first; + var length = 0; + for (final part in parts) { + length += part.length; + } + final out = Uint8List(length); + var offset = 0; + for (final part in parts) { + out.setRange(offset, offset + part.length, part); + offset += part.length; + } + return out; + } + + static bool _startsWith(Uint8List bytes, String magic) { + if (bytes.length < magic.length) return false; + for (var i = 0; i < magic.length; i++) { + if (bytes[i] != magic.codeUnitAt(i)) return false; + } + return true; + } + + static int _packetDuration(Uint8List packet) { + if (packet.isEmpty) return 0; + final toc = packet[0]; + final frameSamples = _frameSamples(toc >> 3); + final int frames; + switch (toc & 0x03) { + case 0: + frames = 1; + case 1: + case 2: + frames = 2; + default: + if (packet.length < 2) return 0; + frames = packet[1] & 0x3f; + } + if (frames <= 0) return 0; + final total = frameSamples * frames; + return total > _maxPacketSamples ? 0 : total; + } + + static int _frameSamples(int config) { + const silkOrHybrid = [480, 960, 1920, 2880]; + const celt = [120, 240, 480, 960]; + if (config < 12) return silkOrHybrid[config & 0x03]; + if (config < 16) return (config & 0x01) == 0 ? 480 : 960; + return celt[config & 0x03]; + } +} + +class _PagePlan { + const _PagePlan({ + required this.start, + required this.end, + required this.granulePos, + required this.bytes, + this.last = false, + }); + + final int start; + final int end; + final int granulePos; + final int bytes; + final bool last; +} diff --git a/lib/core/media/preview_image.dart b/lib/core/media/preview_image.dart new file mode 100644 index 0000000..34567ff --- /dev/null +++ b/lib/core/media/preview_image.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; + +final Expando _providers = Expando('preview'); + +ImageProvider? dataUriImage(Object owner, String? data) { + if (data == null || !data.startsWith('data:')) return null; + final cached = _providers[owner]; + if (cached != null) return cached; + final comma = data.indexOf(','); + if (comma < 0) return null; + try { + final provider = MemoryImage(base64Decode(data.substring(comma + 1))); + _providers[owner] = provider; + return provider; + } catch (_) { + return null; + } +} diff --git a/lib/core/media/share_thumbnail.dart b/lib/core/media/share_thumbnail.dart new file mode 100644 index 0000000..ff8676b --- /dev/null +++ b/lib/core/media/share_thumbnail.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show ImageProvider, MemoryImage; +import 'package:flutter/services.dart' show MissingPluginException; +import 'package:image/image.dart' as img; + +import '../../models/shared_payload.dart'; +import '../utils/logger.dart'; +import 'video_transcoder.dart'; + +const int _thumbMaxDimension = 128; +const int _thumbQuality = 70; + +Future sharedThumbnailDataUri(SharedFile source) async { + switch (source.kind) { + case SharedFileKind.photo: + return _photoThumb(source.file); + case SharedFileKind.video: + return _videoThumb(source.file); + case SharedFileKind.file: + return null; + } +} + +Future _photoThumb(File file) async { + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (e) { + logger.w('Поделиться: не прочитать ${file.path}: $e'); + return null; + } + final jpeg = await compute(_encodeThumbIsolate, bytes); + return _asDataUri(jpeg); +} + +Future _videoThumb(File file) async { + try { + final frames = await VideoTranscoder.frames(file.path, const [ + 0, + ], size: _thumbMaxDimension); + if (frames.isEmpty) return null; + return _asDataUri(frames.first); + } on MissingPluginException { + return null; + } catch (e) { + logger.w('Поделиться: не взять кадр из ${file.path}: $e'); + return null; + } +} + +String? _asDataUri(Uint8List? bytes) { + if (bytes == null || bytes.isEmpty) return null; + return 'data:image/jpeg;base64,${base64Encode(bytes)}'; +} + +Uint8List? _encodeThumbIsolate(Uint8List bytes) { + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final longest = oriented.width >= oriented.height + ? oriented.width + : oriented.height; + final scaled = longest > _thumbMaxDimension + ? img.copyResize( + oriented, + width: oriented.width >= oriented.height ? _thumbMaxDimension : null, + height: oriented.height > oriented.width ? _thumbMaxDimension : null, + interpolation: img.Interpolation.average, + ) + : oriented; + return img.encodeJpg(scaled, quality: _thumbQuality); +} + +final Map _sharedThumbCache = {}; + +ImageProvider? decodeSharedThumb(String? dataUri) { + if (dataUri == null || dataUri.isEmpty) return null; + final cached = _sharedThumbCache[dataUri]; + if (cached != null) return cached; + final comma = dataUri.indexOf(','); + if (comma < 0) return null; + try { + final provider = MemoryImage(base64Decode(dataUri.substring(comma + 1))); + if (_sharedThumbCache.length >= 32) { + _sharedThumbCache.remove(_sharedThumbCache.keys.first); + } + _sharedThumbCache[dataUri] = provider; + return provider; + } catch (_) { + return null; + } +} diff --git a/lib/core/media/video_note_cropper.dart b/lib/core/media/video_note_cropper.dart index 257ef0d..c469b13 100644 --- a/lib/core/media/video_note_cropper.dart +++ b/lib/core/media/video_note_cropper.dart @@ -5,14 +5,14 @@ import 'package:flutter/services.dart'; import '../utils/logger.dart'; /// Центр-кроп записанного видео в квадрат для видеосообщений-кружков. -/// На Android выполняется нативно (media3 Transformer, без искажений — -/// заполняет квадрат и обрезает лишнее по бокам). На других платформах -/// возвращает `null` (кружки там не записываются). +/// Выполняется нативно: на Android — media3 Transformer, на iOS — +/// AVAssetExportSession. Без искажений: заполняет квадрат и обрезает +/// лишнее по бокам. На других платформах возвращает `null`. class VideoNoteCropper { static const _channel = MethodChannel('ru.komet.app/video'); static Future cropSquare(String input, {int size = 480}) async { - if (!Platform.isAndroid) return null; + if (!Platform.isAndroid && !Platform.isIOS) return null; try { final dot = input.lastIndexOf('.'); final base = dot > 0 ? input.substring(0, dot) : input; diff --git a/lib/core/media/video_note_frame.dart b/lib/core/media/video_note_frame.dart new file mode 100644 index 0000000..1eb806f --- /dev/null +++ b/lib/core/media/video_note_frame.dart @@ -0,0 +1,11 @@ +import 'dart:ui'; + +Size videoNoteFrameSize(Size frame, double fallback) { + final width = frame.width.isFinite && frame.width > 0 + ? frame.width + : fallback; + final height = frame.height.isFinite && frame.height > 0 + ? frame.height + : fallback; + return Size(width, height); +} diff --git a/lib/core/media/video_note_preloader.dart b/lib/core/media/video_note_preloader.dart new file mode 100644 index 0000000..f48fbde --- /dev/null +++ b/lib/core/media/video_note_preloader.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + +import '../utils/media_cache.dart'; + +class VideoNotePreloader { + static const int autoLoadMaxMs = 30000; + static const int _maxConcurrent = 2; + + static int _running = 0; + static final Queue<_PreloadJob> _queue = Queue(); + + static bool autoLoads(int? durationMs) => + durationMs != null && durationMs > 0 && durationMs <= autoLoadMaxMs; + + static Future load( + String cacheName, + Future Function() resolveUrl, { + bool priority = false, + void Function(double progress)? onProgress, + bool Function()? cancelled, + }) async { + final cached = await MediaCache.existing(cacheName); + if (cached != null) return cached; + + final job = _PreloadJob(cacheName, resolveUrl, onProgress, cancelled); + if (priority) { + _queue.addFirst(job); + } else { + _queue.addLast(job); + } + _pump(); + return job.result.future; + } + + static void _pump() { + while (_running < _maxConcurrent && _queue.isNotEmpty) { + final job = _queue.removeFirst(); + _running++; + _run(job).whenComplete(() { + _running--; + _pump(); + }); + } + } + + static Future _run(_PreloadJob job) async { + if (job.cancelled?.call() ?? false) { + job.result.complete(null); + return; + } + File? file; + try { + final url = await job.resolveUrl(); + if (url != null && url.isNotEmpty) { + file = await MediaCache.getOrDownload( + job.cacheName, + url, + onProgress: job.onProgress, + ); + } + } catch (_) { + file = null; + } + if (!job.result.isCompleted) job.result.complete(file); + } +} + +class _PreloadJob { + _PreloadJob(this.cacheName, this.resolveUrl, this.onProgress, this.cancelled); + + final String cacheName; + final Future Function() resolveUrl; + final void Function(double progress)? onProgress; + final bool Function()? cancelled; + final Completer result = Completer(); +} diff --git a/lib/core/media/video_transcoder.dart b/lib/core/media/video_transcoder.dart new file mode 100644 index 0000000..77d7208 --- /dev/null +++ b/lib/core/media/video_transcoder.dart @@ -0,0 +1,365 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../utils/logger.dart'; +import 'desktop_video_probe.dart'; + +class VideoInfo { + final int width; + final int height; + final int durationMs; + final double fps; + final bool hasAudio; + + const VideoInfo({ + required this.width, + required this.height, + required this.durationMs, + required this.fps, + required this.hasAudio, + }); +} + +class VideoExportSpec { + final String input; + final String output; + final int? startMs; + final int? endMs; + final bool removeAudio; + + final double rotationDegrees; + final bool flipH; + + final Rect? crop; + final int outWidth; + final int outHeight; + + final List? rgbMatrix; + final String? overlayPath; + final int? bitrate; + + const VideoExportSpec({ + required this.input, + required this.output, + required this.outWidth, + required this.outHeight, + this.startMs, + this.endMs, + this.removeAudio = false, + this.rotationDegrees = 0, + this.flipH = false, + this.crop, + this.rgbMatrix, + this.overlayPath, + this.bitrate, + }); + + bool get hasGeometry => + rotationDegrees.abs() > 0.01 || + flipH || + (crop != null && crop != const Rect.fromLTRB(0, 0, 1, 1)); +} + +class VideoTranscoder { + static const _channel = MethodChannel('ru.komet.app/video'); + + static bool get _native => Platform.isAndroid || Platform.isIOS; + + static Process? _desktopProcess; + static bool _desktopCancelled = false; + + static bool get supported => + _native || (DesktopVideoProbe.supported && _ffmpegReady); + + static bool _ffmpegReady = false; + + static Future ensureAvailable() async { + if (_native) return true; + if (!DesktopVideoProbe.supported) return false; + _ffmpegReady = await DesktopVideoProbe.toolsAvailable(); + return _ffmpegReady; + } + + static Future probe(String path) async { + if (_native) { + try { + final res = await _channel.invokeMapMethod('probe', { + 'input': path, + }); + if (res == null) return null; + return VideoInfo( + width: (res['width'] as num?)?.toInt() ?? 0, + height: (res['height'] as num?)?.toInt() ?? 0, + durationMs: (res['durationMs'] as num?)?.toInt() ?? 0, + fps: (res['fps'] as num?)?.toDouble() ?? 30, + hasAudio: res['hasAudio'] == true, + ); + } catch (e) { + logger.w('VideoTranscoder.probe: $e'); + return null; + } + } + if (!await ensureAvailable()) return null; + return DesktopVideoProbe.info(path); + } + + static Future> frames( + String path, + List timesMs, { + int size = 256, + bool precise = false, + }) async { + if (timesMs.isEmpty) return const []; + if (_native) { + try { + final res = await _channel.invokeListMethod('frames', { + 'input': path, + 'times': timesMs, + 'size': size, + 'precise': precise, + }); + if (res == null) return List.filled(timesMs.length, null); + return res.map((e) => e as Uint8List?).toList(); + } catch (e) { + logger.w('VideoTranscoder.frames: $e'); + return List.filled(timesMs.length, null); + } + } + if (!await ensureAvailable()) return List.filled(timesMs.length, null); + final out = []; + for (final t in timesMs) { + out.add(await DesktopVideoProbe.frameAt(path, t, size)); + } + return out; + } + + static Future outputFile(String prefix) async { + final dir = await getTemporaryDirectory(); + return File( + p.join( + dir.path, + 'komet_${prefix}_${DateTime.now().microsecondsSinceEpoch}.mp4', + ), + ); + } + + static Future export( + VideoExportSpec spec, { + void Function(double progress)? onProgress, + }) async { + if (_native) return _exportNative(spec, onProgress); + if (!await ensureAvailable()) return false; + return _exportFfmpeg(spec, onProgress); + } + + static Future cancel() async { + if (_native) { + try { + await _channel.invokeMethod('editCancel'); + } catch (_) {} + return; + } + _desktopCancelled = true; + _desktopProcess?.kill(); + } + + static Future _exportNative( + VideoExportSpec spec, + void Function(double)? onProgress, + ) async { + final poll = onProgress == null + ? null + : Timer.periodic(const Duration(milliseconds: 250), (_) async { + try { + final value = await _channel.invokeMethod('editProgress'); + if (value != null && value >= 0) onProgress(value / 100); + } catch (_) {} + }); + try { + final ok = await _channel.invokeMethod('edit', _nativeArgs(spec)); + return ok == true; + } catch (e) { + logger.w('VideoTranscoder.export: $e'); + return false; + } finally { + poll?.cancel(); + } + } + + static Map _nativeArgs(VideoExportSpec spec) { + final crop = spec.crop; + return { + 'input': spec.input, + 'output': spec.output, + 'startMs': spec.startMs, + 'endMs': spec.endMs, + 'removeAudio': spec.removeAudio, + 'rotationDegrees': spec.rotationDegrees, + 'flipH': spec.flipH, + 'crop': crop == null + ? null + : [ + crop.left * 2 - 1, + crop.right * 2 - 1, + 1 - crop.bottom * 2, + 1 - crop.top * 2, + ], + 'outWidth': spec.outWidth, + 'outHeight': spec.outHeight, + 'rgbMatrix': spec.rgbMatrix, + 'overlay': spec.overlayPath, + 'bitrate': spec.bitrate, + }; + } + + static Future _exportFfmpeg( + VideoExportSpec spec, + void Function(double)? onProgress, + ) async { + _desktopCancelled = false; + File? lut; + try { + final args = [ + '-y', + '-v', + 'error', + '-progress', + 'pipe:1', + '-nostats', + ]; + final startMs = spec.startMs ?? 0; + if (startMs > 0) { + args.addAll(['-ss', (startMs / 1000).toStringAsFixed(3)]); + } + args.addAll(['-i', spec.input]); + final overlay = spec.overlayPath; + if (overlay != null) args.addAll(['-i', overlay]); + final endMs = spec.endMs; + if (endMs != null && endMs > startMs) { + args.addAll(['-t', ((endMs - startMs) / 1000).toStringAsFixed(3)]); + } + + final matrix = spec.rgbMatrix; + if (matrix != null) lut = await _writeCubeLut(matrix); + + final chain = []; + if (spec.flipH) chain.add('hflip'); + final rotation = spec.rotationDegrees; + if (rotation.abs() > 0.01) { + final radians = -rotation * math.pi / 180; + chain.add( + 'rotate=${radians.toStringAsFixed(6)}:' + "ow='rotw(${radians.toStringAsFixed(6)})':" + "oh='roth(${radians.toStringAsFixed(6)})':c=black", + ); + } + final crop = spec.crop; + if (crop != null && crop != const Rect.fromLTRB(0, 0, 1, 1)) { + chain.add( + 'crop=iw*${crop.width.toStringAsFixed(6)}:' + 'ih*${crop.height.toStringAsFixed(6)}:' + 'iw*${crop.left.toStringAsFixed(6)}:' + 'ih*${crop.top.toStringAsFixed(6)}', + ); + } + chain.add('scale=${spec.outWidth}:${spec.outHeight}'); + if (lut != null) { + chain.add("lut3d=file='${lut.path.replaceAll("'", r"\'")}'"); + } + chain.add('format=yuv420p'); + + if (overlay != null) { + args.addAll([ + '-filter_complex', + '[0:v]${chain.join(',')}[base];[base][1:v]overlay=0:0', + ]); + } else { + args.addAll(['-vf', chain.join(',')]); + } + + args.addAll([ + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-pix_fmt', + 'yuv420p', + ]); + final bitrate = spec.bitrate; + if (bitrate != null) { + args.addAll(['-b:v', '$bitrate', '-maxrate', '$bitrate']); + } + if (spec.removeAudio) { + args.add('-an'); + } else { + args.addAll(['-c:a', 'aac', '-b:a', '128k']); + } + args.addAll(['-movflags', '+faststart', spec.output]); + + final process = await Process.start('ffmpeg', args); + _desktopProcess = process; + final totalMs = (endMs ?? 0) - startMs; + final progress = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + if (onProgress == null || totalMs <= 0) return; + if (!line.startsWith('out_time_ms=')) return; + final us = int.tryParse(line.substring(12).trim()); + if (us == null) return; + onProgress((us / 1000 / totalMs).clamp(0.0, 1.0)); + }); + final stderr = process.stderr.transform(utf8.decoder).join(); + final code = await process.exitCode; + await progress.cancel(); + if (code != 0 && !_desktopCancelled) { + logger.w('ffmpeg exited $code: ${await stderr}'); + } + return code == 0; + } catch (e) { + logger.w('VideoTranscoder ffmpeg: $e'); + return false; + } finally { + _desktopProcess = null; + lut?.delete().then((_) {}, onError: (_) {}); + } + } + + static Future _writeCubeLut(List m) async { + const n = 17; + final buffer = StringBuffer('LUT_3D_SIZE $n\n'); + double apply(int row, double r, double g, double b) => + (m[row] * r + m[4 + row] * g + m[8 + row] * b + m[12 + row]).clamp( + 0.0, + 1.0, + ); + for (var bi = 0; bi < n; bi++) { + for (var gi = 0; gi < n; gi++) { + for (var ri = 0; ri < n; ri++) { + final r = ri / (n - 1); + final g = gi / (n - 1); + final b = bi / (n - 1); + buffer.writeln( + '${apply(0, r, g, b).toStringAsFixed(6)} ' + '${apply(1, r, g, b).toStringAsFixed(6)} ' + '${apply(2, r, g, b).toStringAsFixed(6)}', + ); + } + } + } + final dir = await getTemporaryDirectory(); + final file = File( + p.join( + dir.path, + 'komet_lut_${DateTime.now().microsecondsSinceEpoch}.cube', + ), + ); + await file.writeAsString(buffer.toString()); + return file; + } +} diff --git a/lib/core/media/voice_audio_controller.dart b/lib/core/media/voice_audio_controller.dart new file mode 100644 index 0000000..fbc3701 --- /dev/null +++ b/lib/core/media/voice_audio_controller.dart @@ -0,0 +1,359 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:ogg_opus_player/ogg_opus_player.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../utils/download_progress.dart'; +import '../utils/logger.dart'; +import '../utils/media_cache.dart'; +import 'opus_ogg_index.dart'; + +enum VoiceAudioFailure { none, download, playback } + +class VoiceAudioController { + VoiceAudioController({ + required this.cacheName, + required this.resolveUrl, + required Duration fallbackDuration, + }) : duration = ValueNotifier( + fallbackDuration.inMicroseconds / Duration.microsecondsPerSecond, + ); + + final String cacheName; + final Future Function() resolveUrl; + + static const double _endEpsilon = 0.05; + + static VoiceAudioController? _active; + static int _sliceCounter = 0; + static Directory? _sliceDir; + + final ValueNotifier playing = ValueNotifier(false); + final ValueNotifier position = ValueNotifier(0); + final ValueNotifier duration; + final ValueNotifier failure = ValueNotifier( + VoiceAudioFailure.none, + ); + + ValueListenable get downloadProgress => + MediaDownloadProgress.notifier(cacheName); + + ValueListenable get downloaded => MediaCache.presence(cacheName); + + bool get scrubbing => _scrubbing; + + File? _file; + OpusOggIndex? _index; + OggOpusPlayer? _player; + File? _slice; + Timer? _ticker; + Future? _loading; + double _sliceOffset = 0; + double _speed = 1; + int _startGeneration = 0; + bool _scrubbing = false; + bool _resumeAfterScrub = false; + bool _finished = false; + bool _disposed = false; + + Future toggle() async { + if (playing.value) { + pause(); + return; + } + await play(); + } + + Future play() async { + if (_disposed) return; + if (!await _ensureLoaded()) return; + if (_disposed) return; + + if (_active != this) { + _active?.pause(); + _active = this; + } + + if (_finished) { + _finished = false; + _disposePlayer(); + position.value = 0; + } + + final player = _player; + if (player != null) { + player.play(); + _applySpeed(); + playing.value = true; + _startTicker(); + return; + } + + final total = duration.value; + final from = total > 0 && position.value >= total - _endEpsilon + ? 0.0 + : position.value; + await _startAt(from); + } + + void pause() { + _startGeneration++; + _player?.pause(); + playing.value = false; + _stopTicker(); + } + + void setSpeed(double speed) { + if (_disposed) return; + _speed = speed; + _applySpeed(); + } + + void stopAndReset() { + if (_disposed) return; + pause(); + _disposePlayer(); + _finished = false; + _sliceOffset = 0; + position.value = 0; + } + + void _applySpeed() { + final player = _player; + if (player == null) return; + try { + player.setPlaybackRate(_speed); + } catch (e) { + logger.w('VoiceAudioController.setSpeed($cacheName): $e'); + } + } + + Future seekTo(double seconds) async { + scrubStart(); + scrubTo(seconds); + await scrubEnd(); + } + + void scrubStart() { + if (_scrubbing) return; + _scrubbing = true; + _resumeAfterScrub = playing.value; + _startGeneration++; + if (playing.value) pause(); + } + + void scrubTo(double seconds) { + final total = duration.value; + position.value = total <= 0 ? 0 : seconds.clamp(0.0, total); + } + + Future scrubEnd() async { + if (!_scrubbing) return; + _scrubbing = false; + final resume = _resumeAfterScrub; + _resumeAfterScrub = false; + _finished = false; + + if (_file == null) { + if (resume) await play(); + return; + } + + _disposePlayer(); + if (resume) await _startAt(position.value); + } + + Future _ensureLoaded() async { + if (_file != null) return true; + final running = _loading; + if (running != null) { + await running; + return _file != null; + } + final future = _load(); + _loading = future; + try { + await future; + } finally { + _loading = null; + } + return _file != null; + } + + Future _load() async { + failure.value = VoiceAudioFailure.none; + try { + var file = await MediaCache.existing(cacheName); + if (file == null) { + MediaDownloadProgress.set(cacheName, 0); + try { + final url = await resolveUrl(); + if (url != null && url.isNotEmpty) { + file = await MediaCache.getOrDownload( + cacheName, + url, + onProgress: (value) => + MediaDownloadProgress.set(cacheName, value), + ); + } + } finally { + MediaDownloadProgress.set(cacheName, null); + } + } + if (_disposed) return; + if (file == null) { + failure.value = VoiceAudioFailure.download; + return; + } + _file = file; + await _buildIndex(file); + } catch (e) { + logger.w('VoiceAudioController._load($cacheName): $e'); + if (!_disposed) failure.value = VoiceAudioFailure.download; + } + } + + Future _buildIndex(File file) async { + try { + final bytes = await file.readAsBytes(); + if (_disposed) return; + final index = OpusOggIndex.parse(bytes); + if (index == null) return; + _index = index; + if (index.duration > 0) duration.value = index.duration; + } catch (e) { + logger.w('VoiceAudioController: индекс не построен ($cacheName): $e'); + } + } + + Future _startAt(double seconds) async { + final file = _file; + if (file == null) return; + final generation = ++_startGeneration; + _disposePlayer(); + + final total = duration.value; + if (total > 0 && seconds >= total - _endEpsilon) { + _finished = true; + playing.value = false; + position.value = total; + return; + } + + var path = file.path; + var offset = 0.0; + final index = _index; + if (seconds > 0 && index != null) { + final bytes = index.sliceFrom(seconds); + if (bytes != null) { + final slice = await _writeSlice(bytes); + if (_disposed || generation != _startGeneration) return; + if (slice != null) { + path = slice.path; + offset = seconds; + } + } + } + + _sliceOffset = offset; + position.value = offset; + + try { + final player = OggOpusPlayer(path); + _player = player; + player.state.addListener(_onPlayerState); + player.play(); + _applySpeed(); + playing.value = true; + _startTicker(); + } catch (e) { + logger.w('VoiceAudioController._startAt($cacheName): $e'); + failure.value = VoiceAudioFailure.playback; + playing.value = false; + } + } + + Future _writeSlice(Uint8List bytes) async { + try { + final dir = _sliceDir ??= await getTemporaryDirectory(); + final next = File(p.join(dir.path, 'voice_slice_${_sliceCounter++}.ogg')); + await next.writeAsBytes(bytes); + final previous = _slice; + _slice = next; + await _deleteQuietly(previous); + return next; + } catch (e) { + logger.w('VoiceAudioController._writeSlice($cacheName): $e'); + return null; + } + } + + void _onPlayerState() { + final state = _player?.state.value; + if (state == null || _disposed) return; + if (state == PlayerState.ended) { + _finished = true; + playing.value = false; + position.value = duration.value; + _stopTicker(); + return; + } + if (state == PlayerState.error) { + failure.value = VoiceAudioFailure.playback; + playing.value = false; + _stopTicker(); + } + } + + void _startTicker() { + _ticker ??= Timer.periodic( + const Duration(milliseconds: 50), + (_) => _onTick(), + ); + } + + void _stopTicker() { + _ticker?.cancel(); + _ticker = null; + } + + void _onTick() { + final player = _player; + if (player == null || _scrubbing || _finished) return; + final total = duration.value; + final value = _sliceOffset + player.currentPosition; + position.value = total > 0 ? value.clamp(0.0, total) : value; + } + + void _disposePlayer() { + final player = _player; + _player = null; + _stopTicker(); + if (player == null) return; + player.state.removeListener(_onPlayerState); + player.dispose(); + } + + static Future _deleteQuietly(File? file) async { + if (file == null) return; + try { + if (await file.exists()) await file.delete(); + } catch (_) {} + } + + void dispose() { + _disposed = true; + _disposePlayer(); + if (_active == this) _active = null; + final slice = _slice; + _slice = null; + _deleteQuietly(slice).ignore(); + playing.dispose(); + position.dispose(); + duration.dispose(); + failure.dispose(); + } +} diff --git a/lib/core/protocol/chat_cache_fingerprint.dart b/lib/core/protocol/chat_cache_fingerprint.dart index 2387a07..943e942 100644 --- a/lib/core/protocol/chat_cache_fingerprint.dart +++ b/lib/core/protocol/chat_cache_fingerprint.dart @@ -8,10 +8,10 @@ class ChatCacheFingerprint { '1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93', ); static final Uint8List _soDigest = _hex( - '90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d', + '634ecc42b246784d975f180b4fecf903df235cdf0476da47163a85630eb1a6a8', ); static final Uint8List _dexDigest = _hex( - '0a6265f6e5d8231b9cba641f8c40475e6f3baeb06ed41b804b9bf7307aa4214e', + '38cff46f392dc1734c308be011c2f0d8da152a390b41063dbb2c913e3032f4b3', ); static Uint8List compute(int callsSeed, String deviceId) { diff --git a/lib/core/protocol/lz4_block.dart b/lib/core/protocol/lz4_block.dart deleted file mode 100644 index 55d1529..0000000 --- a/lib/core/protocol/lz4_block.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:typed_data'; - -/// LZ4 block декомпрессия (без frame-заголовка). -/// -/// Сервер шлёт block-формат как в транспорте (payload пакетов), так и в -/// `vcp`-параметрах звонка. dart_lz4 поддерживает только frame-формат, поэтому -/// block распаковывается вручную. -Uint8List lz4BlockDecompress(Uint8List src, int maxSize) { - var out = Uint8List(1024); - int outLen = 0; - int pos = 0; - - void ensure(int extra) { - if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит'); - if (outLen + extra <= out.length) return; - var newCap = out.length * 2; - while (newCap < outLen + extra) { - newCap *= 2; - } - if (newCap > maxSize) newCap = maxSize; - final grown = Uint8List(newCap); - grown.setRange(0, outLen, out); - out = grown; - } - - while (pos < src.length) { - final token = src[pos++]; - var litLen = token >> 4; - - if (litLen == 15) { - while (pos < src.length) { - final b = src[pos++]; - litLen += b; - if (b != 255) break; - } - } - - if (litLen > 0) { - ensure(litLen); - out.setRange(outLen, outLen + litLen, src, pos); - outLen += litLen; - pos += litLen; - } - - if (pos >= src.length) break; - - if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input'); - final offset = src[pos] | (src[pos + 1] << 8); - pos += 2; - if (offset == 0) throw StateError('LZ4: offset = 0'); - - var matchLen = (token & 0x0F) + 4; - if ((token & 0x0F) == 0x0F) { - while (pos < src.length) { - final b = src[pos++]; - matchLen += b; - if (b != 255) break; - } - } - - ensure(matchLen); - final start = outLen - offset; - if (start < 0) throw StateError('LZ4: offset за пределами вывода'); - for (var i = 0; i < matchLen; i++) { - out[outLen + i] = out[start + i]; - } - outLen += matchLen; - } - - return Uint8List.sublistView(out, 0, outLen); -} diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 193752f..0d65021 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -29,6 +29,7 @@ abstract class Opcode { static const int authLoginRestorePassword = 101; // Восстановление пароля static const int auth2faDetails = 104; // Детали 2FA static const int externalCallback = 105; // Внешний коллбэк + static const int phoneWebappShare = 106; static const int authValidatePassword = 107; // Валидация пароля static const int authValidateHint = 108; // Валидация подсказки пароля static const int authVerifyEmail = 109; // Верификация email @@ -59,6 +60,7 @@ abstract class Opcode { static const int contactMutual = 38; // Общие контакты static const int contactPhotos = 39; // Фото контакта static const int contactSort = 40; // Сортировка контактов + static const int contactAddByPhone = 41; // Добавление контакта по номеру static const int contactVerify = 42; // Верификация контакта static const int removeContactPhoto = 43; // Удаление фото контакта static const int contactInfoByPhone = 46; // Поиск контакта по номеру @@ -124,6 +126,11 @@ abstract class Opcode { static const int linkInfo = 89; // Информация по ссылке / вход в канал static const int audioPlay = 301; // Воспроизведение аудио + // ── Comments (комментарии к постам каналов) ──────────────────────── + // Загрузка/отправка/набор комментариев переиспользуют chatHistory (49), + // msgSend (64) и msgTyping (65) с добавленным полем postId. + static const int commentsInfo = 91; // Кол-во комментариев к постам (totalCount) + // ── Sessions ─────────────────────────────────────────────────────── static const int sessionsInfo = 96; // Запрос активных сессий static const int sessionsClose = 97; // Закрытие всех сессий @@ -190,6 +197,7 @@ abstract class Opcode { static const int profileDeleteTime = 200; // Таймер удаления профиля static const int authQrApprove = 290; // Подтверждение QR-входа static const int chatSuggest = 300; // Предложения чатов + static const int bannersSync = 302; // Синхронизация баннеров (informer) // ── Polls ────────────────────────────────────────────────────────── static const int sendVote = 304; // Голосование @@ -239,6 +247,7 @@ abstract class Opcode { authLoginRestorePassword: 'AUTH_LOGIN_RESTORE_PASSWORD', auth2faDetails: 'AUTH_2FA_DETAILS', externalCallback: 'EXTERNAL_CALLBACK', + phoneWebappShare: 'PHONE_WEBAPP_SHARE', authValidatePassword: 'AUTH_VALIDATE_PASSWORD', authValidateHint: 'AUTH_VALIDATE_HINT', authVerifyEmail: 'AUTH_VERIFY_EMAIL', @@ -265,6 +274,7 @@ abstract class Opcode { contactMutual: 'CONTACT_MUTUAL', contactPhotos: 'CONTACT_PHOTOS', contactSort: 'CONTACT_SORT', + contactAddByPhone: 'CONTACT_ADD_BY_PHONE', contactVerify: 'CONTACT_VERIFY', removeContactPhoto: 'REMOVE_CONTACT_PHOTO', contactInfoByPhone: 'CONTACT_INFO_BY_PHONE', @@ -319,6 +329,7 @@ abstract class Opcode { fileDownload: 'FILE_DOWNLOAD', linkInfo: 'LINK_INFO', audioPlay: 'AUDIO_PLAY', + commentsInfo: 'COMMENTS_INFO', sessionsInfo: 'SESSIONS_INFO', sessionsClose: 'SESSIONS_CLOSE', phoneBindRequest: 'PHONE_BIND_REQUEST', @@ -370,6 +381,7 @@ abstract class Opcode { profileDeleteTime: 'PROFILE_DELETE_TIME', authQrApprove: 'AUTH_QR_APPROVE', chatSuggest: 'CHAT_SUGGEST', + bannersSync: 'BANNERS_SYNC', sendVote: 'SEND_VOTE', votersListByAnswer: 'VOTERS_LIST_BY_ANSWER', getPollUpdates: 'GET_POLL_UPDATES', diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 565b339..7a79820 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,16 +1,3 @@ -import 'dart:typed_data'; -import 'dart:isolate'; -import 'package:dart_lz4/dart_lz4.dart'; -import 'package:libcompress/libcompress.dart'; -import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; -import 'lz4_block.dart'; - -/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 -const int headerSize = 10; - -/// Потолок распаковки payload (анти-бомба); буфер растёт динамически до него. -const int _maxDecompressedSize = 32 * 1024 * 1024; // 32 MB - /// Типы команд в протоколе abstract class CmdType { static const int request = @@ -22,17 +9,11 @@ abstract class CmdType { static const int error = 3; // ответ: ошибка } -/// Распакованный бинарный пакет +/// Распакованный пакет. /// -/// Формат заголовка (10 байт): -/// ``` -/// [0] ver — версия протокола (uint8) (по умолчанию 10) -/// [1] cmd — тип команды (uint8) (при отправке от клиента равно 0) -/// [2..3] seq — порядковый номер (uint16 BE) -/// [4..5] opcode — код операции (uint16 BE) -/// [6..9] packedLen — флаг сжатия [6] + длина payload [7..9] (uint32 BE) -/// [10..] payload — данные в MsgPack, опционально сжатые LZ4 -/// ``` +/// Провод (фрейминг, MsgPack, сжатие) живёт в Rust-ядре kolibri; здесь пакет — +/// это уже декодированный [payload] (Map/List/скаляр, бинарь — Uint8List) плюс +/// метаданные заголовка. class Packet { int api; int cmd; @@ -69,15 +50,24 @@ class SessionExpiredException extends PacketError { const SessionExpiredException(super.message); } +bool isPermanentSendFailure(Object error) { + if (error is! PacketError) return false; + if (error is SessionExpiredException) return false; + return !(error.errorKey?.contains('not.ready') ?? false); +} + String messageFromErrorPayload(dynamic payload) { if (payload is Map) { final msg = payload['message']; if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') { return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.'; } - for (final key in ['localizedMessage', 'message', 'title']) { + for (final key in ['localizedMessage', 'title', 'message']) { final v = payload[key]; - if (v is String && v.trim().isNotEmpty) return v.trim(); + if (v is! String) continue; + final text = v.trim(); + if (text.isEmpty || _isRawServerTemplate(text)) continue; + return text; } return 'Неизвестная ошибка'; } @@ -86,6 +76,9 @@ String messageFromErrorPayload(dynamic payload) { return s.isNotEmpty ? s : 'Неизвестная ошибка'; } +bool _isRawServerTemplate(String text) => + text.startsWith('Key: ') || text.startsWith('key: '); + bool isSessionExpiredPayload(dynamic payload) { return payload is Map && (payload['message'] == 'FAIL_LOGIN_TOKEN' || @@ -109,132 +102,3 @@ bool isSessionStateError(Object error) { text.contains('авторизационная сессия') || text.contains('сессия не онлайн'); } - -/// Payload меньше этого размера отправляется без сжатия (как в оригинале). -const int _compressionThreshold = 32; - -/// Упаковка пакета для отправки на сервер. -/// -/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold] -/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия: -/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по -/// которому получатель выделяет буфер под распаковку). -Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { - final Uint8List raw = msgpack.serialize(payload); - - final List body; - final int flag; - if (raw.length < _compressionThreshold) { - body = raw; - flag = 0; - } else { - body = lz4Compress(raw); - flag = (raw.length ~/ body.length) + 1; - } - - final out = Uint8List(headerSize + body.length); - final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize); - header.setUint8(0, 10); - header.setUint8(1, CmdType.request); - header.setUint16(2, seq, Endian.big); - header.setUint16(4, opcode, Endian.big); - header.setUint32( - 6, - ((flag & 0xFF) << 24) | (body.length & 0xFFFFFF), - Endian.big, - ); - out.setRange(headerSize, out.length, body); - return out; -} - -const int _isolateDecodeThreshold = 4096; - -Future unpackPacket(Uint8List packet) async { - final header = ByteData.sublistView(packet); - - final apiVer = header.getUint8(0) & 0xFF; - final cmd = header.getUint8(1) & 0xFF; - final seq = header.getUint16(2) & 0xFFFF; - final opcode = header.getUint16(4) & 0xFFFF; - final packedLen = header.getUint32(6); - final compFlag = packedLen >> 24; - final payloadLength = packedLen & 0xFFFFFF; - - if (payloadLength == 0) { - return Packet(api: apiVer, cmd: cmd, seq: seq, opcode: opcode); - } - - final end = headerSize + payloadLength; - if (end > packet.length) { - throw Exception('Packet payload length $payloadLength exceeds buffer'); - } - final slice = Uint8List.sublistView(packet, headerSize, end); - - dynamic payload; - if (compFlag == 0 && slice.length < _isolateDecodeThreshold) { - payload = _deserializePayload(slice, compFlag); - } else { - final owned = Uint8List.fromList(slice); - payload = await Isolate.run(() => _deserializePayload(owned, compFlag)); - } - - return Packet( - api: apiVer, - cmd: cmd, - seq: seq, - opcode: opcode, - payload: payload, - ); -} - -dynamic _deserializePayload(Uint8List payloadBytes, int compFlag) { - var bytes = payloadBytes; - if (compFlag != 0) { - bytes = _decompressPayload(bytes); - } - if (bytes.isEmpty) return null; - try { - return msgpack.deserialize(bytes); - } catch (e) { - throw Exception('MsgPack deserialization error: $e'); - } -} - -/// Определяет формат сжатия по magic-number и распаковывает payload. -/// Сервер может присылать LZ4 block ИЛИ Zstandard в зависимости от ответа. -Uint8List _decompressPayload(Uint8List src) { - // Zstandard: magic 28 B5 2F FD (little-endian) - if (src.length >= 4 && - src[0] == 0x28 && - src[1] == 0xB5 && - src[2] == 0x2F && - src[3] == 0xFD) { - try { - return ZstdCodec( - maxDecompressedSize: _maxDecompressedSize, - ).decompress(src); - } catch (e) { - throw Exception('Zstd decompression error: $e'); - } - } - - // LZ4 frame: magic 04 22 4D 18 - if (src.length >= 4 && - src[0] == 0x04 && - src[1] == 0x22 && - src[2] == 0x4D && - src[3] == 0x18) { - try { - return lz4Decompress(src, decompressedSize: _maxDecompressedSize); - } catch (e) { - throw Exception('LZ4 frame decompression error: $e'); - } - } - - // По умолчанию — LZ4 block (без magic) - try { - return lz4BlockDecompress(src, _maxDecompressedSize); - } catch (e) { - throw Exception('LZ4 block decompression error: $e'); - } -} diff --git a/lib/core/push/fkm_bridge.dart b/lib/core/push/fkm_bridge.dart new file mode 100644 index 0000000..54bfe3e --- /dev/null +++ b/lib/core/push/fkm_bridge.dart @@ -0,0 +1,86 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../utils/logger.dart'; + +/// Канал к нативному сервису FKM (foreground komet messaging). +class FkmBridge { + FkmBridge._(); + static final FkmBridge instance = FkmBridge._(); + + static const _method = MethodChannel('ru.komet.app/fkm'); + + VoidCallback? _onDisabled; + bool _handlerSet = false; + + bool get isSupported { + try { + return Platform.isAndroid; + } catch (_) { + return false; + } + } + + /// Вызывается, когда пользователь выключил FKM кнопкой в самом уведомлении. + void setDisabledCallback(VoidCallback callback) { + _onDisabled = callback; + if (_handlerSet || !isSupported) return; + _handlerSet = true; + _method.setMethodCallHandler((call) async { + if (call.method == 'disabled') _onDisabled?.call(); + return null; + }); + } + + Future isEnabled() async { + if (!isSupported) return false; + return await _invoke('isEnabled') ?? false; + } + + Future setEnabled(bool enabled) => + _invoke('setEnabled', {'enabled': enabled}); + + Future setConnected(bool connected) => + _invoke('setConnected', {'connected': connected}); + + Future showMessage(Map data) => + _invoke('showMessage', {'data': data}); + + Future showCall(Map data) => + _invoke('showCall', {'data': data}); + + Future editMessage(Map data) => + _invoke('editMessage', {'data': data}); + + Future removeMessage(Map data) => + _invoke('removeMessage', {'data': data}); + + Future hasNotificationPermission() async { + if (!isSupported) return false; + return await _invoke('hasNotificationPermission') ?? false; + } + + Future requestNotificationPermission() async { + if (!isSupported) return false; + return await _invoke('requestNotificationPermission') ?? false; + } + + Future isIgnoringBatteryOptimizations() async { + if (!isSupported) return true; + return await _invoke('isIgnoringBatteryOptimizations') ?? true; + } + + Future requestIgnoreBatteryOptimizations() => + _invoke('requestIgnoreBatteryOptimizations'); + + Future _invoke(String method, [Map? args]) async { + if (!isSupported) return null; + try { + return await _method.invokeMethod(method, args); + } catch (e) { + logger.w('FkmBridge.$method: $e'); + return null; + } + } +} diff --git a/lib/core/push/fkm_controller.dart b/lib/core/push/fkm_controller.dart new file mode 100644 index 0000000..a315ea5 --- /dev/null +++ b/lib/core/push/fkm_controller.dart @@ -0,0 +1,286 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../backend/api.dart'; +import '../../backend/modules/account/account_models.dart'; +import '../../backend/modules/chat_preview.dart'; +import '../../backend/modules/chats.dart'; +import '../../backend/modules/messages.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../config/komet_settings.dart'; +import '../utils/logger.dart'; +import 'fkm_bridge.dart'; +import 'push_service.dart'; + +const _fallbackSender = 'MAX'; +const _hiddenPreview = 'Новое сообщение'; + +/// FKM — уведомления через собственное фоновое соединение, без FCM. +/// +/// Пуш из сокета превращается в тот же набор полей, что присылает FCM, и +/// отрисовывается нативным `KometNotifier` — общий код с пушевой версией. +class FkmController { + FkmController._(); + static final FkmController instance = FkmController._(); + + final ValueNotifier enabled = ValueNotifier(false); + + Api? _api; + StreamSubscription? _pushSub; + StreamSubscription? _stateSub; + bool _started = false; + + bool get isSupported => FkmBridge.instance.isSupported; + + Future init(Api api) async { + if (_started || !isSupported) return; + _started = true; + _api = api; + + FkmBridge.instance.setDisabledCallback(_onDisabledFromNotification); + enabled.value = await FkmBridge.instance.isEnabled(); + + _pushSub = api.pushStream + .where( + (packet) => + packet.opcode == Opcode.notifMessage || + packet.opcode == Opcode.notifMsgDelete, + ) + .listen(_onPush); + _stateSub = api.stateStream.listen(_onSessionState); + + if (enabled.value) { + await initLocalNotificationActions(); + await FkmBridge.instance.setEnabled(true); + await _pushConnectionState(); + } + } + + /// Возвращает false, если пользователь не выдал разрешение на уведомления. + Future setEnabled(bool value) async { + if (!isSupported) return false; + if (value && !await FkmBridge.instance.requestNotificationPermission()) { + return false; + } + if (value) await initLocalNotificationActions(); + await FkmBridge.instance.setEnabled(value); + enabled.value = value; + if (value) await _pushConnectionState(); + return true; + } + + void _onDisabledFromNotification() => enabled.value = false; + + void _onSessionState(SessionState state) { + if (!enabled.value) return; + unawaited(FkmBridge.instance.setConnected(state == SessionState.online)); + } + + Future _pushConnectionState() => FkmBridge.instance.setConnected( + _api?.state == SessionState.online, + ); + + /// Входящий звонок, когда приложение не на переднем плане. + /// + /// Отдаётся тому же нативному коду, что и FCM-пуш: CallStyle, полноэкранный + /// интент, рингтон, приём и отклонение уже реализованы там. + Future showIncomingCall(Map payload) async { + if (!enabled.value) return; + try { + final data = await _buildCallNotification(payload); + if (data != null) await FkmBridge.instance.showCall(data); + } catch (e) { + logger.w('FKM: не удалось показать звонок: $e'); + } + } + + Future?> _buildCallNotification( + Map payload, + ) async { + final vcp = payload['vcp']; + final conversationId = payload['conversationId']; + final callerId = payload['callerId']; + if (vcp is! String || conversationId is! String || callerId is! int) { + return null; + } + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + + final rawConfig = await AppDatabase.getPrivacyConfig(accountId); + if (rawConfig != null && + PrivacyConfig.fromJson(rawConfig).mCallPushNotification != 'ON') { + return null; + } + + final name = ContactCache.get(callerId) ?? _fallbackSender; + + return { + 'type': 'InboundCall', + 'vcp': vcp, + 'conversationId': conversationId, + 'callerId': '$callerId', + 'suid': '$callerId', + 'userName': name, + 'title': name, + 'c': '$accountId', + 'iv': payload['type'] == 'VIDEO' ? 'true' : 'false', + }; + } + + Future _onPush(Packet packet) async { + if (!enabled.value) return; + try { + if (packet.opcode == Opcode.notifMsgDelete) { + await _onDeletePush(packet); + } else { + await _onMessagePush(packet); + } + } catch (e) { + logger.w('FKM: не удалось обновить уведомления: $e'); + } + } + + Future _onMessagePush(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + + final chatId = payload['chatId']; + if (chatId is! int) return; + + final msg = payload['message']; + if (msg is! Map) return; + + if (payload['postId'] != null || msg['postId'] != null) return; + + final msgId = msg['id']?.toString(); + switch (msg['status']?.toString()) { + case 'REMOVED': + if (msgId != null) await _removeNotification(chatId, msgId); + return; + case 'EDITED': + if (msgId != null) await _editNotification(chatId, msgId, msg); + return; + } + + final data = await _buildNotification(chatId, msg); + if (data != null) await FkmBridge.instance.showMessage(data); + } + + Future _onDeletePush(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + + final chat = payload['chat']; + final chatId = (chat is Map && chat['id'] is int) + ? chat['id'] as int + : payload['chatId']; + if (chatId is! int) return; + + final ids = payload['messageIds']; + if (ids is! List) return; + for (final raw in ids) { + final id = raw?.toString(); + if (id == null || id.isEmpty) continue; + await _removeNotification(chatId, id); + } + } + + /// Удалённое сообщение уезжает из шторки, а при включённом «показывать + /// удалённые сообщения» остаётся в ней зачёркнутым. + Future _removeNotification(int chatId, String msgId) => + FkmBridge.instance.removeMessage({ + 'mc': '$chatId', + 'msgid': msgId, + 'keep': KometSettings.viewDeleted.value ? 'true' : 'false', + }); + + Future _editNotification( + int chatId, + String msgId, + Map msg, + ) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final rawConfig = await AppDatabase.getPrivacyConfig(accountId); + if (rawConfig != null) { + final config = PrivacyConfig.fromJson(rawConfig); + if (config.chatsPushNotification != 'ON') return; + if (!config.pushDetails) return; + } + + final text = _previewText(msg); + if (text == _hiddenPreview) return; + + await FkmBridge.instance.editMessage({ + 'mc': '$chatId', + 'msgid': msgId, + 'msg': text, + }); + } + + Future?> _buildNotification( + int chatId, + Map msg, + ) async { + final senderId = msg['sender']; + if (senderId is! int) return null; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null || senderId == accountId) return null; + + final rawConfig = await AppDatabase.getPrivacyConfig(accountId); + var showPreview = true; + if (rawConfig != null) { + final config = PrivacyConfig.fromJson(rawConfig); + if (config.chatsPushNotification != 'ON') return null; + showPreview = config.pushDetails; + } + + final rows = await AppDatabase.loadChat(accountId, chatId); + final chat = rows.isEmpty ? null : CachedChat.fromDbRow(rows.first); + if (chat != null && chat.isMuted) return null; + + final senderName = + ContactCache.get(senderId) ?? chat?.title ?? _fallbackSender; + final chatTitle = (chat != null && chat.isGroupChat) + ? (chat.title ?? senderName) + : senderName; + + final text = showPreview ? _previewText(msg) : _hiddenPreview; + final time = msg['time']; + final msgId = msg['id']; + + return { + 'mc': '$chatId', + 'c': '$accountId', + 'suid': '$senderId', + 'userName': senderName, + 'title': chatTitle, + 'msg': text, + 'ctime': '${time is int ? time : DateTime.now().millisecondsSinceEpoch}', + if (msgId != null) 'msgid': '$msgId', + }; + } + + String _previewText(Map msg) { + final text = msg['text']?.toString().trim(); + if (text != null && text.isNotEmpty) return text; + final attach = attachPreviewLabel(msg['attaches']); + if (attach != null && attach.isNotEmpty) return attach; + return _hiddenPreview; + } + + void dispose() { + _pushSub?.cancel(); + _stateSub?.cancel(); + _pushSub = null; + _stateSub = null; + _started = false; + } +} diff --git a/lib/core/push/notification_bridge.dart b/lib/core/push/notification_bridge.dart new file mode 100644 index 0000000..e613482 --- /dev/null +++ b/lib/core/push/notification_bridge.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../../backend/api.dart'; +import '../../frontend/widgets/max_link_nav.dart'; +import '../../main.dart'; +import '../utils/logger.dart'; + +class NotificationBridge { + NotificationBridge._(); + static final NotificationBridge instance = NotificationBridge._(); + + static const _method = MethodChannel('ru.komet.app/notifications'); + static const _events = EventChannel('ru.komet.app/notification_events'); + static const _retryDelay = Duration(milliseconds: 300); + static const _maxRetries = 100; + + bool _started = false; + bool _ready = false; + int _pendingChatId = 0; + int _activeChatId = 0; + int _retriesLeft = 0; + Timer? _retry; + + bool get _native { + try { + return Platform.isAndroid || Platform.isIOS; + } catch (_) { + return false; + } + } + + void init() { + if (_started || !_native) return; + _started = true; + _events.receiveBroadcastStream().listen( + _onEvent, + onError: (e) => logger.w('NotificationBridge: events stream error: $e'), + ); + api.stateStream.listen((state) { + if (state == SessionState.online) _flushPending(); + }); + } + + void markReady() { + _ready = true; + _flushPending(); + } + + Future checkInitialChat() async { + if (!_native) return; + try { + _onEvent(await _method.invokeMethod('consumeInitialChat')); + } catch (e) { + logger.w('NotificationBridge.checkInitialChat: $e'); + } + } + + Future setActiveChat(int chatId) async { + if (!_native || chatId <= 0) return; + if (_activeChatId == chatId) return; + _activeChatId = chatId; + try { + await _method.invokeMethod('setActiveChat', {'chatId': chatId}); + } catch (e) { + logger.w('NotificationBridge.setActiveChat: $e'); + } + } + + Future clearActiveChat(int chatId) async { + if (!_native) return; + if (chatId > 0 && _activeChatId != chatId) return; + _activeChatId = 0; + try { + await _method.invokeMethod('clearActiveChat'); + } catch (e) { + logger.w('NotificationBridge.clearActiveChat: $e'); + } + } + + void _onEvent(Object? event) { + final chatId = event is int ? event : int.tryParse(event?.toString() ?? ''); + if (chatId == null || chatId <= 0) return; + _pendingChatId = chatId; + _retriesLeft = _maxRetries; + _flushPending(); + } + + void _flushPending() { + final chatId = _pendingChatId; + if (chatId <= 0) return; + + final context = KometApp.navigatorKey.currentContext; + if (!_ready || context == null || api.state != SessionState.online) { + if (_retriesLeft <= 0) { + _pendingChatId = 0; + return; + } + _retriesLeft--; + _retry ??= Timer(_retryDelay, () { + _retry = null; + _flushPending(); + }); + return; + } + + _pendingChatId = 0; + if (_activeChatId == chatId) return; + unawaited(openChatById(context, chatId)); + } +} diff --git a/lib/core/push/push_service.dart b/lib/core/push/push_service.dart index 4ecb356..f123de9 100644 --- a/lib/core/push/push_service.dart +++ b/lib/core/push/push_service.dart @@ -5,6 +5,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:kolibri/kolibri.dart' show initKolibri; import 'package:shared_preferences/shared_preferences.dart'; import '../../backend/api.dart'; @@ -15,6 +16,7 @@ import '../calls/ws2_signaling.dart'; import '../protocol/opcode_map.dart'; import '../storage/app_instance.dart'; import '../storage/token_storage.dart'; +import '../transport/tls_config.dart'; import '../utils/logger.dart'; const _channelId = 'komet_messages'; @@ -53,6 +55,10 @@ Future _handleCallDecline(String payloadJson) async { } if (vcp.isEmpty || conversationId.isEmpty) return; + // Фоновый изолят: инициализируем ядро перед vcp-декодом/сигналингом. + await initKolibri(); + await TlsConfig.applyMincifryTrust(); + final params = ConversationParams.decode(vcp); if (params == null) return; @@ -83,11 +89,13 @@ Future _handleReply(String payloadJson, String text) async { if (account == 0 || chatId == 0) return; WidgetsFlutterBinding.ensureInitialized(); + await initKolibri(); if (AppInstance.isNamed) { try { SharedPreferences.setPrefix('flutter.${AppInstance.id}.'); } catch (_) {} } + await TlsConfig.applyMincifryTrust(); final plugin = FlutterLocalNotificationsPlugin(); final notifId = chatId & 0x7fffffff; @@ -140,6 +148,36 @@ Future _handleReply(String payloadJson, String text) async { } } +bool _localActionsReady = false; + +/// Инициализация локальных уведомлений и их action-коллбэков. +/// +/// Нужна и FCM, и FKM: без неё кнопка «Ответить» в уведомлении не доезжает +/// до фонового изолята. +Future initLocalNotificationActions() async { + if (_localActionsReady) return; + _localActionsReady = true; + final plugin = FlutterLocalNotificationsPlugin(); + await plugin.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('ic_notification'), + ), + onDidReceiveNotificationResponse: _onNotificationResponse, + onDidReceiveBackgroundNotificationResponse: _onNotificationResponse, + ); + await plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel( + const AndroidNotificationChannel( + _channelId, + _channelName, + importance: Importance.high, + ), + ); +} + class PushService { PushService._(); static final PushService instance = PushService._(); @@ -150,9 +188,6 @@ class PushService { await _clearHistory(chatId); } - final FlutterLocalNotificationsPlugin _local = - FlutterLocalNotificationsPlugin(); - Api? _api; AccountModule? _account; String? _token; @@ -172,24 +207,7 @@ class PushService { _initialized = true; - await _local.initialize( - settings: const InitializationSettings( - android: AndroidInitializationSettings('ic_notification'), - ), - onDidReceiveNotificationResponse: _onNotificationResponse, - onDidReceiveBackgroundNotificationResponse: _onNotificationResponse, - ); - await _local - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.createNotificationChannel( - const AndroidNotificationChannel( - _channelId, - _channelName, - importance: Importance.high, - ), - ); + await initLocalNotificationActions(); final messaging = FirebaseMessaging.instance; await messaging.requestPermission(); diff --git a/lib/core/share/share_intent_bridge.dart b/lib/core/share/share_intent_bridge.dart new file mode 100644 index 0000000..631a554 --- /dev/null +++ b/lib/core/share/share_intent_bridge.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../../backend/api.dart'; +import '../../frontend/screens/chats/chat_list_screen.dart'; +import '../../frontend/widgets/swipe_route.dart'; +import '../../main.dart'; +import '../../models/shared_payload.dart'; +import '../utils/logger.dart'; + +class ShareIntentBridge { + ShareIntentBridge._(); + static final ShareIntentBridge instance = ShareIntentBridge._(); + + static const _method = MethodChannel('ru.komet.app/share'); + static const _events = EventChannel('ru.komet.app/share_events'); + static const _retryDelay = Duration(milliseconds: 300); + static const _maxRetries = 100; + + bool _started = false; + bool _ready = false; + bool _presenting = false; + SharedPayload? _pending; + int _retriesLeft = 0; + Timer? _retry; + + bool get _native { + try { + return Platform.isAndroid; + } catch (_) { + return false; + } + } + + void init() { + if (_started || !_native) return; + _started = true; + _events.receiveBroadcastStream().listen( + _onEvent, + onError: (e) => logger.w('ShareIntentBridge: events stream error: $e'), + ); + api.stateStream.listen((state) { + if (state == SessionState.online) _flushPending(); + }); + } + + void markReady() { + _ready = true; + _flushPending(); + } + + Future checkInitialShare() async { + if (!_native) return; + try { + _onEvent(await _method.invokeMethod('consumeInitialShare')); + } catch (e) { + logger.w('ShareIntentBridge.checkInitialShare: $e'); + } + } + + Future clearCache() async { + if (!_native) return; + try { + await _method.invokeMethod('clearCache'); + } catch (e) { + logger.w('ShareIntentBridge.clearCache: $e'); + } + } + + void _onEvent(Object? event) { + final payload = SharedPayload.fromMap(event); + if (payload == null) return; + logger.i( + 'Поделиться: получено ${payload.files.length} файлов' + '${payload.text != null ? ' и текст' : ''}', + ); + _pending = payload; + _retriesLeft = _maxRetries; + _flushPending(); + } + + void _flushPending() { + final payload = _pending; + if (payload == null || _presenting) return; + + final context = KometApp.navigatorKey.currentContext; + if (!_ready || context == null || api.state != SessionState.online) { + if (_retriesLeft <= 0) { + _pending = null; + return; + } + _retriesLeft--; + _retry ??= Timer(_retryDelay, () { + _retry = null; + _flushPending(); + }); + return; + } + + _pending = null; + _presenting = true; + unawaited( + pushSwipeable( + context, + (_) => ChatListScreen(sharePayload: payload), + ).whenComplete(() { + _presenting = false; + unawaited(clearCache()); + }), + ); + } +} diff --git a/lib/core/share/share_labels.dart b/lib/core/share/share_labels.dart new file mode 100644 index 0000000..cb8103a --- /dev/null +++ b/lib/core/share/share_labels.dart @@ -0,0 +1,36 @@ +import '../utils/format.dart'; + +String shareTitleFor({ + required int photos, + required int videos, + required int documents, + bool textOnly = false, +}) { + if (textOnly) return 'Отправить сообщение'; + final total = photos + videos + documents; + if (total == 0) return 'Отправить сообщение'; + + if (photos == total) { + return photos == 1 + ? 'Отправить фотографию' + : 'Отправить $photos ' + '${pluralRu(photos, 'фотографию', 'фотографии', 'фотографий')}'; + } + if (videos == total) { + return videos == 1 ? 'Отправить видео' : 'Отправить $videos видео'; + } + if (documents == total) { + return documents == 1 + ? 'Отправить файл' + : 'Отправить $documents ' + '${pluralRu(documents, 'файл', 'файла', 'файлов')}'; + } + return 'Отправить $total ${pluralRu(total, 'файл', 'файла', 'файлов')}'; +} + +String shareSubtitleFor(List recipientNames) { + if (recipientNames.isEmpty) return 'Выберите чат'; + if (recipientNames.length <= 2) return 'В чат ${recipientNames.join(', ')}'; + final count = recipientNames.length; + return 'В $count ${pluralRu(count, 'чат', 'чата', 'чатов')}'; +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 1d50a80..125f667 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -36,6 +36,15 @@ class ProfileData { this.profileOptions, }); + factory ProfileData.stub(int id) => ProfileData( + id: id, + firstName: '', + phone: 0, + country: '', + accountStatus: 0, + updateTime: 0, + ); + factory ProfileData.fromServerProfile(Map profile) { final contact = profile['contact']; if (contact is! Map) { @@ -70,7 +79,7 @@ class ProfileData { id: contact['id'] as int, firstName: firstName, lastName: lastName, - phone: contact['phone'] as int, + phone: (contact['phone'] as int?) ?? 0, photoId: contact['photoId'] as int?, baseUrl: contact['baseUrl'] as String?, baseRawUrl: contact['baseRawUrl'] as String?, @@ -212,7 +221,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 19, + version: 23, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -324,6 +333,34 @@ class AppDatabase { 'INTEGER NOT NULL DEFAULT 0', ); } + if (oldVersion < 20) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_mention_msg_id', + 'INTEGER', + ); + } + if (oldVersion < 21) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_msg_preview', + 'TEXT', + ); + } + if (oldVersion < 22) { + await db.execute(_webAppStorageSchema); + await db.execute(_webAppBiometrySchema); + } + if (oldVersion < 23) { + await _addColumnIfMissing( + db, + 'contacts', + 'account_status', + 'INTEGER NOT NULL DEFAULT 0', + ); + } }, ); } @@ -350,6 +387,8 @@ class AppDatabase { await db.execute(_contactsSchema); await db.execute(_messagesSchema); await db.execute(_chatParticipantsSchema); + await db.execute(_webAppStorageSchema); + await db.execute(_webAppBiometrySchema); await _createIndexes(db); await _createChatParticipantsIndex(db); } @@ -434,7 +473,8 @@ class AppDatabase { base_url TEXT, base_raw_url TEXT, update_time INTEGER NOT NULL DEFAULT 0, - options TEXT + options TEXT, + account_status INTEGER NOT NULL DEFAULT 0 ) '''; @@ -458,6 +498,7 @@ class AppDatabase { last_msg_time INTEGER, last_msg_text TEXT, last_msg_elements TEXT, + last_msg_preview TEXT, last_msg_sender INTEGER, last_msg_status TEXT, unread_count INTEGER NOT NULL DEFAULT 0, @@ -476,6 +517,7 @@ class AppDatabase { pinned_msg_text TEXT, pinned_msg_time INTEGER, pinned_msg_is_preview INTEGER NOT NULL DEFAULT 0, + last_mention_msg_id INTEGER, PRIMARY KEY (id, account_id) ) '''; @@ -508,6 +550,26 @@ class AppDatabase { ) '''; + static const _webAppStorageSchema = ''' + CREATE TABLE webapp_storage ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + bot_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (account_id, bot_id, key) + ) + '''; + + static const _webAppBiometrySchema = ''' + CREATE TABLE webapp_biometry ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + bot_id INTEGER NOT NULL, + access_requested INTEGER NOT NULL DEFAULT 0, + access_granted INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_id, bot_id) + ) + '''; + static Future saveProfile( ProfileData profile, { bool isActive = true, @@ -607,6 +669,104 @@ class AppDatabase { }; } + static Future saveWebAppValue( + int accountId, + int botId, + String key, + String value, + ) async { + final db = await _instance; + await db.insert('webapp_storage', { + 'account_id': accountId, + 'bot_id': botId, + 'key': key, + 'value': value, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getWebAppValue( + int accountId, + int botId, + String key, + ) async { + final db = await _instance; + final rows = await db.query( + 'webapp_storage', + where: 'account_id = ? AND bot_id = ? AND key = ?', + whereArgs: [accountId, botId, key], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String; + } + + static Future removeWebAppValue( + int accountId, + int botId, + String key, + ) async { + final db = await _instance; + await db.delete( + 'webapp_storage', + where: 'account_id = ? AND bot_id = ? AND key = ?', + whereArgs: [accountId, botId, key], + ); + } + + static Future clearWebAppValues(int accountId, int botId) async { + final db = await _instance; + await db.delete( + 'webapp_storage', + where: 'account_id = ? AND bot_id = ?', + whereArgs: [accountId, botId], + ); + } + + static Future countWebAppValues(int accountId, int botId) async { + final db = await _instance; + final rows = await db.rawQuery( + 'SELECT COUNT(*) AS total FROM webapp_storage ' + 'WHERE account_id = ? AND bot_id = ?', + [accountId, botId], + ); + if (rows.isEmpty) return 0; + return (rows.first['total'] as num?)?.toInt() ?? 0; + } + + static Future<(bool, bool)> getWebAppBiometryAccess( + int accountId, + int botId, + ) async { + final db = await _instance; + final rows = await db.query( + 'webapp_biometry', + where: 'account_id = ? AND bot_id = ?', + whereArgs: [accountId, botId], + limit: 1, + ); + if (rows.isEmpty) return (false, false); + final row = rows.first; + return ( + (row['access_requested'] as int? ?? 0) != 0, + (row['access_granted'] as int? ?? 0) != 0, + ); + } + + static Future setWebAppBiometryAccess( + int accountId, + int botId, { + required bool requested, + required bool granted, + }) async { + final db = await _instance; + await db.insert('webapp_biometry', { + 'account_id': accountId, + 'bot_id': botId, + 'access_requested': requested ? 1 : 0, + 'access_granted': granted ? 1 : 0, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + static Future savePrivacyConfig( int accountId, String jsonConfig, @@ -647,6 +807,7 @@ class AppDatabase { static Future close() async { await _db?.close(); _db = null; + _initCompleter = null; } // Chats cache @@ -696,6 +857,25 @@ class AppDatabase { } } + static Future repairLastMessageSenders(int accountId) async { + try { + final db = await _instance; + await db.rawUpdate( + 'UPDATE chats_cache SET last_msg_sender = (' + ' SELECT m.sender_id FROM messages m' + ' WHERE m.account_id = chats_cache.account_id' + ' AND m.chat_id = chats_cache.id' + ' AND m.id = CAST(chats_cache.last_msg_id AS TEXT)' + ') ' + 'WHERE account_id = ? AND last_msg_sender IS NULL ' + 'AND last_msg_id IS NOT NULL', + [accountId], + ); + } catch (e) { + logger.w('Не удалось восстановить отправителей последних сообщений: $e'); + } + } + static Future>> loadChat( int accountId, int chatId, @@ -709,6 +889,16 @@ class AppDatabase { ); } + static bool chatRowIsInList(Map row) { + final value = row['in_list']; + return value is! int || value != 0; + } + + static Future isChatInList(int accountId, int chatId) async { + final rows = await loadChat(accountId, chatId); + return rows.isNotEmpty && chatRowIsInList(rows.first); + } + static Future>> loadChats( int accountId, { bool includeHidden = false, @@ -865,13 +1055,52 @@ class AppDatabase { await batch.commit(noResult: true); } - static Future>> loadContacts(int accountId) async { + static Future>> loadContacts( + int accountId, { + bool includeDeleted = false, + }) async { final db = await _instance; return db.query( 'contacts', + where: includeDeleted + ? 'account_id = ?' + : 'account_id = ? AND account_status = 0', + whereArgs: [accountId], + ); + } + + static Future> loadContactIds(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'contacts', + columns: ['id'], where: 'account_id = ?', whereArgs: [accountId], ); + return [for (final r in rows) r['id'] as int]; + } + + static Future?> loadContact( + int accountId, + int id, + ) async { + final db = await _instance; + final rows = await db.query( + 'contacts', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, id], + limit: 1, + ); + return rows.isEmpty ? null : rows.first; + } + + static Future deleteContact(int accountId, int id) async { + final db = await _instance; + await db.delete( + 'contacts', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, id], + ); } static Future saveMessages(List> rows) async { @@ -928,6 +1157,56 @@ class AppDatabase { ); } + static Future>> loadMessagesBetween( + int accountId, + int chatId, { + required int afterTime, + required int beforeTime, + int limit = 60, + bool onlyVisible = false, + }) async { + final db = await _instance; + return db.query( + 'messages', + where: onlyVisible + ? 'account_id = ? AND chat_id = ? AND deleted = 0 ' + 'AND time > ? AND time < ?' + : 'account_id = ? AND chat_id = ? AND time > ? AND time < ?', + whereArgs: [accountId, chatId, afterTime, beforeTime], + orderBy: 'time ASC', + limit: limit, + ); + } + + static Future>> loadMessagesAround( + int accountId, + int chatId, { + required int centerTime, + int before = 40, + int after = 20, + bool onlyVisible = false, + }) async { + final db = await _instance; + final base = onlyVisible + ? 'account_id = ? AND chat_id = ? AND deleted = 0' + : 'account_id = ? AND chat_id = ?'; + final older = await db.query( + 'messages', + where: '$base AND time <= ?', + whereArgs: [accountId, chatId, centerTime], + orderBy: 'time DESC', + limit: before, + ); + final newer = await db.query( + 'messages', + where: '$base AND time > ?', + whereArgs: [accountId, chatId, centerTime], + orderBy: 'time ASC', + limit: after, + ); + return [...newer.reversed, ...older]; + } + static Future markMessageDeleted( int accountId, int chatId, 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_encryption_store.dart b/lib/core/storage/chat_encryption_store.dart new file mode 100644 index 0000000..3d751de --- /dev/null +++ b/lib/core/storage/chat_encryption_store.dart @@ -0,0 +1,38 @@ +import 'per_chat_json_store.dart'; +import 'token_storage.dart'; + +class ChatEncryptionStore extends PerChatJsonStore { + ChatEncryptionStore._() + : super( + prefsKey: 'chat_encryption', + fromJson: (raw) => raw == true ? true : null, + toJson: (value) => value, + ); + + static final ChatEncryptionStore instance = ChatEncryptionStore._(); + + static const String _keyPrefix = 'chat_encryption_key'; + + bool isEnabled(int accountId, int chatId) => read(accountId, chatId) == true; + + Future setEnabled(int accountId, int chatId, bool enabled) => + write(accountId, chatId, enabled ? true : null); + + Future readKey(int accountId, int chatId) async { + if (accountId == 0) return null; + return TokenStorage.readSecure(_secureKey(accountId, chatId)); + } + + Future writeKey(int accountId, int chatId, String key) async { + if (accountId == 0) return; + await TokenStorage.writeSecure(_secureKey(accountId, chatId), key); + } + + Future deleteKey(int accountId, int chatId) async { + if (accountId == 0) return; + await TokenStorage.deleteSecure(_secureKey(accountId, chatId)); + } + + String _secureKey(int accountId, int chatId) => + '${_keyPrefix}_${accountId}_$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/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index 2f68a7c..5162a63 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -10,8 +10,8 @@ import '../utils/ids.dart'; import '../utils/logger.dart'; class SpoofingService { - static const String hardcodedAppVersion = '26.20.2'; - static const int hardcodedBuildNumber = 6758; + static const String hardcodedAppVersion = '26.23.2'; + static const int hardcodedBuildNumber = 6779; static const String pendingScope = 'pending'; static const String _legacyEnabledKey = 'spoofing_enabled'; @@ -144,6 +144,7 @@ class SpoofingService { 'instance_id': profile.instanceId, 'client_session_id': profile.clientSessionId, 'push_device_type': profile.pushDeviceType, + 'user_agent': profile.userAgent, }; } diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 2740a0b..85bb4b5 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -7,13 +8,32 @@ class TokenStorage { static const _secure = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + synchronizable: false, + ), mOptions: MacOsOptions(usesDataProtectionKeychain: false), ); - static Future writeSecure(String key, String value) async { - await _secure.write(key: key, value: value); + static const int _duplicateKeychainItem = -25299; + + static bool _isDuplicateItem(PlatformException error) => + error.details == _duplicateKeychainItem || + (error.message?.contains('$_duplicateKeychainItem') ?? false); + + static Future _write(String key, String value) async { + try { + await _secure.write(key: key, value: value); + } on PlatformException catch (e) { + if (!_isDuplicateItem(e)) rethrow; + await _secure.delete(key: key); + await _secure.write(key: key, value: value); + } } + static Future writeSecure(String key, String value) => + _write(key, value); + static Future readSecure(String key) async { return _secure.read(key: key); } @@ -22,10 +42,14 @@ class TokenStorage { await _secure.delete(key: key); } - static Future saveToken(String token, int accountId) async { - await _secure.write(key: '$_tokenPrefix$accountId', value: token); + static Future> secureKeysWithPrefix(String prefix) async { + final all = await _secure.readAll(); + return all.keys.where((key) => key.startsWith(prefix)).toList(); } + static Future saveToken(String token, int accountId) => + _write('$_tokenPrefix$accountId', token); + static Future readToken(int accountId) async { final key = '$_tokenPrefix$accountId'; final secured = await _secure.read(key: key); @@ -34,7 +58,7 @@ class TokenStorage { final prefs = await SharedPreferences.getInstance(); final legacy = prefs.getString(key); if (legacy != null) { - await _secure.write(key: key, value: legacy); + await _write(key, legacy); await prefs.remove(key); return legacy; } diff --git a/lib/core/storage/webapp_storage.dart b/lib/core/storage/webapp_storage.dart new file mode 100644 index 0000000..ec40832 --- /dev/null +++ b/lib/core/storage/webapp_storage.dart @@ -0,0 +1,123 @@ +import 'app_database.dart'; +import 'token_storage.dart'; + +enum WebAppStorageBackend { device, secure } + +class WebAppStorage { + static const int deviceKeyLimit = 512; + static const int secureKeyLimit = 128; + + static String _securePrefix(int accountId, int botId) => + 'webapp_ss_${accountId}_${botId}_'; + + static String _secureKey(int accountId, int botId, String key) => + '${_securePrefix(accountId, botId)}$key'; + + static String _biometryTokenKey(int accountId, int botId) => + 'webapp_bio_${accountId}_$botId'; + + static Future read( + int accountId, + int botId, + WebAppStorageBackend backend, + String key, + ) { + if (backend == WebAppStorageBackend.secure) { + return TokenStorage.readSecure(_secureKey(accountId, botId, key)); + } + return AppDatabase.getWebAppValue(accountId, botId, key); + } + + static Future save( + int accountId, + int botId, + WebAppStorageBackend backend, + String key, + String value, + ) async { + if (await read(accountId, botId, backend, key) == null && + await _count(accountId, botId, backend) >= _limit(backend)) { + return false; + } + if (backend == WebAppStorageBackend.secure) { + await TokenStorage.writeSecure(_secureKey(accountId, botId, key), value); + } else { + await AppDatabase.saveWebAppValue(accountId, botId, key, value); + } + return true; + } + + static Future remove( + int accountId, + int botId, + WebAppStorageBackend backend, + String key, + ) async { + if (backend == WebAppStorageBackend.secure) { + await TokenStorage.deleteSecure(_secureKey(accountId, botId, key)); + return; + } + await AppDatabase.removeWebAppValue(accountId, botId, key); + } + + static Future clear( + int accountId, + int botId, + WebAppStorageBackend backend, + ) async { + if (backend == WebAppStorageBackend.secure) { + final keys = await TokenStorage.secureKeysWithPrefix( + _securePrefix(accountId, botId), + ); + for (final key in keys) { + await TokenStorage.deleteSecure(key); + } + return; + } + await AppDatabase.clearWebAppValues(accountId, botId); + } + + static Future biometryToken(int accountId, int botId) => + TokenStorage.readSecure(_biometryTokenKey(accountId, botId)); + + static Future saveBiometryToken( + int accountId, + int botId, + String token, + ) => TokenStorage.writeSecure(_biometryTokenKey(accountId, botId), token); + + static Future removeBiometryToken(int accountId, int botId) => + TokenStorage.deleteSecure(_biometryTokenKey(accountId, botId)); + + static Future<(bool, bool)> biometryAccess(int accountId, int botId) => + AppDatabase.getWebAppBiometryAccess(accountId, botId); + + static Future setBiometryAccess( + int accountId, + int botId, { + required bool requested, + required bool granted, + }) => AppDatabase.setWebAppBiometryAccess( + accountId, + botId, + requested: requested, + granted: granted, + ); + + static int _limit(WebAppStorageBackend backend) => + backend == WebAppStorageBackend.secure ? secureKeyLimit : deviceKeyLimit; + + static Future _count( + int accountId, + int botId, + WebAppStorageBackend backend, + ) async { + if (backend == WebAppStorageBackend.secure) { + final keys = await TokenStorage.secureKeysWithPrefix( + _securePrefix(accountId, botId), + ); + return keys.length; + } + return AppDatabase.countWebAppValues(accountId, botId); + } +} diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart deleted file mode 100644 index dff5aed..0000000 --- a/lib/core/transport/connection.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import '../config/proxy_config.dart'; -import '../utils/logger.dart'; -import 'proxy_connector.dart'; -import 'tls_config.dart'; -import 'traffic_monitor.dart'; -import 'vpn_bypass.dart'; - -enum SocketState { disconnected, connecting, connected } - -/// Обёртка над TCP + TLS сокетом. -/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. -class Connection { - static const Duration _defaultConnectTimeout = Duration(seconds: 15); - static const Duration _proxyLoadTimeout = Duration(seconds: 8); - static const Duration _vpnCallTimeout = Duration(seconds: 5); - - SecureSocket? _socket; - StreamSubscription? _subscription; - SocketState _state = SocketState.disconnected; - - final _dataController = StreamController.broadcast(); - final _stateController = StreamController.broadcast(); - - Stream get dataStream => _dataController.stream; - Stream get stateStream => _stateController.stream; - SocketState get state => _state; - bool get isConnected => _state == SocketState.connected; - - void _setState(SocketState newState) { - if (_state == newState) return; - _state = newState; - if (!_stateController.isClosed) _stateController.add(newState); - } - - Future connect( - String host, - int port, { - bool bypassVpn = false, - Duration? timeout, - }) async { - if (_state != SocketState.disconnected) { - logger.w('Connection.connect пропущен: state=$_state (уже $_state)'); - return; - } - _setState(SocketState.connecting); - - try { - logger.i('Connection: загрузка прокси-конфига'); - ProxySettings proxySettings; - try { - proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout); - } catch (e) { - logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси'); - proxySettings = const ProxySettings(); - } - - logger.i( - 'Connection: VPN ${bypassVpn ? 'bind (обход)' : 'restoreDefault'}', - ); - try { - if (bypassVpn) { - await VpnBypassService.instance.bind().timeout(_vpnCallTimeout); - } else { - await VpnBypassService.instance - .restoreDefault() - .timeout(_vpnCallTimeout); - } - } catch (e) { - logger.w('Connection: VPN-вызов завис/упал ($e) — продолжаю'); - } - - logger.i( - 'Connection: открываю сокет $host:$port ' - '(прокси: ${proxySettings.isEnabled ? proxySettings.type.name : 'нет'})', - ); - final socket = await _openSecureSocket( - host, - port, - proxySettings, - timeout: timeout, - ); - - _socket = socket; - _setState(SocketState.connected); - logger.i('Подключено к $host:$port'); - - final route = proxySettings.isEnabled - ? 'через прокси ${proxySettings.type.name}' - : bypassVpn - ? 'напрямую (обход VPN)' - : 'прямое соединение'; - TrafficMonitor.instance.recordEvent( - 'Подключено', - detail: '$host:$port · TLS · $route', - endpoint: '$host:$port', - ); - - _subscription = _socket!.listen( - (data) { - if (!_dataController.isClosed) _dataController.add(data); - }, - onError: (Object error) { - logger.e('Ошибка сокета: $error'); - disconnect(); - }, - onDone: () { - logger.w('Сокет закрыт сервером'); - disconnect(); - }, - ); - } catch (e) { - logger.e('Не удалось подключиться: $e'); - _setState(SocketState.disconnected); - rethrow; - } - } - - Future _openSecureSocket( - String host, - int port, - ProxySettings proxySettings, { - Duration? timeout, - }) async { - final connectTimeout = timeout ?? _defaultConnectTimeout; - Socket socket; - if (proxySettings.isEnabled) { - final connector = ProxyConnector(proxySettings); - socket = await connector.connect(host, port).timeout(connectTimeout); - logger.i('Подключено через прокси ${proxySettings.type.name}'); - } else { - logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)'); - socket = await Socket.connect(host, port, timeout: connectTimeout); - logger.i('Connection: TCP установлен, начинаю TLS'); - } - final allowInsecure = await TlsConfig.isInsecureAllowed(); - if (allowInsecure) { - logger.w( - 'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM', - ); - } - final secured = allowInsecure - ? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true) - : SecureSocket.secure(socket, host: host); - try { - final result = await secured.timeout(connectTimeout); - logger.i('Connection: TLS-handshake завершён'); - return result; - } on TimeoutException { - logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с'); - socket.destroy(); - rethrow; - } - } - - void write(Uint8List data) { - if (_socket == null || !isConnected) { - throw StateError('Нельзя писать: сокет не подключён'); - } - _socket!.add(data); - } - - Future disconnect() async { - _subscription?.cancel(); - _subscription = null; - final socket = _socket; - _socket = null; - - if (socket != null) { - TrafficMonitor.instance.recordEvent('Соединение закрыто'); - try { - await socket.close(); - } catch (e) { - logger.w('Ошибка при закрытии сокета: $e'); - } - } - - _setState(SocketState.disconnected); - } - - Future dispose() async { - await disconnect(); - await _dataController.close(); - await _stateController.close(); - } -} diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart deleted file mode 100644 index 6770ad0..0000000 --- a/lib/core/transport/proxy_connector.dart +++ /dev/null @@ -1,360 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import '../config/proxy_config.dart'; -import '../utils/logger.dart'; - -class ProxyConnector { - final ProxySettings settings; - - ProxyConnector(this.settings); - - Future connect(String targetHost, int targetPort) async { - switch (settings.type) { - case ProxyType.socks5: - return _connectSocks5(targetHost, targetPort); - case ProxyType.httpConnect: - return _connectHttpConnect(targetHost, targetPort); - case ProxyType.none: - return Socket.connect(targetHost, targetPort); - } - } - - // ── SOCKS5 (RFC 1928) ────────────────────────────────────────────────── - - Future _connectSocks5(String targetHost, int targetPort) async { - final proxySocket = await RawSocket.connect(settings.host, settings.port); - logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}'); - - final io = _RawSocketIO(proxySocket); - try { - // 1. Greeting - final useAuth = settings.hasCredentials; - if (useAuth) { - await io.write([0x05, 0x02, 0x00, 0x02]); - } else { - await io.write([0x05, 0x01, 0x00]); - } - - var response = await io.readExact(2); - if (response[0] != 0x05) { - throw SocketException( - 'SOCKS5: неверная версия протокола: ${response[0]}', - ); - } - - final method = response[1]; - if (method == 0xFF) { - throw SocketException( - 'SOCKS5: сервер отклонил все методы аутентификации', - ); - } - - // 2. Аутентификация (RFC 1929) - if (method == 0x02) { - if (!useAuth) { - throw SocketException('SOCKS5: прокси требует аутентификацию'); - } - final usernameBytes = utf8.encode(settings.username ?? ''); - final passwordBytes = utf8.encode(settings.password ?? ''); - final authPacket = BytesBuilder() - ..addByte(0x01) - ..addByte(usernameBytes.length) - ..add(usernameBytes) - ..addByte(passwordBytes.length) - ..add(passwordBytes); - await io.write(authPacket.toBytes()); - - final authResponse = await io.readExact(2); - if (authResponse[1] != 0x00) { - throw SocketException('SOCKS5: аутентификация не пройдена'); - } - logger.i('SOCKS5: аутентификация пройдена'); - } - - // 3. Connect request - final hostBytes = utf8.encode(targetHost); - final connectPacket = BytesBuilder() - ..addByte(0x05) // VER - ..addByte(0x01) // CMD: CONNECT - ..addByte(0x00) // RSV - ..addByte(0x03) // ATYP: domain - ..addByte(hostBytes.length) - ..add(hostBytes) - ..addByte((targetPort >> 8) & 0xFF) - ..addByte(targetPort & 0xFF); - await io.write(connectPacket.toBytes()); - - // 4. Reply - final reply = await io.readExact(4); - if (reply[0] != 0x05) { - throw SocketException('SOCKS5: неверная версия в ответе'); - } - if (reply[1] != 0x00) { - throw SocketException('SOCKS5: ошибка подключения, код: ${reply[1]}'); - } - - // Пропускаем bind address - switch (reply[3]) { - case 0x01: - await io.readExact(4 + 2); - break; - case 0x03: - final lenBuf = await io.readExact(1); - await io.readExact(lenBuf[0] + 2); - break; - case 0x04: - await io.readExact(16 + 2); - break; - } - - logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен'); - - // Создаём локальную пару и проксируем данные - return _bridgeToFreshSocket(proxySocket, io); - } catch (e) { - io.dispose(); - proxySocket.close(); - rethrow; - } - } - - // ── HTTP CONNECT ──────────────────────────────────────────────────────── - - Future _connectHttpConnect(String targetHost, int targetPort) async { - final proxySocket = await RawSocket.connect(settings.host, settings.port); - logger.i( - 'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}', - ); - - final io = _RawSocketIO(proxySocket); - try { - final request = StringBuffer() - ..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n') - ..write('Host: $targetHost:$targetPort\r\n'); - - if (settings.hasCredentials) { - final credentials = base64Encode( - utf8.encode('${settings.username}:${settings.password}'), - ); - request.write('Proxy-Authorization: Basic $credentials\r\n'); - } - request.write('\r\n'); - - await io.write(utf8.encode(request.toString())); - - // Читаем HTTP-ответ до \r\n\r\n - final headerBytes = []; - while (true) { - final byte = await io.readExact(1); - headerBytes.add(byte[0]); - if (headerBytes.length >= 4 && - headerBytes[headerBytes.length - 4] == 0x0D && - headerBytes[headerBytes.length - 3] == 0x0A && - headerBytes[headerBytes.length - 2] == 0x0D && - headerBytes[headerBytes.length - 1] == 0x0A) { - break; - } - if (headerBytes.length > 8192) { - throw SocketException( - 'HTTP CONNECT: заголовок ответа слишком большой', - ); - } - } - - final responseStr = utf8.decode(headerBytes, allowMalformed: true); - final statusLine = responseStr.split('\r\n').first; - final parts = statusLine.split(' '); - if (parts.length < 2) { - throw SocketException('HTTP CONNECT: некорректный ответ: $statusLine'); - } - final statusCode = int.tryParse(parts[1]) ?? 0; - if (statusCode != 200) { - throw SocketException('HTTP CONNECT: прокси вернул статус $statusCode'); - } - - logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен'); - return _bridgeToFreshSocket(proxySocket, io); - } catch (e) { - io.dispose(); - proxySocket.close(); - rethrow; - } - } - - Future _bridgeToFreshSocket( - RawSocket proxySocket, - _RawSocketIO io, - ) async { - ServerSocket? server; - try { - server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - } catch (e) { - io.dispose(); - proxySocket.close(); - rethrow; - } - final clientFuture = Socket.connect( - InternetAddress.loopbackIPv4, - server.port, - ); - final serverSide = await server.first; - final clientSide = await clientFuture; - await server.close(); - - io.onData = (data) { - serverSide.add(data); - }; - io.onClosed = () { - serverSide.close(); - }; - - serverSide.listen( - (data) { - unawaited( - io.write(data).catchError((Object _) { - try { - serverSide.destroy(); - } catch (_) {} - }), - ); - }, - onError: (Object _) { - proxySocket.shutdown(SocketDirection.send); - }, - onDone: () { - proxySocket.shutdown(SocketDirection.send); - }, - ); - - // Сливаем данные, буферизованные во время handshake - io.flushBuffered(); - - logger.i('Прокси-мост через loopback создан'); - return clientSide; - } -} - -/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения. -/// -/// После handshake переключается в режим моста: -/// данные из proxy-сокета пересылаются через [onData] в loopback-пару. -class _RawSocketIO { - final RawSocket _socket; - late final StreamSubscription _sub; - - final _readBuffer = []; - Completer? _readWaiter; - Completer? _writeWaiter; - bool _closed = false; - Object? _error; - - /// Коллбэк для данных в режиме моста. - void Function(Uint8List data)? onData; - - /// Коллбэк закрытия в режиме моста. - void Function()? onClosed; - - _RawSocketIO(this._socket) { - _sub = _socket.listen( - _onEvent, - onError: (Object err) { - _error = err; - _closed = true; - _readWaiter?.completeError(err); - _readWaiter = null; - _writeWaiter?.completeError(err); - _writeWaiter = null; - }, - ); - } - - void _onEvent(RawSocketEvent event) { - switch (event) { - case RawSocketEvent.read: - final data = _socket.read(); - if (data != null) { - if (onData != null) { - // Режим моста — пересылаем напрямую - onData!(data); - } else { - // Режим handshake — буферизуем - _readBuffer.addAll(data); - _readWaiter?.complete(); - _readWaiter = null; - } - } - break; - case RawSocketEvent.write: - _writeWaiter?.complete(); - _writeWaiter = null; - break; - case RawSocketEvent.readClosed: - case RawSocketEvent.closed: - _closed = true; - onClosed?.call(); - _readWaiter?.completeError(SocketException('Прокси закрыл соединение')); - _readWaiter = null; - _writeWaiter?.completeError( - SocketException('Прокси закрыл соединение'), - ); - _writeWaiter = null; - break; - } - } - - /// Читает ровно [count] байт. - Future readExact(int count) async { - while (_readBuffer.length < count) { - if (_error != null) throw _error!; - if (_closed) { - throw SocketException( - 'Соединение закрыто ' - '(ожидали $count байт, получили ${_readBuffer.length})', - ); - } - _readWaiter = Completer(); - await _readWaiter!.future.timeout( - const Duration(seconds: 15), - onTimeout: () => throw SocketException('Тайм-аут при чтении от прокси'), - ); - } - final result = Uint8List.fromList(_readBuffer.sublist(0, count)); - _readBuffer.removeRange(0, count); - return result; - } - - /// Записывает все байты. - Future write(List data) async { - var offset = 0; - while (offset < data.length) { - if (_error != null) throw _error!; - if (_closed) throw SocketException('Соединение закрыто при записи'); - final written = _socket.write(data, offset); - if (written > 0) { - offset += written; - } else { - _writeWaiter = Completer(); - await _writeWaiter!.future.timeout( - const Duration(seconds: 15), - onTimeout: () => - throw SocketException('Тайм-аут при записи в прокси'), - ); - } - } - } - - /// Пересылает данные, оставшиеся в буфере после handshake, в мост. - void flushBuffered() { - if (_readBuffer.isNotEmpty && onData != null) { - onData!(Uint8List.fromList(_readBuffer)); - _readBuffer.clear(); - } - } - - void dispose() { - _sub.cancel(); - } -} diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart deleted file mode 100644 index 4113e4f..0000000 --- a/lib/core/transport/receiver.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:typed_data'; - -import '../protocol/packet.dart'; - -class ReceiverOverflowException implements Exception { - final int size; - const ReceiverOverflowException(this.size); - @override - String toString() => 'PacketReceiver: переполнение буфера ($size B)'; -} - -class PacketReceiver { - Uint8List _buffer = Uint8List(0); - int _start = 0; - int _end = 0; - - static const int _maxBufferSize = 16 * 1024 * 1024; - - List feed(Uint8List data) { - _append(data); - - if (_end - _start > _maxBufferSize) { - final overflow = _end - _start; - reset(); - throw ReceiverOverflowException(overflow); - } - - final packets = []; - while (_end - _start >= headerSize) { - final bd = ByteData.view( - _buffer.buffer, - _buffer.offsetInBytes + _start, - headerSize, - ); - final packedLen = bd.getUint32(6, Endian.big); - final payloadLength = packedLen & 0xFFFFFF; - final totalLength = headerSize + payloadLength; - - if (_end - _start < totalLength) break; - - packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength)); - _start += totalLength; - } - - if (_start == _end) { - _start = 0; - _end = 0; - } - return packets; - } - - void _append(Uint8List data) { - final pending = _end - _start; - if (pending == 0) { - _buffer = Uint8List.fromList(data); - _start = 0; - _end = data.length; - return; - } - final total = pending + data.length; - final newBuffer = Uint8List(total); - newBuffer.setRange(0, pending, _buffer, _start); - newBuffer.setRange(pending, total, data); - _buffer = newBuffer; - _start = 0; - _end = total; - } - - void reset() { - _buffer = Uint8List(0); - _start = 0; - _end = 0; - } -} diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart deleted file mode 100644 index 758fab0..0000000 --- a/lib/core/transport/sender.dart +++ /dev/null @@ -1,27 +0,0 @@ -import '../protocol/packet.dart'; -import '../utils/log_redact.dart'; -import '../utils/logger.dart'; -import 'connection.dart'; -import 'traffic_monitor.dart'; - -class PacketSender { - int _seq = 0; - - int get currentSeq => _seq; - - int _nextSeq() { - _seq = (_seq + 1) % 65536; - return _seq; - } - - int send(Connection connection, int opcode, Map payload) { - final seq = _nextSeq(); - final data = packPacket(opcode, payload, seq: seq); - connection.write(data); - TrafficMonitor.instance.recordOutgoing(opcode, payload, seq, data.length); - logger.i( - '=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${payloadForLog(payload)}}', - ); - return seq; - } -} diff --git a/lib/core/transport/tls_config.dart b/lib/core/transport/tls_config.dart index 50d8639..4db0a81 100644 --- a/lib/core/transport/tls_config.dart +++ b/lib/core/transport/tls_config.dart @@ -1,8 +1,20 @@ +import 'package:kolibri/kolibri.dart' show setTrustMincifryCa; import 'package:shared_preferences/shared_preferences.dart'; +import '../config/config.dart'; + abstract class TlsConfig { static const String prefKey = 'dev_tls_insecure'; + static Future applyMincifryTrust() async { + final prefs = await SharedPreferences.getInstance(); + setTrustMincifryCa( + enabled: + prefs.getBool(ServerConfig.prefTrustMincifryKey) ?? + ServerConfig.defaultTrustMincifryCa, + ); + } + static Future isInsecureAllowed() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(prefKey) ?? false; diff --git a/lib/core/utils/debug_session_log.dart b/lib/core/utils/debug_session_log.dart index 02cb02f..75eda80 100644 --- a/lib/core/utils/debug_session_log.dart +++ b/lib/core/utils/debug_session_log.dart @@ -5,8 +5,16 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; import '../protocol/opcode_map.dart'; +import 'format.dart'; import 'log_redact.dart'; +class DebugExportFile { + final String name; + final String content; + + DebugExportFile(this.name, this.content); +} + class _LogEntry { final int opcode; final int seq; @@ -99,7 +107,8 @@ class DebugSessionLog { DebugSessionLog._(); static final DebugSessionLog instance = DebugSessionLog._(); - static const int _maxSessions = 3; + static const Duration _retention = Duration(hours: 24); + static const int _maxStoredSessions = 30; static const int _maxEntriesPerSession = 2000; static const int _maxLogLinesPerSession = 5000; static const Duration _flushDebounce = Duration(seconds: 3); @@ -225,9 +234,17 @@ class DebugSessionLog { Future _rotate() async { final files = await _sessionFiles(); - const keep = _maxSessions - 1; - if (files.length <= keep) return; - for (final file in files.take(files.length - keep)) { + final cutoff = DateTime.now().subtract(_retention).millisecondsSinceEpoch; + final stale = []; + final fresh = []; + for (final file in files) { + (_startMillis(file) < cutoff ? stale : fresh).add(file); + } + const keep = _maxStoredSessions - 1; + if (fresh.length > keep) { + stale.addAll(fresh.take(fresh.length - keep)); + } + for (final file in stale) { try { await file.delete(); } catch (_) {} @@ -255,7 +272,8 @@ class DebugSessionLog { return int.tryParse(digits) ?? 0; } - Future buildExport({String? endpoint}) async { + Future?> buildExportFiles({String? endpoint}) async { + final cutoff = DateTime.now().subtract(_retention); final sessions = <_SessionData>[]; final dir = _dir; if (dir != null) { @@ -263,7 +281,10 @@ class DebugSessionLog { if (_currentFile != null && file.path == _currentFile!.path) continue; try { final decoded = jsonDecode(await file.readAsString()); - if (decoded is Map) sessions.add(_SessionData.fromJson(decoded)); + if (decoded is Map) { + final session = _SessionData.fromJson(decoded); + if (!session.startedAt.isBefore(cutoff)) sessions.add(session); + } } catch (_) {} } } @@ -277,56 +298,66 @@ class DebugSessionLog { ), ); sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt)); - final lastN = sessions.length > _maxSessions - ? sessions.sublist(sessions.length - _maxSessions) - : sessions; - final totalEntries = lastN.fold(0, (sum, s) => sum + s.entries.length); - final totalLogs = lastN.fold(0, (sum, s) => sum + s.logLines.length); + final totalEntries = sessions.fold( + 0, + (sum, s) => sum + s.entries.length, + ); + final totalLogs = sessions.fold( + 0, + (sum, s) => sum + s.logLines.length, + ); if (totalEntries == 0 && totalLogs == 0) return null; + final info = StringBuffer(); + info.writeln('Komet — отладочный лог'); + if (endpoint != null) info.writeln('Сервер: $endpoint'); + info.writeln('Экспортирован: ${DateTime.now().toIso8601String()}'); + info.writeln('Период: последние ${_retention.inHours} часа'); + info.writeln('Заходов в приложение: ${sessions.length}'); + info.writeln('Всего запросов: $totalEntries'); + info.writeln('Всего строк лога: $totalLogs'); + info.writeln('Скрыто: токен полностью, номер кроме первых 3 символов'); + + final files = [DebugExportFile('info.txt', '$info')]; + for (var s = 0; s < sessions.length; s++) { + final session = sessions[s]; + final name = + 'session_${(s + 1).toString().padLeft(2, '0')}_' + '${formatFileStamp(session.startedAt)}.txt'; + files.add(DebugExportFile(name, _buildSessionText(s + 1, session))); + } + return files; + } + + String _buildSessionText(int index, _SessionData session) { final buffer = StringBuffer(); - buffer.writeln('Komet — отладочный лог'); - if (endpoint != null) buffer.writeln('Сервер: $endpoint'); - buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}'); - buffer.writeln('Заходов в приложение: ${lastN.length}'); - buffer.writeln('Всего запросов: $totalEntries'); - buffer.writeln('Всего строк лога: $totalLogs'); - buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов'); + buffer.writeln('=================================================='); + buffer.writeln('ЗАХОД #$index — ${session.startedAt.toIso8601String()}'); + buffer.writeln( + 'запросов: ${session.entries.length}' + '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}' + ' · строк лога: ${session.logLines.length}' + '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}', + ); + buffer.writeln('=================================================='); buffer.writeln(); - for (var s = 0; s < lastN.length; s++) { - final session = lastN[s]; - buffer.writeln('=================================================='); - buffer.writeln( - 'ЗАХОД #${s + 1} — ${session.startedAt.toIso8601String()}', - ); - buffer.writeln( - 'запросов: ${session.entries.length}' - '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}' - ' · строк лога: ${session.logLines.length}' - '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}', - ); - buffer.writeln('=================================================='); - buffer.writeln(); - - buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----'); - if (session.logLines.isEmpty) { - buffer.writeln('(пусто)'); - } else { - for (final line in session.logLines) { - buffer.writeln(line); - } + buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----'); + if (session.logLines.isEmpty) { + buffer.writeln('(пусто)'); + } else { + for (final line in session.logLines) { + buffer.writeln(line); } - buffer.writeln(); + } + buffer.writeln(); - buffer.writeln('----- ЗАПРОСЫ -----'); - if (session.entries.isEmpty) { - buffer.writeln('(пусто)'); - buffer.writeln(); - } else { - for (var i = 0; i < session.entries.length; i++) { - _writeEntry(buffer, i + 1, session.entries[i]); - } + buffer.writeln('----- ЗАПРОСЫ -----'); + if (session.entries.isEmpty) { + buffer.writeln('(пусто)'); + } else { + for (var i = 0; i < session.entries.length; i++) { + _writeEntry(buffer, i + 1, session.entries[i]); } } return buffer.toString(); diff --git a/lib/core/utils/download_history.dart b/lib/core/utils/download_history.dart new file mode 100644 index 0000000..b399624 --- /dev/null +++ b/lib/core/utils/download_history.dart @@ -0,0 +1,338 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../storage/app_instance.dart'; +import 'media_cache.dart'; + +enum DownloadKind { photo, video, gif, audio, file } + +DownloadKind downloadKindForName( + String name, { + DownloadKind fallback = DownloadKind.file, +}) { + final extension = p.extension(name).toLowerCase(); + if (extension == '.gif') return DownloadKind.gif; + if (const { + '.mp4', + '.mkv', + '.mov', + '.webm', + '.avi', + '.m4v', + }.contains(extension)) { + return DownloadKind.video; + } + if (const { + '.mp3', + '.ogg', + '.opus', + '.wav', + '.m4a', + '.flac', + }.contains(extension)) { + return DownloadKind.audio; + } + if (const { + '.jpg', + '.jpeg', + '.png', + '.webp', + '.heic', + '.bmp', + }.contains(extension)) { + return DownloadKind.photo; + } + return fallback; +} + +class DownloadMetadata { + final String cacheName; + final String name; + final DownloadKind kind; + final String sourceName; + final String? thumbnailUrl; + final int expectedSize; + final int? chatId; + final String? messageId; + final int? messageTime; + + const DownloadMetadata({ + required this.cacheName, + this.name = '', + required this.kind, + this.sourceName = '', + this.thumbnailUrl, + this.expectedSize = 0, + this.chatId, + this.messageId, + this.messageTime, + }); +} + +class DownloadRecord { + final String cacheName; + final String name; + final DownloadKind kind; + final String sourceName; + final String? thumbnailUrl; + final int size; + final int downloadedAt; + final int? chatId; + final String? messageId; + final int? messageTime; + + const DownloadRecord({ + required this.cacheName, + required this.name, + required this.kind, + required this.sourceName, + required this.thumbnailUrl, + required this.size, + required this.downloadedAt, + required this.chatId, + required this.messageId, + required this.messageTime, + }); + + factory DownloadRecord.fromJson(Map json) { + final rawKind = json['kind']?.toString(); + final kind = DownloadKind.values.firstWhere( + (value) => value.name == rawKind, + orElse: () => DownloadKind.file, + ); + return DownloadRecord( + cacheName: json['cacheName']?.toString() ?? '', + name: json['name']?.toString() ?? '', + kind: kind, + sourceName: json['sourceName']?.toString() ?? '', + thumbnailUrl: json['thumbnailUrl']?.toString(), + size: (json['size'] as num?)?.toInt() ?? 0, + downloadedAt: (json['downloadedAt'] as num?)?.toInt() ?? 0, + chatId: switch (json['chatId']) { + final num value => value.toInt(), + final String value => int.tryParse(value), + _ => null, + }, + messageId: json['messageId']?.toString(), + messageTime: switch (json['messageTime']) { + final num value => value.toInt(), + final String value => int.tryParse(value), + _ => null, + }, + ); + } + + Map toJson() => { + 'cacheName': cacheName, + 'name': name, + 'kind': kind.name, + 'sourceName': sourceName, + 'thumbnailUrl': thumbnailUrl, + 'size': size, + 'downloadedAt': downloadedAt, + 'chatId': chatId, + 'messageId': messageId, + 'messageTime': messageTime, + }; + + DownloadRecord withSize(int value) => DownloadRecord( + cacheName: cacheName, + name: name, + kind: kind, + sourceName: sourceName, + thumbnailUrl: thumbnailUrl, + size: value, + downloadedAt: downloadedAt, + chatId: chatId, + messageId: messageId, + messageTime: messageTime, + ); +} + +class DownloadHistory { + static const int maxEntries = 200; + static final ValueNotifier> records = ValueNotifier( + const [], + ); + + static Future? _loading; + static Future _mutations = Future.value(); + + static String get _key => 'recent_downloads_v1${AppInstance.suffix}'; + + @visibleForTesting + static void resetForTesting() { + _loading = null; + _mutations = Future.value(); + records.value = const []; + } + + static Future load() => _loading ??= _load(); + + static Future refresh() => _enqueue(() async { + await load(); + final available = await _available(records.value); + if (listEquals(available, records.value)) return; + records.value = List.unmodifiable(available); + await _save(); + }); + + static Future record(DownloadMetadata metadata, File file) => + _enqueue(() async { + await load(); + if (!await file.exists()) return; + final actualSize = await file.length(); + final entry = DownloadRecord( + cacheName: metadata.cacheName, + name: metadata.name, + kind: metadata.kind, + sourceName: metadata.sourceName, + thumbnailUrl: metadata.thumbnailUrl, + size: actualSize > 0 ? actualSize : metadata.expectedSize, + downloadedAt: DateTime.now().millisecondsSinceEpoch, + chatId: metadata.chatId, + messageId: metadata.messageId, + messageTime: metadata.messageTime, + ); + final next = [ + entry, + ...records.value.where((item) => item.cacheName != entry.cacheName), + ]; + if (next.length > maxEntries) next.removeRange(maxEntries, next.length); + records.value = List.unmodifiable(next); + await _save(); + }); + + static Future remove(String cacheName) => _enqueue(() async { + await load(); + final next = records.value + .where((item) => item.cacheName != cacheName) + .toList(growable: false); + if (next.length == records.value.length) return; + records.value = List.unmodifiable(next); + await _save(); + }); + + static Future clear() => _enqueue(() async { + await load(); + records.value = const []; + await _save(); + }); + + static Future fileFor( + DownloadRecord record, { + bool touch = true, + }) async { + if (touch) return MediaCache.existing(record.cacheName); + final file = await MediaCache.fileFor(record.cacheName); + return await file.exists() && await file.length() > 0 ? file : null; + } + + static Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + List loaded; + if (raw == null) { + loaded = await _migrateCache(); + } else { + loaded = _decode(raw); + } + loaded = await _available(loaded); + loaded.sort((a, b) => b.downloadedAt.compareTo(a.downloadedAt)); + if (loaded.length > maxEntries) { + loaded = loaded.sublist(0, maxEntries); + } + records.value = List.unmodifiable(loaded); + await _save(); + } + + static List _decode(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return decoded + .whereType() + .map( + (item) => DownloadRecord.fromJson(Map.from(item)), + ) + .where((item) => item.cacheName.isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + static Future> _available( + List source, + ) async { + final available = []; + for (final record in source) { + final file = await fileFor(record, touch: false); + if (file == null) continue; + final size = await file.length(); + available.add(size == record.size ? record : record.withSize(size)); + } + return available; + } + + static Future> _migrateCache() async { + final files = await MediaCache.files(); + final migrated = []; + for (final file in files) { + final cacheName = p.basename(file.path); + if (cacheName.startsWith('avatar_') || + cacheName.startsWith('decrypted_') || + cacheName.startsWith('download_thumb_')) { + continue; + } + final stat = await file.stat(); + final kind = cacheName.startsWith('photo_') + ? DownloadKind.photo + : cacheName.startsWith('video_') + ? DownloadKind.video + : downloadKindForName(cacheName); + migrated.add( + DownloadRecord( + cacheName: cacheName, + name: kind == DownloadKind.file ? _fileName(cacheName) : '', + kind: kind, + sourceName: '', + thumbnailUrl: null, + size: stat.size, + downloadedAt: stat.modified.millisecondsSinceEpoch, + chatId: null, + messageId: null, + messageTime: null, + ), + ); + } + return migrated; + } + + static String _fileName(String cacheName) { + final separator = cacheName.indexOf('_'); + if (separator <= 0) return cacheName; + final prefix = cacheName.substring(0, separator); + return int.tryParse(prefix) == null + ? cacheName + : cacheName.substring(separator + 1); + } + + static Future _save() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key, + jsonEncode(records.value.map((item) => item.toJson()).toList()), + ); + } + + static Future _enqueue(Future Function() operation) { + final next = _mutations.then((_) => operation()); + _mutations = next.catchError((_) {}); + return next; + } +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart index a9fe696..dc2f2b7 100644 --- a/lib/core/utils/file_download.dart +++ b/lib/core/utils/file_download.dart @@ -1,5 +1,6 @@ import 'package:open_filex/open_filex.dart'; +import 'download_history.dart'; import 'media_cache.dart'; class FileDownloadResult { @@ -15,17 +16,30 @@ class FileDownloadResult { /// [cacheName] — стабильное имя в кэше (например, `_имя.ext`). /// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше, /// чтобы не дёргать сервер за временной ссылкой повторно. +/// [onReady] вызывается как только файл лежит на диске — до открытия во +/// внешнем приложении, которое может не возвращать управление, пока его не +/// закроют. Без этого индикатор загрузки висел бы всё это время. Future openCachedFile( String cacheName, Future Function() resolveUrl, { void Function(double progress)? onProgress, + void Function()? onReady, + DownloadMetadata? download, }) async { + var readyFired = false; + void ready() { + if (readyFired) return; + readyFired = true; + onReady?.call(); + } + try { var file = await MediaCache.existing(cacheName); if (file == null) { final url = await resolveUrl(); if (url == null || url.isEmpty) { + ready(); return const FileDownloadResult(ok: false, error: 'нет ссылки'); } file = await MediaCache.getOrDownload( @@ -34,10 +48,17 @@ Future openCachedFile( onProgress: onProgress, ); if (file == null) { + ready(); return const FileDownloadResult(ok: false, error: 'ошибка загрузки'); } } + ready(); + if (download != null) { + try { + await DownloadHistory.record(download, file); + } catch (_) {} + } final opened = await OpenFilex.open(file.path); return FileDownloadResult( ok: opened.type == ResultType.done, @@ -45,6 +66,7 @@ Future openCachedFile( error: opened.type == ResultType.done ? null : opened.message, ); } catch (e) { + ready(); return FileDownloadResult(ok: false, error: e.toString()); } } diff --git a/lib/core/utils/link_opener.dart b/lib/core/utils/link_opener.dart index 089fa3f..beb1a4b 100644 --- a/lib/core/utils/link_opener.dart +++ b/lib/core/utils/link_opener.dart @@ -1,10 +1,37 @@ +import 'dart:io' show Platform; + import 'package:flutter/widgets.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../links/deep_link_service.dart'; + import '../../frontend/widgets/custom_notification.dart'; import '../../frontend/widgets/max_link_handler.dart'; +const Set _webViewSchemes = { + 'http', + 'https', + 'about', + 'data', + 'blob', + 'javascript', + 'file', +}; + +bool leavesWebView(String? scheme) { + if (scheme == null || scheme.isEmpty) return false; + return !_webViewSchemes.contains(scheme.toLowerCase()); +} + +const Set _appSchemes = {'komet', 'max'}; + Future openExternalUrl(BuildContext context, String url) async { + final appUri = Uri.tryParse(url.trim()); + if (appUri != null && _appSchemes.contains(appUri.scheme.toLowerCase())) { + DeepLinkService.instance.handle(appUri); + return; + } + if (await tryHandleMaxLink(context, url)) return; if (!context.mounted) return; @@ -26,10 +53,13 @@ Future openLocationOnMap( double? zoom, }) async { final z = (zoom ?? 15).round(); - final geo = Uri.parse('geo:$latitude,$longitude?z=$z'); - if (await canLaunchUrl(geo)) { - final ok = await launchUrl(geo, mode: LaunchMode.externalApplication); - if (ok) return; + for (final uri in _nativeMapUris(latitude, longitude, z)) { + try { + if (!await canLaunchUrl(uri)) continue; + if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return; + } catch (_) { + continue; + } } if (!context.mounted) return; await openExternalUrl( @@ -37,3 +67,16 @@ Future openLocationOnMap( 'https://yandex.ru/maps/?pt=$longitude,$latitude&z=$z&l=map', ); } + +List _nativeMapUris(double latitude, double longitude, int zoom) { + if (Platform.isIOS) { + return [ + Uri.parse( + 'yandexmaps://maps.yandex.ru/' + '?ll=$longitude,$latitude&z=$zoom&pt=$longitude,$latitude', + ), + Uri.parse('maps://?ll=$latitude,$longitude&q=$latitude,$longitude'), + ]; + } + return [Uri.parse('geo:$latitude,$longitude?z=$zoom')]; +} diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart index 8a783d3..f911ce3 100644 --- a/lib/core/utils/media_cache.dart +++ b/lib/core/utils/media_cache.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -18,6 +19,8 @@ class MediaCache { static Directory? _dir; static int? _cachedSize; static final Map> _inFlight = {}; + static final Set _present = {}; + static final Map> _presence = {}; static Future _cacheDir() async { final cached = _dir; @@ -39,6 +42,15 @@ class MediaCache { return File(p.join(dir.path, _sanitize(name))); } + static Future> files() async { + final dir = await _cacheDir(); + final files = []; + await for (final entity in dir.list()) { + if (entity is File && !entity.path.endsWith('.part')) files.add(entity); + } + return files; + } + /// Существует ли непустой кэш-файл [name]. /// /// При попадании обновляет mtime файла — это делает вытеснение LRU @@ -49,8 +61,10 @@ class MediaCache { try { await file.setLastModified(DateTime.now()); } catch (_) {} + _markPresent(name, true); return file; } + _markPresent(name, false); return null; } @@ -103,6 +117,7 @@ class MediaCache { } await sink.close(); await part.rename(file.path); + _markPresent(name, true); final known = _cachedSize; if (known != null) { try { @@ -161,6 +176,10 @@ class MediaCache { } } _cachedSize = 0; + _present.clear(); + for (final notifier in _presence.values) { + notifier.value = false; + } return freed; } @@ -195,11 +214,35 @@ class MediaCache { try { total -= await file.length(); await file.delete(); + _markAbsentByBasename(p.basename(file.path)); } catch (_) {} } _cachedSize = total; } + /// Реактивный флаг наличия файла [name] в кэше (для UI-иконки «скачано»). + static ValueListenable presence(String name) { + final key = _sanitize(name); + return _presence.putIfAbsent(key, () { + final notifier = ValueNotifier(_present.contains(key)); + if (!notifier.value) existing(name).ignore(); + return notifier; + }); + } + + static void _markPresent(String name, bool present) { + final key = _sanitize(name); + final changed = present ? _present.add(key) : _present.remove(key); + if (!changed) return; + _presence[key]?.value = present; + } + + static void _markAbsentByBasename(String basename) { + if (_present.remove(basename)) { + _presence[basename]?.value = false; + } + } + static String _sanitize(String name) { final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); return cleaned.isEmpty ? 'file' : cleaned; diff --git a/lib/core/utils/media_saver.dart b/lib/core/utils/media_saver.dart index a29ff0b..6ea6ebe 100644 --- a/lib/core/utils/media_saver.dart +++ b/lib/core/utils/media_saver.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; +import 'download_history.dart'; import 'media_cache.dart'; class MediaSaveResult { @@ -30,22 +31,11 @@ Future saveImageFromUrl(String url) async { if (file == null) { return const MediaSaveResult(ok: false, error: 'не удалось загрузить'); } - final saveName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg'; - - if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { - final state = await PhotoManager.requestPermissionExtend(); - if (!state.isAuth && !state.hasAccess) { - return const MediaSaveResult(ok: false, error: 'нет доступа к галерее'); - } - final bytes = await file.readAsBytes(); - await PhotoManager.editor.saveImage(bytes, filename: saveName); - return const MediaSaveResult(ok: true, toGallery: true); - } - - final dir = await _targetDirectory(); - final target = File('${dir.path}${Platform.pathSeparator}$saveName'); - await file.copy(target.path); - return MediaSaveResult(ok: true, location: target.path); + return _persist( + file, + saveName: 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg', + kind: SaveMediaKind.image, + ); } catch (e) { return MediaSaveResult(ok: false, error: e.toString()); } @@ -58,6 +48,7 @@ Future saveMediaFile({ required Future Function() resolveUrl, required String saveName, required SaveMediaKind kind, + DownloadMetadata? download, }) async { try { var file = await MediaCache.existing(cacheName); @@ -71,32 +62,60 @@ Future saveMediaFile({ if (file == null) { return const MediaSaveResult(ok: false, error: 'не удалось загрузить'); } - - final toGallery = - kind == SaveMediaKind.image || kind == SaveMediaKind.video; - if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) { - final state = await PhotoManager.requestPermissionExtend(); - if (!state.isAuth && !state.hasAccess) { - return const MediaSaveResult(ok: false, error: 'нет доступа к галерее'); - } - if (kind == SaveMediaKind.video) { - await PhotoManager.editor.saveVideo(file, title: saveName); - } else { - final bytes = await file.readAsBytes(); - await PhotoManager.editor.saveImage(bytes, filename: saveName); - } - return const MediaSaveResult(ok: true, toGallery: true); + final result = await _persist(file, saveName: saveName, kind: kind); + if (result.ok && download != null) { + try { + await DownloadHistory.record(download, file); + } catch (_) {} } - - final dir = await _targetDirectory(); - final target = File('${dir.path}${Platform.pathSeparator}$saveName'); - await file.copy(target.path); - return MediaSaveResult(ok: true, location: target.path); + return result; } catch (e) { return MediaSaveResult(ok: false, error: e.toString()); } } +Future saveLocalImage(String path, {String? saveName}) async { + try { + final file = File(path); + if (!await file.exists()) { + return const MediaSaveResult(ok: false, error: 'файл не найден'); + } + return _persist( + file, + saveName: saveName ?? 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', + kind: SaveMediaKind.image, + ); + } catch (e) { + return MediaSaveResult(ok: false, error: e.toString()); + } +} + +Future _persist( + File file, { + required String saveName, + required SaveMediaKind kind, +}) async { + final toGallery = kind == SaveMediaKind.image || kind == SaveMediaKind.video; + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) { + final state = await PhotoManager.requestPermissionExtend(); + if (!state.isAuth && !state.hasAccess) { + return const MediaSaveResult(ok: false, error: 'нет доступа к галерее'); + } + if (kind == SaveMediaKind.video) { + await PhotoManager.editor.saveVideo(file, title: saveName); + } else { + final bytes = await file.readAsBytes(); + await PhotoManager.editor.saveImage(bytes, filename: saveName); + } + return const MediaSaveResult(ok: true, toGallery: true); + } + + final dir = await _targetDirectory(); + final target = File('${dir.path}${Platform.pathSeparator}$saveName'); + await file.copy(target.path); + return MediaSaveResult(ok: true, location: target.path); +} + Future _targetDirectory() async { try { final downloads = await getDownloadsDirectory(); diff --git a/lib/core/utils/route_settle.dart b/lib/core/utils/route_settle.dart new file mode 100644 index 0000000..a1b727f --- /dev/null +++ b/lib/core/utils/route_settle.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +class RouteSettle { + RouteSettle({required this.isMounted}); + + static const Duration _safetyMargin = Duration(milliseconds: 250); + + final bool Function() isMounted; + final List _queued = []; + Animation? _animation; + Timer? _timer; + bool _bindScheduled = false; + bool _settled = false; + bool _disposed = false; + + bool get settled => _settled; + + void bind(BuildContext context) { + if (_disposed || _settled || _bindScheduled || _animation != null) return; + _bindScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _bindScheduled = false; + if (_disposed || _settled) return; + if (!isMounted()) { + settleNow(); + return; + } + _attach(context); + }); + } + + void run(VoidCallback action) { + if (_settled) { + action(); + return; + } + _queued.add(action); + } + + void settleNow() { + if (_disposed || _settled) return; + _settled = true; + _detach(); + final pending = List.of(_queued); + _queued.clear(); + if (!isMounted()) return; + for (final action in pending) { + action(); + } + } + + void dispose() { + _disposed = true; + _detach(); + _queued.clear(); + } + + void _attach(BuildContext context) { + final route = ModalRoute.of(context); + final animation = route?.animation; + if (animation == null || animation.status == AnimationStatus.completed) { + settleNow(); + return; + } + _animation = animation..addStatusListener(_onStatus); + _timer = Timer(route!.transitionDuration + _safetyMargin, settleNow); + } + + void _onStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) settleNow(); + } + + void _detach() { + _animation?.removeStatusListener(_onStatus); + _animation = null; + _timer?.cancel(); + _timer = null; + } +} diff --git a/lib/core/utils/save_file_as.dart b/lib/core/utils/save_file_as.dart new file mode 100644 index 0000000..e0ba479 --- /dev/null +++ b/lib/core/utils/save_file_as.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:path/path.dart' as p; + +class SaveFileAsResult { + final bool saved; + final bool cancelled; + final String? path; + final String? error; + + const SaveFileAsResult({ + required this.saved, + this.cancelled = false, + this.path, + this.error, + }); +} + +Future saveFileAs({ + required File source, + required String fileName, + required String dialogTitle, +}) async { + try { + final directory = await FilePicker.platform.getDirectoryPath( + dialogTitle: dialogTitle, + ); + if (directory == null) { + return const SaveFileAsResult(saved: false, cancelled: true); + } + final safeName = p.basename(fileName).trim(); + final target = await _availableTarget( + directory, + safeName.isEmpty ? p.basename(source.path) : safeName, + ); + if (p.equals(p.absolute(source.path), p.absolute(target.path))) { + return SaveFileAsResult(saved: true, path: target.path); + } + await source.copy(target.path); + return SaveFileAsResult(saved: true, path: target.path); + } catch (error) { + return SaveFileAsResult(saved: false, error: error.toString()); + } +} + +Future _availableTarget(String directory, String fileName) async { + var target = File(p.join(directory, fileName)); + if (!await target.exists()) return target; + final extension = p.extension(fileName); + final stem = p.basenameWithoutExtension(fileName); + var suffix = 2; + while (await target.exists()) { + target = File(p.join(directory, '$stem ($suffix)$extension')); + suffix++; + } + return target; +} diff --git a/lib/core/utils/screen_wake.dart b/lib/core/utils/screen_wake.dart new file mode 100644 index 0000000..da88482 --- /dev/null +++ b/lib/core/utils/screen_wake.dart @@ -0,0 +1,41 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import 'logger.dart'; + +class ScreenWake { + ScreenWake._(); + + static final ScreenWake instance = ScreenWake._(); + + static const _channel = MethodChannel('ru.komet.app/screen'); + + final Set _holders = {}; + + bool get _supported { + try { + return Platform.isAndroid || Platform.isIOS; + } catch (_) { + return false; + } + } + + Future acquire(Object holder) async { + if (!_supported || !_holders.add(holder)) return; + if (_holders.length == 1) await _apply(true); + } + + Future release(Object holder) async { + if (!_supported || !_holders.remove(holder)) return; + if (_holders.isEmpty) await _apply(false); + } + + Future _apply(bool enabled) async { + try { + await _channel.invokeMethod('setKeepAwake', {'enabled': enabled}); + } catch (e) { + logger.w('ScreenWake._apply: enabled=$enabled $e'); + } + } +} diff --git a/lib/core/utils/share_origin.dart b/lib/core/utils/share_origin.dart new file mode 100644 index 0000000..bcf7f96 --- /dev/null +++ b/lib/core/utils/share_origin.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +Rect shareOriginOf(BuildContext? context) { + final box = context?.findRenderObject() as RenderBox?; + if (box != null && box.hasSize && box.attached) { + final rect = box.localToGlobal(Offset.zero) & box.size; + if (!rect.isEmpty) return rect; + } + final view = WidgetsBinding.instance.platformDispatcher.views.first; + final size = view.physicalSize / view.devicePixelRatio; + return Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: 1, + height: 1, + ); +} diff --git a/lib/core/utils/text_entities.dart b/lib/core/utils/text_entities.dart new file mode 100644 index 0000000..2d975ad --- /dev/null +++ b/lib/core/utils/text_entities.dart @@ -0,0 +1,179 @@ +enum TextEntityKind { mention, phone, card } + +class TextEntity { + final TextEntityKind kind; + final int start; + final int end; + final String value; + + const TextEntity({ + required this.kind, + required this.start, + required this.end, + required this.value, + }); + + int get length => end - start; +} + +typedef TextSpanRange = ({int start, int end}); + +final RegExp _cardPattern = RegExp( + r'(? _cardBrands = { + 'MIR': 'МИР', + 'VISA': 'Visa', + 'MASTERCARD': 'Mastercard', + 'MAESTRO': 'Maestro', + 'AMEX': 'American Express', + 'UNIONPAY': 'UnionPay', + 'JCB': 'JCB', + 'DINERS': 'Diners Club', + 'DISCOVER': 'Discover', +}; + +String? cardBrand(String digits) { + if (digits.length < 13) return null; + int prefix(int length) => int.parse(digits.substring(0, length)); + + final p1 = prefix(1); + final p2 = prefix(2); + final p3 = prefix(3); + final p4 = prefix(4); + final p6 = digits.length >= 6 ? prefix(6) : 0; + + if (p4 >= 2200 && p4 <= 2204) return 'MIR'; + if (p1 == 4) return 'VISA'; + if (p2 >= 51 && p2 <= 55) return 'MASTERCARD'; + if (p4 >= 2221 && p4 <= 2720) return 'MASTERCARD'; + if (p2 == 34 || p2 == 37) return 'AMEX'; + if (p2 == 62) return 'UNIONPAY'; + if (p4 >= 3528 && p4 <= 3589) return 'JCB'; + if (p3 >= 300 && p3 <= 305) return 'DINERS'; + if (p2 == 36 || p2 == 38 || p2 == 39) return 'DINERS'; + if (p4 == 6011 || p2 == 65) return 'DISCOVER'; + if (p3 >= 644 && p3 <= 649) return 'DISCOVER'; + if (p6 >= 622126 && p6 <= 622925) return 'DISCOVER'; + if (p4 == 5018 || p4 == 5020 || p4 == 5038 || p4 == 6304) return 'MAESTRO'; + if (p4 == 6759 || (p4 >= 6761 && p4 <= 6763)) return 'MAESTRO'; + return null; +} + +String? cardBrandTitle(String digits) { + final brand = cardBrand(digits); + return brand == null ? null : _cardBrands[brand]; +} + +String cardMask(String digits) { + final brand = cardBrand(digits) ?? 'CARD'; + final tail = digits.length >= 4 + ? digits.substring(digits.length - 4) + : digits; + return '$brand*$tail'; +} + +String formatCardNumber(String digits) { + final buffer = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + if (i > 0 && i % 4 == 0) buffer.write(' '); + buffer.write(digits[i]); + } + return buffer.toString(); +} + +bool isLuhnValid(String digits) { + if (digits.length < 12) return false; + var sum = 0; + var double = false; + for (var i = digits.length - 1; i >= 0; i--) { + var value = digits.codeUnitAt(i) - 0x30; + if (value < 0 || value > 9) return false; + if (double) { + value *= 2; + if (value > 9) value -= 9; + } + sum += value; + double = !double; + } + return sum % 10 == 0; +} + +String _digitsOf(String raw) { + final buffer = StringBuffer(); + for (var i = 0; i < raw.length; i++) { + final code = raw.codeUnitAt(i); + if (code >= 0x30 && code <= 0x39) buffer.writeCharCode(code); + } + return buffer.toString(); +} + +bool _mayContainEntities(String text) { + for (var i = 0; i < text.length; i++) { + final code = text.codeUnitAt(i); + if (code == 0x40) return true; + if (code >= 0x30 && code <= 0x39) return true; + } + return false; +} + +List detectTextEntities( + String text, { + Iterable skip = const [], +}) { + if (text.isEmpty || !_mayContainEntities(text)) return const []; + + final taken = [...skip]; + bool free(int start, int end) => + !taken.any((r) => start < r.end && end > r.start); + + final found = []; + + void collect( + RegExp pattern, + TextEntityKind kind, + String? Function(RegExpMatch match) valueOf, + ) { + for (final match in pattern.allMatches(text)) { + if (!free(match.start, match.end)) continue; + final value = valueOf(match); + if (value == null) continue; + taken.add((start: match.start, end: match.end)); + found.add( + TextEntity( + kind: kind, + start: match.start, + end: match.end, + value: value, + ), + ); + } + } + + collect(_cardPattern, TextEntityKind.card, (match) { + final digits = _digitsOf(match.group(0)!); + if (digits.length < 13 || digits.length > 19) return null; + if (cardBrand(digits) == null) return null; + if (!isLuhnValid(digits)) return null; + return digits; + }); + + collect(_phonePattern, TextEntityKind.phone, (match) { + final digits = _digitsOf(match.group(0)!); + if (digits.length < 10 || digits.length > 15) return null; + return '+$digits'; + }); + + collect(_mentionPattern, TextEntityKind.mention, (match) => match.group(1)); + + found.sort((a, b) => a.start.compareTo(b.start)); + return found; +} + +bool hasTextEntities(String text, {Iterable skip = const []}) => + detectTextEntities(text, skip: skip).isNotEmpty; diff --git a/lib/core/utils/text_format.dart b/lib/core/utils/text_format.dart index 4100ad9..90ae1d4 100644 --- a/lib/core/utils/text_format.dart +++ b/lib/core/utils/text_format.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; enum TextFormat { + heading, strong, emphasized, underline, @@ -9,9 +10,11 @@ enum TextFormat { quote, link, animoji, + userMention, } const Map _formatToServer = { + TextFormat.heading: 'HEADING', TextFormat.strong: 'STRONG', TextFormat.emphasized: 'EMPHASIZED', TextFormat.underline: 'UNDERLINE', @@ -20,6 +23,7 @@ const Map _formatToServer = { TextFormat.quote: 'QUOTE', TextFormat.link: 'LINK', TextFormat.animoji: 'ANIMOJI', + TextFormat.userMention: 'USER_MENTION', }; final Map _serverToFormat = { @@ -35,12 +39,16 @@ class FormatRange { final TextFormat format; final int start; final int length; + final int? entityId; + final String? entityName; final Map? attributes; const FormatRange({ required this.format, required this.start, required this.length, + this.entityId, + this.entityName, this.attributes, }); @@ -60,6 +68,8 @@ class FormatRange { 'type': textFormatToServer(format), 'from': start, 'length': length, + if (entityId != null) 'entityId': entityId, + if (entityName != null) 'entityName': entityName, if (attributes != null) 'attributes': attributes, }; } @@ -78,11 +88,17 @@ List parseFormatElements(dynamic raw) { final attributes = attrsRaw is Map ? Map.from(attrsRaw) : null; + final entityId = item['entityId']; + final entityName = item['entityName']; result.add( FormatRange( format: format, start: from, length: length, + entityId: entityId is int ? entityId : null, + entityName: entityName is String && entityName.isNotEmpty + ? entityName + : null, attributes: attributes, ), ); @@ -134,6 +150,8 @@ class FormatSegment { final Set formats; final String? url; final String? animojiUrl; + final int? mentionId; + final String? mentionName; const FormatSegment({ required this.start, @@ -141,6 +159,8 @@ class FormatSegment { required this.formats, this.url, this.animojiUrl, + this.mentionId, + this.mentionName, }); } @@ -157,6 +177,8 @@ List segmentizeFormats(String text, List ranges) { format: range.format, start: start, length: end - start, + entityId: range.entityId, + entityName: range.entityName, attributes: range.attributes, ), ); @@ -180,11 +202,17 @@ List segmentizeFormats(String text, List ranges) { final formats = {}; String? url; String? animojiUrl; + int? mentionId; + String? mentionName; for (final range in clamped) { if (range.start <= start && range.end >= end) { formats.add(range.format); if (range.format == TextFormat.link) url ??= range.url; if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl; + if (range.format == TextFormat.userMention) { + mentionId ??= range.entityId; + mentionName ??= range.entityName; + } } } segments.add( @@ -194,6 +222,8 @@ List segmentizeFormats(String text, List ranges) { formats: formats, url: url, animojiUrl: animojiUrl, + mentionId: mentionId, + mentionName: mentionName, ), ); } @@ -204,6 +234,8 @@ TextStyle applyTextFormats( TextStyle base, Set formats, { Color? quoteColor, + Color? mentionColor, + Paint? quoteBackground, }) { if (formats.isEmpty) return base; @@ -216,14 +248,21 @@ TextStyle applyTextFormats( decorations.add(TextDecoration.lineThrough); } - final isItalic = formats.contains(TextFormat.emphasized) || - formats.contains(TextFormat.quote); + final isItalic = formats.contains(TextFormat.emphasized); + + final isQuote = formats.contains(TextFormat.quote); + final isMention = formats.contains(TextFormat.userMention); + final isHeading = formats.contains(TextFormat.heading); return base.copyWith( - fontWeight: formats.contains(TextFormat.strong) ? FontWeight.w700 : null, + fontSize: isHeading ? (base.fontSize ?? 16) * 1.08 : null, + fontWeight: formats.contains(TextFormat.strong) || isHeading + ? FontWeight.w700 + : null, fontStyle: isItalic ? FontStyle.italic : null, fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null, - color: formats.contains(TextFormat.quote) ? quoteColor : null, + background: isQuote ? quoteBackground : null, + color: isMention ? mentionColor : (isQuote ? quoteColor : null), decoration: decorations.isEmpty ? null : TextDecoration.combine(decorations), diff --git a/lib/core/utils/update_checker.dart b/lib/core/utils/update_checker.dart index cdf3dd4..6501979 100644 --- a/lib/core/utils/update_checker.dart +++ b/lib/core/utils/update_checker.dart @@ -22,6 +22,22 @@ class AppUpdateInfo { }); } +enum UpdateCheckStatus { updateAvailable, upToDate, failed } + +class UpdateCheckResult { + final UpdateCheckStatus status; + final AppUpdateInfo? update; + + const UpdateCheckResult._(this.status, [this.update]); + + const UpdateCheckResult.updateAvailable(AppUpdateInfo update) + : this._(UpdateCheckStatus.updateAvailable, update); + + const UpdateCheckResult.upToDate() : this._(UpdateCheckStatus.upToDate); + + const UpdateCheckResult.failed() : this._(UpdateCheckStatus.failed); +} + abstract class UpdateChecker { static const String _owner = 'KometTeam'; static const String _repo = 'Komet'; @@ -96,6 +112,21 @@ abstract class UpdateChecker { return update; } + /// Runs a user-initiated check without applying the automatic-check interval + /// or the "skip this version" preference. + static Future checkNow() async { + try { + final update = await fetchLatest(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_lastCheckKey, DateTime.now().millisecondsSinceEpoch); + return update == null + ? const UpdateCheckResult.upToDate() + : UpdateCheckResult.updateAvailable(update); + } catch (_) { + return const UpdateCheckResult.failed(); + } + } + static Future skip(String tag) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_skippedTagKey, tag); @@ -117,22 +148,25 @@ abstract class UpdateChecker { final resp = await req.close().timeout(_timeout); if (resp.statusCode != HttpStatus.ok) { await resp.drain(); - return null; + throw HttpException( + 'GitHub returned HTTP ${resp.statusCode}', + uri: uri, + ); } final body = await resp .transform(const Utf8Decoder()) .join() .timeout(_timeout); final decoded = jsonDecode(body); - if (decoded is! List) return null; + if (decoded is! List) { + throw const FormatException('Invalid GitHub releases response'); + } for (final entry in decoded) { if (entry is! Map) continue; if (entry['draft'] == true) continue; return entry.cast(); } return null; - } catch (_) { - return null; } finally { client.close(force: true); } diff --git a/lib/core/webpush/max_web_protocol.dart b/lib/core/webpush/max_web_protocol.dart new file mode 100644 index 0000000..65f0830 --- /dev/null +++ b/lib/core/webpush/max_web_protocol.dart @@ -0,0 +1,453 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class MaxWebFrame { + final int cmd; + final int seq; + final int opcode; + final Object? payload; + + const MaxWebFrame({ + required this.cmd, + required this.seq, + required this.opcode, + this.payload, + }); + + bool get isOk => cmd == MaxWebCmd.ok; + bool get isError => cmd != MaxWebCmd.ok && cmd != MaxWebCmd.request; +} + +abstract class MaxWebCmd { + static const int request = 0; + static const int ok = 1; + static const int notFound = 2; + static const int error = 3; +} + +abstract class MaxWebFraming { + static const int protocolVersion = 10; + static const int headerSize = 10; + + static Uint8List encode({ + required int cmd, + required int seq, + required int opcode, + Object? payload, + }) { + final body = payload == null + ? Uint8List(0) + : MaxMsgpack.encode(payload); + final frame = Uint8List(headerSize + body.length); + final view = ByteData.view(frame.buffer); + + view.setUint8(0, protocolVersion); + view.setUint8(1, cmd); + view.setInt16(2, seq); + view.setInt16(4, opcode); + view.setUint8(6, 0); + view.setUint8(7, (body.length >> 16) & 0xFF); + view.setUint8(8, (body.length >> 8) & 0xFF); + view.setUint8(9, body.length & 0xFF); + + frame.setRange(headerSize, frame.length, body); + return frame; + } + + static MaxWebFrame decode(Uint8List frame) { + if (frame.length < headerSize) { + throw const FormatException('MaxWebFraming: кадр короче заголовка'); + } + final view = ByteData.view(frame.buffer, frame.offsetInBytes, frame.length); + + final cmd = view.getUint8(1); + final seq = view.getInt16(2); + final opcode = view.getInt16(4); + final compressionRatio = view.getUint8(6); + final length = + (view.getUint8(7) << 16) | (view.getUint8(8) << 8) | view.getUint8(9); + + if (length <= 0) { + return MaxWebFrame(cmd: cmd, seq: seq, opcode: opcode); + } + + var body = Uint8List.sublistView(frame, headerSize, headerSize + length); + if (compressionRatio > 0) { + body = Lz4Block.decompress(body, length * compressionRatio * 16); + } + + return MaxWebFrame( + cmd: cmd, + seq: seq, + opcode: opcode, + payload: MaxMsgpack.decode(body), + ); + } +} + +abstract class Lz4Block { + static Uint8List decompress(Uint8List source, int maxOutputSize) { + final output = Uint8List(maxOutputSize); + var input = 0; + var written = 0; + + while (input < source.length) { + final token = source[input++]; + + var literalLength = token >> 4; + if (literalLength == 15) { + literalLength += _readLengthExtension(source, () => input, (v) => input = v); + } + + if (written + literalLength > output.length) { + throw const FormatException('Lz4Block: литералы не помещаются'); + } + output.setRange(written, written + literalLength, + Uint8List.sublistView(source, input, input + literalLength)); + written += literalLength; + input += literalLength; + + if (input >= source.length) break; + + final offset = source[input] | (source[input + 1] << 8); + input += 2; + if (offset == 0 || offset > written) { + throw const FormatException('Lz4Block: неверное смещение совпадения'); + } + + var matchLength = token & 0x0F; + if (matchLength == 15) { + matchLength += _readLengthExtension(source, () => input, (v) => input = v); + } + matchLength += 4; + + if (written + matchLength > output.length) { + throw const FormatException('Lz4Block: совпадение не помещается'); + } + + var from = written - offset; + for (var i = 0; i < matchLength; i++) { + output[written++] = output[from++]; + } + } + + return Uint8List.sublistView(output, 0, written); + } + + static int _readLengthExtension( + Uint8List source, + int Function() get, + void Function(int) set, + ) { + var cursor = get(); + var extra = 0; + while (true) { + if (cursor >= source.length) { + throw const FormatException('Lz4Block: обрыв в расширении длины'); + } + final byte = source[cursor++]; + extra += byte; + if (byte != 255) break; + } + set(cursor); + return extra; + } +} + +abstract class MaxMsgpack { + static Uint8List encode(Object? value) { + final sink = BytesBuilder(copy: false); + _write(sink, value); + return sink.takeBytes(); + } + + static Object? decode(Uint8List bytes) => _Reader(bytes).read(); + + static void _write(BytesBuilder sink, Object? value) { + if (value == null) { + sink.addByte(0xC0); + } else if (value is bool) { + sink.addByte(value ? 0xC3 : 0xC2); + } else if (value is int) { + _writeInt(sink, value); + } else if (value is double) { + final buffer = ByteData(9) + ..setUint8(0, 0xCB) + ..setFloat64(1, value); + sink.add(buffer.buffer.asUint8List()); + } else if (value is String) { + _writeString(sink, value); + } else if (value is Uint8List) { + _writeBinary(sink, value); + } else if (value is List) { + _writePrefix(sink, value.length, 0x90, 0xDC, 0xDD); + for (final item in value) { + _write(sink, item); + } + } else if (value is Map) { + _writePrefix(sink, value.length, 0x80, 0xDE, 0xDF); + value.forEach((key, item) { + _write(sink, key); + _write(sink, item); + }); + } else { + throw ArgumentError('MaxMsgpack: неподдерживаемый тип ${value.runtimeType}'); + } + } + + static void _writePrefix( + BytesBuilder sink, + int length, + int fixBase, + int wide16, + int wide32, + ) { + if (length < 16) { + sink.addByte(fixBase | length); + } else if (length < 0x10000) { + sink.addByte(wide16); + sink.add([(length >> 8) & 0xFF, length & 0xFF]); + } else { + sink.addByte(wide32); + sink.add([ + (length >> 24) & 0xFF, + (length >> 16) & 0xFF, + (length >> 8) & 0xFF, + length & 0xFF, + ]); + } + } + + static void _writeString(BytesBuilder sink, String value) { + final utf8Bytes = utf8.encode(value); + final length = utf8Bytes.length; + if (length < 32) { + sink.addByte(0xA0 | length); + } else if (length < 0x100) { + sink.add([0xD9, length]); + } else if (length < 0x10000) { + sink.add([0xDA, (length >> 8) & 0xFF, length & 0xFF]); + } else { + sink.add([ + 0xDB, + (length >> 24) & 0xFF, + (length >> 16) & 0xFF, + (length >> 8) & 0xFF, + length & 0xFF, + ]); + } + sink.add(utf8Bytes); + } + + static void _writeBinary(BytesBuilder sink, Uint8List value) { + final length = value.length; + if (length < 0x100) { + sink.add([0xC4, length]); + } else if (length < 0x10000) { + sink.add([0xC5, (length >> 8) & 0xFF, length & 0xFF]); + } else { + sink.add([ + 0xC6, + (length >> 24) & 0xFF, + (length >> 16) & 0xFF, + (length >> 8) & 0xFF, + length & 0xFF, + ]); + } + sink.add(value); + } + + static void _writeInt(BytesBuilder sink, int value) { + if (value >= 0) { + if (value < 0x80) { + sink.addByte(value); + } else if (value < 0x100) { + sink.add([0xCC, value]); + } else if (value < 0x10000) { + sink.add([0xCD, (value >> 8) & 0xFF, value & 0xFF]); + } else if (value < 0x100000000) { + sink.add([ + 0xCE, + (value >> 24) & 0xFF, + (value >> 16) & 0xFF, + (value >> 8) & 0xFF, + value & 0xFF, + ]); + } else { + final buffer = ByteData(9) + ..setUint8(0, 0xCF) + ..setUint64(1, value); + sink.add(buffer.buffer.asUint8List()); + } + } else if (value >= -32) { + sink.addByte(0xE0 | (value + 32)); + } else if (value >= -128) { + final buffer = ByteData(2) + ..setUint8(0, 0xD0) + ..setInt8(1, value); + sink.add(buffer.buffer.asUint8List()); + } else if (value >= -32768) { + final buffer = ByteData(3) + ..setUint8(0, 0xD1) + ..setInt16(1, value); + sink.add(buffer.buffer.asUint8List()); + } else if (value >= -2147483648) { + final buffer = ByteData(5) + ..setUint8(0, 0xD2) + ..setInt32(1, value); + sink.add(buffer.buffer.asUint8List()); + } else { + final buffer = ByteData(9) + ..setUint8(0, 0xD3) + ..setInt64(1, value); + sink.add(buffer.buffer.asUint8List()); + } + } +} + +class _Reader { + _Reader(this._bytes) : _view = ByteData.view( + _bytes.buffer, + _bytes.offsetInBytes, + _bytes.length, + ); + + final Uint8List _bytes; + final ByteData _view; + int _cursor = 0; + + Object? read() { + final byte = _u8(); + + if (byte <= 0x7F) return byte; + if (byte >= 0xE0) return byte - 256; + if (byte >= 0x80 && byte <= 0x8F) return _map(byte & 0x0F); + if (byte >= 0x90 && byte <= 0x9F) return _list(byte & 0x0F); + if (byte >= 0xA0 && byte <= 0xBF) return _string(byte & 0x1F); + + switch (byte) { + case 0xC0: + return null; + case 0xC2: + return false; + case 0xC3: + return true; + case 0xC4: + return _binary(_u8()); + case 0xC5: + return _binary(_u16()); + case 0xC6: + return _binary(_u32()); + case 0xCA: + final value = _view.getFloat32(_cursor); + _cursor += 4; + return value; + case 0xCB: + final value = _view.getFloat64(_cursor); + _cursor += 8; + return value; + case 0xCC: + return _u8(); + case 0xCD: + return _u16(); + case 0xCE: + return _u32(); + case 0xCF: + final value = _view.getUint64(_cursor); + _cursor += 8; + return value; + case 0xD0: + final value = _view.getInt8(_cursor); + _cursor += 1; + return value; + case 0xD1: + final value = _view.getInt16(_cursor); + _cursor += 2; + return value; + case 0xD2: + final value = _view.getInt32(_cursor); + _cursor += 4; + return value; + case 0xD3: + final value = _view.getInt64(_cursor); + _cursor += 8; + return value; + case 0xD9: + return _string(_u8()); + case 0xDA: + return _string(_u16()); + case 0xDB: + return _string(_u32()); + case 0xDC: + return _list(_u16()); + case 0xDD: + return _list(_u32()); + case 0xDE: + return _map(_u16()); + case 0xDF: + return _map(_u32()); + } + + if (byte >= 0xD4 && byte <= 0xD8) return _ext(1 << (byte - 0xD4)); + if (byte == 0xC7) return _ext(_u8()); + if (byte == 0xC8) return _ext(_u16()); + if (byte == 0xC9) return _ext(_u32()); + + throw FormatException('MaxMsgpack: неизвестный маркер 0x${byte.toRadixString(16)}'); + } + + static const int _numberExtType = 1; + + Object? _ext(int length) { + final type = _u8(); + final data = Uint8List.fromList( + Uint8List.sublistView(_bytes, _cursor, _cursor + length), + ); + _cursor += length; + if (type != _numberExtType) return null; + return _Reader(data).read(); + } + + int _u8() => _bytes[_cursor++]; + + int _u16() { + final value = _view.getUint16(_cursor); + _cursor += 2; + return value; + } + + int _u32() { + final value = _view.getUint32(_cursor); + _cursor += 4; + return value; + } + + String _string(int length) { + final value = utf8.decode( + Uint8List.sublistView(_bytes, _cursor, _cursor + length), + allowMalformed: true, + ); + _cursor += length; + return value; + } + + Uint8List _binary(int length) { + final value = Uint8List.fromList( + Uint8List.sublistView(_bytes, _cursor, _cursor + length), + ); + _cursor += length; + return value; + } + + List _list(int length) => + List.generate(length, (_) => read(), growable: false); + + Map _map(int length) { + final map = {}; + for (var i = 0; i < length; i++) { + final key = read(); + map[key] = read(); + } + return map; + } +} diff --git a/lib/core/webpush/max_web_socket.dart b/lib/core/webpush/max_web_socket.dart new file mode 100644 index 0000000..1ffe8a3 --- /dev/null +++ b/lib/core/webpush/max_web_socket.dart @@ -0,0 +1,190 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../utils/logger.dart'; +import 'max_web_protocol.dart'; + +class MaxWebException implements Exception { + final String message; + final String? code; + + const MaxWebException(this.message, {this.code}); + + @override + String toString() => code == null ? message : '$code: $message'; +} + +class MaxWebDevice { + final String deviceId; + final String appVersion; + final String osVersion; + final String deviceName; + final String screen; + final String timezone; + final String locale; + final String userAgent; + + const MaxWebDevice({ + required this.deviceId, + this.appVersion = '26.8.8', + this.osVersion = 'iOS', + this.deviceName = 'Safari', + required this.screen, + this.timezone = 'Europe/Moscow', + this.locale = 'ru', + required this.userAgent, + }); + + Map toHandshakeUserAgent() => { + 'deviceType': 'WEB', + 'pushDeviceType': 'WEBPUSH', + 'locale': locale, + 'deviceLocale': locale, + 'osVersion': osVersion, + 'deviceName': deviceName, + 'headerUserAgent': userAgent, + 'isPwa': true, + 'appVersion': appVersion, + 'screen': screen, + 'timezone': timezone, + }; +} + +class MaxWebSocketSession { + static const String endpoint = 'wss://api.oneme.ru/websocket'; + static const String origin = 'https://web.max.ru'; + static const Duration requestTimeout = Duration(seconds: 30); + + static const int _opcodePing = 1; + static const int _opcodeSessionInit = 6; + + final MaxWebDevice device; + + WebSocket? _socket; + StreamSubscription? _subscription; + final Map> _pending = >{}; + int _seq = 0; + bool _closed = false; + + MaxWebSocketSession({required this.device}); + + Future connect() async { + final socket = await WebSocket.connect( + endpoint, + headers: { + 'Origin': origin, + 'User-Agent': device.userAgent, + }, + ); + _socket = socket; + _subscription = socket.listen( + _onFrame, + onError: (Object error) => _failAll(MaxWebException('сокет: $error')), + onDone: () => _failAll(const MaxWebException('соединение закрыто сервером')), + cancelOnError: true, + ); + + return request(_opcodeSessionInit, { + 'userAgent': device.toHandshakeUserAgent(), + 'deviceId': device.deviceId, + }); + } + + Future request(int opcode, Object? payload) { + final socket = _socket; + if (socket == null || _closed) { + return Future.error( + const MaxWebException('сессия не подключена'), + ); + } + + _seq += 1; + final seq = _seq; + final completer = Completer(); + _pending[seq] = completer; + + socket.add( + MaxWebFraming.encode( + cmd: MaxWebCmd.request, + seq: seq, + opcode: opcode, + payload: payload, + ), + ); + + return completer.future.timeout( + requestTimeout, + onTimeout: () { + _pending.remove(seq); + throw MaxWebException('опкод $opcode: сервер не ответил'); + }, + ); + } + + void _onFrame(dynamic raw) { + if (raw is! List) return; + + final MaxWebFrame frame; + try { + frame = MaxWebFraming.decode(Uint8List.fromList(raw)); + } catch (e) { + logger.w('WebPush: не разобрал кадр ($e)'); + return; + } + + if (frame.cmd == MaxWebCmd.request) { + if (frame.opcode == _opcodePing) { + _socket?.add( + MaxWebFraming.encode( + cmd: MaxWebCmd.ok, + seq: frame.seq, + opcode: _opcodePing, + ), + ); + } + return; + } + + final completer = _pending.remove(frame.seq); + if (completer == null || completer.isCompleted) return; + + if (frame.isError) { + completer.completeError(_errorFrom(frame)); + return; + } + completer.complete(frame.payload); + } + + MaxWebException _errorFrom(MaxWebFrame frame) { + final payload = frame.payload; + if (payload is Map) { + final message = payload['localizedMessage'] ?? payload['message']; + final code = payload['error']; + if (message is String && message.isNotEmpty) { + return MaxWebException(message, code: code is String ? code : null); + } + if (code is String && code.isNotEmpty) { + return MaxWebException(code, code: code); + } + } + return MaxWebException('опкод ${frame.opcode}: ошибка сервера (cmd=${frame.cmd})'); + } + + void _failAll(MaxWebException error) { + for (final completer in _pending.values) { + if (!completer.isCompleted) completer.completeError(error); + } + _pending.clear(); + } + + Future close() async { + if (_closed) return; + _closed = true; + _failAll(const MaxWebException('сессия закрыта')); + await _subscription?.cancel(); + _subscription = null; + await _socket?.close(); + _socket = null; + } +} diff --git a/lib/core/webpush/web_push_service.dart b/lib/core/webpush/web_push_service.dart new file mode 100644 index 0000000..c4a28dc --- /dev/null +++ b/lib/core/webpush/web_push_service.dart @@ -0,0 +1,387 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; + +import 'package:device_info_plus/device_info_plus.dart'; + +import '../protocol/opcode_map.dart'; +import '../storage/token_storage.dart'; +import '../utils/ids.dart'; +import '../utils/logger.dart'; +import 'max_web_socket.dart'; + +class WebPushSubscription { + final String endpoint; + final String publicKey; + final String authKey; + + const WebPushSubscription({ + required this.endpoint, + required this.publicKey, + required this.authKey, + }); +} + +class WebPushLinkInfo { + final String endpoint; + final DateTime? linkedAt; + final String deviceId; + + const WebPushLinkInfo({ + required this.endpoint, + required this.linkedAt, + required this.deviceId, + }); + + String get shortEndpoint { + final token = endpoint.split('/').last; + if (token.length <= 24) return token; + return '${token.substring(0, 12)}…${token.substring(token.length - 8)}'; + } + + String get host => Uri.tryParse(endpoint)?.host ?? endpoint; +} + +class WebPushQrTrack { + final String trackId; + final String qrLink; + final Duration pollInterval; + final Duration lifetime; + + const WebPushQrTrack({ + required this.trackId, + required this.qrLink, + required this.pollInterval, + required this.lifetime, + }); +} + +class WebPushPasswordChallenge { + final String trackId; + final String? hint; + + const WebPushPasswordChallenge({required this.trackId, this.hint}); +} + +class WebPushAuthStep { + final String? loginToken; + final WebPushPasswordChallenge? passwordChallenge; + + const WebPushAuthStep({this.loginToken, this.passwordChallenge}); + + bool get needsPassword => loginToken == null && passwordChallenge != null; +} + +class WebPushService { + WebPushService._(); + + static final WebPushService instance = WebPushService._(); + + static const int _opcodeQrCreate = 288; + static const int _opcodeQrStatus = 289; + static const int _opcodeQrFinish = 291; + + static const String _tokenKey = 'webpush_login_token'; + static const String _deviceIdKey = 'webpush_device_id'; + static const String _endpointKey = 'webpush_endpoint'; + static const String _linkedAtKey = 'webpush_linked_at'; + + static const String _appVersion = '26.8.8'; + static const Duration _defaultPoll = Duration(seconds: 5); + static const Duration _defaultLifetime = Duration(minutes: 2); + + MaxWebSocketSession? _authSocket; + MaxWebDevice? _device; + + final ValueNotifier changes = ValueNotifier(0); + + void _notifyChanged() => changes.value++; + + Future isAuthorized() async => + (await TokenStorage.readSecure(_tokenKey))?.isNotEmpty ?? false; + + Future linkedEndpoint() => TokenStorage.readSecure(_endpointKey); + + Future linkInfo() async { + final endpoint = await TokenStorage.readSecure(_endpointKey); + if (endpoint == null || endpoint.isEmpty) return null; + + final stamp = await TokenStorage.readSecure(_linkedAtKey); + return WebPushLinkInfo( + endpoint: endpoint, + linkedAt: stamp == null ? null : DateTime.tryParse(stamp), + deviceId: await deviceId(), + ); + } + + Future deviceId() async { + final saved = await TokenStorage.readSecure(_deviceIdKey); + if (saved != null && saved.isNotEmpty) return saved; + + final generated = uuidV4(); + await TokenStorage.writeSecure(_deviceIdKey, generated); + return generated; + } + + Future device() async { + final cached = _device; + if (cached != null) return cached; + + final built = MaxWebDevice( + deviceId: await deviceId(), + appVersion: _appVersion, + userAgent: await _safariUserAgent(), + screen: _browserScreen(), + ); + _device = built; + return built; + } + + Future startQrAuth() async { + await cancelAuth(); + + final socket = MaxWebSocketSession(device: await device()); + await socket.connect(); + _authSocket = socket; + + final payload = _asMap( + await socket.request(_opcodeQrCreate, null), + 'создание QR', + ); + + final trackId = payload['trackId']; + final qrLink = payload['qrLink']; + if (trackId is! String || qrLink is! String) { + throw const MaxWebException('сервер не вернул ссылку для входа'); + } + + return WebPushQrTrack( + trackId: trackId, + qrLink: qrLink, + pollInterval: _durationFrom(payload['pollingInterval'], _defaultPoll), + lifetime: _durationFrom(payload['ttl'], _defaultLifetime), + ); + } + + Future awaitApproval(WebPushQrTrack track) async { + final socket = _requireSocket(); + final deadline = DateTime.now().add(track.lifetime); + + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(track.pollInterval); + + final payload = _asMap( + await socket.request(_opcodeQrStatus, { + 'trackId': track.trackId, + }), + 'опрос входа', + ); + + final status = payload['status']; + final available = status is Map ? status['loginAvailable'] : null; + if (available != true) continue; + + final finish = _asMap( + await socket.request(_opcodeQrFinish, { + 'trackId': track.trackId, + }), + 'завершение входа', + ); + return _stepFrom(finish); + } + + throw const MaxWebException('время подтверждения истекло'); + } + + Future submitPassword(String trackId, String password) async { + final socket = _requireSocket(); + final payload = _asMap( + await socket.request(Opcode.authLoginCheckPassword, { + 'trackId': trackId, + 'password': password, + }), + 'проверка пароля', + ); + return _stepFrom(payload); + } + + Future finishAuth(String loginToken) async { + await TokenStorage.writeSecure(_tokenKey, loginToken); + await cancelAuth(); + _notifyChanged(); + logger.i('WebPush: WEB-сессия авторизована по QR'); + } + + Future cancelAuth() async { + final socket = _authSocket; + _authSocket = null; + await socket?.close(); + } + + Future registerSubscription(WebPushSubscription subscription) async { + final token = await TokenStorage.readSecure(_tokenKey); + if (token == null || token.isEmpty) { + throw const MaxWebException('сначала подключите уведомления в настройках'); + } + + final socket = MaxWebSocketSession(device: await device()); + try { + await socket.connect(); + await socket.request(Opcode.login, { + 'token': token, + 'chatsCount': 0, + 'interactive': false, + 'chatsSync': 0, + 'contactsSync': 0, + 'presenceSync': -1, + 'draftsSync': 0, + }); + await socket.request(Opcode.config, { + 'subscribe': true, + 'pushToken': subscription.endpoint, + 'secretKey': subscription.authKey, + 'publicKey': subscription.publicKey, + }); + await TokenStorage.writeSecure(_endpointKey, subscription.endpoint); + await TokenStorage.writeSecure( + _linkedAtKey, + DateTime.now().toIso8601String(), + ); + _notifyChanged(); + logger.i('WebPush: подписка зарегистрирована'); + } finally { + await socket.close(); + } + } + + Future signOut() async { + await cancelAuth(); + + final token = await TokenStorage.readSecure(_tokenKey); + final endpoint = await TokenStorage.readSecure(_endpointKey); + if (token != null && token.isNotEmpty) { + try { + await _terminateWebSession(token, endpoint); + } catch (e) { + logger.w('WebPush: веб-сессию завершить не удалось ($e)'); + } + } + + await TokenStorage.deleteSecure(_tokenKey); + await TokenStorage.deleteSecure(_endpointKey); + await TokenStorage.deleteSecure(_linkedAtKey); + _notifyChanged(); + } + + Future _terminateWebSession(String token, String? endpoint) async { + final socket = MaxWebSocketSession(device: await device()); + try { + await socket.connect(); + await socket.request(Opcode.login, { + 'token': token, + 'chatsCount': 0, + 'interactive': false, + 'chatsSync': 0, + 'contactsSync': 0, + 'presenceSync': -1, + 'draftsSync': 0, + }); + + if (endpoint != null && endpoint.isNotEmpty) { + try { + await socket.request(Opcode.config, { + 'subscribe': false, + 'pushToken': endpoint, + 'secretKey': '', + 'publicKey': '', + }); + } catch (e) { + logger.w('WebPush: подписку снять не удалось ($e)'); + } + } + + await socket.request(Opcode.logout, {}); + logger.i('WebPush: веб-сессия завершена'); + } finally { + await socket.close(); + } + } + + MaxWebSocketSession _requireSocket() { + final socket = _authSocket; + if (socket == null) { + throw const MaxWebException('сессия входа потеряна, начните заново'); + } + return socket; + } + + WebPushAuthStep _stepFrom(Map payload) { + final attrs = payload['tokenAttrs']; + if (attrs is Map) { + final login = attrs['LOGIN']; + if (login is Map) { + final token = login['token']; + if (token is String && token.isNotEmpty) { + return WebPushAuthStep(loginToken: token); + } + } + } + + final challenge = payload['passwordChallenge']; + if (challenge is Map) { + final trackId = challenge['trackId']; + if (trackId is String && trackId.isNotEmpty) { + final hint = challenge['hint']; + return WebPushAuthStep( + passwordChallenge: WebPushPasswordChallenge( + trackId: trackId, + hint: hint is String && hint.isNotEmpty ? hint : null, + ), + ); + } + } + + throw const MaxWebException('сервер не вернул ни токен, ни запрос пароля'); + } + + Map _asMap(Object? payload, String step) { + if (payload is Map) return payload; + throw MaxWebException('$step: неожиданный ответ сервера'); + } + + Duration _durationFrom(Object? value, Duration fallback) { + if (value is int && value > 0) return Duration(milliseconds: value); + return fallback; + } + + Future _safariUserAgent() async { + var release = '18_5'; + var version = '18.5'; + + if (Platform.isIOS) { + try { + final info = await DeviceInfoPlugin().iosInfo; + final systemVersion = info.systemVersion; + if (systemVersion.isNotEmpty) { + version = systemVersion; + release = systemVersion.replaceAll('.', '_'); + } + } catch (e) { + logger.w('WebPush: не удалось прочитать версию iOS ($e)'); + } + } + + return 'Mozilla/5.0 (iPhone; CPU iPhone OS $release like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/$version ' + 'Mobile/15E148 Safari/604.1'; + } + + String _browserScreen() { + final view = PlatformDispatcher.instance.views.first; + final ratio = view.devicePixelRatio; + final height = (view.physicalSize.height / ratio).round(); + final width = (view.physicalSize.width / ratio).round(); + return '${height}x$width ${ratio.toStringAsFixed(1)}x'; + } +} diff --git a/lib/frontend/commands/command_registry.dart b/lib/frontend/commands/command_registry.dart index ca54a7d..1bea90a 100644 --- a/lib/frontend/commands/command_registry.dart +++ b/lib/frontend/commands/command_registry.dart @@ -1,7 +1,9 @@ import 'anim_command.dart'; import 'crush_command.dart'; import 'epsh_files_command.dart'; +import 'fake_video_message_command.dart'; import 'info_command.dart'; +import 'send_control_command.dart'; import 'slash_command.dart'; import 'watching_command.dart'; @@ -21,6 +23,16 @@ const List kSlashCommands = [ run: runCrush, hidden: true, ), + SlashCommand( + '/FakeVideoMessage', + 'кружок с подменённой длиной: [13:37 | 10000 (сек)]', + run: runFakeVideoMessage, + ), + SlashCommand( + '/sendControlPayload', + 'CONTROL в текущий чат: [event | json]', + run: runSendControl, + ), ]; SlashCommand? findSlashCommand(String text) { diff --git a/lib/frontend/commands/fake_video_message_command.dart b/lib/frontend/commands/fake_video_message_command.dart new file mode 100644 index 0000000..546f65e --- /dev/null +++ b/lib/frontend/commands/fake_video_message_command.dart @@ -0,0 +1,67 @@ +import 'probe_send.dart'; +import 'slash_command.dart'; + +const String _noteAsset = 'assets/debug/fake_video_note.mp4'; +const String _usage = + 'Формат: /FakeVideoMessage 13:37 (мм:сс) или /FakeVideoMessage 10000 (секунды)'; + +int? parseFakeDurationMs(String raw) { + final input = raw.trim(); + if (input.isEmpty) return null; + + final negative = input.startsWith('-'); + final body = negative ? input.substring(1).trim() : input; + if (body.isEmpty) return null; + + int? seconds; + if (body.contains(':')) { + final parts = body.split(':'); + if (parts.length > 3) return null; + var total = 0; + for (var i = 0; i < parts.length; i++) { + final value = int.tryParse(parts[i]); + if (value == null || value < 0) return null; + if (i > 0 && value > 59) return null; + total = total * 60 + value; + } + seconds = total; + } else { + final value = int.tryParse(body); + if (value == null || value < 0) return null; + seconds = value; + } + + final ms = seconds * 1000; + return negative ? -ms : ms; +} + +String formatDurationMs(int ms) { + final sign = ms < 0 ? '-' : ''; + final totalSeconds = (ms.abs() / 1000).round(); + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + final mm = minutes.toString().padLeft(2, '0'); + final ss = seconds.toString().padLeft(2, '0'); + return hours > 0 ? '$sign$hours:$mm:$ss' : '$sign$minutes:$ss'; +} + +Future runFakeVideoMessage(CommandContext ctx) async { + final durationMs = parseFakeDurationMs(ctx.args); + if (durationMs == null) { + ctx.notify(_usage); + return; + } + + ctx.notify( + 'Кружок с подменой: ${formatDurationMs(durationMs)} ' + '(duration=$durationMs мс)', + ); + + try { + final file = await assetToTempFile(_noteAsset, extension: 'mp4'); + await ctx.sendVideoNote(file, durationMs); + } catch (e) { + ctx.notify('Не удалось подготовить кружок: $e'); + } +} diff --git a/lib/frontend/commands/probe_send.dart b/lib/frontend/commands/probe_send.dart new file mode 100644 index 0000000..8dd4693 --- /dev/null +++ b/lib/frontend/commands/probe_send.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../core/media/gallery_source.dart'; +import 'slash_command.dart'; + +Future sendFileAsPhoto(CommandContext ctx, File file) => + ctx.sendPhotos([PickedPhoto(item: GalleryItem.fromFile(file))], ''); + +Future _tempFile(String extension) async { + final dir = await getTemporaryDirectory(); + return File( + p.join( + dir.path, + 'komet_probe_${DateTime.now().microsecondsSinceEpoch}.$extension', + ), + ); +} + +Future assetToTempFile( + String assetPath, { + required String extension, +}) async { + final data = await rootBundle.load(assetPath); + final file = await _tempFile(extension); + await file.writeAsBytes(data.buffer.asUint8List(), flush: true); + return file; +} + +Future buildProbeZipFile({ + required String extension, + List? prefix, +}) async { + final archive = Archive(); + final data = utf8.encode('This is a zip, not a photo. Komet probe.'); + archive.addFile(ArchiveFile('not_a_photo.txt', data.length, data)); + final zip = ZipEncoder().encodeBytes(archive); + final bytes = prefix == null ? zip : [...prefix, ...zip]; + + final file = await _tempFile(extension); + await file.writeAsBytes(bytes, flush: true); + return file; +} diff --git a/lib/frontend/commands/send_control_command.dart b/lib/frontend/commands/send_control_command.dart new file mode 100644 index 0000000..45a36d0 --- /dev/null +++ b/lib/frontend/commands/send_control_command.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import '../../core/protocol/packet.dart'; +import 'slash_command.dart'; + +Future runSendControl(CommandContext ctx) async { + final rest = ctx.args.trim(); + + Map control; + if (rest.startsWith('{')) { + try { + final decoded = jsonDecode(rest); + if (decoded is! Map) { + ctx.notify('JSON должен быть объектом'); + return; + } + control = Map.from(decoded); + } catch (e) { + ctx.notify('Кривой JSON: $e'); + return; + } + } else { + control = {'event': rest.isEmpty ? 'test' : rest}; + } + control['_type'] = 'CONTROL'; + + try { + final Packet packet = await ctx.messages.sendControlMessage( + ctx.chatId, + control, + ); + if (packet.isOk) { + final data = packet.payload; + final msg = data is Map ? data['message'] : null; + final id = msg is Map ? msg['id'] : null; + ctx.notify('Принято ✅${id != null ? ' · msgId=$id' : ''}'); + } else { + final p = packet.payload; + final err = p is Map + ? (p['localizedMessage'] ?? p['message'] ?? p).toString() + : p.toString(); + ctx.notify('Отклонено ❌: $err'); + } + } catch (e) { + ctx.notify('Ошибка: $e'); + } +} diff --git a/lib/frontend/commands/send_degenerate_command.dart b/lib/frontend/commands/send_degenerate_command.dart new file mode 100644 index 0000000..23a4002 --- /dev/null +++ b/lib/frontend/commands/send_degenerate_command.dart @@ -0,0 +1,26 @@ +import 'probe_send.dart'; +import 'slash_command.dart'; + +Future runSend1x1(CommandContext ctx) async { + try { + final file = await assetToTempFile( + 'assets/debug/red_1x1.png', + extension: 'png', + ); + await sendFileAsPhoto(ctx, file); + } catch (e) { + ctx.notify('Не удалось отправить 1×1: $e'); + } +} + +Future runSend1x8192(CommandContext ctx) async { + try { + final file = await assetToTempFile( + 'assets/debug/red_1x8192.png', + extension: 'png', + ); + await sendFileAsPhoto(ctx, file); + } catch (e) { + ctx.notify('Не удалось отправить 1×8192: $e'); + } +} diff --git a/lib/frontend/commands/send_fake_zip_as_jpeg_command.dart b/lib/frontend/commands/send_fake_zip_as_jpeg_command.dart new file mode 100644 index 0000000..df93ff8 --- /dev/null +++ b/lib/frontend/commands/send_fake_zip_as_jpeg_command.dart @@ -0,0 +1,21 @@ +import 'probe_send.dart'; +import 'slash_command.dart'; + +const List _jpegMagic = [ + 0xFF, 0xD8, 0xFF, 0xE0, // SOI + APP0 marker + 0x00, 0x10, // APP0 length (16) + 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" + 0x01, 0x01, // version 1.1 + 0x00, // density units + 0x00, 0x01, 0x00, 0x01, // X/Y density + 0x00, 0x00, // thumbnail 0x0 +]; + +Future runSendFakeZipAsJpeg(CommandContext ctx) async { + try { + final file = await buildProbeZipFile(extension: 'jpeg', prefix: _jpegMagic); + await sendFileAsPhoto(ctx, file); + } catch (e) { + ctx.notify('Не удалось отправить fake jpeg: $e'); + } +} diff --git a/lib/frontend/commands/send_heic_command.dart b/lib/frontend/commands/send_heic_command.dart new file mode 100644 index 0000000..8b1cd5f --- /dev/null +++ b/lib/frontend/commands/send_heic_command.dart @@ -0,0 +1,14 @@ +import 'probe_send.dart'; +import 'slash_command.dart'; + +Future runSendHeic(CommandContext ctx) async { + try { + final file = await assetToTempFile( + 'assets/debug/red.heic', + extension: 'heic', + ); + await sendFileAsPhoto(ctx, file); + } catch (e) { + ctx.notify('Не удалось отправить .heic: $e'); + } +} diff --git a/lib/frontend/commands/send_zip_as_image_command.dart b/lib/frontend/commands/send_zip_as_image_command.dart new file mode 100644 index 0000000..a5940df --- /dev/null +++ b/lib/frontend/commands/send_zip_as_image_command.dart @@ -0,0 +1,10 @@ +import 'probe_send.dart'; +import 'slash_command.dart'; + +Future runSendZipAsImage(CommandContext ctx) async { + try { + await sendFileAsPhoto(ctx, await buildProbeZipFile(extension: 'zip')); + } catch (e) { + ctx.notify('Не удалось отправить zip: $e'); + } +} diff --git a/lib/frontend/commands/slash_command.dart b/lib/frontend/commands/slash_command.dart index d6b7201..36af2f7 100644 --- a/lib/frontend/commands/slash_command.dart +++ b/lib/frontend/commands/slash_command.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import '../../backend/modules/messages.dart'; +import '../../core/media/gallery_source.dart'; const String kAntiFloodNotification = 'Упс! МАХ сбросил соединение, кажется, тебе стоит немного помедлить с командами.'; @@ -15,6 +18,9 @@ class CommandContext { final void Function(String message, {Duration? duration}) notify; final Future Function(String text) postMessage; final Future Function(String id, String text) updateMessage; + final Future Function(List photos, String caption) + sendPhotos; + final Future Function(File file, int durationMs) sendVideoNote; const CommandContext({ required this.accountId, @@ -27,6 +33,8 @@ class CommandContext { required this.notify, required this.postMessage, required this.updateMessage, + required this.sendPhotos, + required this.sendVideoNote, }); void notifyAntiFlood() => diff --git a/lib/frontend/debug/cache_section.dart b/lib/frontend/debug/cache_section.dart index eeacb77..0b13f59 100644 --- a/lib/frontend/debug/cache_section.dart +++ b/lib/frontend/debug/cache_section.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../core/utils/format.dart'; +import '../widgets/small_spinner.dart'; class DebugCacheSection extends StatelessWidget { final int cacheSize; @@ -129,14 +130,7 @@ class DebugCacheSection extends StatelessWidget { ), ), if (clearingCache) - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onSurfaceVariant, - ), - ), + SmallSpinner(size: 20, color: cs.onSurfaceVariant), ], ), ), diff --git a/lib/frontend/debug/feature_toggles_section.dart b/lib/frontend/debug/feature_toggles_section.dart index 85ff610..2ecf27c 100644 --- a/lib/frontend/debug/feature_toggles_section.dart +++ b/lib/frontend/debug/feature_toggles_section.dart @@ -1,20 +1,122 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../backend/modules/contacts.dart'; import '../../core/config/app_commands.dart'; import '../../core/config/app_digital_id_mode.dart'; import '../../core/config/app_link_preview.dart'; +import '../../core/config/app_phonebook_names.dart'; import '../../core/config/app_pranks.dart'; import '../../core/config/app_show_extra_info.dart'; import '../../core/config/app_stories.dart'; import '../../core/config/app_swipe_back_desktop.dart'; +import '../../core/config/app_video_note_quality.dart'; +import '../../core/contacts/device_contacts_service.dart'; import '../screens/digital_id/digital_id_web_screen.dart'; import '../widgets/custom_notification.dart'; +import '../widgets/sheet_helpers.dart'; import 'debug_toggle_tile.dart'; class DebugFeatureTogglesSection extends StatelessWidget { const DebugFeatureTogglesSection({super.key}); + Future _onPhonebookNamesChanged( + BuildContext context, + bool value, + ) async { + await AppPhonebookNames.save(value); + if (value) { + final ok = await DeviceContactsService.reload(); + if (!ok && context.mounted) { + showCustomNotification( + context, + 'Не удалось загрузить контакты телефона', + ); + } + } + ContactsModule.revision.value++; + } + + void _pickVideoNoteQuality(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Качество записи кружков', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Разрешение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + ), + for (final preset in AppVideoNoteResolution.presets) + ValueListenableBuilder( + valueListenable: AppVideoNoteResolution.current, + builder: (context, value, _) => ListTile( + title: Text( + '$preset×$preset', + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + trailing: value == preset + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () => AppVideoNoteResolution.save(preset), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Частота кадров', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + ), + for (final preset in AppVideoNoteFps.presets) + ValueListenableBuilder( + valueListenable: AppVideoNoteFps.current, + builder: (context, value, _) => ListTile( + title: Text( + '$preset fps', + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + trailing: value == preset + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () => AppVideoNoteFps.save(preset), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -122,6 +224,89 @@ class DebugFeatureTogglesSection extends StatelessWidget { onChanged: AppStories.save, ), ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _pickVideoNoteQuality(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.video_camera_front, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Качество записи кружков', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + ValueListenableBuilder( + valueListenable: AppVideoNoteResolution.current, + builder: (context, res, _) => + ValueListenableBuilder( + valueListenable: AppVideoNoteFps.current, + builder: (context, fps, _) => Text( + '$res×$res • $fps fps (только Android)', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.flip_camera_android, + title: 'Кружки с задней камеры', + subtitle: (v) => v + ? 'Запись кружка начинается с задней камеры' + : 'Запись кружка начинается с фронтальной камеры', + valueListenable: AppVideoNoteRearCamera.current, + onChanged: AppVideoNoteRearCamera.save, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.contacts, + title: 'Имена из телефонной книги', + subtitle: (v) => v + ? 'Имена собеседников показываются так, как записаны в ' + 'телефонной книге устройства' + : 'Имена показываются так, как их прислал сервер', + valueListenable: AppPhonebookNames.current, + onChanged: (v) => _onPhonebookNamesChanged(context, v), + ), + ), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: DebugToggleTile( diff --git a/lib/frontend/debug/fps_overlay_layer.dart b/lib/frontend/debug/fps_overlay_layer.dart index c9af7b0..fc94a5f 100644 --- a/lib/frontend/debug/fps_overlay_layer.dart +++ b/lib/frontend/debug/fps_overlay_layer.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -10,15 +11,34 @@ class FpsOverlayLayer extends StatefulWidget { State createState() => _FpsOverlayLayerState(); } +class _FrameSample { + const _FrameSample({ + required this.endMicros, + required this.costMicros, + required this.rasterBound, + }); + + final int endMicros; + final int costMicros; + final bool rasterBound; +} + class _FpsOverlayLayerState extends State { - static const int _maxSamples = 90; - static const int _minUiRefreshMs = 160; - static const double _initialWidthGuess = 96; + static const int _fpsWindowMicros = 1000000; + static const int _jankWindowMicros = 3000000; + static const int _minUiRefreshMs = 100; + static const double _initialWidthGuess = 132; static const double _initialHeightGuess = 36; - final List _frameMicros = []; + final List<_FrameSample> _recent = <_FrameSample>[]; + final List<_FrameSample> _janky = <_FrameSample>[]; final GlobalKey _badgeKey = GlobalKey(); + double _refreshRate = 60; + double _budgetMicros = 1000000 / 60; double _fps = 0; + int _worstMicros = 0; + int _jankCount = 0; + bool _worstRasterBound = false; DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0); double? _left; double? _top; @@ -38,6 +58,8 @@ class _FpsOverlayLayerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + _refreshRate = _resolveRefreshRate(); + _budgetMicros = 1000000 / _refreshRate; if (_left != null && _top != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -47,6 +69,11 @@ class _FpsOverlayLayerState extends State { } } + double _resolveRefreshRate() { + final hz = View.of(context).display.refreshRate; + return hz.isFinite && hz >= 30 ? hz : 60; + } + void _ensureInitialPosition() { if (_left != null) return; final mq = MediaQuery.of(context); @@ -63,12 +90,8 @@ class _FpsOverlayLayerState extends State { final bottomMax = screen.height - mq.padding.bottom; final box = _badgeKey.currentContext?.findRenderObject() as RenderBox?; - final bw = box?.hasSize == true - ? box!.size.width - : _initialWidthGuess; - final bh = box?.hasSize == true - ? box!.size.height - : _initialHeightGuess; + final bw = box?.hasSize == true ? box!.size.width : _initialWidthGuess; + final bh = box?.hasSize == true ? box!.size.height : _initialHeightGuess; _left = _left!.clamp(0.0, math.max(0.0, screen.width - bw)); _top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh)); @@ -76,23 +99,54 @@ class _FpsOverlayLayerState extends State { void _onTimings(List timings) { for (final t in timings) { - final us = t.totalSpan.inMicroseconds; - if (us <= 0) continue; - _frameMicros.add(us); - while (_frameMicros.length > _maxSamples) { - _frameMicros.removeAt(0); + final build = t.buildDuration.inMicroseconds; + final raster = t.rasterDuration.inMicroseconds; + final sample = _FrameSample( + endMicros: t.timestampInMicroseconds(ui.FramePhase.rasterFinish), + costMicros: build > raster ? build : raster, + rasterBound: raster >= build, + ); + _recent.add(sample); + if (sample.costMicros > _budgetMicros) _janky.add(sample); + } + if (_recent.isEmpty) return; + + final newest = _recent.last.endMicros; + _recent.removeWhere((s) => newest - s.endMicros > _fpsWindowMicros); + _janky.removeWhere((s) => newest - s.endMicros > _jankWindowMicros); + + var sum = 0; + for (final s in _recent) { + sum += s.costMicros; + } + var worst = 0; + var worstRasterBound = false; + for (final s in _janky) { + if (s.costMicros > worst) { + worst = s.costMicros; + worstRasterBound = s.rasterBound; } } + + final mean = sum / _recent.length; + final fps = 1000000 / math.max(mean, _budgetMicros); + final now = DateTime.now(); - if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) { - return; - } + if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) return; _lastUiUpdate = now; - if (!mounted || _frameMicros.isEmpty) return; - final sum = _frameMicros.fold(0, (a, b) => a + b); - final avg = sum / _frameMicros.length; - final fps = avg > 0 ? (1000000.0 / avg).clamp(0.0, 999.0) : 0.0; - setState(() => _fps = fps); + if (!mounted) return; + setState(() { + _fps = fps; + _worstMicros = worst; + _jankCount = _janky.length; + _worstRasterBound = worstRasterBound; + }); + } + + Color get _tint { + if (_jankCount == 0) return const Color(0xFFB8F5C6); + if (_worstMicros > _budgetMicros * 3) return const Color(0xFFFFAB91); + return const Color(0xFFFFE082); } @override @@ -100,6 +154,7 @@ class _FpsOverlayLayerState extends State { _ensureInitialPosition(); _clampPositionToScreen(); + final tint = _tint; return Positioned( left: _left, top: _top, @@ -123,18 +178,31 @@ class _FpsOverlayLayerState extends State { color: const Color(0xCC000000), borderRadius: BorderRadius.circular(8), ), - child: Text( - '${_fps.round()} FPS', - style: TextStyle( - color: _fps >= 55 - ? const Color(0xFFB8F5C6) - : _fps >= 30 - ? const Color(0xFFFFE082) - : const Color(0xFFFFAB91), - fontSize: 13, - fontWeight: FontWeight.w600, - fontFeatures: const [FontFeature.tabularFigures()], - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${_fps.round()} FPS · ${_refreshRate.round()} Hz', + style: TextStyle( + color: tint, + fontSize: 13, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + if (_jankCount > 0) + Text( + '${(_worstMicros / 1000).round()} ms ×$_jankCount ' + '${_worstRasterBound ? 'gpu' : 'ui'}', + style: TextStyle( + color: tint, + fontSize: 11, + fontWeight: FontWeight.w500, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], ), ), ), diff --git a/lib/frontend/debug/header_section.dart b/lib/frontend/debug/header_section.dart index e0bc0cd..4204033 100644 --- a/lib/frontend/debug/header_section.dart +++ b/lib/frontend/debug/header_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../core/config/app_fonts.dart'; class DebugHeaderSection extends StatelessWidget { const DebugHeaderSection({super.key}); @@ -28,7 +29,7 @@ class DebugHeaderSection extends StatelessWidget { color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), diff --git a/lib/frontend/debug/id_search_section.dart b/lib/frontend/debug/id_search_section.dart index b8ee1d3..ba9360a 100644 --- a/lib/frontend/debug/id_search_section.dart +++ b/lib/frontend/debug/id_search_section.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../widgets/custom_notification.dart'; import '../widgets/glossy_pill.dart'; +import '../widgets/small_spinner.dart'; class DebugIdSearchSection extends StatelessWidget { final TextEditingController idController; @@ -72,11 +73,7 @@ class DebugIdSearchSection extends StatelessWidget { FilledButton( onPressed: isSearching ? null : onSearch, child: isSearching - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 20) : const Icon(Symbols.search, size: 20), ), ], diff --git a/lib/frontend/debug/log_export.dart b/lib/frontend/debug/log_export.dart new file mode 100644 index 0000000..4b6d1b2 --- /dev/null +++ b/lib/frontend/debug/log_export.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/widgets.dart'; + +import '../../core/transport/traffic_monitor.dart'; +import '../../core/utils/debug_session_log.dart'; +import '../../core/utils/format.dart'; +import '../widgets/custom_notification.dart'; + +Future exportDebugLog(BuildContext context) async { + final exportFiles = await DebugSessionLog.instance.buildExportFiles( + endpoint: TrafficMonitor.instance.activeEndpoint, + ); + if (exportFiles == null) { + if (context.mounted) showCustomNotification(context, 'Лог пуст'); + return; + } + final archive = Archive(); + for (final file in exportFiles) { + final data = utf8.encode(file.content); + archive.addFile(ArchiveFile(file.name, data.length, data)); + } + final bytes = ZipEncoder().encodeBytes(archive); + final fileName = 'komet_debug_${formatFileStamp(DateTime.now())}.zip'; + final isMobile = Platform.isAndroid || Platform.isIOS; + try { + final path = await FilePicker.platform.saveFile( + dialogTitle: 'Сохранить отладочный лог', + fileName: fileName, + type: FileType.any, + bytes: isMobile ? bytes : null, + ); + if (path == null) return; + if (!isMobile) { + await File(path).writeAsBytes(bytes); + } + if (context.mounted) { + showCustomNotification(context, 'Лог сохранён: $path'); + } + } catch (e) { + if (context.mounted) { + showCustomNotification(context, 'Не удалось сохранить лог: $e'); + } + } +} diff --git a/lib/frontend/debug/lottie_polygon_section.dart b/lib/frontend/debug/lottie_polygon_section.dart new file mode 100644 index 0000000..2df50bc --- /dev/null +++ b/lib/frontend/debug/lottie_polygon_section.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../screens/profile/lottie_polygon_screen.dart'; + +class DebugLottiePolygonSection extends StatelessWidget { + const DebugLottiePolygonSection({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LottiePolygonScreen()), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.animation, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Lottie полигон', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Анимированные иконки — тапни, чтобы проиграть', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/debug/quick_actions_section.dart b/lib/frontend/debug/quick_actions_section.dart index 0df3867..f5c3ae2 100644 --- a/lib/frontend/debug/quick_actions_section.dart +++ b/lib/frontend/debug/quick_actions_section.dart @@ -49,7 +49,7 @@ class DebugQuickActionsSection extends StatelessWidget { ), const SizedBox(height: 2), Text( - 'Все логи и запросы за последние 3 захода в приложение', + 'Zip-архив: логи и запросы за последние 24 часа, каждый заход отдельным файлом', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, diff --git a/lib/frontend/debug/sync_probe_section.dart b/lib/frontend/debug/sync_probe_section.dart index 48c696b..d1ad614 100644 --- a/lib/frontend/debug/sync_probe_section.dart +++ b/lib/frontend/debug/sync_probe_section.dart @@ -6,6 +6,8 @@ import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../main.dart'; import '../widgets/glossy_pill.dart'; +import '../widgets/small_spinner.dart'; +import '../../core/config/app_shape.dart'; class DebugSyncProbeSection extends StatefulWidget { const DebugSyncProbeSection({super.key}); @@ -142,16 +144,10 @@ class _DebugSyncProbeSectionState extends State { onPressed: _loading ? null : _send, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(44), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: AppShape.buttonBorder, ), child: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 20) : const Text('Отправить'), ), if (_result != null) ...[ diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 8e4484b..03912ba 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'password_2fa_screen.dart'; import 'registration_screen.dart'; import 'session_stale_recovery.dart'; @@ -11,6 +12,7 @@ import '../../../core/utils/sms_code_listener.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; +import '../../widgets/small_spinner.dart'; class CodeConfirmationScreen extends StatefulWidget { final String phoneNumber; @@ -313,7 +315,7 @@ class _CodeConfirmationScreenState extends State backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), + icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant), onPressed: () => Navigator.pop(context), ), ), @@ -525,16 +527,9 @@ class _CodeConfirmationScreenState extends State borderRadius: BorderRadius.circular(50), ), child: recovering - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimaryContainer, - ), - ) + ? SmallSpinner(size: 24, color: cs.onPrimaryContainer) : Icon( - Icons.arrow_forward, + Symbols.arrow_forward, color: _codeController.text.length == 6 ? cs.onPrimaryContainer : cs.onSurfaceVariant, diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 398b8de..60875a0 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'token_login_screen.dart'; import 'select_country_screen.dart'; +import 'phone_input_formatter.dart'; import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; import '../profile/spoof_screen.dart'; @@ -18,9 +19,12 @@ import '../digital_id/digital_id_web_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import '../../../backend/api.dart'; import '../../../core/protocol/packet.dart'; import '../../../main.dart'; +import '../../../core/config/app_frost.dart'; +import '../../../core/config/app_shape.dart'; class LoginScreen extends StatefulWidget { final int? returnToAccountId; @@ -407,7 +411,7 @@ class _LoginScreenState extends State { context: screenContext, barrierDismissible: true, barrierLabel: '', - barrierColor: Colors.black54, + barrierColor: AppFrost.scrim(), transitionDuration: const Duration(milliseconds: 250), pageBuilder: (context, anim1, anim2) => const SizedBox.shrink(), transitionBuilder: (context, anim1, anim2, child) { @@ -422,9 +426,7 @@ class _LoginScreenState extends State { child: AlertDialog( backgroundColor: cs.surfaceContainerHigh, surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + shape: AppShape.dialogBorder, contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 8), actionsPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12), content: Column( @@ -527,10 +529,7 @@ class _LoginScreenState extends State { void _validateAndSubmit() { if (!_isTOSRead) { - showCustomNotification( - context, - AppLocalizations.of(context)!.loginReadTermsNotification, - ); + _showTOS(context); return; } _showPhoneConfirmationDialog(_phoneController.text); @@ -838,7 +837,7 @@ class _LoginScreenState extends State { ), const Spacer(), Icon( - Icons.keyboard_arrow_down, + Symbols.keyboard_arrow_down, color: cs.onSurfaceVariant, ), ], @@ -872,7 +871,7 @@ class _LoginScreenState extends State { keyboardType: TextInputType.phone, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, - _PhoneInputFormatter(_selectedCountry), + PhoneInputFormatter(_selectedCountry), ], style: TextStyle( color: cs.onSurface, @@ -998,16 +997,12 @@ class _LoginScreenState extends State { borderRadius: BorderRadius.circular(50), ), child: _isPhoneValid && !_isOnline - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimaryContainer, - ), + ? SmallSpinner( + size: 24, + color: cs.onPrimaryContainer, ) : Icon( - Icons.arrow_forward, + Symbols.arrow_forward, color: _isPhoneValid ? cs.onPrimaryContainer : cs.onSurfaceVariant, @@ -1070,61 +1065,3 @@ class _LoginScreenState extends State { ); } } - -class _PhoneInputFormatter extends TextInputFormatter { - final CountryName country; - _PhoneInputFormatter(this.country); - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - var text = newValue.text.replaceAll(RegExp(r'\D'), ''); - - if (newValue.text.length < oldValue.text.length) { - final oldDigits = oldValue.text.replaceAll(RegExp(r'\D'), ''); - if (text.length == oldDigits.length && text.isNotEmpty) { - text = text.substring(0, text.length - 1); - } - } - - if (text.length > country.phoneDigits) { - text = text.substring(0, country.phoneDigits); - } - - final buffer = StringBuffer(); - int digitIdx = 0; - - for (int i = 0; i < country.phoneGroupSizes.length; i++) { - if (digitIdx >= text.length) break; - - buffer.write(country.phoneGroupSeparators[i]); - - final groupSize = country.phoneGroupSizes[i]; - final remainingDigits = text.length - digitIdx; - final digitsToTake = remainingDigits < groupSize - ? remainingDigits - : groupSize; - - buffer.write(text.substring(digitIdx, digitIdx + digitsToTake)); - digitIdx += digitsToTake; - - if (digitIdx == text.length && - i < country.phoneGroupSeparators.length - 1) {} - } - - if (digitIdx == text.length && text.length == country.phoneDigits) { - if (country.phoneGroupSeparators.length > - country.phoneGroupSizes.length) { - buffer.write(country.phoneGroupSeparators.last); - } - } - - final formattedText = buffer.toString(); - return TextEditingValue( - text: formattedText, - selection: TextSelection.collapsed(offset: formattedText.length), - ); - } -} diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index dae30dd..58cb650 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../backend/modules/account/account_models.dart'; import '../../../core/protocol/packet.dart'; +import '../../../l10n/app_localizations.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'; import 'session_stale_recovery.dart'; class Password2FAScreen extends StatefulWidget { @@ -98,10 +103,13 @@ class _Password2FAScreenState extends State _isLoading = false; }); + final l10n = AppLocalizations.of(context)!; if (!passed && (isSessionStateError(e) || sessionStale)) { recoverStaleSession(); + } else if (e is WrongPasswordException) { + showCustomNotification(context, l10n.passwordEntryWrongPassword); } else { - showCustomNotification(context, 'Неверный пароль: $e'); + showCustomNotification(context, l10n.devicesGenericError('$e')); } } } @@ -115,7 +123,7 @@ class _Password2FAScreenState extends State backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), + icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant), onPressed: () => Navigator.pop(context), ), ), @@ -170,10 +178,10 @@ class _Password2FAScreenState extends State borderSide: BorderSide.none, ), suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible - ? Icons.visibility_off - : Icons.visibility, + icon: AnimatedSlashIcon( + icon: Symbols.visibility, + slashedIcon: Symbols.visibility_off, + slashed: _isPasswordVisible, color: cs.onSurfaceVariant, ), onPressed: () { @@ -199,16 +207,9 @@ class _Password2FAScreenState extends State borderRadius: BorderRadius.circular(50), ), child: _isLoading - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimaryContainer, - ), - ) + ? SmallSpinner(size: 24, color: cs.onPrimaryContainer) : Icon( - Icons.arrow_forward, + Symbols.arrow_forward, color: _passwordController.text.isNotEmpty ? cs.onPrimaryContainer : cs.onSurfaceVariant, diff --git a/lib/frontend/screens/auth/phone_input_formatter.dart b/lib/frontend/screens/auth/phone_input_formatter.dart new file mode 100644 index 0000000..49b5807 --- /dev/null +++ b/lib/frontend/screens/auth/phone_input_formatter.dart @@ -0,0 +1,58 @@ +import 'package:flutter/services.dart'; + +import '../../../core/config/countries.dart'; + +class PhoneInputFormatter extends TextInputFormatter { + final CountryName country; + PhoneInputFormatter(this.country); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + var text = newValue.text.replaceAll(RegExp(r'\D'), ''); + + if (newValue.text.length < oldValue.text.length) { + final oldDigits = oldValue.text.replaceAll(RegExp(r'\D'), ''); + if (text.length == oldDigits.length && text.isNotEmpty) { + text = text.substring(0, text.length - 1); + } + } + + if (text.length > country.phoneDigits) { + text = text.substring(0, country.phoneDigits); + } + + final buffer = StringBuffer(); + int digitIdx = 0; + + for (int i = 0; i < country.phoneGroupSizes.length; i++) { + if (digitIdx >= text.length) break; + + buffer.write(country.phoneGroupSeparators[i]); + + final groupSize = country.phoneGroupSizes[i]; + final remainingDigits = text.length - digitIdx; + final digitsToTake = remainingDigits < groupSize + ? remainingDigits + : groupSize; + + buffer.write(text.substring(digitIdx, digitIdx + digitsToTake)); + digitIdx += digitsToTake; + } + + if (digitIdx == text.length && text.length == country.phoneDigits) { + if (country.phoneGroupSeparators.length > + country.phoneGroupSizes.length) { + buffer.write(country.phoneGroupSeparators.last); + } + } + + final formattedText = buffer.toString(); + return TextEditingValue( + text: formattedText, + selection: TextSelection.collapsed(offset: formattedText.length), + ); + } +} diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart index f55ea1b..532fbc0 100644 --- a/lib/frontend/screens/auth/proxy_settings_sheet.dart +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -22,6 +22,7 @@ class _ProxySettingsSheetState extends State { final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); ProxyType _selectedType = ProxyType.none; + ProxySettings _applied = const ProxySettings(); bool _busy = false; @override @@ -34,6 +35,7 @@ class _ProxySettingsSheetState extends State { final settings = await ProxyConfig.load(); if (!mounted) return; setState(() { + _applied = settings; _selectedType = settings.type; _hostController.text = settings.host; _portController.text = '${settings.port}'; @@ -58,18 +60,18 @@ class _ProxySettingsSheetState extends State { try { final username = _usernameController.text.trim(); final password = _passwordController.text.trim(); - await ProxyConfig.save( - ProxySettings( - type: _selectedType, - host: host, - port: port, - username: username.isNotEmpty ? username : null, - password: password.isNotEmpty ? password : null, - ), + final settings = ProxySettings( + type: _selectedType, + host: host, + port: port, + username: username.isNotEmpty ? username : null, + password: password.isNotEmpty ? password : null, ); + await ProxyConfig.save(settings); await api.disconnect(); await api.connect(); if (!mounted) return; + setState(() => _applied = settings); if (api.state == SessionState.online) { showCustomNotification(context, l10n.proxySettingsSaved); } else { @@ -84,7 +86,10 @@ class _ProxySettingsSheetState extends State { setState(() => _busy = true); try { await ProxyConfig.clear(); - setState(() => _selectedType = ProxyType.none); + setState(() { + _selectedType = ProxyType.none; + _applied = const ProxySettings(); + }); await api.disconnect(); await api.connect(); if (!mounted) return; @@ -134,6 +139,11 @@ class _ProxySettingsSheetState extends State { fontWeight: FontWeight.w600, ), ), + const SizedBox(height: 4), + Text( + l10n.proxyCurrentState(_appliedLabel(l10n)), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 20), // Proxy type selector @@ -191,7 +201,9 @@ class _ProxySettingsSheetState extends State { const SizedBox(height: 16), FilledButton( - onPressed: _busy ? null : () => _apply(l10n), + onPressed: (_busy || !(isActive || _applied.isEnabled)) + ? null + : () => _apply(l10n), child: Text(isActive ? l10n.proxyApply : l10n.proxyDisable), ), ], @@ -201,6 +213,14 @@ class _ProxySettingsSheetState extends State { ); } + String _appliedLabel(AppLocalizations l10n) { + if (!_applied.isEnabled) return l10n.proxyTypeNone; + final type = _applied.type == ProxyType.socks5 + ? l10n.proxyTypeSocks5 + : l10n.proxyTypeHttp; + return '$type · ${_applied.host}:${_applied.port}'; + } + Widget _buildTypeSelector(ColorScheme cs, AppLocalizations l10n) { final labels = { ProxyType.none: l10n.proxyTypeNone, diff --git a/lib/frontend/screens/auth/registration_screen.dart b/lib/frontend/screens/auth/registration_screen.dart index 2a6b1da..a338b1e 100644 --- a/lib/frontend/screens/auth/registration_screen.dart +++ b/lib/frontend/screens/auth/registration_screen.dart @@ -1,11 +1,13 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:komet/l10n/app_localizations.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/account.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; +import '../../widgets/small_spinner.dart'; class RegistrationScreen extends StatefulWidget { final String phoneNumber; @@ -98,7 +100,7 @@ class _RegistrationScreenState extends State { backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), + icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant), onPressed: _isSubmitting ? null : () => Navigator.pop(context), ), ), @@ -110,16 +112,9 @@ class _RegistrationScreenState extends State { elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), child: _isSubmitting - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: cs.onSurfaceVariant, - ), - ) + ? SmallSpinner(size: 22, color: cs.onSurfaceVariant) : Icon( - Icons.arrow_forward, + Symbols.arrow_forward, color: _canSubmit ? cs.onPrimaryContainer : cs.onSurfaceVariant, ), ), diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart index 31a5793..a32ba21 100644 --- a/lib/frontend/screens/auth/select_country_screen.dart +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -28,18 +28,24 @@ class _CountrySearchEntry { class _SelectCountryScreenState extends State { bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); - late List _filteredCountries; - late final List<_CountrySearchEntry> _searchEntries; + List _sortedCountries = const []; + List _filteredCountries = const []; + List<_CountrySearchEntry> _searchEntries = const []; + String _lang = ''; @override - void initState() { - super.initState(); - _filteredCountries = widget.countries; - _searchEntries = widget.countries + void didChangeDependencies() { + super.didChangeDependencies(); + final lang = Localizations.localeOf(context).languageCode; + if (lang == _lang) return; + _lang = lang; + _sortedCountries = sortedByDisplayName(widget.countries, lang); + _searchEntries = _sortedCountries .map( (c) => _CountrySearchEntry(c, c.ru.toLowerCase(), c.en.toLowerCase()), ) .toList(); + _filteredCountries = _applyFilter(_searchController.text); } @override @@ -48,23 +54,38 @@ class _SelectCountryScreenState extends State { super.dispose(); } + List _applyFilter(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return _sortedCountries; + final matches = _searchEntries + .where( + (e) => + e.ruLower.contains(q) || + e.enLower.contains(q) || + e.country.phoneCode.contains(q), + ) + .map((e) => e.country) + .toList(); + if (_looksLikePhoneCode(q)) { + matches.sort((a, b) { + final byPrimary = + (isPrimaryForPhoneCode(b) ? 1 : 0) - + (isPrimaryForPhoneCode(a) ? 1 : 0); + if (byPrimary != 0) return byPrimary; + return a + .displayName(_lang) + .toLowerCase() + .compareTo(b.displayName(_lang).toLowerCase()); + }); + } + return matches; + } + + static bool _looksLikePhoneCode(String query) => + RegExp(r'^\+?\d+$').hasMatch(query); + void _filterCountries(String query) { - setState(() { - if (query.isEmpty) { - _filteredCountries = widget.countries; - } else { - final q = query.toLowerCase(); - _filteredCountries = _searchEntries - .where( - (e) => - e.ruLower.contains(q) || - e.enLower.contains(q) || - e.country.phoneCode.contains(q), - ) - .map((e) => e.country) - .toList(); - } - }); + setState(() => _filteredCountries = _applyFilter(query)); } @override @@ -118,7 +139,7 @@ class _SelectCountryScreenState extends State { _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); - _filteredCountries = widget.countries; + _filteredCountries = _sortedCountries; } }); }, diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart index 7eb1707..67128a6 100644 --- a/lib/frontend/screens/auth/server_settings_sheet.dart +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -27,6 +27,7 @@ class _ServerSettingsSheetState extends State { text: '${ServerConfig.defaultPort}', ); bool _busy = false; + bool _trustMincifryCa = ServerConfig.defaultTrustMincifryCa; @override void initState() { @@ -40,6 +41,7 @@ class _ServerSettingsSheetState extends State { setState(() { _hostController.text = endpoint.host; _portController.text = '${endpoint.port}'; + _trustMincifryCa = endpoint.trustMincifryCa; }); } @@ -55,6 +57,7 @@ class _ServerSettingsSheetState extends State { final prefs = await SharedPreferences.getInstance(); await prefs.setString(ServerConfig.prefHostKey, host); await prefs.setInt(ServerConfig.prefPortKey, port); + await prefs.setBool(ServerConfig.prefTrustMincifryKey, _trustMincifryCa); await api.disconnect(); unawaited(api.connect()); final online = await api.stateStream @@ -82,8 +85,10 @@ class _ServerSettingsSheetState extends State { final prefs = await SharedPreferences.getInstance(); await prefs.remove(ServerConfig.prefHostKey); await prefs.remove(ServerConfig.prefPortKey); + await prefs.remove(ServerConfig.prefTrustMincifryKey); _hostController.text = ServerConfig.defaultHost; _portController.text = '${ServerConfig.defaultPort}'; + _trustMincifryCa = ServerConfig.defaultTrustMincifryCa; await api.disconnect(); api.connect(); final online = await api.stateStream @@ -154,6 +159,49 @@ class _ServerSettingsSheetState extends State { inputFormatters: [FilteringTextInputFormatter.digitsOnly], enabled: !_busy, ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.fromLTRB(14, 12, 10, 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.serverTrustMincifryTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + l10n.serverTrustMincifrySubtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12.5, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Switch( + value: _trustMincifryCa, + onChanged: _busy + ? null + : (v) => setState(() => _trustMincifryCa = v), + ), + ], + ), + ), const SizedBox(height: 24), FilledButton( onPressed: _busy ? null : () => _apply(l10n), diff --git a/lib/frontend/screens/auth/token_login_screen.dart b/lib/frontend/screens/auth/token_login_screen.dart index 382df99..916f7e6 100644 --- a/lib/frontend/screens/auth/token_login_screen.dart +++ b/lib/frontend/screens/auth/token_login_screen.dart @@ -8,6 +8,8 @@ import '../../../models/spoof_profile.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/small_spinner.dart'; +import '../../../core/config/app_shape.dart'; class TokenLoginScreen extends StatefulWidget { final int? returnToAccountId; @@ -140,14 +142,10 @@ class _TokenLoginScreenState extends State { onPressed: _isLoading ? null : _login, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), - shape: const StadiumBorder(), + shape: AppShape.buttonBorder, ), child: _isLoading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 22) : Text(l10n.tokenLoginButton), ), ), @@ -349,7 +347,7 @@ class _TokenLoginScreenState extends State { return ChoiceChip( label: Text(opt.label), avatar: isSelected - ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer) + ? Icon(Symbols.check, size: 18, color: cs.onSecondaryContainer) : Icon(opt.icon, size: 18, color: cs.onSurfaceVariant), selected: isSelected, showCheckmark: false, diff --git a/lib/frontend/screens/calls/call_link_sheet.dart b/lib/frontend/screens/calls/call_link_sheet.dart new file mode 100644 index 0000000..e64368d --- /dev/null +++ b/lib/frontend/screens/calls/call_link_sheet.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/backend/modules/calls.dart'; +import 'package:komet/frontend/screens/chats/chat_list_screen.dart'; +import 'package:komet/frontend/screens/contacts/contact_sheet_common.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/small_spinner.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/main.dart' show messagesModule; +import '../../../core/config/app_shape.dart'; + +Future showCreatedCallSheet( + BuildContext context, { + required CreatedCall call, +}) async { + final started = await showBlurredCard( + context, + (host) => _CreatedCallCard(call: call, hostContext: host), + ); + return started ?? false; +} + +class _CreatedCallCard extends StatefulWidget { + final CreatedCall call; + final BuildContext hostContext; + + const _CreatedCallCard({required this.call, required this.hostContext}); + + @override + State<_CreatedCallCard> createState() => _CreatedCallCardState(); +} + +class _CreatedCallCardState extends State<_CreatedCallCard> { + bool _sending = false; + + Future _copy() async { + final message = AppLocalizations.of(context)!.sharedLinkCopied; + await Clipboard.setData(ClipboardData(text: widget.call.url)); + if (!mounted) return; + showCustomNotification(context, message); + } + + Future _sendInMax() async { + if (_sending) return; + final target = await openForwardScreen(context: context); + if (target == null || !mounted) return; + + setState(() => _sending = true); + final ok = await messagesModule.sendLinkMessage( + target.chatId, + widget.call.url, + ); + if (!mounted) return; + setState(() => _sending = false); + + final l10n = AppLocalizations.of(context)!; + showCustomNotification( + context, + ok ? l10n.callLinkSent : l10n.callLinkSendFailed, + ); + } + + Widget _action( + ColorScheme cs, { + required IconData icon, + required String label, + required VoidCallback onTap, + bool busy = false, + }) { + return InkWell( + onTap: busy ? null : onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + SizedBox( + width: 24, + height: 24, + child: busy + ? SmallSpinner(size: 24, color: cs.primary) + : Icon(icon, color: cs.primary, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final width = MediaQuery.sizeOf(context).width; + final title = widget.call.callName ?? l10n.callLinkGroupCall; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: Container( + width: width > 420 ? 380 : double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + cs.primary, + Color.alphaBlend( + Colors.white.withValues(alpha: 0.25), + cs.primary, + ), + ], + ), + ), + child: Icon( + Symbols.call, + fill: 1, + color: cs.onPrimary, + size: 40, + ), + ), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + widget.call.url, + textAlign: TextAlign.center, + style: TextStyle(color: cs.primary, fontSize: 14), + ), + const SizedBox(height: 20), + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _action( + cs, + icon: Symbols.content_copy, + label: l10n.sharedCopyLink, + onTap: _copy, + ), + _action( + cs, + icon: Symbols.reply, + label: l10n.callLinkSendInMax, + onTap: _sendInMax, + busy: _sending, + ), + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + shape: AppShape.buttonBorder, + ), + child: Text( + l10n.callLinkStart, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/calls/call_mic_sheet.dart b/lib/frontend/screens/calls/call_mic_sheet.dart new file mode 100644 index 0000000..84020b8 --- /dev/null +++ b/lib/frontend/screens/calls/call_mic_sheet.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/calls/audio_devices.dart'; +import '../../../core/calls/call_session.dart'; +import '../../../core/calls/pulse_audio.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/call_no_mute.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; + +Future showCallMicrophoneSheet( + BuildContext context, { + required CallSession session, + required ColorScheme scheme, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: scheme.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => Theme( + data: Theme.of(context).copyWith(colorScheme: scheme), + child: _MicrophoneSheet(session: session), + ), + ); +} + +class _MicOption { + const _MicOption({ + required this.id, + required this.label, + this.detail, + this.isMonitor = false, + this.isDevice = false, + }); + + final String id; + final String label; + final String? detail; + final bool isMonitor; + final bool isDevice; +} + +class _MicrophoneSheet extends StatefulWidget { + final CallSession session; + + const _MicrophoneSheet({required this.session}); + + @override + State<_MicrophoneSheet> createState() => _MicrophoneSheetState(); +} + +class _MicrophoneSheetState extends State<_MicrophoneSheet> { + List<_MicOption>? _options; + bool _pulseMode = false; + String? _selected; + bool _switching = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final pulse = PulseAudio.supported && await PulseAudio.isAvailable() + ? await PulseAudio.sources() + : const []; + final devices = await AudioDevices.microphones(); + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final routed = pulse.map((source) => source.name).toSet(); + setState(() { + _pulseMode = pulse.isNotEmpty; + _selected = widget.session.pulseSource ?? widget.session.micDeviceId; + _options = [ + for (final source in pulse) + _MicOption( + id: source.name, + label: source.label, + detail: source.name, + isMonitor: source.isMonitor, + ), + for (var i = 0; i < devices.length; i++) + if (!routed.contains(devices[i].id)) + _MicOption( + id: devices[i].id, + label: devices[i].label.isNotEmpty + ? devices[i].label + : l10n.callMicrophoneFallback(i + 1), + isDevice: true, + ), + ]; + }); + } + + Future _select(String? id, {required bool viaDevice}) async { + if (_switching || id == _selected) return; + final l10n = AppLocalizations.of(context)!; + setState(() => _switching = true); + try { + if (_pulseMode && !viaDevice) { + await widget.session.setPulseSource(id); + } else { + await widget.session.setMicrophone(id); + } + if (mounted) setState(() => _selected = id); + } catch (e) { + if (mounted) { + showCustomNotification(context, l10n.callMicrophoneFailed(e)); + } + } finally { + if (mounted) setState(() => _switching = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final options = _options; + final inputs = options?.where((o) => !o.isMonitor).toList() ?? const []; + final monitors = options?.where((o) => o.isMonitor).toList() ?? const []; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(cs, l10n), + if (CallNoMute.enabled) _noMuteHint(cs, l10n), + if (options == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else + Flexible( + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: [ + _tile( + cs, + id: null, + label: l10n.callMicrophoneSystem, + icon: Symbols.settings_voice, + viaDevice: true, + ), + if (options.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Text( + l10n.callMicrophoneEmpty, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + ), + for (final option in inputs) + _tile( + cs, + id: option.id, + label: option.label, + detail: option.detail, + icon: Symbols.mic, + viaDevice: option.isDevice, + ), + if (monitors.isNotEmpty) ...[ + _group(cs, l10n.callMicrophoneMonitors), + for (final option in monitors) + _tile( + cs, + id: option.id, + label: option.label, + detail: option.detail, + icon: Symbols.graphic_eq, + viaDevice: option.isDevice, + ), + ], + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _header(ColorScheme cs, AppLocalizations l10n) => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 8, 12), + child: Row( + children: [ + Expanded( + child: Text( + l10n.callMicrophoneTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + ), + IconButton( + onPressed: () { + setState(() => _options = null); + _load(); + }, + tooltip: l10n.callMicrophoneRefresh, + icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant), + ), + ], + ), + ); + + Widget _noMuteHint(ColorScheme cs, AppLocalizations l10n) => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Row( + children: [ + Icon(Symbols.graphic_eq, size: 18, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.callNoMuteHint, + style: TextStyle(color: cs.primary, fontSize: 13), + ), + ), + ], + ), + ); + + Widget _group(ColorScheme cs, String title) => Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 6), + child: Text( + title, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); + + Widget _tile( + ColorScheme cs, { + required String? id, + required String label, + required IconData icon, + String? detail, + bool viaDevice = false, + }) { + final selected = _selected == id; + return ListTile( + leading: Icon(icon, color: selected ? cs.primary : cs.onSurface), + title: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? cs.primary : cs.onSurface, + fontSize: 16, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + subtitle: detail == null + ? null + : Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + trailing: selected ? Icon(Symbols.check, color: cs.primary) : null, + enabled: !_switching, + onTap: () => _select(id, viaDevice: viaDevice), + ); + } +} diff --git a/lib/frontend/screens/calls/call_participants_sheet.dart b/lib/frontend/screens/calls/call_participants_sheet.dart new file mode 100644 index 0000000..f22131c --- /dev/null +++ b/lib/frontend/screens/calls/call_participants_sheet.dart @@ -0,0 +1,514 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +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'; +import '../../widgets/sheet_helpers.dart'; +import '../../../core/config/app_fonts.dart'; + +class CallParticipantView { + final String name; + final String? avatarUrl; + + const CallParticipantView({required this.name, this.avatarUrl}); +} + +typedef CallParticipantResolver = + CallParticipantView Function(CallParticipant participant); + +Future showCallParticipantsSheet( + BuildContext context, { + required CallSession session, + required ColorScheme scheme, + required CallParticipantResolver resolve, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: scheme.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => Theme( + data: Theme.of(context).copyWith(colorScheme: scheme), + child: _ParticipantsSheet(session: session, resolve: resolve), + ), + ); +} + +class _ParticipantsSheet extends StatefulWidget { + final CallSession session; + final CallParticipantResolver resolve; + + const _ParticipantsSheet({required this.session, required this.resolve}); + + @override + State<_ParticipantsSheet> createState() => _ParticipantsSheetState(); +} + +class _ParticipantsSheetState extends State<_ParticipantsSheet> { + final Map _options = {}; + final Map> _features = {}; + bool _recording = false; + StreamSubscription? _infoSub; + + @override + void initState() { + super.initState(); + _infoSub = widget.session.infoUpdates.listen((_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _infoSub?.cancel(); + super.dispose(); + } + + CallParticipant? get _self { + for (final p in widget.session.participants) { + if (p.isSelf) return p; + } + return null; + } + + Future _run(Future Function(CallAdmin admin) action) async { + final admin = widget.session.admin; + if (admin == null) { + showCustomNotification(context, 'Нет связи с сервером звонка'); + return false; + } + try { + await action(admin); + return true; + } catch (e) { + if (mounted) showCustomNotification(context, 'Не удалось: $e'); + return false; + } + } + + CallParticipantRef _ref(CallParticipant p) => CallParticipantRef(p.id); + + void _participantActions(CallParticipant p) { + final cs = Theme.of(context).colorScheme; + final view = widget.resolve(p); + final isAdmin = p.isAdmin; + final isSpeaker = p.isSpeaker; + + showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 4), + child: Text( + view.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: displayFontOf(context), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text( + p.roles.isEmpty ? 'Участник' : p.roles.join(' · '), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + _action(cs, Symbols.mic_off, 'Выключить микрофон', () { + Navigator.pop(sheetContext); + _run((a) => a.muteMicrophone(_ref(p))); + }), + _action(cs, Symbols.videocam_off, 'Запросить камеру', () { + Navigator.pop(sheetContext); + _run( + (a) => + a.requestMedia({CallMedia.video}, participant: _ref(p)), + ); + }), + _action( + cs, + isAdmin ? Symbols.remove_moderator : Symbols.shield_person, + isAdmin ? 'Снять администратора' : 'Назначить администратором', + () { + Navigator.pop(sheetContext); + _run( + (a) => a.setRoles(_ref(p), [ + CallRoleName.admin, + ], revoke: isAdmin), + ); + }, + ), + _action( + cs, + isSpeaker ? Symbols.voice_over_off : Symbols.record_voice_over, + isSpeaker ? 'Убрать из спикеров' : 'Сделать спикером', + () { + Navigator.pop(sheetContext); + _run( + (a) => a.setRoles(_ref(p), [ + CallRoleName.speaker, + ], revoke: isSpeaker), + ); + }, + ), + _action(cs, Symbols.arrow_upward, 'Повысить (promote)', () { + Navigator.pop(sheetContext); + _run((a) => a.setPromoted(_ref(p), true)); + }), + _action(cs, Symbols.arrow_downward, 'Понизить (demote)', () { + Navigator.pop(sheetContext); + _run((a) => a.setPromoted(_ref(p), false)); + }), + _action(cs, Symbols.push_pin, 'Закрепить', () { + Navigator.pop(sheetContext); + _run((a) => a.setPinned(_ref(p), true)); + }), + _action(cs, Symbols.keep_off, 'Открепить', () { + Navigator.pop(sheetContext); + _run((a) => a.setPinned(_ref(p), false)); + }), + _action(cs, Symbols.person_remove, 'Удалить из звонка', () { + Navigator.pop(sheetContext); + _run((a) => a.removeParticipant(_ref(p))); + }, destructive: true), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + void _showOptions() { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => Theme( + data: Theme.of(context).copyWith(colorScheme: cs), + child: StatefulBuilder( + builder: (_, setSheet) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _sheetTitle(cs, 'Настройки звонка'), + for (final option in CallOption.values) + SwitchListTile( + value: _options[option] ?? false, + title: Text( + _optionLabel(option), + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + subtitle: Text( + option.wire, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + onChanged: (value) async { + setSheet(() => _options[option] = value); + final ok = await _run( + (a) => a.setOptions({option: value}), + ); + if (!ok) setSheet(() => _options[option] = !value); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ), + ); + } + + void _showFeatures() { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => Theme( + data: Theme.of(context).copyWith(colorScheme: cs), + child: StatefulBuilder( + builder: (_, setSheet) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _sheetTitle(cs, 'Кому доступны функции'), + for (final feature in CallFeature.values) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _featureLabel(feature), + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + for (final role in CallRoleName.values) + FilterChip( + label: Text(role.wire), + selected: + _features[feature]?.contains(role) ?? + false, + onSelected: (selected) { + final set = _features.putIfAbsent( + feature, + () => {}, + ); + setSheet(() { + selected + ? set.add(role) + : set.remove(role); + }); + _run( + (a) => a.enableFeatureForRoles( + feature, + set.toList(), + ), + ); + }, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ), + ); + } + + Future _addByLink() async { + final link = await showTextInputDialog( + context, + title: 'Добавить участника', + description: 'Ссылка-приглашение участника', + confirmLabel: 'Добавить', + ); + if (link == null || link.trim().isEmpty || !mounted) return; + await _run((a) => a.addParticipantByLink(link.trim())); + } + + String _optionLabel(CallOption option) => switch (option) { + CallOption.requireAuthToJoin => 'Только авторизованные', + CallOption.waitingHall => 'Зал ожидания', + CallOption.recurring => 'Повторяющийся звонок', + CallOption.feedback => 'Сбор отзывов', + CallOption.audienceMode => 'Режим зрителей', + CallOption.asr => 'Расшифровка речи', + CallOption.waitForAdmin => 'Ждать администратора', + CallOption.adminIsHere => 'Администратор на месте', + }; + + String _featureLabel(CallFeature feature) => switch (feature) { + CallFeature.addParticipant => 'Добавлять участников', + CallFeature.admin => 'Права администратора', + CallFeature.asr => 'Расшифровка речи', + CallFeature.movieShare => 'Совместный просмотр', + CallFeature.record => 'Запись звонка', + CallFeature.speaker => 'Быть спикером', + }; + + Widget _sheetTitle(ColorScheme cs, String text) => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Text( + text, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + ); + + Widget _action( + ColorScheme cs, + IconData icon, + String label, + VoidCallback onTap, { + bool destructive = false, + }) { + final color = destructive ? cs.error : cs.onSurface; + return ListTile( + leading: Icon(icon, color: color), + title: Text(label, style: TextStyle(color: color, fontSize: 16)), + onTap: onTap, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final participants = widget.session.participants; + final self = _self; + final handRaised = self?.handRaised ?? false; + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _sheetTitle(cs, 'Участники · ${participants.length}'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _chip(cs, Symbols.mic_off, 'Заглушить всех', () { + _run((a) => a.muteEveryone()); + }), + _chip(cs, Symbols.do_not_touch, 'Опустить руки', () { + _run((a) => a.lowerAllHands()); + }), + _chip( + cs, + handRaised ? Symbols.back_hand : Symbols.front_hand, + handRaised ? 'Опустить руку' : 'Поднять руку', + () => _run((a) => a.setHandRaised(!handRaised)), + active: handRaised, + ), + _chip( + cs, + _recording + ? Symbols.stop_circle + : Symbols.radio_button_checked, + _recording ? 'Остановить запись' : 'Начать запись', + () async { + final next = !_recording; + setState(() => _recording = next); + final ok = await _run( + (a) => next + ? a.startRecord(name: 'Запись звонка') + : a.stopRecord(), + ); + if (!ok && mounted) setState(() => _recording = !next); + }, + active: _recording, + ), + _chip(cs, Symbols.tune, 'Настройки', _showOptions), + _chip(cs, Symbols.shield_person, 'Права ролей', _showFeatures), + _chip(cs, Symbols.person_add, 'Добавить по ссылке', _addByLink), + ], + ), + ), + const SizedBox(height: 12), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: participants.length, + itemBuilder: (_, i) => _tile(cs, participants[i]), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + Widget _chip( + ColorScheme cs, + IconData icon, + String label, + VoidCallback onTap, { + bool active = false, + }) { + return ActionChip( + avatar: Icon( + icon, + size: 18, + color: active ? cs.onPrimary : cs.onSurfaceVariant, + ), + label: Text(label), + labelStyle: TextStyle(color: active ? cs.onPrimary : cs.onSurface), + backgroundColor: active ? cs.primary : cs.surfaceContainerHighest, + side: BorderSide.none, + onPressed: onTap, + ); + } + + Widget _tile(ColorScheme cs, CallParticipant p) { + final view = widget.resolve(p); + final subtitle = [ + if (p.isCreator) 'Создатель' else if (p.isAdmin) 'Администратор', + if (p.isSpeaker) 'Спикер', + if (p.handRaised) 'Поднял руку', + ]; + + return ListTile( + leading: KometAvatar(name: view.name, imageUrl: view.avatarUrl, size: 40), + title: Text( + view.name, + style: TextStyle(color: cs.onSurface, fontSize: 16), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: subtitle.isEmpty + ? null + : Text( + subtitle.join(' · '), + style: TextStyle(color: cs.primary, fontSize: 13), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (p.screenSharing) + Icon(Symbols.screen_share, size: 18, color: cs.primary), + if (p.videoEnabled) + Icon(Symbols.videocam, size: 18, color: cs.onSurfaceVariant), + AnimatedSlashIcon( + icon: Symbols.mic, + slashedIcon: Symbols.mic_off, + slashed: !p.audioEnabled, + size: 18, + color: p.audioEnabled ? cs.onSurfaceVariant : cs.error, + ), + ], + ), + onTap: p.isSelf ? null : () => _participantActions(p), + ); + } +} diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 42ac11f..6ee6274 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -5,29 +5,30 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' - show - Helper, - MediaStream, - RTCVideoRenderer, - RTCVideoValue, - RTCVideoView, - RTCVideoViewObjectFit; + show MediaStream, RTCVideoRenderer, RTCVideoValue, RTCVideoViewObjectFit; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/cache/info_cache.dart'; +import '../../../core/calls/active_call.dart'; import '../../../core/calls/call_controller.dart'; import '../../../core/calls/call_info.dart'; import '../../../core/calls/call_session.dart'; +import '../../../core/config/app_colors.dart'; +import '../../../core/config/call_no_mute.dart'; import '../../../core/utils/format.dart'; +import '../../../core/utils/screen_wake.dart'; import '../../../l10n/app_localizations.dart'; +import '../../widgets/call_video_view.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_mic_sheet.dart'; +import 'call_participants_sheet.dart'; import 'komet_hub.dart'; - -const Color _kEndRed = Color(0xFFE5484D); -const Color _kAcceptGreen = Color(0xFF2EC36B); +import '../../../core/config/app_fonts.dart'; class CallScreen extends StatefulWidget { final String name; @@ -70,6 +71,8 @@ class _CallScreenState extends State with TickerProviderStateMixin { late final AnimationController _videoController; final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer(); final RTCVideoRenderer _localRenderer = RTCVideoRenderer(); + final Map _tileRenderers = {}; + StreamSubscription? _tileStreamSub; bool _rendererReady = false; bool _localRendererReady = false; bool _videoAttached = false; @@ -85,6 +88,38 @@ class _CallScreenState extends State with TickerProviderStateMixin { bool get _isGroup => widget.isGroup || (_session?.participantCount ?? 0) > 2; + void _onTileStream(int id) { + final stream = _session?.streamOf(id); + final existing = _tileRenderers[id]; + if (existing != null) { + existing.srcObject = stream; + if (mounted) setState(() {}); + return; + } + if (stream == null) return; + unawaited(_createTileRenderer(id, stream)); + } + + Future _createTileRenderer(int id, MediaStream stream) async { + final renderer = RTCVideoRenderer(); + await renderer.initialize(); + if (!mounted) { + await renderer.dispose(); + return; + } + renderer.srcObject = stream; + _tileRenderers[id] = renderer; + setState(() {}); + } + + RTCVideoRenderer? _tileRenderer(CallParticipant p) { + if (p.isSelf) return null; + final own = _tileRenderers[p.id]; + final src = own?.srcObject; + if (src != null && src.getVideoTracks().isNotEmpty) return own; + return _tileVideoReady ? _remoteRenderer : null; + } + bool get _tileVideoReady { if (_session?.topology == 'SERVER') return false; final others = (_session?.participants ?? const []) @@ -98,6 +133,8 @@ class _CallScreenState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); + ActiveCall.instance.enterScreen(); + unawaited(ScreenWake.instance.acquire(this)); _dotsController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), @@ -144,6 +181,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { if (name != null && name.isNotEmpty) _name = name; if (avatar != null && avatar.isNotEmpty) _avatarUrl = avatar; }); + _publishActiveCall(); } Future _initRenderer() async { @@ -208,9 +246,12 @@ class _CallScreenState extends State with TickerProviderStateMixin { _resolveParticipants(); _syncVideo(); _syncLocalPreview(); + _isSpeaker = session.isSpeaker; setState(() {}); + _publishActiveCall(); }); _remoteStreamSub = session.remoteStreamStream.listen(_attachStream); + _tileStreamSub = session.participantStreamUpdates.listen(_onTileStream); _kometSub = session.peerKometDetected.listen((_) => _showKometBadge()); _chatSub = session.chatMessages.listen(_onChatMessage); if (session.peerIsKomet) { @@ -220,6 +261,19 @@ class _CallScreenState extends State with TickerProviderStateMixin { if (existing != null) _attachStream(existing); _resolveParticipants(); _syncVideo(); + _isSpeaker = session.isSpeaker; + _publishActiveCall(); + } + + void _publishActiveCall() { + final session = _session; + if (session == null) return; + ActiveCall.instance.attach( + session: session, + name: _name, + avatarUrl: _avatarUrl, + isGroup: _isGroup, + ); } void _showKometBadge() { @@ -318,9 +372,11 @@ class _CallScreenState extends State with TickerProviderStateMixin { } Future _toggleSpeaker() async { - final next = !_isSpeaker; + final session = _session; + if (session == null) return; + final next = !session.isSpeaker; setState(() => _isSpeaker = next); - await Helper.setSpeakerphoneOn(next); + await session.setSpeaker(next); } bool _videoBusy = false; @@ -328,10 +384,15 @@ class _CallScreenState extends State with TickerProviderStateMixin { Future _toggleVideo() async { final session = _session; if (session == null || _videoBusy) return; + final l10n = AppLocalizations.of(context)!; setState(() => _videoBusy = true); await WidgetsBinding.instance.endOfFrame; try { await session.setVideoEnabled(!session.localVideo); + } catch (e) { + if (mounted) { + showCustomNotification(context, l10n.callCameraUnavailable(e)); + } } finally { _syncLocalPreview(); if (mounted) setState(() => _videoBusy = false); @@ -345,6 +406,10 @@ class _CallScreenState extends State with TickerProviderStateMixin { await WidgetsBinding.instance.endOfFrame; try { await session.setScreenSharing(!session.localScreen); + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Трансляция не запустилась: $e'); + } } finally { _syncLocalPreview(); if (mounted) setState(() => _videoBusy = false); @@ -358,21 +423,66 @@ class _CallScreenState extends State with TickerProviderStateMixin { @override void dispose() { + ActiveCall.instance.leaveScreen(); + unawaited(ScreenWake.instance.release(this)); _stateSub?.cancel(); _canceledSub?.cancel(); _infoSub?.cancel(); _kometSub?.cancel(); _chatSub?.cancel(); _remoteStreamSub?.cancel(); + _tileStreamSub?.cancel(); + for (final renderer in _tileRenderers.values) { + renderer.srcObject = null; + renderer.dispose(); + } + _tileRenderers.clear(); _dotsController.dispose(); _videoController.dispose(); - _remoteRenderer.srcObject = null; + if (_rendererReady) _remoteRenderer.srcObject = null; _remoteRenderer.dispose(); - _localRenderer.srcObject = null; + if (_localRendererReady) _localRenderer.srcObject = null; _localRenderer.dispose(); super.dispose(); } + void _showParticipants() { + final session = _session; + if (session == null) return; + final l10n = AppLocalizations.of(context)!; + showCallParticipantsSheet( + context, + session: session, + scheme: _darkScheme(context), + resolve: (p) { + if (p.isSelf) { + return CallParticipantView( + name: l10n.callParticipantYou, + avatarUrl: _avatarUrl, + ); + } + final ext = p.externalId; + final info = ext != null ? _peerInfo[ext] : null; + return CallParticipantView( + name: info?.name?.isNotEmpty == true + ? info!.name! + : l10n.callParticipantFallback, + avatarUrl: info?.avatar, + ); + }, + ); + } + + void _showMicrophones() { + final session = _session; + if (session == null) return; + showCallMicrophoneSheet( + context, + session: session, + scheme: _darkScheme(context), + ); + } + void _showInfoSheet() { final cs = _darkScheme(context); showModalBottomSheet( @@ -449,25 +559,26 @@ class _CallScreenState extends State with TickerProviderStateMixin { border: Border.all(color: cs.outlineVariant, width: 1), ), child: _localRendererReady && _localRenderer.srcObject != null - ? RTCVideoView( - _localRenderer, + ? CallVideoView( + renderer: _localRenderer, mirror: _session?.localScreen != true, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + placeholder: _localPreviewIcon(cs), ) - : Center( - child: Icon( - _session?.localScreen == true - ? Symbols.screen_share - : Symbols.videocam, - color: cs.onSurfaceVariant, - size: 28, - ), - ), + : _localPreviewIcon(cs), ), ), ); } + Widget _localPreviewIcon(ColorScheme cs) => Center( + child: Icon( + _session?.localScreen == true ? Symbols.screen_share : Symbols.videocam, + color: cs.onSurfaceVariant, + size: 28, + ), + ); + Widget _buildGroupBody(ColorScheme cs) { final l10n = AppLocalizations.of(context)!; final participants = _session?.participants ?? const []; @@ -515,13 +626,33 @@ class _CallScreenState extends State with TickerProviderStateMixin { color: cs.onSurface, fontSize: 24, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + InkWell( + onTap: count > 0 ? _showParticipants : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + if (count > 0) ...[ + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + size: 16, + color: cs.onSurfaceVariant, + ), + ], + ], + ), + ), ), ], ), @@ -556,19 +687,20 @@ class _CallScreenState extends State with TickerProviderStateMixin { final url = p.isSelf ? _avatarUrl : info?.avatar; final muted = p.isSelf ? _isMuted : !p.audioEnabled; final speaking = !muted && _session?.isSpeaking(p.id) == true; + final renderer = _tileRenderer(p); final showVideo = - !p.isSelf && (p.videoEnabled || p.screenSharing) && _tileVideoReady; + !p.isSelf && (p.videoEnabled || p.screenSharing) && renderer != null; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), depth: 6, borderSide: speaking - ? const BorderSide(color: _kAcceptGreen, width: 2.5) + ? const BorderSide(color: kSuccessGreen, width: 2.5) : null, padding: EdgeInsets.all(showVideo ? 0 : 12), child: showVideo - ? _videoTile(cs, name, muted, p.handRaised, p.screenSharing) + ? _videoTile(cs, renderer, name, muted, p.handRaised, p.screenSharing) : _avatarTile(cs, name, url, muted, p.handRaised, p.screenSharing), ); } @@ -653,6 +785,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { Widget _videoTile( ColorScheme cs, + RTCVideoRenderer renderer, String name, bool muted, bool hand, @@ -663,9 +796,10 @@ class _CallScreenState extends State with TickerProviderStateMixin { child: Stack( fit: StackFit.expand, children: [ - RTCVideoView( - _remoteRenderer, + CallVideoView( + renderer: renderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + placeholder: ColoredBox(color: cs.surfaceContainerHighest), ), Positioned( left: 8, @@ -771,8 +905,8 @@ class _CallScreenState extends State with TickerProviderStateMixin { child: AspectRatio( aspectRatio: ar, child: RepaintBoundary( - child: RTCVideoView( - _remoteRenderer, + child: CallVideoView( + renderer: _remoteRenderer, objectFit: RTCVideoViewObjectFit .RTCVideoViewObjectFitCover, ), @@ -887,6 +1021,16 @@ class _CallScreenState extends State with TickerProviderStateMixin { size: 26, ), ), + IconButton( + onPressed: _showMicrophones, + tooltip: l10n.callTooltipMicrophone, + icon: Icon( + Symbols.settings_voice, + color: cs.onSurface, + weight: 500, + size: 26, + ), + ), IconButton( onPressed: _showInfoSheet, tooltip: l10n.callInfoTitle, @@ -926,7 +1070,8 @@ class _CallScreenState extends State with TickerProviderStateMixin { final session = _session; if (session == null) return null; final pills = [ - if (session.peerMuted) _statePill(cs, Symbols.mic_off, l10n.callPeerMicOff), + if (session.peerMuted) + _statePill(cs, Symbols.mic_off, l10n.callPeerMicOff), if (session.peerVideo) _statePill(cs, Symbols.videocam, l10n.callPeerCameraOn), ]; @@ -1025,7 +1170,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { color: cs.onPrimaryContainer, fontSize: size * 0.38, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ); @@ -1043,7 +1188,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { color: cs.onSurface, fontSize: 30, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), height: 1.1, ), ), @@ -1117,14 +1262,14 @@ class _CallScreenState extends State with TickerProviderStateMixin { _CallButton( icon: Symbols.call_end, label: l10n.callDecline, - background: _kEndRed, + background: kDangerRed, foreground: Colors.white, onTap: _decline, ), _CallButton( icon: Symbols.call, label: l10n.callAccept, - background: _kAcceptGreen, + background: kSuccessGreen, foreground: Colors.white, onTap: _accept, ), @@ -1150,7 +1295,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, @@ -1166,16 +1313,21 @@ class _CallScreenState extends State with TickerProviderStateMixin { onTap: _toggleScreen, ), _CallButton( - icon: _isMuted ? Symbols.mic_off : Symbols.mic, - label: _isMuted ? l10n.callUnmute : l10n.callMute, + icon: Symbols.mic, + slashedIcon: Symbols.mic_off, + slashed: _isMuted, + label: _isMuted + ? (CallNoMute.enabled ? l10n.callMicStillLive : l10n.callUnmute) + : l10n.callMute, background: _isMuted ? cs.primary : cs.surfaceContainerHighest, foreground: _isMuted ? cs.onPrimary : cs.onSurface, onTap: _toggleMute, + onLongPress: _showMicrophones, ), _CallButton( icon: Symbols.call_end, label: l10n.callEndButton, - background: _kEndRed, + background: kDangerRed, foreground: Colors.white, onTap: _hangup, ), @@ -1230,10 +1382,13 @@ 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; final VoidCallback onTap; + final VoidCallback? onLongPress; final bool busy; const _CallButton({ @@ -1242,9 +1397,27 @@ class _CallButton extends StatelessWidget { required this.background, required this.foreground, required this.onTap, + this.onLongPress, + 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; @@ -1258,18 +1431,12 @@ class _CallButton extends StatelessWidget { color: background, borderRadius: BorderRadius.circular(31), onTap: busy ? null : onTap, + onLongPress: busy ? null : onLongPress, depth: 9, child: Center( child: busy - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - valueColor: AlwaysStoppedAnimation(foreground), - ), - ) - : Icon(icon, color: foreground, size: 26, fill: 1), + ? SmallSpinner(size: 22, color: foreground) + : _buildIcon(), ), ), ), @@ -1352,7 +1519,10 @@ class _CallInfoSheet extends StatelessWidget { add(l10n.callInfoCountry, incoming?.country); final isContact = incoming?.isContact; if (isContact != null) { - add(l10n.callInfoInContacts, isContact ? l10n.callValueYes : l10n.callValueNo); + add( + l10n.callInfoInContacts, + isContact ? l10n.callValueYes : l10n.callValueNo, + ); } add(l10n.callInfoPeerIp, info?.peerIp); add(l10n.callInfoPeerNetwork, info?.peerNetwork); @@ -1384,9 +1554,7 @@ class _CallInfoSheet extends StatelessWidget { final vtracks = renderer.srcObject?.getVideoTracks().length ?? 0; add( l10n.callInfoVideoTrack, - vtracks > 0 - ? l10n.callInfoVideoTrackPresent(vtracks) - : l10n.callValueNo, + vtracks > 0 ? l10n.callInfoVideoTrackPresent(vtracks) : l10n.callValueNo, ); final w = renderer.value.width.toInt(); final h = renderer.value.height.toInt(); @@ -1422,7 +1590,7 @@ class _CallInfoSheet extends StatelessWidget { color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), const SizedBox(height: 2), diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 8dd0c37..68f92b5 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -10,9 +10,17 @@ import '../../../core/calls/call_controller.dart'; import '../../../backend/modules/calls.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/chat_menu_overlay.dart'; +import '../../widgets/small_spinner.dart'; +import '../../widgets/prompt_dialog.dart'; +import '../../widgets/call_link_handler.dart'; +import '../../widgets/spectrum_tint.dart'; +import '../../../l10n/app_localizations.dart'; +import 'call_link_sheet.dart'; import 'call_screen.dart'; +import '../../../core/config/app_fonts.dart'; class CallsTab extends StatefulWidget { const CallsTab({super.key}); @@ -21,7 +29,8 @@ class CallsTab extends StatefulWidget { State createState() => _CallsTabState(); } -class _CallsTabState extends State { +class _CallsTabState extends State + with ReloadOnReconnect, SpectrumSurface { List _calls = []; final Set _removing = {}; bool _isLoading = true; @@ -50,6 +59,11 @@ class _CallsTabState extends State { super.dispose(); } + @override + void reloadAfterReconnect() { + if (accountModule.isLoggedIn) _loadHistory(); + } + Future _loadHistory() async { final p = await AppDatabase.loadActiveProfile(); if (p == null) { @@ -323,6 +337,104 @@ class _CallsTabState extends State { } } + Widget _buildLinkAction( + ColorScheme cs, { + required IconData icon, + required String label, + required VoidCallback onTap, + bool alignEnd = false, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( + mainAxisAlignment: alignEnd + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Icon(icon, color: cs.primary, size: 24), + const SizedBox(width: 12), + Flexible( + child: Text( + label, + style: TextStyle( + color: cs.primary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + Future _createGroupCall() async { + final controller = CallController.instance; + if (controller.isBusy) { + showCustomNotification(context, 'Звонок уже идёт'); + return; + } + + final l10n = AppLocalizations.of(context)!; + CreatedCall created; + try { + created = await controller.createConference(); + } catch (e) { + if (mounted) { + showCustomNotification(context, '${l10n.callLinkCreateFailed}: $e'); + } + return; + } + if (!mounted) return; + + final start = await showCreatedCallSheet(context, call: created); + if (!start || !mounted) return; + + final navigator = Navigator.of(context); + final name = created.callName ?? l10n.callLinkGroupCall; + try { + final session = await controller.joinByLink(created.joinToken); + if (!mounted) return; + await navigator.push( + MaterialPageRoute( + builder: (_) => + CallScreen(name: name, session: session, isGroup: true), + ), + ); + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось начать звонок: $e'); + } + } + + Future _joinGroupCall() async { + if (CallController.instance.isBusy) { + showCustomNotification(context, 'Звонок уже идёт'); + return; + } + + final url = await showTextInputDialog( + context, + title: 'Присоединиться к звонку', + description: 'Вставьте ссылку-приглашение', + hint: 'https://max.ru/joincall/...', + confirmLabel: 'Присоединиться', + keyboardType: TextInputType.url, + ); + if (url == null || url.trim().isEmpty || !mounted) return; + + final handled = await tryHandleCallLink(context, url.trim()); + if (!handled && mounted) { + showCustomNotification(context, 'Это не ссылка на звонок'); + } + } + Widget _buildTabItem(String label, int index, ColorScheme cs) { final isSelected = _selectedTabIndex == index; return GestureDetector( @@ -362,7 +474,7 @@ class _CallsTabState extends State { : _calls; return Scaffold( - backgroundColor: cs.surface, + backgroundColor: spectrumSurfaceColor(cs), body: SafeArea( bottom: false, child: Column( @@ -379,34 +491,35 @@ class _CallsTabState extends State { color: cs.onSurface, fontSize: 24, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), const ConnectionStatusLine(), ], ), ), - InkWell( - onTap: () {}, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - child: Row( - children: [ - Icon(Symbols.link, color: cs.primary, size: 24), - const SizedBox(width: 16), - Text( - 'Создать групповой звонок', - style: TextStyle( - color: cs.primary, - fontSize: 16, - fontWeight: FontWeight.w500, - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: _buildLinkAction( + cs, + icon: Symbols.link, + label: 'Создать звонок', + onTap: _createGroupCall, ), - ], - ), + ), + Expanded( + child: _buildLinkAction( + cs, + icon: Symbols.group_add, + label: 'Присоединиться', + onTap: _joinGroupCall, + alignEnd: true, + ), + ), + ], ), ), Padding( @@ -421,7 +534,7 @@ class _CallsTabState extends State { ), Expanded( child: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : filteredCalls.isEmpty ? Center( child: Text( diff --git a/lib/frontend/screens/calls/komet_hub.dart b/lib/frontend/screens/calls/komet_hub.dart index 43b6b44..b36ed44 100644 --- a/lib/frontend/screens/calls/komet_hub.dart +++ b/lib/frontend/screens/calls/komet_hub.dart @@ -7,6 +7,7 @@ import '../../../core/calls/call_session.dart'; import '../../../core/games/checkers.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../../core/config/app_fonts.dart'; Future showKometHub( BuildContext context, { @@ -115,7 +116,7 @@ class _KometHubState extends State<_KometHub> { color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ], @@ -550,7 +551,9 @@ class _CheckersViewState extends State<_CheckersView> { final l10n = AppLocalizations.of(context)!; final w = _result; if (w != null) return w == _me ? l10n.hubCheckersWon : l10n.hubCheckersLost; - return _turn == _me ? l10n.hubCheckersYourMove : l10n.hubCheckersOpponentMove; + return _turn == _me + ? l10n.hubCheckersYourMove + : l10n.hubCheckersOpponentMove; } Widget _boardWidget(ColorScheme cs) { diff --git a/lib/frontend/screens/chats/chat/chat_controller.dart b/lib/frontend/screens/chats/chat/chat_controller.dart index e91bc49..81270e1 100644 --- a/lib/frontend/screens/chats/chat/chat_controller.dart +++ b/lib/frontend/screens/chats/chat/chat_controller.dart @@ -10,9 +10,25 @@ import '../../../../core/storage/app_database.dart'; import '../../../../core/utils/logger.dart'; import '../../../../main.dart'; +class HistoryGap { + HistoryGap({ + required this.edgeId, + required this.edgeTime, + required this.tailTime, + }); + + String edgeId; + int edgeTime; + final int tailTime; +} + class ChatController extends ChangeNotifier { static const int historyPageSize = 30; static const int historyInitialLimit = 50; + static const int jumpWindowBefore = 40; + static const int jumpWindowAfter = 20; + static const int historyWalkPageSize = 200; + static const int gapPageSize = 60; int chatId = 0; int myId = 0; @@ -23,6 +39,16 @@ class ChatController extends ChangeNotifier { bool hasMoreHistory = true; bool isLoadingMore = false; bool historyKickedOff = false; + bool loadingGap = false; + + final List gaps = []; + + bool get hasGap => gaps.isNotEmpty; + + static bool gapFillLeavesViewportInPlace( + HistoryGap gap, + int? oldestRenderedTime, + ) => oldestRenderedTime != null && oldestRenderedTime >= gap.tailTime; bool Function() isMounted = () => true; @@ -93,20 +119,186 @@ class ChatController extends ChangeNotifier { Future> loadOlderFromDb( int beforeTime, - bool onlyVisible, - ) async { + bool onlyVisible, { + int? limit, + }) async { final rows = await AppDatabase.loadMessagesBefore( myId, chatId, beforeTime: beforeTime, - limit: historyPageSize, + limit: limit ?? historyPageSize, onlyVisible: onlyVisible, ); return CachedMessage.fromDbRowsAsync(rows); } + Future> loadGapSliceFromDb( + int afterTime, + int beforeTime, + bool onlyVisible, + ) async { + final rows = await AppDatabase.loadMessagesBetween( + myId, + chatId, + afterTime: afterTime, + beforeTime: beforeTime, + limit: gapPageSize, + onlyVisible: onlyVisible, + ); + return CachedMessage.fromDbRowsAsync(rows); + } + + Future> loadWindowFromDb( + int centerTime, + bool onlyVisible, + ) async { + final rows = await AppDatabase.loadMessagesAround( + myId, + chatId, + centerTime: centerTime, + before: jumpWindowBefore, + after: jumpWindowAfter, + onlyVisible: onlyVisible, + ); + return CachedMessage.fromDbRowsAsync(rows); + } + + Future loadMessageWindow({ + required String targetId, + required int targetTime, + }) async { + if (myId == 0 || targetTime <= 0) return false; + final onlyVisible = !KometSettings.viewDeleted.value; + + var window = await loadWindowFromDb(targetTime, onlyVisible); + if (!isMounted()) return false; + + if (!window.any((m) => m.id == targetId)) { + final fetched = await messagesModule.fetchHistory( + myId, + chatId, + fromTime: targetTime + 1, + forward: jumpWindowAfter, + backward: jumpWindowBefore + 1, + ); + if (!isMounted()) return false; + if (fetched.isNotEmpty && KometSettings.viewDeleted.value) { + await chats.reconcileDeletedFromFetch(myId, chatId, fetched); + } + window = await loadWindowFromDb(targetTime, onlyVisible); + if (!isMounted()) return false; + } + + if (window.isEmpty) return false; + + final oldestLoaded = messages.isEmpty ? 0 : messages.first.time; + final reachesLoaded = + messages.isEmpty || window.any((m) => m.time >= oldestLoaded); + + mergeMessages(window); + + if (reachesLoaded) { + persistSessionCache(); + } else { + _markGapAfterWindow(window); + } + return messages.any((m) => m.id == targetId); + } + + void _markGapAfterWindow(List window) { + var edge = window.first; + for (final m in window) { + if (m.time > edge.time) edge = m; + } + final idx = messages.indexWhere((m) => m.id == edge.id); + if (idx == -1 || idx + 1 >= messages.length) return; + final tailTime = messages[idx + 1].time; + gaps.removeWhere((g) => g.tailTime == tailTime); + gaps.add( + HistoryGap(edgeId: edge.id, edgeTime: edge.time, tailTime: tailTime), + ); + } + + void _closeGap(HistoryGap gap) { + gaps.remove(gap); + if (gaps.isEmpty) persistSessionCache(); + } + + Future fillGapForward( + HistoryGap gap, { + void Function()? beforeApply, + }) async { + if (loadingGap || myId == 0 || !gaps.contains(gap)) return 0; + if (gap.edgeTime <= 0 || gap.tailTime <= gap.edgeTime) { + _closeGap(gap); + return 0; + } + + loadingGap = true; + try { + final onlyVisible = !KometSettings.viewDeleted.value; + var slice = await loadGapSliceFromDb( + gap.edgeTime, + gap.tailTime, + onlyVisible, + ); + if (!isMounted()) return 0; + + if (slice.length < gapPageSize) { + final fetched = await messagesModule.fetchHistory( + myId, + chatId, + fromTime: gap.edgeTime, + forward: gapPageSize, + backward: 0, + ); + if (!isMounted()) return 0; + if (fetched.isNotEmpty && KometSettings.viewDeleted.value) { + await chats.reconcileDeletedFromFetch(myId, chatId, fetched); + } + final refreshed = await loadGapSliceFromDb( + gap.edgeTime, + gap.tailTime, + onlyVisible, + ); + if (!isMounted()) return 0; + if (refreshed.length <= slice.length) { + if (refreshed.isNotEmpty) { + beforeApply?.call(); + mergeMessages(refreshed); + } + _closeGap(gap); + return refreshed.length; + } + slice = refreshed; + } + + if (slice.isEmpty) { + _closeGap(gap); + return 0; + } + + beforeApply?.call(); + mergeMessages(slice); + + var edge = slice.first; + for (final m in slice) { + if (m.time > edge.time) edge = m; + } + gap.edgeId = edge.id; + gap.edgeTime = edge.time; + if (edge.time >= gap.tailTime) _closeGap(gap); + return slice.length; + } catch (e) { + logger.e('Error filling history gap: $e'); + return 0; + } finally { + loadingGap = false; + } + } + void persistSessionCache() { - if (myId == 0 || messages.isEmpty) return; + if (myId == 0 || messages.isEmpty || hasGap) return; MessageSessionCache.save( myId, chatId, @@ -119,29 +311,32 @@ class ChatController extends ChangeNotifier { required void Function() onLoadingStarted, required void Function(int added) onLoaded, required void Function(Object error) onError, + int? pageSize, + bool persist = true, }) async { if (isLoadingMore || !hasMoreHistory || messages.isEmpty) return; isLoadingMore = true; onLoadingStarted(); + final size = pageSize ?? historyPageSize; final oldest = messages.first; final onlyVisible = !KometSettings.viewDeleted.value; try { - var older = await loadOlderFromDb(oldest.time, onlyVisible); + var older = await loadOlderFromDb(oldest.time, onlyVisible, limit: size); - if (older.length < historyPageSize) { + if (older.length < size) { final fetched = await messagesModule.fetchHistory( myId, chatId, fromTime: oldest.time, - count: historyPageSize, + count: size, ); if (fetched.isNotEmpty) { if (KometSettings.viewDeleted.value) { await chats.reconcileDeletedFromFetch(myId, chatId, fetched); } - older = await loadOlderFromDb(oldest.time, onlyVisible); + older = await loadOlderFromDb(oldest.time, onlyVisible, limit: size); } } @@ -149,7 +344,7 @@ class ChatController extends ChangeNotifier { final added = prependOlder(older); isLoadingMore = false; if (added == 0) hasMoreHistory = false; - persistSessionCache(); + if (persist) persistSessionCache(); onLoaded(added); } catch (e) { logger.e('Error loading more history: $e'); @@ -165,6 +360,17 @@ class ChatController extends ChangeNotifier { required void Function() onSenderNames, }) async { final onlyVisible = !KometSettings.viewDeleted.value; + final cachedRows = await AppDatabase.loadChat(myId, chatId); + final preview = + cachedRows.isEmpty || !AppDatabase.chatRowIsInList(cachedRows.first); + if (preview) { + onPreview(); + if (cachedRows.isEmpty) { + await chats.ensureChatCached(api, myId, chatId); + } + await chats.subscribeChat(api, chatId); + } + final fullDecoded = await loadInitialFromDb(onlyVisible: onlyVisible); if (isMounted()) { onApplyMerged(fullDecoded); @@ -179,12 +385,6 @@ class ChatController extends ChangeNotifier { } try { - final cachedRows = await AppDatabase.loadChat(myId, chatId); - if (cachedRows.isEmpty) { - onPreview(); - await chats.ensureChatCached(api, myId, chatId); - await chats.subscribeChat(api, chatId); - } final serverMessages = await messagesModule.fetchHistory(myId, chatId); chats.markHistoryFetched(chatId); if (KometSettings.viewDeleted.value) { diff --git a/lib/frontend/screens/chats/chat/mention_panel_controller.dart b/lib/frontend/screens/chats/chat/mention_panel_controller.dart new file mode 100644 index 0000000..2ec1f0b --- /dev/null +++ b/lib/frontend/screens/chats/chat/mention_panel_controller.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../../backend/modules/chats.dart' show chats; +import '../../../../main.dart' show api; + +class MentionCandidate { + final int id; + final String name; + final String? avatarUrl; + final bool isContact; + + const MentionCandidate({ + required this.id, + required this.name, + this.avatarUrl, + this.isContact = false, + }); + + @override + bool operator ==(Object other) => + other is MentionCandidate && + other.id == id && + other.name == name && + other.isContact == isContact; + + @override + int get hashCode => Object.hash(id, name, isContact); +} + +class MentionQuery { + final int start; + final int end; + final String text; + + const MentionQuery({ + required this.start, + required this.end, + required this.text, + }); +} + +MentionQuery? mentionQueryAt(String text, int cursor) { + if (cursor <= 0 || cursor > text.length) return null; + const maxQueryLength = 32; + + var index = cursor - 1; + while (index >= 0) { + final code = text.codeUnitAt(index); + if (code == 0x40) break; + if (code == 0x20 || code == 0x0A || code == 0x09) return null; + if (cursor - index > maxQueryLength) return null; + index--; + } + if (index < 0) return null; + if (index > 0) { + final before = text.codeUnitAt(index - 1); + if (before != 0x20 && before != 0x0A && before != 0x09) return null; + } + + return MentionQuery( + start: index, + end: cursor, + text: text.substring(index + 1, cursor), + ); +} + +class MentionPanelController { + MentionPanelController({ + required TickerProvider vsync, + required this.chatId, + required this.enabled, + required this.selfId, + required this.valueOf, + required this.onSelected, + }) { + anim = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 200), + ); + } + + static const int _pageSize = 50; + static const int _desiredMatches = 30; + static const int _autoFetchLimit = 300; + + final int chatId; + final bool Function() enabled; + final int Function() selfId; + final TextEditingValue Function() valueOf; + final void Function(MentionCandidate candidate, MentionQuery query) + onSelected; + + late final AnimationController anim; + final ValueNotifier> matches = ValueNotifier(const []); + final ValueNotifier loadingMore = ValueNotifier(false); + + final List _members = []; + final Set _seen = {}; + int _marker = 0; + bool _end = false; + bool _fetching = false; + bool _visible = false; + MentionQuery? _query; + + bool get hasMore => !_end; + + void update() { + final query = enabled() ? _queryAt(valueOf()) : null; + _query = query; + + if (query == null) { + _setVisible(false); + return; + } + + if (_members.isEmpty && !_end) unawaited(_fetchPage()); + + final found = _match(query.text); + if (!listEquals(matches.value, found)) matches.value = found; + if (found.length < _desiredMatches && _members.length < _autoFetchLimit) { + unawaited(_fetchPage()); + } + + _setVisible(found.isNotEmpty || (_members.isEmpty && !_end)); + } + + void select(MentionCandidate candidate) { + final query = _query; + if (query == null) return; + onSelected(candidate, query); + } + + Future loadMore() => _fetchPage(); + + MentionQuery? _queryAt(TextEditingValue value) { + final selection = value.selection; + if (!selection.isValid || !selection.isCollapsed) return null; + return mentionQueryAt(value.text, selection.baseOffset); + } + + List _match(String raw) { + final me = selfId(); + final query = raw.toLowerCase().trim(); + final found = _members + .where((c) => c.id != me) + .where((c) => query.isEmpty || _matchesQuery(c.name, query)); + return [ + ...found.where((c) => c.isContact), + ...found.where((c) => !c.isContact), + ]; + } + + bool _matchesQuery(String name, String query) { + final lower = name.toLowerCase(); + if (lower.startsWith(query)) return true; + for (final word in lower.split(' ')) { + if (word.startsWith(query)) return true; + } + return lower.contains(query); + } + + Future _fetchPage() async { + if (_fetching || _end) return; + _fetching = true; + loadingMore.value = true; + try { + final page = await chats.getChatMembers( + api, + chatId, + marker: _marker, + count: _pageSize, + ); + if (page == null) { + _end = true; + return; + } + + var added = 0; + for (final member in page.members) { + final name = member.fullName ?? member.name; + if (name == null || name.isEmpty) continue; + if (member.blocked) continue; + if (!_seen.add(member.id)) continue; + _members.add( + MentionCandidate( + id: member.id, + name: name, + avatarUrl: member.avatarUrl, + isContact: member.isContact, + ), + ); + added++; + } + + if (added == 0 || page.members.isEmpty || page.marker == _marker) { + _end = true; + } + _marker = page.marker; + + if (added > 0 && _query != null) { + final found = _match(_query!.text); + if (!listEquals(matches.value, found)) matches.value = found; + _setVisible(found.isNotEmpty); + } + } finally { + _fetching = false; + loadingMore.value = false; + } + } + + void _setVisible(bool show) { + if (show == _visible) return; + _visible = show; + if (show) { + anim.forward(); + } else { + anim.reverse(); + } + } + + void dispose() { + anim.dispose(); + matches.dispose(); + loadingMore.dispose(); + } +} diff --git a/lib/frontend/screens/chats/chat/retain_offset_physics.dart b/lib/frontend/screens/chats/chat/retain_offset_physics.dart new file mode 100644 index 0000000..a92c664 --- /dev/null +++ b/lib/frontend/screens/chats/chat/retain_offset_physics.dart @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; + +class RetainOffsetScrollPhysics extends ScrollPhysics { + const RetainOffsetScrollPhysics({super.parent, required this.retain}); + + final bool Function() retain; + + @override + RetainOffsetScrollPhysics applyTo(ScrollPhysics? ancestor) => + RetainOffsetScrollPhysics(parent: buildParent(ancestor), retain: retain); + + @override + double adjustPositionForNewDimensions({ + required ScrollMetrics oldPosition, + required ScrollMetrics newPosition, + required bool isScrolling, + required double velocity, + }) { + final adjusted = super.adjustPositionForNewDimensions( + oldPosition: oldPosition, + newPosition: newPosition, + isScrolling: isScrolling, + velocity: velocity, + ); + if (!retain()) return adjusted; + final grown = newPosition.maxScrollExtent - oldPosition.maxScrollExtent; + if (grown <= 0) return adjusted; + return (adjusted + grown).clamp( + newPosition.minScrollExtent, + newPosition.maxScrollExtent, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/sticker_panel_controller.dart b/lib/frontend/screens/chats/chat/sticker_panel_controller.dart index 4f403a6..4c17e13 100644 --- a/lib/frontend/screens/chats/chat/sticker_panel_controller.dart +++ b/lib/frontend/screens/chats/chat/sticker_panel_controller.dart @@ -20,14 +20,30 @@ class StickerPanelController { final VoidCallback onSendTyping; + static const double _minPanelHeight = 120; + late final AnimationController anim; final ValueNotifier showPanel = ValueNotifier(false); final ValueNotifier panelHold = ValueNotifier(true); - double panelHeight = 300; + final ValueNotifier panelHeight = ValueNotifier(300); + double baseHeight = 300; + double maxHeight = 300; Timer? _typingTimer; void hide() => showPanel.value = false; + void setBaseHeight(double value) { + if (value < _minPanelHeight) return; + baseHeight = value; + if (panelHeight.value < value) panelHeight.value = value; + } + + void resizeBy(double delta) { + final upper = maxHeight < baseHeight ? baseHeight : maxHeight; + final next = (panelHeight.value + delta).clamp(baseHeight, upper); + if (next != panelHeight.value) panelHeight.value = next; + } + void _onAnimStatus(AnimationStatus status) { final held = status != AnimationStatus.completed; if (panelHold.value != held) panelHold.value = held; @@ -61,5 +77,6 @@ class StickerPanelController { anim.dispose(); showPanel.dispose(); panelHold.dispose(); + panelHeight.dispose(); } } 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/video_note_controller.dart b/lib/frontend/screens/chats/chat/video_note_controller.dart index cbe3842..f3deb64 100644 --- a/lib/frontend/screens/chats/chat/video_note_controller.dart +++ b/lib/frontend/screens/chats/chat/video_note_controller.dart @@ -1,15 +1,28 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException, rootBundle; +import 'package:lottie/lottie.dart' show AssetLottie; +import 'package:path_provider/path_provider.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../widgets/glossy_pill.dart'; + +import '../../../../core/config/app_video_note_quality.dart'; import '../../../../core/media/native_video_note_recorder.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/logger.dart'; +import '../../../../core/utils/screen_wake.dart'; import '../../../widgets/custom_notification.dart'; +import '../../../widgets/lottie_slash_icon.dart'; import 'voice_record_controller.dart'; +import '../../../../core/config/app_frost.dart'; + +const String _flashIcon = 'assets/lottie/ic_flash_on_to_off.json'; class VideoNoteController { VideoNoteController({ @@ -17,12 +30,14 @@ class VideoNoteController { required this.isMounted, required this.onRecorded, required this.formatElapsed, + required this.bottomInset, }); final BuildContext Function() contextOf; final bool Function() isMounted; final Future Function(File file, int durationMs) onRecorded; final String Function(int ms) formatElapsed; + final double Function() bottomInset; final NativeVideoNoteRecorder _rec = NativeVideoNoteRecorder(); final ValueNotifier _videoNoteMode = ValueNotifier(false); @@ -31,15 +46,40 @@ class VideoNoteController { final ValueNotifier _isRecording = ValueNotifier(false); final ValueNotifier _elapsedMs = ValueNotifier(0); final ValueNotifier _cancelDrag = ValueNotifier(0); + final ValueNotifier _locked = ValueNotifier(false); + final ValueNotifier _lockDrag = ValueNotifier(0); + final ValueNotifier _flashOn = ValueNotifier(false); final Stopwatch _stopwatch = Stopwatch(); Timer? _timer; bool _cancelled = false; bool _stopRequested = false; - OverlayEntry? _overlay; + bool? _frontOverride; + bool _switchingCamera = false; + + bool get _front => _frontOverride ?? !AppVideoNoteRearCamera.current.value; + + static const int maxMs = 60000; + static const double _lockThreshold = 90; ValueListenable get videoNoteMode => _videoNoteMode; ValueListenable get camReady => _camReady; ValueListenable get isRecording => _isRecording; + ValueListenable get elapsedMs => _elapsedMs; + ValueListenable get cancelDrag => _cancelDrag; + ValueListenable get locked => _locked; + ValueListenable get lockDrag => _lockDrag; + ValueListenable get flashOn => _flashOn; + ValueListenable get textureId => _textureId; + + bool get flashAvailable => _rec.hasFlash || _stub; + + bool get cameraControlsAvailable => true; + + Future toggleFlash() async { + final next = !_flashOn.value; + _flashOn.value = _stub ? next : await _rec.setTorch(next); + Haptics.tap(); + } Future toggleMode() async { final toVideo = !_videoNoteMode.value; @@ -52,18 +92,32 @@ class VideoNoteController { } } + bool get _stub => !_rec.isAvailable; + Future _initCamera() async { + unawaited(AssetLottie(_flashIcon).load()); + if (_stub) { + _camReady.value = true; + unawaited(ScreenWake.instance.acquire(this)); + _textureId.value = null; + return; + } if (_rec.textureId != null) return; - if (!_rec.isAvailable) { - if (isMounted()) showCustomNotification(contextOf(), 'Камера недоступна'); + final access = await _rec.requestAccess(); + if (!access.granted) { + _videoNoteMode.value = false; + _notify(_accessMessage(access)); return; } try { - final ok = await _rec.init(); + final ok = await _rec.init( + front: _front, + size: AppVideoNoteResolution.current.value, + fps: AppVideoNoteFps.current.value, + ); if (!ok) { - if (isMounted()) { - showCustomNotification(contextOf(), 'Камера недоступна'); - } + _videoNoteMode.value = false; + _notify('Камера недоступна'); return; } if (!isMounted() || !_videoNoteMode.value) { @@ -72,33 +126,55 @@ class VideoNoteController { } _textureId.value = _rec.textureId; _camReady.value = true; + unawaited(ScreenWake.instance.acquire(this)); } catch (e) { logger.w('initNoteCamera: $e'); - if (isMounted()) showCustomNotification(contextOf(), 'Камера недоступна'); + await _disposeCamera(); + _videoNoteMode.value = false; + _notify(_failureMessage(e, 'Камера недоступна')); } } + void _notify(String message) { + if (isMounted()) showCustomNotification(contextOf(), message); + } + + String _accessMessage(VideoNoteAccess access) { + if (!access.camera && !access.microphone) { + return 'Для кружков нужен доступ к камере и микрофону'; + } + return access.camera ? 'Нет доступа к микрофону' : 'Нет доступа к камере'; + } + + String _failureMessage(Object error, String fallback) { + if (error is! PlatformException) return fallback; + return switch (error.code) { + 'NO_CAMERA_PERMISSION' => 'Нет доступа к камере', + 'NO_MIC_PERMISSION' => 'Нет доступа к микрофону', + 'NO_CAMERA' => 'Камера недоступна', + 'NOT_READY' => 'Камера ещё не готова', + _ => fallback, + }; + } + Future _disposeCamera() async { _camReady.value = false; + unawaited(ScreenWake.instance.release(this)); _textureId.value = null; - await _rec.dispose(); + if (!_stub) await _rec.dispose(); } Future start() async { if (_isRecording.value) return; _stopRequested = false; - if (_rec.textureId == null) { + if (!_stub && _rec.textureId == null) { await _initCamera(); - return; + if (_rec.textureId == null) return; } try { - final ok = await _rec.start(); - if (!ok) { - _isRecording.value = false; - return; - } + if (!_stub) await _rec.start(); if (!isMounted()) { - await _rec.stop(); + if (!_stub) await _rec.stop(); return; } _stopwatch @@ -106,14 +182,17 @@ class VideoNoteController { ..start(); _elapsedMs.value = 0; _cancelDrag.value = 0; + _lockDrag.value = 0; + _locked.value = false; _cancelled = false; _isRecording.value = true; FocusManager.instance.primaryFocus?.unfocus(); Haptics.send(); - _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { - _elapsedMs.value = _stopwatch.elapsedMilliseconds; + _timer = Timer.periodic(const Duration(milliseconds: 50), (_) { + final ms = _stopwatch.elapsedMilliseconds; + _elapsedMs.value = ms >= maxMs ? maxMs : ms; + if (ms >= maxMs) unawaited(stop(cancel: false)); }); - _showOverlay(); if (_stopRequested) { _stopRequested = false; await stop(cancel: false); @@ -121,11 +200,56 @@ class VideoNoteController { } catch (e) { logger.w('startNoteRecording: $e'); _isRecording.value = false; + _notify(_failureMessage(e, 'Не удалось начать запись кружка')); + } + } + + Future _stubClip() async { + try { + final data = await rootBundle.load('assets/debug/fake_video_note.mp4'); + final dir = await getTemporaryDirectory(); + final file = File( + '${dir.path}/note_stub_${DateTime.now().millisecondsSinceEpoch}.mp4', + ); + await file.writeAsBytes(data.buffer.asUint8List(), flush: true); + return file.path; + } catch (e) { + logger.w('VideoNoteController._stubClip: $e'); + return null; + } + } + + Future flipCamera() async { + if (_stub) { + Haptics.tap(); + return; + } + if (!_camReady.value || _switchingCamera) return; + _switchingCamera = true; + try { + final ok = await _rec.switchCamera(); + if (ok) { + _frontOverride = !_front; + Haptics.tap(); + } + } finally { + _switchingCamera = false; } } void handleDrag(Offset offsetFromOrigin) { - if (!_isRecording.value) return; + if (!_isRecording.value || _locked.value) return; + + final lock = (-offsetFromOrigin.dy / _lockThreshold).clamp(0.0, 1.0); + _lockDrag.value = lock; + if (lock >= 1.0) { + _locked.value = true; + _lockDrag.value = 0; + _cancelDrag.value = 0; + Haptics.send(); + return; + } + final drag = (-offsetFromOrigin.dx / VoiceRecordController.cancelThreshold) .clamp(0.0, 1.0); _cancelDrag.value = drag; @@ -136,7 +260,10 @@ class VideoNoteController { } } - void handleEnd() => stop(cancel: false); + void handleEnd() { + if (_locked.value) return; + stop(cancel: false); + } Future stop({required bool cancel}) async { if (!_isRecording.value) { @@ -149,89 +276,40 @@ class VideoNoteController { final elapsed = _stopwatch.elapsedMilliseconds; _isRecording.value = false; _cancelDrag.value = 0; - _hideOverlay(); - - final path = await _rec.stop(); + _lockDrag.value = 0; + _locked.value = false; + if (_flashOn.value) { + _flashOn.value = false; + unawaited(_rec.setTorch(false)); + } final shouldCancel = cancel || _cancelled || elapsed < VoiceRecordController.minMs; + + String? path; + try { + path = _stub ? await _stubClip() : await _rec.stop(); + } catch (e) { + logger.w('stopNoteRecording: $e'); + } + if (shouldCancel || path == null) { if (path != null) { try { await File(path).delete(); } catch (_) {} + } else if (!shouldCancel) { + _notify('Не удалось сохранить кружок'); } return; } - // Файл уже квадратный 480×480 (нативная запись) — шлём как есть. await onRecorded(File(path), elapsed); } - void _showOverlay() { - _overlay?.remove(); - _overlay = OverlayEntry( - builder: (context) { - final texId = _textureId.value; - return Positioned.fill( - child: IgnorePointer( - child: Container( - color: Colors.black.withValues(alpha: 0.55), - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipOval( - child: SizedBox( - width: 260, - height: 260, - child: texId != null - ? Texture(textureId: texId) - : Container(color: Colors.black), - ), - ), - const SizedBox(height: 20), - ValueListenableBuilder( - valueListenable: _elapsedMs, - builder: (context, ms, _) => Text( - formatElapsed(ms), - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontFeatures: [ui.FontFeature.tabularFigures()], - ), - ), - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: _cancelDrag, - builder: (context, drag, _) => Opacity( - opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0), - child: const Text( - '‹ влево — отмена', - style: TextStyle(color: Colors.white70, fontSize: 13), - ), - ), - ), - ], - ), - ), - ), - ); - }, - ); - final overlay = Overlay.of(contextOf(), rootOverlay: true); - overlay.insert(_overlay!); - } - - void _hideOverlay() { - _overlay?.remove(); - _overlay = null; - } - void dispose() { + unawaited(ScreenWake.instance.release(this)); _timer?.cancel(); - _overlay?.remove(); _rec.dispose(); _textureId.dispose(); _videoNoteMode.dispose(); @@ -239,5 +317,245 @@ class VideoNoteController { _isRecording.dispose(); _elapsedMs.dispose(); _cancelDrag.dispose(); + _locked.dispose(); + _lockDrag.dispose(); + _flashOn.dispose(); } } + +class VideoNoteRecordingLayer extends StatefulWidget { + const VideoNoteRecordingLayer({super.key, required this.controller}); + + final VideoNoteController controller; + + @override + State createState() => + _VideoNoteRecordingLayerState(); +} + +class _VideoNoteRecordingLayerState extends State + with SingleTickerProviderStateMixin { + static const double _circle = 260; + static const double _maxBlur = AppFrost.overlaySigma; + + late final AnimationController _reveal = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + ); + + @override + void initState() { + super.initState(); + widget.controller.isRecording.addListener(_onRecordingChanged); + } + + void _onRecordingChanged() { + if (widget.controller.isRecording.value) { + _reveal.forward(); + } else { + _reveal.reverse(); + } + } + + @override + void dispose() { + widget.controller.isRecording.removeListener(_onRecordingChanged); + _reveal.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final controller = widget.controller; + return Positioned.fill( + child: AnimatedBuilder( + animation: _reveal, + builder: (context, child) { + final t = Curves.easeOut.transform(_reveal.value); + if (t <= 0.001) return const SizedBox.shrink(); + return Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + child: ClipRect( + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: _maxBlur * t, + sigmaY: _maxBlur * t, + ), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.35 * t), + ), + ), + ), + ), + ), + const Positioned.fill( + child: AbsorbPointer(child: SizedBox.expand()), + ), + Opacity(opacity: t, child: child), + ], + ); + }, + child: Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + child: Center( + child: ValueListenableBuilder( + valueListenable: controller.elapsedMs, + builder: (context, ms, child) => CustomPaint( + foregroundPainter: _NoteProgressPainter( + progress: (ms / VideoNoteController.maxMs).clamp( + 0.0, + 1.0, + ), + color: Colors.white, + ), + child: child, + ), + child: SizedBox( + width: _circle, + height: _circle, + child: Padding( + padding: const EdgeInsets.all(5), + child: ClipOval( + child: ValueListenableBuilder( + valueListenable: controller.textureId, + builder: (context, texId, _) => texId == null + ? _StubPreview(controller: controller) + : Texture(textureId: texId), + ), + ), + ), + ), + ), + ), + ), + ), + Positioned( + left: 12, + right: 12, + bottom: + MediaQuery.paddingOf(context).bottom + + widget.controller.bottomInset() + + 12, + child: Align( + alignment: Alignment.centerLeft, + child: _CameraControls(controller: controller, cs: cs), + ), + ), + ], + ), + ), + ); + } +} + +class _StubPreview extends StatelessWidget { + const _StubPreview({required this.controller}); + + final VideoNoteController controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.elapsedMs, + builder: (context, ms, _) { + final hue = (ms / 40) % 360; + return ColoredBox( + color: HSVColor.fromAHSV(1, hue, 0.45, 0.35).toColor(), + child: const Center( + child: Icon(Symbols.videocam, size: 64, color: Colors.white54), + ), + ); + }, + ); + } +} + +class _CameraControls extends StatelessWidget { + const _CameraControls({required this.controller, required this.cs}); + + final VideoNoteController controller; + final ColorScheme cs; + + @override + Widget build(BuildContext context) { + return GlossyPill( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(26), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + depth: 10, + elevated: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (controller.cameraControlsAvailable) + _ControlButton( + onTap: controller.flipCamera, + child: Icon( + Symbols.flip_camera_ios, + size: 24, + color: cs.onSurface, + fill: 1, + ), + ), + if (controller.flashAvailable) + ValueListenableBuilder( + valueListenable: controller.flashOn, + builder: (context, on, _) => _ControlButton( + onTap: controller.toggleFlash, + child: LottieSlashIcon( + asset: _flashIcon, + slashed: !on, + color: on ? cs.primary : cs.onSurface, + ), + ), + ), + ], + ), + ); + } +} + +class _ControlButton extends StatelessWidget { + const _ControlButton({required this.child, required this.onTap}); + + final Widget child; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkResponse( + onTap: onTap, + radius: 26, + child: Padding(padding: const EdgeInsets.all(8), child: child), + ); + } +} + +class _NoteProgressPainter extends CustomPainter { + const _NoteProgressPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + static const double _stroke = 4; + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0) return; + final circle = (Offset.zero & size).deflate(_stroke / 2); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = _stroke + ..strokeCap = StrokeCap.round + ..color = color; + canvas.drawArc(circle, -math.pi / 2, math.pi * 2 * progress, false, paint); + } + + @override + bool shouldRepaint(_NoteProgressPainter old) => old.progress != progress; +} diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart index 4fb57f3..5c800f8 100644 --- a/lib/frontend/screens/chats/chat/view/chat_header.dart +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -1,19 +1,38 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/core/config/app_frost.dart'; +import 'package:komet/core/config/app_stories.dart'; +import 'package:komet/core/utils/haptics.dart'; +import 'package:komet/frontend/screens/stories/story_owner_info.dart'; +import 'package:komet/frontend/screens/stories/story_ring.dart'; +import 'package:komet/frontend/screens/stories/story_viewer_screen.dart'; +import 'package:komet/frontend/widgets/encryption_lock_badge.dart'; import 'package:komet/frontend/widgets/glossy_pill.dart'; import 'package:komet/frontend/widgets/online_dot.dart'; +import 'package:komet/frontend/widgets/profile_hero.dart'; +import 'package:komet/main.dart' show storiesModule; +import 'package:komet/models/story.dart'; +import '../../../../../core/config/app_fonts.dart'; class ChatHeaderRow extends StatelessWidget { final bool glossy; + final bool frosted; + final bool backdropVisible; + final bool liquid; + final BackdropKey? backdropKey; final ColorScheme cs; final bool embedded; final int chatId; + final Object heroTag; final String name; final String imageUrl; final String chatType; final bool isOfficial; + final bool encrypted; final int myId; final ValueListenable headerStatus; final ValueListenable scheduledCount; @@ -28,13 +47,19 @@ class ChatHeaderRow extends StatelessWidget { const ChatHeaderRow({ super.key, required this.glossy, + required this.frosted, + this.backdropVisible = true, + this.liquid = false, + this.backdropKey, required this.cs, required this.embedded, required this.chatId, + required this.heroTag, required this.name, required this.imageUrl, required this.chatType, required this.isOfficial, + this.encrypted = false, required this.myId, required this.headerStatus, required this.scheduledCount, @@ -51,7 +76,18 @@ class ChatHeaderRow extends StatelessWidget { Widget build(BuildContext context) => glossy ? _glossyRow(context) : _materialRow(context); + Color? get _pillColor => frosted || liquid ? AppFrost.glassTint(cs) : null; + + double? get _pillBlur => + frosted && !liquid && backdropVisible ? AppFrost.sigma : null; + Widget _glossyRow(BuildContext context) { + final nameStyle = TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + fontFamily: displayFontOf(context), + ); return Padding( padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), child: Row( @@ -62,6 +98,10 @@ class ChatHeaderRow extends StatelessWidget { width: 56, height: 56, child: GlossyPill( + color: _pillColor, + blurSigma: _pillBlur, + liquid: liquid, + backdropKey: backdropKey, onTap: () { if (embedded) { onClose?.call(); @@ -83,34 +123,41 @@ class ChatHeaderRow extends StatelessWidget { const SizedBox(width: 8), Expanded( child: GlossyPill( + color: _pillColor, + blurSigma: _pillBlur, + liquid: liquid, + backdropKey: backdropKey, onTap: onOpenInfo, padding: const EdgeInsets.fromLTRB(6, 6, 16, 6), child: Row( children: [ _withOnlineDot( cs, - imageUrl.isNotEmpty - ? CircleAvatar( - radius: 22, - backgroundImage: CachedNetworkImageProvider( - imageUrl, - maxWidth: 144, - maxHeight: 144, - ), - ) - : CircleAvatar( - radius: 22, - backgroundColor: cs.primaryContainer, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + _heroAvatar( + 44, + (d) => imageUrl.isNotEmpty + ? CircleAvatar( + radius: d / 2, + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + ) + : CircleAvatar( + radius: d / 2, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: d * 0.36, + fontWeight: FontWeight.w600, + fontFamily: displayFontOf(context), + ), ), ), - ), + ), ), const SizedBox(width: 12), Expanded( @@ -122,16 +169,16 @@ class ChatHeaderRow extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + child: ProfileHeroName( + tag: heroTag, + text: name, + style: nameStyle, + child: Text( + name, + style: nameStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ), if (isOfficial) ...[ @@ -168,6 +215,10 @@ class ChatHeaderRow extends StatelessWidget { ), const SizedBox(width: 8), GlossyPill( + color: _pillColor, + blurSigma: _pillBlur, + liquid: liquid, + backdropKey: backdropKey, padding: const EdgeInsets.symmetric(horizontal: 2), child: SizedBox( height: 56, @@ -216,6 +267,12 @@ class ChatHeaderRow extends StatelessWidget { } Widget _materialRow(BuildContext context) { + final nameStyle = TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: displayFontOf(context), + ); return Row( children: [ _backWithBadge( @@ -242,26 +299,29 @@ class ChatHeaderRow extends StatelessWidget { children: [ _withOnlineDot( cs, - imageUrl.isNotEmpty - ? CircleAvatar( - radius: 18, - backgroundImage: CachedNetworkImageProvider( - imageUrl, - maxWidth: 144, - maxHeight: 144, - ), - ) - : CircleAvatar( - radius: 18, - backgroundColor: cs.primaryContainer, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 12, + _heroAvatar( + 36, + (d) => imageUrl.isNotEmpty + ? CircleAvatar( + radius: d / 2, + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + ) + : CircleAvatar( + radius: d / 2, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: d / 3, + ), ), ), - ), + ), dotSize: 11, ), const SizedBox(width: 12), @@ -274,16 +334,16 @@ class ChatHeaderRow extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + child: ProfileHeroName( + tag: heroTag, + text: name, + style: nameStyle, + child: Text( + name, + style: nameStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ), if (isOfficial) ...[ @@ -346,10 +406,76 @@ class ChatHeaderRow extends StatelessWidget { ); } + bool get _isSavedMessages => chatId == 0; + + int get _storyOwnerId => + chatType == 'DIALOG' ? (_isSavedMessages ? 0 : chatId ^ myId) : chatId; + + Widget _heroAvatar( + double size, + Widget Function(double diameter) avatarBuilder, + ) { + final ownerId = _storyOwnerId; + if (!AppStories.current.value || ownerId <= 0) { + return ProfileHeroAvatar( + tag: heroTag, + size: size, + child: avatarBuilder(size), + ); + } + const gap = 3.0; + return ValueListenableBuilder( + valueListenable: storiesModule.storiesChanged, + builder: (context, _, _) { + final preview = storiesModule.previewFor(ownerId); + final hasStory = preview != null && !preview.isEmpty; + final inner = hasStory ? size - gap * 2 : size; + return GestureDetector( + behavior: hasStory + ? HitTestBehavior.opaque + : HitTestBehavior.deferToChild, + onTap: hasStory ? () => _openStories(context, preview) : null, + child: StoryAvatarRing( + diameter: inner, + total: preview?.totalCount ?? 0, + read: preview?.readCount ?? 0, + strokeWidth: 2, + ringGap: hasStory ? gap : 0, + haloWidth: 1.2, + child: ProfileHeroAvatar( + tag: heroTag, + size: inner, + child: avatarBuilder(inner), + ), + ), + ); + }, + ); + } + + void _openStories(BuildContext context, StoryPreview preview) { + Haptics.tap(); + unawaited( + openStoryViewer( + context, + previews: [preview], + origin: storyOriginOf(context), + ownerOverrides: { + preview.owner.ownerId: StoryOwnerInfo( + name: name, + avatarUrl: imageUrl.isEmpty ? null : imageUrl, + ), + }, + ), + ); + } + Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) { final otherId = chatId ^ myId; - final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0; + final showDot = + chatType == 'DIALOG' && myId != 0 && otherId > 0 && !_isSavedMessages; return Stack( + clipBehavior: Clip.none, children: [ avatar, if (showDot) @@ -362,6 +488,12 @@ class ChatHeaderRow extends StatelessWidget { size: dotSize, ), ), + if (encrypted) + Positioned( + left: -2, + bottom: -2, + child: EncryptionLockBadge(size: dotSize + 4), + ), ], ); } 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/chat_preview_line.dart b/lib/frontend/screens/chats/chat/view/chat_preview_line.dart new file mode 100644 index 0000000..a9c55fb --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/chat_preview_line.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/core/utils/text_format.dart'; +import 'package:komet/frontend/widgets/formatted_message_text.dart'; +import 'package:komet/models/chat_preview_media.dart'; + +const String _forwardMark = '↪ '; + +IconData? chatKindIcon(String chatType, {required bool isBot}) { + switch (chatType) { + case 'CHANNEL': + return Symbols.campaign; + case 'CHAT': + case 'GROUP': + return Symbols.group; + default: + return isBot ? Symbols.smart_toy : null; + } +} + +IconData previewKindIcon(ChatPreviewKind kind) { + switch (kind) { + case ChatPreviewKind.photo: + return Symbols.image; + case ChatPreviewKind.video: + return Symbols.movie; + case ChatPreviewKind.videoNote: + return Symbols.videocam; + case ChatPreviewKind.audio: + return Symbols.mic; + case ChatPreviewKind.file: + return Symbols.description; + case ChatPreviewKind.sticker: + return Symbols.emoji_emotions; + case ChatPreviewKind.contact: + return Symbols.person; + case ChatPreviewKind.location: + return Symbols.location_on; + case ChatPreviewKind.poll: + return Symbols.bar_chart; + case ChatPreviewKind.share: + return Symbols.link; + case ChatPreviewKind.call: + return Symbols.call; + case ChatPreviewKind.missedCall: + return Symbols.call_missed; + case ChatPreviewKind.videoCall: + return Symbols.videocam; + case ChatPreviewKind.missedVideoCall: + return Symbols.missed_video_call; + case ChatPreviewKind.control: + return Symbols.info; + case ChatPreviewKind.other: + return Symbols.attach_file; + } +} + +const Set _iconWithCaption = { + ChatPreviewKind.photo, + ChatPreviewKind.video, + ChatPreviewKind.videoNote, + ChatPreviewKind.audio, + ChatPreviewKind.file, + ChatPreviewKind.sticker, + ChatPreviewKind.contact, + ChatPreviewKind.location, + ChatPreviewKind.poll, + ChatPreviewKind.call, + ChatPreviewKind.missedCall, + ChatPreviewKind.videoCall, + ChatPreviewKind.missedVideoCall, +}; + +class ChatPreviewLine extends StatelessWidget { + final String prefix; + final String text; + final List ranges; + final ChatPreviewMedia? media; + final TextStyle style; + final bool italic; + + const ChatPreviewLine({ + super.key, + required this.text, + required this.style, + this.prefix = '', + this.ranges = const [], + this.media, + this.italic = false, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final preview = media; + final label = preview?.label; + final detail = preview?.detail; + final labelled = label != null || detail != null; + final forwarded = label != null && label.startsWith(_forwardMark); + + final bodyStyle = style.copyWith( + fontStyle: italic || labelled ? FontStyle.italic : style.fontStyle, + ); + + final spans = []; + if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style)); + if (forwarded) spans.add(TextSpan(text: _forwardMark, style: bodyStyle)); + + final leading = _leading( + cs, + preview, + labelled: labelled, + gap: detail == null ? 4 : 0, + ); + if (leading != null) { + spans.add( + WidgetSpan(alignment: PlaceholderAlignment.middle, child: leading), + ); + } + + if (labelled) { + final body = detail != null + ? ': $detail' + : label!.substring(forwarded ? _forwardMark.length : 0); + spans.add(TextSpan(text: body, style: bodyStyle)); + } else { + spans.addAll( + FormattedMessageText.buildInlineChildren(text, ranges, bodyStyle), + ); + } + + return Text.rich( + TextSpan(style: bodyStyle, children: spans), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + Widget? _leading( + ColorScheme cs, + ChatPreviewMedia? preview, { + required bool labelled, + required double gap, + }) { + if (preview == null) return null; + final size = (style.fontSize ?? 14) + 2; + if (preview.thumbs.isNotEmpty) { + return Padding( + padding: EdgeInsets.only(right: gap), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 2, + children: [ + for (final thumb in preview.thumbs) + _PreviewThumb(thumb: thumb, size: size), + ], + ), + ); + } + if (!labelled && !_iconWithCaption.contains(preview.kind)) return null; + return Padding( + padding: EdgeInsets.only(right: gap), + child: Icon( + previewKindIcon(preview.kind), + size: size, + color: cs.outline, + weight: 500, + ), + ); + } +} + +class _PreviewThumb extends StatelessWidget { + final ChatPreviewThumb thumb; + final double size; + + const _PreviewThumb({required this.thumb, required this.size}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final provider = _provider(); + return ClipRRect( + borderRadius: BorderRadius.circular(3), + child: SizedBox( + width: size, + height: size, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: cs.surfaceContainerHighest), + if (provider != null) + Image(image: provider, fit: BoxFit.cover, gaplessPlayback: true), + if (thumb.video) + Center( + child: Icon( + Symbols.play_arrow, + size: size * 0.7, + fill: 1, + color: Colors.white, + shadows: const [Shadow(color: Colors.black54, blurRadius: 2)], + ), + ), + ], + ), + ), + ); + } + + ImageProvider? _provider() => _thumbProvider(thumb.source); +} + +const int _thumbCacheLimit = 128; +final Map _thumbCache = {}; + +ImageProvider? _thumbProvider(String source) { + final cached = _thumbCache[source]; + if (cached != null) return cached; + final ImageProvider? provider; + if (source.startsWith('data:')) { + provider = _decodeDataUri(source); + } else if (source.startsWith('http')) { + provider = CachedNetworkImageProvider(source, maxWidth: 64, maxHeight: 64); + } else { + provider = null; + } + if (provider == null) return null; + if (_thumbCache.length >= _thumbCacheLimit) { + _thumbCache.remove(_thumbCache.keys.first); + } + _thumbCache[source] = provider; + return provider; +} + +ImageProvider? _decodeDataUri(String source) { + final comma = source.indexOf(','); + if (comma < 0) return null; + try { + return MemoryImage(base64Decode(source.substring(comma + 1))); + } catch (_) { + return null; + } +} diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index 4f2ee27..d413b50 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -8,10 +8,15 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/backend/modules/messages.dart'; import 'package:komet/core/config/app_chat_chrome.dart'; import 'package:komet/core/config/app_colors.dart'; +import 'package:komet/core/config/app_composer_background.dart'; +import 'package:komet/core/config/app_composer_style.dart'; +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'; class ComposerInputBar extends StatelessWidget { @@ -19,8 +24,12 @@ class ComposerInputBar extends StatelessWidget { super.key, required this.chatType, required this.chrome, + required this.style, + required this.background, + this.backdropKey, required this.attachAnim, required this.replyTo, + required this.forwardMessages, required this.myId, required this.hasText, required this.uploadStatus, @@ -35,16 +44,31 @@ class ComposerInputBar extends StatelessWidget { required this.onOpenAttachScheduled, required this.onSendHistory, required this.onCancelReply, + required this.onCancelForward, + this.onPickReplyChat, required this.formatElapsed, required this.contextMenuBuilder, required this.isMuted, required this.onToggleMute, + this.channelSubscribed = true, + this.channelSubscribing = false, + this.onSubscribe, + this.showStickerButton = true, + this.showAttachButton = true, + this.forceSend = false, + this.hintText = 'Message', + this.bottomSafe = true, + this.vignette = false, }); final String chatType; final ChatChromeStyle chrome; + final ComposerStyle style; + final ComposerBackground background; + final BackdropKey? backdropKey; final Animation attachAnim; final ValueListenable replyTo; + final ValueListenable> forwardMessages; final int myId; final ValueListenable hasText; final ValueListenable uploadStatus; @@ -59,17 +83,79 @@ class ComposerInputBar extends StatelessWidget { final VoidCallback onOpenAttachScheduled; final Future Function(FileHistoryEntry entry) onSendHistory; final VoidCallback onCancelReply; + final VoidCallback onCancelForward; + final VoidCallback? onPickReplyChat; final String Function(int ms) formatElapsed; final Widget Function(BuildContext, EditableTextState) contextMenuBuilder; final bool isMuted; final VoidCallback onToggleMute; + final bool channelSubscribed; + final bool channelSubscribing; + final VoidCallback? onSubscribe; + final bool showStickerButton; + final bool showAttachButton; + final bool forceSend; + final String hintText; + final bool bottomSafe; + final bool vignette; @override Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: forwardMessages, + builder: (context, forwards, _) => _build(context, forwards), + ); + } + + Widget _build(BuildContext context, List forwards) { final cs = Theme.of(context).colorScheme; final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85); + final hasForward = forwards.isNotEmpty; - if (chatType == "CHANNEL") { + if (chatType == "CHANNEL" && !hasForward) { + if (!channelSubscribed) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + child: GlossyPill( + onTap: channelSubscribing ? null : onSubscribe, + color: cs.primary, + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.symmetric(vertical: 16), + depth: 8, + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + child: SizedBox( + width: double.infinity, + child: Center( + child: channelSubscribing + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text( + 'Подписаться', + style: TextStyle( + color: cs.onPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + } return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), @@ -104,15 +190,16 @@ class ComposerInputBar extends StatelessWidget { ); } - return SafeArea( + final bar = SafeArea( + bottom: bottomSafe, child: Column( mainAxisSize: MainAxisSize.min, children: [ - _replyPreview(cs), + _messagePreview(cs, forwards), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, + padding: EdgeInsets.symmetric( + horizontal: _barSideInset, + vertical: _barVerticalInset, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, @@ -120,105 +207,129 @@ class ComposerInputBar extends StatelessWidget { Expanded( child: AnimatedContainer( duration: const Duration(milliseconds: 200), - constraints: const BoxConstraints( - minHeight: 54, + constraints: BoxConstraints( + minHeight: _controlSize, maxHeight: 180, ), - child: GlossyPill( - color: Color.alphaBlend( - cs.surfaceContainerHighest.withValues(alpha: 0.92), - cs.surface, - ), - borderRadius: BorderRadius.circular(28), - depth: 8, - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.5), - width: 0.5, - ), - child: Stack( + child: _fieldSurface( + cs, + Stack( alignment: Alignment.center, children: [ AnimatedBuilder( - animation: attachAnim, + animation: Listenable.merge([ + voiceRec.isRecording, + note.isRecording, + ]), builder: (context, child) { - final t = attachAnim.value; + final recording = + voiceRec.isRecording.value || + note.isRecording.value; return IgnorePointer( - ignoring: t > 0.5, - child: Opacity( - opacity: (1 - t).clamp(0.0, 1.0), + ignoring: recording, + child: AnimatedOpacity( + opacity: recording ? 0 : 1, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, child: child, ), ); }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onToggleStickerPanel, - child: Icon( - Symbols.face, - color: mutedIcon, - size: 24, - weight: 400, - ), + child: AnimatedBuilder( + animation: attachAnim, + builder: (context, child) { + final t = attachAnim.value; + return IgnorePointer( + ignoring: t > 0.5, + child: Opacity( + opacity: (1 - t).clamp(0.0, 1.0), + child: child, ), - const SizedBox(width: 12), - Expanded( - child: Focus( - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - event.logicalKey == - LogicalKeyboardKey.enter && - !HardwareKeyboard - .instance - .isShiftPressed) { - if (hasText.value) onSendText(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: TextField( - controller: messageController, - focusNode: messageFocusNode, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, + ); + }, + child: Padding( + padding: EdgeInsets.only( + left: _fieldSideInset, + right: _fieldTrailingInset, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showStickerButton) ...[ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggleStickerPanel, + child: Icon( + Symbols.face, + color: mutedIcon, + size: 24, + weight: 400, ), - maxLines: null, - keyboardType: TextInputType.multiline, - textAlignVertical: - TextAlignVertical.center, - contextMenuBuilder: contextMenuBuilder, - decoration: InputDecoration( - hintText: 'Message', - hintStyle: TextStyle( - color: cs.onSurfaceVariant, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == + LogicalKeyboardKey.enter && + !HardwareKeyboard + .instance + .isShiftPressed) { + if (hasText.value || + hasForward || + forceSend) { + onSendText(); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: messageController, + focusNode: messageFocusNode, + style: TextStyle( + color: cs.onSurface, fontSize: 16, ), - border: InputBorder.none, - isDense: true, - contentPadding: - const EdgeInsets.symmetric( - vertical: 14, - ), + maxLines: null, + keyboardType: TextInputType.multiline, + textCapitalization: + TextCapitalization.sentences, + textAlignVertical: + TextAlignVertical.center, + contextMenuBuilder: + contextMenuBuilder, + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: + const EdgeInsets.symmetric( + vertical: 10, + ), + ), ), ), ), - ), - _AttachButton( - hasText: hasText, - onOpen: onOpenAttach, - onLongOpen: onOpenAttachScheduled, - uploadStatus: uploadStatus, - mutedIcon: mutedIcon, - cs: cs, - ), - ], + if (showAttachButton) + _AttachButton( + hasText: hasText, + onOpen: onOpenAttach, + onLongOpen: onOpenAttachScheduled, + uploadStatus: uploadStatus, + mutedIcon: mutedIcon, + cs: cs, + slot: _attachSlot, + leading: _attachLeading, + ), + ], + ), ), ), ), @@ -227,7 +338,7 @@ class ComposerInputBar extends StatelessWidget { right: 0, bottom: 0, child: SizedBox( - height: 54, + height: _controlSize, child: AnimatedBuilder( animation: attachAnim, builder: (context, child) { @@ -249,24 +360,34 @@ class ComposerInputBar extends StatelessWidget { ), ), Positioned.fill( - child: ValueListenableBuilder( - valueListenable: voiceRec.isRecording, - builder: (context, recording, _) => IgnorePointer( - ignoring: !recording, - child: AnimatedSlide( - offset: recording - ? Offset.zero - : const Offset(0.06, 0), - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - child: AnimatedOpacity( - opacity: recording ? 1 : 0, - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - child: _voiceRecordingIndicator(cs), + child: AnimatedBuilder( + animation: Listenable.merge([ + voiceRec.isRecording, + note.isRecording, + ]), + builder: (context, _) { + final video = note.isRecording.value; + final recording = + video || voiceRec.isRecording.value; + return IgnorePointer( + ignoring: !recording, + child: AnimatedSlide( + offset: recording + ? Offset.zero + : const Offset(0.06, 0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: recording ? 1 : 0, + duration: const Duration( + milliseconds: 180, + ), + curve: Curves.easeOut, + child: _recordingIndicator(cs, video), + ), ), - ), - ), + ); + }, ), ), ], @@ -290,7 +411,7 @@ class ComposerInputBar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 8), + SizedBox(width: _actionGap), AnimatedBuilder( animation: attachAnim, builder: (context, child) { @@ -308,51 +429,80 @@ class ComposerInputBar extends StatelessWidget { builder: (context, hasText, _) => ValueListenableBuilder( valueListenable: voiceRec.locked, - builder: (context, locked, _) => + builder: (context, voiceLocked, _) => ValueListenableBuilder( valueListenable: voiceRec.isRecording, - builder: (context, recording, _) => - ValueListenableBuilder( - valueListenable: note.videoNoteMode, - builder: (context, videoMode, _) { + builder: (context, voiceRecording, _) => + AnimatedBuilder( + animation: Listenable.merge([ + note.videoNoteMode, + note.isRecording, + note.locked, + ]), + builder: (context, _) { + final videoMode = + note.videoNoteMode.value; + final noteRecording = + note.isRecording.value; + final recording = + voiceRecording || + noteRecording; + final locked = noteRecording + ? note.locked.value + : voiceLocked; final sendMode = - hasText || locked; - final pill = GlossyPill( - color: sendMode - ? cs.primary + hasText || + hasForward || + locked || + forceSend; + final pill = _actionSurface( + color: _flat + ? Colors.transparent : recording ? cs.error + : _frost + ? AppFrost.glassTint(cs) : cs.surfaceContainerHighest, - borderRadius: - BorderRadius.circular(27), - onTap: hasText + onTap: + (hasText || + hasForward || + forceSend) ? onSendText : locked - ? () => voiceRec.stop( - cancel: false, - ) + ? () => noteRecording + ? note.stop( + cancel: false, + ) + : voiceRec.stop( + cancel: false, + ) : null, - onLongPress: hasText + onLongPress: + (hasText && + !forceSend && + !hasForward) ? onScheduleMessage : null, - depth: 8, child: SizedBox( - width: 54, - height: 54, + width: _controlSize, + height: _controlSize, child: Center( - child: Icon( - sendMode - ? Symbols.send + child: ComposerMorphIcon( + action: sendMode + ? ComposerAction.send : videoMode - ? Symbols.videocam - : Symbols.mic, - color: sendMode - ? cs.onPrimary - : recording - ? cs.onError + ? ComposerAction + .videocam + : ComposerAction.mic, + color: recording + ? (_flat + ? cs.error + : cs.onError) + : sendMode + ? cs.primary + : _flat + ? cs.onSurfaceVariant : cs.onSurface, - size: 24, - weight: 400, ), ), ), @@ -364,30 +514,32 @@ class ComposerInputBar extends StatelessWidget { active: recording && !locked, ); + final voiceEnabled = + !sendMode && !forceSend; return GestureDetector( - onTap: sendMode - ? null - : note.toggleMode, - onLongPressStart: sendMode - ? null - : (_) => videoMode + onTap: voiceEnabled + ? note.toggleMode + : null, + onLongPressStart: voiceEnabled + ? (_) => videoMode ? note.start() - : voiceRec.start(), - onLongPressMoveUpdate: sendMode - ? null - : (d) => videoMode + : voiceRec.start() + : null, + onLongPressMoveUpdate: + voiceEnabled + ? (d) => videoMode ? note.handleDrag( d.offsetFromOrigin, ) : voiceRec.handleDrag( d.offsetFromOrigin, - ), - onLongPressEnd: sendMode - ? null - : (_) => videoMode + ) + : null, + onLongPressEnd: voiceEnabled + ? (_) => videoMode ? note.handleEnd() - : voiceRec - .handleEnd(), + : voiceRec.handleEnd() + : null, child: visual, ); }, @@ -405,6 +557,198 @@ class ComposerInputBar extends StatelessWidget { ], ), ); + + return _barSurface(cs, bar); + } + + bool get _flat => !ComposerChrome.isGlossy(style); + + bool get _frost => ComposerMaterial.isFrost(background); + + bool get _liquid => ComposerMaterial.isLiquid(background); + + bool get _translucent => _frost || _liquid; + + double get _controlSize => _flat ? 48 : 54; + + double get _barSideInset => _flat ? 0 : 12; + + double get _barVerticalInset => _flat ? 4 : 8; + + double get _fieldSideInset => _flat ? 12 : 14; + + double get _fieldTrailingInset => _flat ? 0 : 14; + + double get _actionGap => _flat ? 0 : 8; + + double get _attachSlot => _flat ? 48 : 36; + + double get _attachLeading => _flat ? 0 : 12; + + Widget _barSurface(ColorScheme cs, Widget child) { + if (!_flat || _translucent) return child; + if (chrome == ChatChromeStyle.blur) return child; + return DecoratedBox( + decoration: BoxDecoration( + color: cs.surface, + border: vignette ? null : Border(top: AppFrost.hairline(cs)), + ), + child: child, + ); + } + + Widget _fieldSurface(ColorScheme cs, Widget child) { + if (_flat) return child; + return GlossyPill( + color: _translucent + ? AppFrost.glassTint(cs) + : Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + blurSigma: _frost ? AppFrost.sigma : null, + liquid: _liquid, + backdropKey: backdropKey, + borderRadius: BorderRadius.circular(28), + depth: 8, + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + child: child, + ); + } + + Widget _actionSurface({ + required Color color, + 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: InkWell(onTap: onTap, onLongPress: onLongPress, child: child), + ); + } + return GlossyPill( + color: color, + blurSigma: _frost ? AppFrost.sigma : null, + liquid: _liquid, + backdropKey: backdropKey, + borderRadius: BorderRadius.circular(_controlSize / 2), + onTap: onTap, + onLongPress: onLongPress, + keepInkLayer: true, + depth: 8, + child: child, + ); + } + + Widget _replyIconButton(ColorScheme cs) { + final icon = Icon(Symbols.reply, size: 20, color: cs.primary); + if (onPickReplyChat == null) return icon; + return InkWell( + onTap: onPickReplyChat, + customBorder: const CircleBorder(), + child: Padding(padding: const EdgeInsets.all(4), child: icon), + ); + } + + Widget _messagePreview(ColorScheme cs, List forwards) { + if (forwards.isNotEmpty) return _forwardPreview(cs, forwards); + return _replyPreview(cs); + } + + Widget _forwardPreview(ColorScheme cs, List messages) { + final first = messages.first; + final senderName = ContactCache.get(first.senderId); + final info = ReplyInfo( + senderId: first.senderId, + text: first.text, + attachments: first.attachments, + ); + final preview = info.previewText(); + final title = messages.length == 1 + ? first.senderId == myId + ? 'Пересылка от вас' + : senderName == null + ? 'Пересылка сообщения' + : 'Пересылка от $senderName' + : 'Пересылка: ${_forwardCount(messages.length)}'; + final row = Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), + child: Row( + children: [ + Icon(Symbols.forward, size: 20, color: cs.primary), + const SizedBox(width: 10), + Container(width: 2, height: 34, color: cs.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (preview.isNotEmpty) + Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + IconButton( + icon: const Icon(Symbols.close, size: 20), + color: cs.onSurfaceVariant, + onPressed: onCancelForward, + ), + ], + ), + ); + return _previewSurface(cs, row); + } + + String _forwardCount(int count) { + final last = count % 10; + final lastTwo = count % 100; + if (last == 1 && lastTwo != 11) return '$count сообщение'; + if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) { + return '$count сообщения'; + } + return '$count сообщений'; } Widget _replyPreview(ColorScheme cs) { @@ -425,7 +769,7 @@ class ComposerInputBar extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), child: Row( children: [ - Icon(Symbols.reply, size: 20, color: cs.primary), + _replyIconButton(cs), const SizedBox(width: 10), Container(width: 2, height: 34, color: cs.primary), const SizedBox(width: 10), @@ -465,28 +809,23 @@ class ComposerInputBar extends StatelessWidget { ], ), ); - if (chrome != ChatChromeStyle.transparent) return row; - return ClipRect( - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 34, sigmaY: 34), - child: DecoratedBox( - decoration: BoxDecoration( - color: cs.surface.withValues(alpha: 0.38), - border: Border( - top: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - width: 0.5, - ), - ), - ), - child: row, - ), - ), - ); + return _previewSurface(cs, row); }, ); } + Widget _previewSurface(ColorScheme cs, Widget child) { + if (_flat && _translucent) return child; + if (!_translucent && chrome != ChatChromeStyle.transparent) return child; + return GlassSurface( + liquid: _liquid, + frostTint: AppFrost.glassTint(cs), + border: Border(top: AppFrost.hairline(cs)), + backdropKey: backdropKey, + child: child, + ); + } + Widget _recordingButtonVisual({ required Widget pill, required ColorScheme cs, @@ -497,11 +836,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); @@ -510,8 +848,8 @@ class ComposerInputBar extends StatelessWidget { alignment: Alignment.center, children: [ Positioned( - left: 27 - glow / 2, - top: 27 - glow / 2, + left: _controlSize / 2 - glow / 2, + top: _controlSize / 2 - glow / 2, child: Container( width: glow, height: glow, @@ -523,7 +861,14 @@ class ComposerInputBar extends StatelessWidget { ), ), ), - _voiceLockChip(cs), + ValueListenableBuilder( + valueListenable: note.isRecording, + builder: (context, video, _) => _lockChip( + cs, + a, + video ? note.lockDrag : voiceRec.lockDrag, + ), + ), Transform.scale( scale: 1.0 + a * 0.14 + a * v * 0.24, child: pill, @@ -537,13 +882,17 @@ class ComposerInputBar extends StatelessWidget { ); } - Widget _voiceLockChip(ColorScheme cs) { + Widget _lockChip( + ColorScheme cs, + double reveal, + ValueListenable lockDrag, + ) { return Positioned( - bottom: 62, + bottom: _controlSize + 8, child: ValueListenableBuilder( - valueListenable: voiceRec.lockDrag, + valueListenable: 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( @@ -583,29 +932,28 @@ class ComposerInputBar extends StatelessWidget { ); } - Widget _voiceRecordingIndicator(ColorScheme cs) { - return Container( - color: Color.alphaBlend( - cs.surfaceContainerHighest.withValues(alpha: 0.92), - cs.surface, - ), - padding: const EdgeInsets.symmetric(horizontal: 16), + Widget _recordingIndicator(ColorScheme cs, bool video) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: _fieldSideInset), child: Row( children: [ - ValueListenableBuilder( - valueListenable: voiceRec.amplitude, - builder: (context, amp, child) => TweenAnimationBuilder( - tween: Tween(begin: 0, end: amp), - duration: const Duration(milliseconds: 120), - builder: (context, v, child) => - Transform.scale(scale: 1.0 + v * 0.7, child: child), - child: child, + if (video) + _RecordingDot(color: cs.error) + else + ValueListenableBuilder( + valueListenable: voiceRec.amplitude, + builder: (context, amp, child) => TweenAnimationBuilder( + tween: Tween(begin: 0, end: amp), + duration: const Duration(milliseconds: 120), + builder: (context, v, child) => + Transform.scale(scale: 1.0 + v * 0.7, child: child), + child: child, + ), + child: _RecordingDot(color: cs.error), ), - child: _RecordingDot(color: cs.error), - ), const SizedBox(width: 12), ValueListenableBuilder( - valueListenable: voiceRec.elapsedMs, + valueListenable: video ? note.elapsedMs : voiceRec.elapsedMs, builder: (context, ms, _) => Text( formatElapsed(ms), style: TextStyle( @@ -618,8 +966,22 @@ class ComposerInputBar extends StatelessWidget { const SizedBox(width: 14), Expanded( child: ValueListenableBuilder( - valueListenable: voiceRec.cancelDrag, + valueListenable: video ? note.cancelDrag : voiceRec.cancelDrag, builder: (context, drag, _) { + if (video) { + return Opacity( + opacity: (0.55 + drag * 0.45).clamp(0.0, 1.0), + child: Center( + child: Text( + '‹ Влево — отмена', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + ), + ); + } if (drag > 0.01) { return Opacity( opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0), @@ -661,16 +1023,20 @@ class ComposerInputBar extends StatelessWidget { ), const SizedBox(width: 8), ValueListenableBuilder( - valueListenable: voiceRec.locked, + valueListenable: video ? note.locked : voiceRec.locked, builder: (context, locked, _) => locked ? GestureDetector( - onTap: () => voiceRec.stop(cancel: true), + onTap: () => video + ? note.stop(cancel: true) + : voiceRec.stop(cancel: true), behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Icon(Symbols.delete, size: 22, color: cs.error), ), ) + : video + ? const SizedBox.shrink() : Text( '‹ влево — отмена', style: TextStyle(color: cs.mutedText, fontSize: 11), @@ -689,6 +1055,8 @@ class _AttachButton extends StatelessWidget { final ValueListenable uploadStatus; final Color mutedIcon; final ColorScheme cs; + final double slot; + final double leading; const _AttachButton({ required this.hasText, @@ -697,6 +1065,8 @@ class _AttachButton extends StatelessWidget { required this.uploadStatus, required this.mutedIcon, required this.cs, + required this.slot, + required this.leading, }); @override @@ -716,7 +1086,7 @@ class _AttachButton extends StatelessWidget { final onLongPress = disabled ? null : onLongOpen; return AnimatedContainer( duration: const Duration(milliseconds: 200), - width: isText ? 0 : 36, + width: isText ? 0 : slot, child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: isText ? 0 : 1, @@ -727,7 +1097,7 @@ class _AttachButton extends StatelessWidget { onTap: onTap, onLongPress: onLongPress, child: Padding( - padding: const EdgeInsets.only(left: 12), + padding: EdgeInsets.only(left: leading), child: Stack( alignment: Alignment.center, children: [ diff --git a/lib/frontend/screens/chats/chat/view/mention_panel_view.dart b/lib/frontend/screens/chats/chat/view/mention_panel_view.dart new file mode 100644 index 0000000..cf33ed1 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/mention_panel_view.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:komet/frontend/screens/chats/chat/mention_panel_controller.dart'; +import 'package:komet/frontend/widgets/mention_suggestions_panel.dart'; + +class MentionPanelView extends StatelessWidget { + const MentionPanelView({super.key, required this.mentionPanel}); + + final MentionPanelController mentionPanel; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: mentionPanel.anim, + child: ValueListenableBuilder>( + valueListenable: mentionPanel.matches, + builder: (context, matches, _) => ValueListenableBuilder( + valueListenable: mentionPanel.loadingMore, + builder: (context, loading, _) => MentionSuggestionsPanel( + candidates: matches, + loadingMore: loading && mentionPanel.hasMore, + onSelected: mentionPanel.select, + onLoadMore: mentionPanel.loadMore, + ), + ), + ), + builder: (context, child) { + final t = mentionPanel.anim.value; + if (t == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: IgnorePointer( + ignoring: t < 1, + child: Opacity(opacity: t, child: child), + ), + ); + }, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/view/search_view.dart b/lib/frontend/screens/chats/chat/view/search_view.dart index 2c7c683..82bf958 100644 --- a/lib/frontend/screens/chats/chat/view/search_view.dart +++ b/lib/frontend/screens/chats/chat/view/search_view.dart @@ -9,8 +9,10 @@ import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/widgets/animated_lottie_icon.dart'; import 'package:komet/frontend/widgets/glossy_pill.dart'; import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/frontend/widgets/small_spinner.dart'; import 'package:komet/frontend/screens/chats/chat/chat_search_controller.dart'; import 'package:komet/frontend/screens/chats/chat/message_search_result.dart'; +import '../../../../../core/config/app_fonts.dart'; class SearchTopBar extends StatelessWidget { const SearchTopBar({ @@ -36,13 +38,17 @@ class SearchTopBar extends StatelessWidget { textInputAction: TextInputAction.search, onSubmitted: search.submit, cursorColor: cs.primary, - style: TextStyle(color: cs.onSurface, fontSize: 16, fontFamily: 'Outfit'), + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontFamily: displayFontOf(context), + ), decoration: InputDecoration( hintText: 'Поиск...', hintStyle: TextStyle( color: cs.onSurfaceVariant, fontSize: 16, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), border: InputBorder.none, isDense: true, @@ -163,19 +169,13 @@ class SearchOverlay extends StatelessWidget { bottom: MediaQuery.paddingOf(context).bottom + 16, ), itemCount: results.length, - itemBuilder: (context, index) => _tile(results[index]), + itemBuilder: (context, index) => + _tile(context, results[index]), ); } if (loading) { return Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: cs.onSurfaceVariant, - ), - ), + child: SmallSpinner(size: 26, color: cs.onSurfaceVariant), ); } return ValueListenableBuilder( @@ -202,7 +202,7 @@ class SearchOverlay extends StatelessWidget { ); } - Widget _tile(MessageSearchResult r) { + Widget _tile(BuildContext context, MessageSearchResult r) { final name = senderName(r.senderId); final date = formatDateWords(DateTime.fromMillisecondsSinceEpoch(r.time)); return InkWell( @@ -234,7 +234,7 @@ class SearchOverlay extends StatelessWidget { color: cs.primary, fontSize: 15, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), diff --git a/lib/frontend/screens/chats/chat/view/selection_bar.dart b/lib/frontend/screens/chats/chat/view/selection_bar.dart index 8344868..882c2f8 100644 --- a/lib/frontend/screens/chats/chat/view/selection_bar.dart +++ b/lib/frontend/screens/chats/chat/view/selection_bar.dart @@ -2,15 +2,16 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/backend/modules/messages.dart'; import 'package:komet/frontend/widgets/glossy_pill.dart'; +import '../../../../../core/config/app_fonts.dart'; class SelectionTopBar extends StatelessWidget { final ColorScheme cs; final Set selected; final bool glossy; - final CachedMessage? copyMsg; + final List copyMsgs; final CachedMessage? editMsg; final VoidCallback onClear; - final void Function(CachedMessage) onCopy; + final void Function(List) onCopy; final void Function(CachedMessage) onEdit; final VoidCallback onDelete; @@ -19,7 +20,7 @@ class SelectionTopBar extends StatelessWidget { required this.cs, required this.selected, required this.glossy, - required this.copyMsg, + required this.copyMsgs, required this.editMsg, required this.onClear, required this.onCopy, @@ -51,14 +52,14 @@ class SelectionTopBar extends StatelessWidget { color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), - if (copyMsg != null) + if (copyMsgs.isNotEmpty) IconButton( icon: Icon(Symbols.content_copy, color: cs.onSurface), - onPressed: () => onCopy(copyMsg!), + onPressed: () => onCopy(copyMsgs), ), if (editMsg != null) IconButton( @@ -114,7 +115,7 @@ class SelectionTopBar extends StatelessWidget { color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), @@ -129,8 +130,8 @@ class SelectionTopBar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (copyMsg != null) - actionBtn(Symbols.content_copy, () => onCopy(copyMsg!)), + if (copyMsgs.isNotEmpty) + actionBtn(Symbols.content_copy, () => onCopy(copyMsgs)), if (editMsg != null) actionBtn(Symbols.edit, () => onEdit(editMsg!)), actionBtn(Symbols.delete, onDelete), @@ -149,6 +150,7 @@ class SelectionBottomBar extends StatelessWidget { final Set selected; final VoidCallback onReply; final VoidCallback onForward; + final bool allowForward; const SelectionBottomBar({ super.key, @@ -156,6 +158,7 @@ class SelectionBottomBar extends StatelessWidget { required this.selected, required this.onReply, required this.onForward, + this.allowForward = true, }); @override @@ -169,6 +172,7 @@ class SelectionBottomBar extends StatelessWidget { if (single) ...[ Expanded( child: _pill( + context, cs, icon: Symbols.reply, label: 'Ответить', @@ -176,18 +180,22 @@ class SelectionBottomBar extends StatelessWidget { onTap: onReply, ), ), - const SizedBox(width: 12), + if (allowForward) const SizedBox(width: 12), ] else const Spacer(), - Expanded( - child: _pill( - cs, - icon: Symbols.forward, - label: 'Переслать', - iconLeading: true, - onTap: onForward, - ), - ), + if (allowForward) + Expanded( + child: _pill( + context, + cs, + icon: Symbols.forward, + label: 'Переслать', + iconLeading: true, + onTap: onForward, + ), + ) + else if (!single) + const Spacer(), ], ), ), @@ -195,6 +203,7 @@ class SelectionBottomBar extends StatelessWidget { } Widget _pill( + BuildContext context, ColorScheme cs, { required IconData icon, required String label, @@ -207,7 +216,7 @@ class SelectionBottomBar extends StatelessWidget { color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ); final iconWidget = Icon(icon, color: cs.onSurface, size: 22, weight: 500); diff --git a/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart index 374857f..69795bb 100644 --- a/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart +++ b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart @@ -20,14 +20,21 @@ class StickerPanelView extends StatelessWidget { @override Widget build(BuildContext context) { + final media = MediaQuery.of(context); + stickers.maxHeight = media.size.height - media.padding.top - 160; + return AnimatedBuilder( animation: stickers.anim, child: LottieHoldScope( isHeld: stickers.panelHold, - child: StickerPanel( - height: stickers.panelHeight, - onStickerTap: onStickerTap, - onEmojiTap: onEmojiTap, + child: ValueListenableBuilder( + valueListenable: stickers.panelHeight, + builder: (context, height, _) => StickerPanel( + height: height, + onStickerTap: onStickerTap, + onEmojiTap: onEmojiTap, + onResize: stickers.resizeBy, + ), ), ), builder: (context, child) { diff --git a/lib/frontend/screens/chats/chat/voice_record_controller.dart b/lib/frontend/screens/chats/chat/voice_record_controller.dart index 493ae12..26fda70 100644 --- a/lib/frontend/screens/chats/chat/voice_record_controller.dart +++ b/lib/frontend/screens/chats/chat/voice_record_controller.dart @@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart'; import '../../../../core/media/opus_ogg_encoder.dart'; import '../../../../core/utils/haptics.dart'; +import '../../../../core/utils/screen_wake.dart'; import '../../../widgets/custom_notification.dart'; class VoiceRecordController { @@ -112,6 +113,7 @@ class VoiceRecordController { _locked.value = false; _lockDrag.value = 0; _isRecording.value = true; + unawaited(ScreenWake.instance.acquire(this)); FocusManager.instance.primaryFocus?.unfocus(); Haptics.send(); _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { @@ -131,6 +133,7 @@ class VoiceRecordController { } } catch (_) { _isRecording.value = false; + unawaited(ScreenWake.instance.release(this)); if (isMounted()) { showCustomNotification(contextOf(), 'Не удалось начать запись'); } @@ -172,6 +175,7 @@ class VoiceRecordController { final rec = _recorder; if (rec == null) { _isRecording.value = false; + unawaited(ScreenWake.instance.release(this)); return; } @@ -182,6 +186,7 @@ class VoiceRecordController { _stopwatch.stop(); final elapsed = _stopwatch.elapsedMilliseconds; _isRecording.value = false; + unawaited(ScreenWake.instance.release(this)); _cancelDrag.value = 0; _amplitude.value = 0; _locked.value = false; @@ -237,6 +242,7 @@ class VoiceRecordController { } void dispose() { + unawaited(ScreenWake.instance.release(this)); _timer?.cancel(); _ampSub?.cancel(); _recorder?.dispose(); diff --git a/lib/frontend/screens/chats/chat_encryption_screen.dart b/lib/frontend/screens/chats/chat_encryption_screen.dart new file mode 100644 index 0000000..ccc775c --- /dev/null +++ b/lib/frontend/screens/chats/chat_encryption_screen.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +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'; +import '../../widgets/small_spinner.dart'; +import '../../../core/config/app_fonts.dart'; + +class ChatEncryptionScreen extends StatefulWidget { + final int accountId; + final int chatId; + + const ChatEncryptionScreen({ + super.key, + required this.accountId, + required this.chatId, + }); + + @override + State createState() => _ChatEncryptionScreenState(); +} + +class _ChatEncryptionScreenState extends State { + final _keyController = TextEditingController(); + final ValueNotifier _saving = ValueNotifier(false); + + bool _loading = true; + bool _enabled = false; + bool _keyVisible = false; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _keyController.dispose(); + _saving.dispose(); + super.dispose(); + } + + Future _load() async { + final store = ChatEncryptionStore.instance; + await store.load(); + final key = await store.readKey(widget.accountId, widget.chatId); + if (!mounted) return; + setState(() { + _enabled = store.isEnabled(widget.accountId, widget.chatId); + _keyController.text = key ?? ''; + _loading = false; + }); + } + + Future _save() async { + if (widget.accountId == 0) { + showCustomNotification(context, 'Профиль ещё не загружен'); + return; + } + final key = _keyController.text.trim(); + if (_enabled && key.isEmpty) { + showCustomNotification(context, 'Введите ключ шифрования'); + return; + } + _saving.value = true; + final store = ChatEncryptionStore.instance; + if (key.isEmpty) { + await store.deleteKey(widget.accountId, widget.chatId); + } else { + await store.writeKey(widget.accountId, widget.chatId, key); + } + await store.setEnabled(widget.accountId, widget.chatId, _enabled); + if (!mounted) return; + _saving.value = false; + showCustomNotification( + context, + _enabled ? 'Шифрование включено' : 'Шифрование отключено', + ); + Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface, weight: 400), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Шифрование сообщений', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + ), + body: _loading + ? Center(child: SmallSpinner(size: 36, color: cs.primary)) + : SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsCard( + children: [ + SettingsToggleTile( + icon: _enabled ? Symbols.lock : Symbols.lock_open, + label: 'Шифровать сообщения', + subtitle: + 'Текст сообщений в этом чате будет зашифрован ' + 'ключом ниже', + value: _enabled, + onChanged: (v) => setState(() => _enabled = v), + ), + ], + ), + const SizedBox(height: 16), + GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + padding: const EdgeInsets.all(20), + depth: 6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ключ', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _keyController, + obscureText: !_keyVisible, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Введите ключ', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + suffixIcon: IconButton( + icon: AnimatedSlashIcon( + icon: Symbols.visibility, + slashedIcon: Symbols.visibility_off, + slashed: _keyVisible, + color: cs.onSurfaceVariant, + ), + onPressed: () => + setState(() => _keyVisible = !_keyVisible), + ), + ), + ), + const SizedBox(height: 12), + Text( + 'Ключ хранится только на этом устройстве. ' + 'Собеседник должен ввести такой же ключ, иначе он ' + 'не прочитает сообщения.', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.35, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: PrimaryLoadingButton( + loading: _saving, + onPressed: _save, + child: const Text('Сохранить'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index eb00c5e..aa87935 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,42 +1,90 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../contacts/edit_contact_sheet.dart'; +import '../../../backend/modules/complaints.dart'; +import '../../../backend/modules/contacts.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/cache/info_cache.dart'; +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/route_settle.dart'; +import '../../../core/utils/haptics.dart'; 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'; import '../../widgets/connection_status.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/formatted_message_text.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/profile_header_scroll.dart'; +import '../../widgets/profile_hero.dart'; import '../../widgets/swipe_route.dart'; +import '../../../backend/modules/chats.dart'; +import '../calls/call_screen.dart'; +import '../contacts/open_contact_profile.dart'; +import '../stories/story_owner_info.dart'; +import '../stories/story_peanut.dart'; +import '../stories/story_ring.dart'; +import '../stories/story_viewer_screen.dart'; import 'chat_screen.dart'; +import 'group_invite_sheets.dart'; +import 'profile_action_sheets.dart'; +import '../../../core/config/app_fonts.dart'; class _MemberInfo { final int id; + final String? name; + final String? avatarUrl; final bool isAdmin; final bool isOwner; final bool isMe; + final String? alias; final int? seenTime; - final bool isOnline; + final int presenceStatus; + final bool blocked; + final bool isContact; const _MemberInfo({ required this.id, + this.name, + this.avatarUrl, required this.isAdmin, required this.isOwner, required this.isMe, + this.alias, this.seenTime, - required this.isOnline, + required this.presenceStatus, + this.blocked = false, + this.isContact = false, }); + + bool get isOnline => presenceStatus == 1; } +enum ChatInfoTab { media } + class ChatInfoScreen extends StatefulWidget { final int chatId; final String name; @@ -44,6 +92,9 @@ class ChatInfoScreen extends StatefulWidget { final String chatType; final int? dialogPeerId; + final ChatInfoTab? initialTab; + final Object? heroTag; + final bool openedFromChat; final void Function(String messageId, int time)? onJumpToMessage; @@ -54,6 +105,9 @@ class ChatInfoScreen extends StatefulWidget { required this.imageUrl, required this.chatType, this.dialogPeerId, + this.initialTab, + this.heroTag, + this.openedFromChat = false, this.onJumpToMessage, }); @@ -61,9 +115,10 @@ class ChatInfoScreen extends StatefulWidget { State createState() => _ChatInfoScreenState(); } -class _ChatInfoScreenState extends State { +class _ChatInfoScreenState extends State + with ReloadOnReconnect { final _tabScrollController = ScrollController(); - final _bodyScrollController = ScrollController(); + ScrollController? _bodyScrollController; int _myId = 0; bool _isLoading = true; @@ -71,35 +126,118 @@ class _ChatInfoScreenState extends State { ChatInfo? _chatInfo; String _selectedTab = ''; bool _descExpanded = false; + bool _showRealName = false; int? _otherId; ContactInfo? _contactData; + CachedContact? _localContact; int? _seenTime; bool _isOnline = false; int _presenceStatus = 0; bool _isBot = false; - List<_MemberInfo> _members = []; - int _onlineCount = 0; + final List<_MemberInfo> _members = []; + final List<_MemberInfo> _owners = []; + final List<_MemberInfo> _admins = []; + final List<_MemberInfo> _contactMembers = []; + final List<_MemberInfo> _otherMembers = []; + final Set _seenMemberIds = {}; + Set _contactIds = {}; + int _memberMarker = 0; + bool _membersLoading = false; + bool _membersEnd = false; + static const int _memberRenderChunk = 24; + int _memberRenderLimit = _memberRenderChunk; + bool _memberFillScheduled = false; int _mediaChatId = 0; String? _anchorMsgId; + int _dontDisturbUntil = 0; + int _lastEventTime = 0; + bool _blocked = false; + bool _muteBusy = false; + bool _addContactBusy = false; + + StoryPreview? _storyPreview; + List _unreadStories = const []; + final GlobalKey _avatarKey = GlobalKey(); + + final PageController _avatarPageController = PageController(); + List _avatarPages = const []; + int _avatarIndex = 0; + int _avatarTotal = 0; + bool _avatarHover = false; + bool _avatarHistoryBusy = false; + bool _avatarHistoryLoaded = false; + + late final RouteSettle _routeSettle = RouteSettle(isMounted: () => mounted); + bool _rebuildQueued = false; + + double _headerDelta = 0; + bool _expandArmed = false; + bool _headerDragging = false; + bool _headerEverExpanded = false; + @override void initState() { super.initState(); + storiesModule.storiesChanged.addListener(_onStoriesChanged); + ChatMembersStore.instance + .listenable(widget.chatId) + .addListener(_onMemberCountChanged); _load(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _routeSettle.bind(context); + } + + int? get _memberCount => ChatMembersStore.instance.count(widget.chatId); + + void _onMemberCountChanged() => _loadedRebuild(); + + void _onStoriesChanged() { + if (!mounted) return; + _loadedUpdate(_refreshUnreadStories); + } + + void _loadedRebuild() { + if (_routeSettle.settled) { + if (mounted) setState(() {}); + return; + } + if (_rebuildQueued) return; + _rebuildQueued = true; + _routeSettle.run(() { + _rebuildQueued = false; + if (mounted) setState(() {}); + }); + } + + void _loadedUpdate(VoidCallback mutation) { + mutation(); + _loadedRebuild(); + } + @override void dispose() { + _routeSettle.dispose(); + storiesModule.storiesChanged.removeListener(_onStoriesChanged); + ChatMembersStore.instance + .listenable(widget.chatId) + .removeListener(_onMemberCountChanged); _tabScrollController.dispose(); - _bodyScrollController.dispose(); + _bodyScrollController?.dispose(); + _avatarPageController.dispose(); super.dispose(); } + AppLocalizations get l10n => AppLocalizations.of(context)!; + List get _tabs { - final l10n = AppLocalizations.of(context)!; final showInfo = AppShowExtraInfo.current.value; switch (widget.chatType) { case 'DIALOG': @@ -142,6 +280,9 @@ class _ChatInfoScreenState extends State { } } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { final profile = await AppDatabase.loadActiveProfile(); _myId = profile?.id ?? 0; @@ -151,6 +292,16 @@ class _ChatInfoScreenState extends State { _chatInfo = info; _mediaChatId = (info?.raw['id'] as int?) ?? widget.chatId; + + final cached = await chats.getChat(_myId, _mediaChatId); + if (!mounted) return; + if (cached.isNotEmpty) { + _dontDisturbUntil = cached.first.dontDisturbUntil; + _lastEventTime = cached.first.lastEventTime; + } + final serverEventTime = (info?.raw['lastEventTime'] as int?) ?? 0; + if (serverEventTime > _lastEventTime) _lastEventTime = serverEventTime; + final lastMessage = info?.raw['lastMessage']; if (lastMessage is Map) { _anchorMsgId = lastMessage['id']?.toString(); @@ -179,6 +330,7 @@ class _ChatInfoScreenState extends State { } if (_otherId != null) { + _localContact = await ContactsModule.getContact(_myId, _otherId!); final contact = await ContactInfoFetch.get(_otherId!); if (contact != null) { _contactData = contact; @@ -192,51 +344,249 @@ class _ChatInfoScreenState extends State { _presenceStatus = st; _isOnline = st == 1; } + + if (!_isBot && _otherId != _myId) _loadBlockedState(_otherId!); + if (!_isBot) unawaited(_loadStories(_otherId!)); + unawaited(_loadAvatarHistory(_otherId!)); } } else if (info == null) { - setState(() => _isLoading = false); + _loadedUpdate(() => _isLoading = false); return; } else if (widget.chatType == 'CHAT') { - final chatInfo = _chatInfo!; - final memberIds = chatInfo.participantIds; - - Map> presenceMap = {}; - if (memberIds.isNotEmpty) { - presenceMap = await PresenceFetch.getMany(memberIds); - } - - _onlineCount = 0; - _members = memberIds.map((id) { - final pres = presenceMap[id]; - final online = (pres?['status'] as int?) == 1; - if (online) _onlineCount++; - return _MemberInfo( - id: id, - isAdmin: chatInfo.isAdmin(id), - isOwner: chatInfo.isOwner(id), - isMe: id == _myId, - seenTime: pres?['seen'] as int?, - isOnline: online, - ); - }).toList(); - - _members.sort((a, b) { - if (a.isMe != b.isMe) return a.isMe ? -1 : 1; - if (a.isOnline != b.isOnline) return a.isOnline ? -1 : 1; - return (b.seenTime ?? 0).compareTo(a.seenTime ?? 0); - }); + _contactIds = (await AppDatabase.loadContactIds(_myId)).toSet(); + await _loadLeaders(); + await _fetchMembersPage(initial: true); } if (mounted) { - setState(() { + _loadedUpdate(() { _isLoading = false; if (_selectedTab.isEmpty && _tabs.isNotEmpty) { - _selectedTab = _tabs.first; + _selectedTab = _initialTabLabel() ?? _tabs.first; } }); } } + Future _loadBlockedState(int peerId) async { + final blocked = await ContactsModule.isBlocked(api, peerId); + if (!mounted || blocked == _blocked) return; + _loadedUpdate(() => _blocked = blocked); + } + + String? _initialTabLabel() { + if (widget.initialTab != ChatInfoTab.media) return null; + final media = AppLocalizations.of(context)!.chatInfoTabMedia; + return _tabs.contains(media) ? media : null; + } + + _MemberInfo _memberFrom(ChatMemberEntry e) => _MemberInfo( + id: e.id, + name: e.name, + avatarUrl: e.avatarUrl, + isAdmin: _chatInfo?.isAdmin(e.id) ?? false, + isOwner: _chatInfo?.isOwner(e.id) ?? false, + isMe: e.id == _myId, + alias: _chatInfo?.adminAlias(e.id), + seenTime: e.seenTime, + presenceStatus: e.presenceStatus, + blocked: e.blocked, + isContact: _contactIds.contains(e.id), + ); + + Future _loadLeaders() async { + final info = _chatInfo; + if (info == null) return; + + final owner = info.owner; + final leaderIds = [ + if (owner != null && owner != 0) owner, + for (final a in info.adminIds) + if (a != owner) a, + ]; + if (leaderIds.isEmpty) return; + + final contacts = await ContactInfoFetch.getMany(leaderIds); + final presence = await PresenceFetch.getMany(leaderIds); + if (!mounted) return; + + for (final id in leaderIds) { + if (!_seenMemberIds.add(id)) continue; + final c = contacts[id]; + final pres = presence[id]; + _addMember( + _MemberInfo( + id: id, + name: c?.displayName ?? ContactCache.get(id), + avatarUrl: c?.avatarUrl ?? ContactCache.getAvatar(id), + isAdmin: info.isAdmin(id), + isOwner: info.isOwner(id), + isMe: id == _myId, + alias: info.adminAlias(id), + seenTime: pres?['seen'] as int?, + presenceStatus: (pres?['status'] as int?) ?? 0, + blocked: c?.isDeleted ?? false, + isContact: _contactIds.contains(id), + ), + ); + } + _rebuildMembers(); + } + + int _memberRank(_MemberInfo m) { + if (m.isOwner) return 0; + if (m.isAdmin) return 1; + if (m.isContact) return 2; + return 3; + } + + void _addMember(_MemberInfo m) { + switch (_memberRank(m)) { + case 0: + _owners.add(m); + case 1: + _admins.add(m); + case 2: + _contactMembers.add(m); + default: + _otherMembers.add(m); + } + } + + void _rebuildMembers() { + _members + ..clear() + ..addAll(_owners) + ..addAll(_admins) + ..addAll(_contactMembers) + ..addAll(_otherMembers); + } + + Future _fetchMembersPage({bool initial = false}) async { + if (_membersLoading || _membersEnd) return; + _membersLoading = true; + if (!initial) _loadedRebuild(); + + final page = await chats.getChatMembers( + api, + widget.chatId, + marker: _memberMarker, + ); + _membersLoading = false; + if (!mounted) return; + + if (page == null) { + if (!initial) _loadedRebuild(); + return; + } + + var added = 0; + final fresh = []; + for (final e in page.members) { + if (_seenMemberIds.add(e.id)) { + _addMember(_memberFrom(e)); + fresh.add(e.id); + added++; + } + } + if (added > 0) { + _rebuildMembers(); + _scheduleMemberFillCheck(); + } + if (fresh.isNotEmpty && AppStories.current.value) { + unawaited(storiesModule.loadOwnersPreviews(fresh)); + } + + final total = _memberCount; + if (page.members.isEmpty || + added == 0 || + page.marker == _memberMarker || + (total != null && _members.length >= total)) { + _membersEnd = true; + } + _memberMarker = page.marker; + + if (!initial) _loadedRebuild(); + } + + bool _revealMoreMembers() { + if (_memberRenderLimit >= _members.length) return false; + setState(() => _memberRenderLimit += _memberRenderChunk); + _scheduleMemberFillCheck(); + return true; + } + + void _scheduleMemberFillCheck() { + if (_memberFillScheduled) return; + _memberFillScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _memberFillScheduled = false; + if (!mounted) return; + if (_memberRenderLimit >= _members.length) return; + final controller = _bodyScrollController; + if (controller == null || !controller.hasClients) return; + if (controller.position.maxScrollExtent > 0) return; + _revealMoreMembers(); + }); + } + + void _onBodyScroll() { + if (!mounted || widget.chatType != 'CHAT') return; + if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers) + return; + final controller = _bodyScrollController; + if (controller == null || !controller.hasClients) return; + final pos = controller.position; + if (pos.pixels < pos.maxScrollExtent - 400) return; + if (_revealMoreMembers()) return; + if (_membersLoading || _membersEnd) return; + _fetchMembersPage(); + } + + String? get _inviteLink { + final link = _chatInfo?.link; + return (link != null && link.isNotEmpty) ? link : null; + } + + Future _openAddMembers() async { + final exclude = {_myId, ..._members.map((m) => m.id)}; + final added = await showAddMembersSheet( + context, + chatId: widget.chatId, + excludeIds: exclude, + ); + if (added == true && mounted) await _refreshMembers(); + } + + void _openInviteLink(String link) { + showInviteLinkSheet( + context, + link: link, + title: widget.name, + avatarUrl: widget.imageUrl, + ); + } + + 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 + ..clear() + ..addAll(_owners.map((m) => m.id)) + ..addAll(_admins.map((m) => m.id)); + _memberMarker = 0; + _membersEnd = false; + _membersLoading = false; + _memberRenderLimit = _memberRenderChunk; + _rebuildMembers(); + _loadedRebuild(); + await _fetchMembersPage(initial: true); + _loadedRebuild(); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -245,33 +595,543 @@ class _ChatInfoScreenState extends State { backgroundColor: cs.surface, floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, floatingActionButton: const ConnectionSpinner(), - body: SafeArea( - child: _isLoading ? _buildShimmer(cs) : _buildScrollBody(cs), + body: _buildScrollBody(cs), + ); + } + + static const double _headerAvatarSize = 96; + static const double _headerCollapsedBody = 232; + static const double _headerVignette = 64; + + bool get _headerHasPhoto => widget.imageUrl.isNotEmpty && !_peerDeleted; + + Widget _buildScrollBody(ColorScheme cs) { + return LayoutBuilder( + builder: (context, viewport) { + final media = MediaQuery.of(context); + final topPad = media.padding.top; + final collapsedH = topPad + _headerCollapsedBody; + final expandedH = _headerHasPhoto + ? math.max( + collapsedH, + math.min(media.size.width, viewport.maxHeight * 0.62), + ) + : collapsedH; + final delta = expandedH - collapsedH; + _syncHeaderDelta(delta); + final controller = _bodyScrollController ??= (ScrollController( + initialScrollOffset: delta, + )..addListener(_onBodyScroll)); + + return NotificationListener( + onNotification: (n) => _onHeaderScrollNotification(n, delta), + child: CustomScrollView( + key: ValueKey(delta), + controller: controller, + physics: HeaderPullScrollPhysics( + delta: delta, + isArmed: () => _expandArmed, + parent: const BouncingScrollPhysics(), + ), + slivers: [ + SliverPersistentHeader( + delegate: MorphHeaderDelegate( + collapsedExtent: collapsedH, + expandedExtent: expandedH, + headerBuilder: (ctx, t) => + _buildMorphHeader(ctx, cs, _headerHasPhoto ? t : 0.0), + ), + ), + SliverToBoxAdapter( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max(0, viewport.maxHeight - collapsedH), + ), + child: _buildBody(cs), + ), + ), + ], + ), + ); + }, + ); + } + + void _syncHeaderDelta(double delta) { + if (_headerDelta == delta) return; + final prev = _headerDelta; + _headerDelta = delta; + if (_bodyScrollController == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + final c = _bodyScrollController; + if (!mounted || c == null || !c.hasClients) return; + final target = (c.offset + (delta - prev)).clamp( + 0.0, + c.position.maxScrollExtent, + ); + c.jumpTo(target); + }); + } + + bool _onHeaderScrollNotification(ScrollNotification n, double delta) { + if (n.depth != 0) return false; + if (n is ScrollStartNotification) { + _headerDragging = n.dragDetails != null; + if (n.dragDetails != null) { + _expandArmed = delta > 0 && n.metrics.pixels <= delta + 8; + } + } else if (n is ScrollEndNotification) { + if (_headerDragging) { + _headerDragging = false; + _snapHeader(delta); + } + } + return false; + } + + void _snapHeader(double delta) { + final c = _bodyScrollController; + if (c == null || !c.hasClients || delta <= 0) return; + final collapsed = math.min(delta, c.position.maxScrollExtent); + final offset = c.offset; + if (offset <= 0 || offset >= collapsed) return; + final target = offset < collapsed / 2 ? 0.0 : collapsed; + if ((target - offset).abs() < 1) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !c.hasClients) return; + c.animateTo( + target, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + }); + } + + Widget _buildMorphHeader(BuildContext context, ColorScheme cs, double t) { + final topPad = MediaQuery.paddingOf(context).top; + if (t > 0) { + _headerEverExpanded = true; + final peerId = _otherId; + if (!_avatarHistoryLoaded && peerId != null) { + unawaited(_loadAvatarHistory(peerId)); + } + } + final iconColor = Color.lerp(cs.onSurface, Colors.white, t)!; + final nameColor = Color.lerp(cs.onSurface, Colors.white, t)!; + final subColor = Color.lerp( + cs.onSurfaceVariant, + Colors.white.withValues(alpha: 0.85), + t, + )!; + final ringOpacity = (1 - t * 3).clamp(0.0, 1.0); + final chipOpacity = ((t - 0.3) / 0.5).clamp(0.0, 1.0); + final unread = _unreadStories; + final totalPhotos = math.max(_avatarTotal, _avatarPages.length); + + return ClipRect( + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + const size = _headerAvatarSize; + final avatarRect = Rect.lerp( + Rect.fromLTWH((w - size) / 2, topPad + 52, size, size), + Rect.fromLTWH(0, 0, w, h), + t, + )!; + final radius = lerpDouble(size / 2, 0, t)!; + + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fromRect( + rect: avatarRect, + child: _headerAvatar(cs, radius, t), + ), + if (_headerHasPhoto) ...[ + Positioned( + left: 0, + right: 0, + top: 0, + height: topPad + 72, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black45, Colors.transparent], + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 170, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black54], + stops: [0.0, 0.62], + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _headerVignette, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + cs.surface.withValues(alpha: 0), + cs.surface.withValues(alpha: 0.55), + cs.surface, + ], + stops: const [0.0, 0.55, 1.0], + ), + ), + ), + ), + ), + ), + ], + Positioned.fromRect( + rect: avatarRect.inflate(7 * (1 - t)), + child: IgnorePointer( + child: Opacity( + opacity: ringOpacity, + child: CustomPaint( + painter: _storyPreview == null + ? null + : SegmentedRingPainter( + total: _storyPreview!.totalCount, + read: _storyPreview!.readCount, + unreadColors: [ + cs.primary, + cs.tertiary, + cs.primary, + ], + readColor: cs.outlineVariant, + strokeWidth: 3.4, + ), + ), + ), + ), + ), + Positioned( + left: 4, + right: 4, + top: topPad + 4, + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: iconColor), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: chipOpacity > 0 && unread.isNotEmpty + ? Align( + alignment: Alignment.centerLeft, + child: Opacity( + opacity: chipOpacity, + child: _storyChip(unread), + ), + ) + : const SizedBox.shrink(), + ), + if (chipOpacity > 0 && totalPhotos > 1) + Opacity( + opacity: chipOpacity, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Text( + '${_avatarIndex + 1}/$totalPhotos', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + _buildMoreButton(cs, iconColor), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: lerpDouble(16, 18, t)!, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _headerAligned(t, _buildNameRow(cs, nameColor, t)), + const SizedBox(height: 2), + _headerAligned( + t, + SelectionArea( + child: Text( + _subtitle(), + style: TextStyle(color: subColor, fontSize: 14), + ), + ), + ), + ], + ), + ), + ], + ); + }, ), ); } - Widget _buildScrollBody(ColorScheme cs) { - return CustomScrollView( - controller: _bodyScrollController, - slivers: [ - SliverAppBar( - backgroundColor: Colors.transparent, - elevation: 0, - floating: true, - leading: IconButton( - icon: Icon(Icons.arrow_back, color: cs.onSurface), - onPressed: () => Navigator.pop(context), - ), - actions: [ - IconButton( - icon: Icon(Icons.more_vert, color: cs.onSurface), - onPressed: () {}, + Widget _headerAligned(double t, Widget child) { + return Align( + alignment: Alignment.lerp(Alignment.center, Alignment.centerLeft, t)!, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: lerpDouble(12, 18, t)!), + child: child, + ), + ); + } + + Widget _headerAvatar(ColorScheme cs, double radius, double t) { + final expanded = t > 0.5; + final openHistory = _headerHasPhoto + ? () => AvatarHistoryScreen.open( + context, + contactId: _otherId ?? widget.dialogPeerId ?? 0, + name: widget.name, + currentAvatarUrl: _avatarPages.isEmpty + ? widget.imageUrl + : _avatarPages[_avatarIndex.clamp(0, _avatarPages.length - 1)], + ) + : null; + final openStories = _storyPreview == null ? null : _openStories; + + return KeyedSubtree( + key: _avatarKey, + child: GestureDetector( + onTap: expanded ? openHistory : (openStories ?? openHistory), + onLongPress: expanded + ? null + : (openStories == null ? null : openHistory), + child: Stack( + fit: StackFit.expand, + children: [ + ProfileHeroAvatar( + tag: widget.heroTag, + size: _headerAvatarSize, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: _headerAvatarContent(cs), + ), + ), + Offstage( + offstage: t < 0.5 || _avatarPages.length < 2, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: _avatarPager(cs, t), + ), ), ], ), - SliverToBoxAdapter(child: _buildBody(cs)), - ], + ), + ); + } + + Widget _avatarPager(ColorScheme cs, double t) { + final pages = _avatarPages; + if (pages.length < 2) return const SizedBox.shrink(); + final interactive = t > 0.5; + return MouseRegion( + onEnter: (_) { + if (!_avatarHover) setState(() => _avatarHover = true); + }, + onExit: (_) { + if (_avatarHover) setState(() => _avatarHover = false); + }, + child: Stack( + fit: StackFit.expand, + children: [ + ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: PointerDeviceKind.values.toSet(), + scrollbars: false, + overscroll: false, + ), + child: PageView.builder( + controller: _avatarPageController, + itemCount: pages.length, + physics: interactive + ? const PageScrollPhysics() + : const NeverScrollableScrollPhysics(), + onPageChanged: (i) => setState(() => _avatarIndex = i), + itemBuilder: (_, i) => _avatarPhoto(cs, pages[i]), + ), + ), + if (interactive && _avatarHover) ...[ + _avatarArrow( + alignment: Alignment.centerLeft, + icon: Symbols.chevron_left, + enabled: _avatarIndex > 0, + onTap: () => _stepAvatar(-1), + ), + _avatarArrow( + alignment: Alignment.centerRight, + icon: Symbols.chevron_right, + enabled: _avatarIndex < pages.length - 1, + onTap: () => _stepAvatar(1), + ), + ], + ], + ), + ); + } + + Widget _avatarArrow({ + required Alignment alignment, + required IconData icon, + required bool enabled, + required VoidCallback onTap, + }) { + return Align( + alignment: alignment, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: enabled ? 1 : 0, + child: IgnorePointer( + ignoring: !enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Colors.black38, + shape: BoxShape.circle, + ), + child: Icon(icon, color: Colors.white, size: 24), + ), + ), + ), + ), + ), + ); + } + + void _stepAvatar(int delta) { + final target = (_avatarIndex + delta).clamp(0, _avatarPages.length - 1); + if (target == _avatarIndex) return; + _avatarPageController.animateToPage( + target, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + Widget _avatarPhoto(ColorScheme cs, String url) { + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + memCacheWidth: _headerEverExpanded ? 720 : 288, + fadeInDuration: const Duration(milliseconds: 150), + errorWidget: (_, _, _) => ColoredBox( + color: cs.surfaceContainerHigh, + child: Center( + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 32), + ), + ), + ), + ); + } + + Widget _headerAvatarContent(ColorScheme cs) { + if (_peerDeleted) { + return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52); + } + final url = _avatarPages.isNotEmpty ? _avatarPages.first : widget.imageUrl; + if (url.isEmpty) { + return KometAvatar( + name: widget.name, + size: _headerAvatarSize, + fontSize: 36, + fadeIn: false, + ); + } + return _avatarPhoto(cs, url); + } + + void _refreshUnreadStories() { + final preview = _storyPreview; + if (preview == null || preview.unreadCount <= 0) { + _unreadStories = const []; + return; + } + final stories = storiesModule.cachedStories(preview.owner.ownerId); + if (stories == null || stories.isEmpty) { + _unreadStories = const []; + return; + } + final from = (stories.length - preview.unreadCount).clamp( + 0, + stories.length, + ); + _unreadStories = stories.sublist(from); + } + + Widget _storyChip(List unread) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _openStories, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StoryPeanut(stories: unread), + const SizedBox(width: 8), + Flexible( + child: Text( + '${unread.length} ' + '${pluralRu(unread.length, 'история', 'истории', 'историй')}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w600, + fontFamily: displayFontOf(context), + ), + ), + ), + ], + ), ); } @@ -281,151 +1141,630 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - const SizedBox(height: 4), - _avatar(), - const SizedBox(height: 14), - Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 22, - fontWeight: FontWeight.w700, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - _subtitle(), - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - _buildActions(cs), - const SizedBox(height: 16), - _buildPersistentInfo(cs), - _buildTabBar(cs), - const SizedBox(height: 12), - _buildTabContent(cs), - const SizedBox(height: 40), - ], - ), - ); - } - - String _subtitle() { - final l10n = AppLocalizations.of(context)!; - switch (widget.chatType) { - case 'DIALOG': - if (_isBot) return l10n.contactProfileBot; - if (_isOnline) return l10n.contactProfileOnline; - if (_presenceStatus == 2 || _presenceStatus == 3) return l10n.contactProfileRecentlyActive; - if (_seenTime != null && _seenTime! > 0) { - return formatLastSeen(_seenTime!); - } - return ''; - case 'CHAT': - final total = _chatInfo?.participantsCount ?? _members.length; - if (_onlineCount > 0) { - return l10n.chatInfoOnlineOfTotal('$_onlineCount', '$total'); - } - return '$total ${pluralRu(total, 'участник', 'участника', 'участников')}'; - case 'CHANNEL': - final count = _chatInfo?.participantsCount ?? 0; - return '$count ${pluralRu(count, 'подписчик', 'подписчика', 'подписчиков')}'; - default: - return ''; - } - } - - Widget _buildActions(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; - final List<({IconData icon, String label, VoidCallback? onTap})> btns; - - if (widget.chatType == 'DIALOG') { - if (_isBot) { - btns = [ - ( - icon: Icons.chat_bubble, - label: l10n.contactProfileActionChat, - onTap: _openChat, - ), - ( - icon: Icons.notifications, - label: l10n.contactProfileActionSound, - onTap: null, - ), - ]; - } else { - btns = [ - ( - icon: Icons.chat_bubble, - label: l10n.contactProfileActionChat, - onTap: _openChat, - ), - ( - icon: Icons.notifications, - label: l10n.contactProfileActionSound, - onTap: null, - ), - (icon: Icons.call, label: l10n.contactProfileActionCall, onTap: null), - ]; - } - } else if (widget.chatType == 'CHANNEL') { - btns = [ - ( - icon: Icons.notifications, - label: l10n.contactProfileActionSound, - onTap: null, - ), - (icon: Icons.exit_to_app, label: l10n.chatInfoActionLeave, onTap: null), - ]; - } else { - btns = [ - ( - icon: Icons.chat_bubble, - label: l10n.contactProfileActionChat, - onTap: null, - ), - ( - icon: Icons.notifications, - label: l10n.contactProfileActionSound, - onTap: null, - ), - (icon: Icons.exit_to_app, label: l10n.chatInfoActionLeave, onTap: null), - ]; - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - for (int i = 0; i < btns.length; i++) ...[ - _actionBtn(cs, btns[i].icon, btns[i].label, btns[i].onTap), - if (i < btns.length - 1) const SizedBox(width: 8), + if (_isLoading) + ..._loadingBlocks(cs) + else ...[ + _buildActions(cs), + const SizedBox(height: 16), + _buildPersistentInfo(cs), + _buildTabBar(cs), + const SizedBox(height: 12), + _buildTabContent(cs), + SizedBox(height: 40 + MediaQuery.paddingOf(context).bottom), ], ], ), ); } + ContactName? _nameEntry(String type) { + final data = _contactData; + if (data == null) return null; + for (final n in data.names) { + if (n.type == type) return n; + } + return null; + } + + bool get _isContact => _localContact != null; + + bool get _peerDeleted => _contactData?.isDeleted ?? false; + + String _joinName(String first, String last) => + last.trim().isEmpty ? first.trim() : '${first.trim()} ${last.trim()}'; + + String get _customName { + final c = _localContact; + if (c != null) return _joinName(c.firstName, c.lastName ?? ''); + return widget.name; + } + + Widget _buildMoreButton(ColorScheme cs, [Color? iconColor]) { + final entries = _moreMenuEntries(); + final color = iconColor ?? cs.onSurface; + if (entries.isEmpty) { + return IconButton( + icon: Icon(Symbols.more_vert, color: color), + onPressed: null, + ); + } + return PopupMenuButton( + icon: Icon(Symbols.more_vert, color: color), + onSelected: (action) => action(), + itemBuilder: (_) => [ + for (final entry in entries) + PopupMenuItem( + value: entry.onTap, + child: Row( + children: [ + Icon( + entry.icon, + size: 20, + color: entry.destructive ? cs.error : cs.onSurface, + ), + const SizedBox(width: 12), + Text( + entry.label, + style: entry.destructive ? TextStyle(color: cs.error) : null, + ), + ], + ), + ), + ], + ); + } + + List<({IconData icon, String label, bool destructive, VoidCallback onTap})> + _moreMenuEntries() { + if (_isLoading) return const []; + final entries = + < + ({IconData icon, String label, bool destructive, VoidCallback onTap}) + >[]; + + if (widget.chatType == 'DIALOG') { + if (_isContact) { + entries.add(( + icon: Symbols.edit, + label: l10n.editContactMenu, + destructive: false, + onTap: _openEdit, + )); + } + if (!_isBot && _otherId != null && _otherId != _myId) { + entries.add(( + icon: _blocked ? Symbols.lock_open : Symbols.block, + label: _blocked ? l10n.chatInfoMenuUnblock : l10n.chatInfoMenuBlock, + destructive: !_blocked, + onTap: _toggleBlock, + )); + } + entries.add(( + icon: Symbols.delete, + label: l10n.chatInfoMenuDeleteChat, + destructive: true, + onTap: _deleteChat, + )); + } + + entries.add(( + icon: Symbols.mop, + label: l10n.chatInfoMenuClearHistory, + destructive: true, + onTap: _clearHistory, + )); + + return entries; + } + + Future _openEdit() async { + final oneme = _nameEntry('ONEME'); + final local = _localContact; + final peerId = _otherId ?? widget.dialogPeerId ?? 0; + if (peerId == 0) return; + + final result = await showEditContactSheet( + context, + contactId: peerId, + avatarUrl: _contactData?.avatarUrl ?? local?.baseUrl ?? widget.imageUrl, + customFirst: local?.firstName ?? '', + customLast: local?.lastName ?? '', + onemeFirst: oneme?.firstName ?? '', + onemeLast: oneme?.lastName ?? '', + ); + if (!mounted || result == null) return; + + switch (result.action) { + case EditContactAction.updated: + final fresh = await ContactsModule.getContact(_myId, peerId); + if (!mounted) return; + setState(() { + _localContact = fresh; + _showRealName = false; + }); + case EditContactAction.removed: + Navigator.of(context).pop(); + } + } + + String? get _realName { + final data = _contactData; + if (data == null) return null; + for (final n in data.names) { + if (n.type == 'ONEME') { + final combined = [n.firstName, n.lastName] + .where((s) => s != null && s.trim().isNotEmpty) + .map((s) => s!.trim()) + .join(' '); + if (combined.isNotEmpty) return combined; + final label = n.label; + if (label != null && label.isNotEmpty) return label; + } + } + return null; + } + + Widget _buildNameRow(ColorScheme cs, Color textColor, double t) { + final nameStyle = TextStyle( + color: textColor, + fontSize: lerpDouble(22, 25, t)!, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ); + final custom = _customName; + final real = _realName; + final hasToggle = _isContact && real != null && real != custom; + + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(width: (hasToggle ? 30.0 : 0.0) * (1 - t)), + Flexible( + child: SelectionArea( + child: ProfileHeroName( + tag: widget.heroTag, + text: custom, + style: nameStyle, + child: AnimatedTextSwap( + showAlternate: _showRealName, + alignment: Alignment.center, + alternate: Text( + real ?? custom, + style: nameStyle, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + child: Text( + custom, + style: nameStyle, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), + SizedBox( + width: hasToggle ? 30 : 0, + height: 28, + child: hasToggle + ? IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + iconSize: 20, + color: _showRealName + ? Color.lerp(cs.primary, Colors.white, t) + : textColor.withValues(alpha: 0.7), + icon: AnimatedSlashIcon( + icon: Symbols.visibility, + slashedIcon: Symbols.visibility_off, + slashed: !_showRealName, + ), + tooltip: real, + onPressed: () => + setState(() => _showRealName = !_showRealName), + ) + : null, + ), + ], + ); + } + + String _subtitle() { + switch (widget.chatType) { + case 'DIALOG': + if (_peerDeleted) return l10n.chatInfoMemberDeleted; + if (_isBot) return l10n.contactProfileBot; + if (_isOnline) return l10n.contactProfileOnline; + if (_presenceStatus == 2 || _presenceStatus == 3) + return l10n.contactProfileRecentlyActive; + if (_seenTime != null && _seenTime! > 0) { + return formatLastSeen(_seenTime!); + } + return ''; + case 'CHAT': + final total = _memberCount ?? _members.length; + return '$total ${pluralRu(total, 'участник', 'участника', 'участников')}'; + case 'CHANNEL': + final count = _memberCount ?? 0; + return '$count ${pluralRu(count, 'подписчик', 'подписчика', 'подписчиков')}'; + default: + return ''; + } + } + + bool get _isMuted { + if (_dontDisturbUntil == ChatsModule.muteOff) return false; + if (_dontDisturbUntil < 0) return true; + return _dontDisturbUntil > DateTime.now().millisecondsSinceEpoch; + } + + bool get _iAmAdmin { + final info = _chatInfo; + if (info == null || _myId == 0) return false; + return info.isOwner(_myId) || info.isAdmin(_myId); + } + + bool get _isGroupOrChannel => + widget.chatType == 'CHAT' || widget.chatType == 'CHANNEL'; + + Widget _buildActions(ColorScheme cs) { + final muteBtn = ( + icon: Symbols.notifications, + slashedIcon: Symbols.notifications_off, + slashed: _isMuted, + label: _isMuted + ? l10n.chatInfoActionMuted + : l10n.contactProfileActionSound, + onTap: _muteBusy ? null : _toggleMute, + ); + final chatBtn = ( + icon: Symbols.chat_bubble, + slashedIcon: null, + slashed: false, + label: l10n.contactProfileActionChat, + onTap: _openChat, + ); + final leaveBtn = ( + icon: Symbols.exit_to_app, + slashedIcon: null, + slashed: false, + label: l10n.chatInfoActionLeave, + onTap: _leaveChat, + ); + + final List< + ({ + IconData icon, + IconData? slashedIcon, + bool slashed, + String label, + VoidCallback? onTap, + }) + > + btns; + if (widget.chatType == 'DIALOG') { + btns = [ + chatBtn, + muteBtn, + if (!_isBot) + ( + icon: Symbols.call, + slashedIcon: null, + slashed: false, + label: l10n.contactProfileActionCall, + onTap: _confirmAndStartCall, + ), + ]; + } else if (widget.chatType == 'CHANNEL') { + btns = [muteBtn, leaveBtn]; + } else { + btns = [chatBtn, muteBtn, leaveBtn]; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + for (int i = 0; i < btns.length; i++) ...[ + _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), + ], + ], + ), + if (_canAddContact) ...[ + const SizedBox(height: 8), + _wideActionBtn( + cs, + Symbols.person_add, + l10n.contactProfileActionAddContact, + _addContactBusy ? null : _addToContacts, + ), + ], + ], + ); + } + + bool get _canAddContact => + widget.chatType == 'DIALOG' && + !_isContact && + !_isBot && + !_peerDeleted && + _otherId != null && + _otherId != _myId; + + Future _addToContacts() async { + final peerId = _otherId; + if (peerId == null || _addContactBusy) return; + setState(() => _addContactBusy = true); + + CachedContact? contact; + try { + contact = await ContactsModule.addContact(api, peerId, ''); + } catch (_) {} + + if (!mounted) return; + setState(() { + _addContactBusy = false; + if (contact != null) { + _localContact = contact; + _showRealName = false; + } + }); + showCustomNotification( + context, + contact != null ? l10n.nfcContactAdded : l10n.addContactError, + ); + } + void _openChat() { + if (widget.openedFromChat) { + Navigator.of(context).pop(); + return; + } pushSwipeable( context, (_) => ChatScreen( - chatId: widget.chatId, + chatId: _mediaChatId, name: widget.name, imageUrl: widget.imageUrl, - chatType: 'DIALOG', + chatType: widget.chatType, ), ); } + Future _toggleMute() async { + if (_muteBusy) return; + setState(() => _muteBusy = true); + final muted = _isMuted; + final target = muted ? ChatsModule.muteOff : ChatsModule.muteForever; + final error = await chats.setChatMute( + api, + chatId: _mediaChatId, + dontDisturbUntil: target, + ); + if (!mounted) return; + setState(() { + _muteBusy = false; + if (error == null) _dontDisturbUntil = target; + }); + showCustomNotification( + context, + error ?? + (muted + ? l10n.chatInfoNotificationsOn + : l10n.chatInfoNotificationsOff), + ); + } + + Future _confirmAndStartCall() async { + final peerId = _otherId; + if (peerId == null || peerId == _myId) return; + + final choice = await showBlurredConfirm( + context, + title: l10n.chatInfoCallConfirmTitle, + message: l10n.chatInfoCallConfirmMessage(_customName), + confirmLabel: l10n.chatInfoConfirmYes, + cancelLabel: l10n.chatInfoConfirmNo, + ); + if (!mounted || !choice.confirmed) return; + + final navigator = Navigator.of(context); + final avatarUrl = widget.imageUrl.isNotEmpty ? widget.imageUrl : null; + final active = CallController.instance.activeSession; + if (active != null) { + await navigator.push( + MaterialPageRoute( + builder: (_) => CallScreen( + name: _customName, + avatarUrl: avatarUrl, + session: active, + ), + ), + ); + return; + } + + try { + final session = await CallController.instance.startOutgoing(peerId); + if (!mounted) return; + await navigator.push( + MaterialPageRoute( + builder: (_) => CallScreen( + name: _customName, + avatarUrl: avatarUrl, + session: session, + ), + ), + ); + } catch (_) { + if (!mounted) return; + showCustomNotification(context, l10n.chatInfoCallFailed); + } + } + + Future _leaveChat() async { + final isChannel = widget.chatType == 'CHANNEL'; + final choice = await showBlurredConfirm( + context, + title: isChannel + ? l10n.chatInfoLeaveChannelTitle + : l10n.chatInfoLeaveGroupTitle, + message: isChannel + ? l10n.chatInfoLeaveChannelMessage + : l10n.chatInfoLeaveGroupMessage, + confirmLabel: l10n.chatInfoLeaveConfirm, + cancelLabel: l10n.chatInfoActionCancel, + destructive: true, + ); + if (!mounted || !choice.confirmed) return; + + final ok = await chats.leaveChat(api, chatId: _mediaChatId); + if (!mounted) return; + if (!ok) { + showCustomNotification(context, l10n.chatInfoLeaveFailed); + return; + } + Navigator.of(context).popUntil((route) => route.isFirst); + } + + Future _clearHistory() async { + final canClearForAll = _isGroupOrChannel && _iAmAdmin; + final choice = await showBlurredConfirm( + context, + title: l10n.chatInfoClearHistoryTitle, + message: l10n.chatInfoClearHistoryMessage, + confirmLabel: l10n.chatInfoClearHistoryConfirm, + cancelLabel: l10n.chatInfoActionCancel, + destructive: true, + checkboxLabel: canClearForAll ? l10n.chatInfoClearHistoryForAll : null, + ); + if (!mounted || !choice.confirmed) return; + + final error = await chats.clearHistory( + api, + chatId: _mediaChatId, + lastEventTime: _lastEventTime, + forAll: canClearForAll && choice.checked, + ); + if (!mounted) return; + showCustomNotification(context, error ?? l10n.chatInfoClearHistoryDone); + } + + Future _deleteChat() async { + final choice = await showBlurredConfirm( + context, + title: l10n.chatInfoDeleteChatTitle, + message: l10n.chatInfoDeleteChatMessage, + confirmLabel: l10n.chatInfoDeleteChatConfirm, + cancelLabel: l10n.chatInfoActionCancel, + destructive: true, + ); + if (!mounted || !choice.confirmed) return; + + final error = await chats.deleteChat( + api, + chatId: _mediaChatId, + lastEventTime: _lastEventTime, + forAll: false, + ); + if (!mounted) return; + if (error != null) { + showCustomNotification(context, error); + return; + } + Navigator.of(context).popUntil((route) => route.isFirst); + } + + Future _toggleBlock() async { + final peerId = _otherId; + if (peerId == null) return; + + final block = !_blocked; + if (block) { + final choice = await showBlurredConfirm( + context, + title: l10n.chatInfoBlockConfirmTitle, + message: l10n.chatInfoBlockConfirmMessage(_customName), + confirmLabel: l10n.chatInfoConfirmYes, + cancelLabel: l10n.chatInfoConfirmNo, + destructive: true, + ); + if (!mounted || !choice.confirmed) return; + } + + final ok = await ContactsModule.setBlocked(api, peerId, block); + if (!mounted) return; + if (!ok) { + showCustomNotification(context, l10n.chatInfoBlockFailed); + return; + } + setState(() => _blocked = block); + showCustomNotification( + context, + block ? l10n.chatInfoBlockDone : l10n.chatInfoUnblockDone, + ); + if (block) await _openComplaintCard(peerId); + } + + Future _openComplaintCard(int peerId) async { + if (!mounted) return; + await showComplaintCard( + context, + title: l10n.chatInfoComplaintTitle, + subtitle: l10n.chatInfoComplaintSubtitle, + sendLabel: l10n.chatInfoComplaintSend, + closeLabel: l10n.chatInfoComplaintClose, + emptyLabel: l10n.chatInfoComplaintEmpty, + loadReasons: () async { + final reasons = await ComplaintsModule.reasonsFor( + api, + ComplaintsModule.userTypeId, + ); + return reasons + .map((r) => (id: r.reasonId, title: r.reasonTitle)) + .toList(); + }, + onSend: (reasonId) async { + final ok = await ComplaintsModule.sendComplaint( + api, + reasonId: reasonId, + typeId: ComplaintsModule.userTypeId, + ids: [peerId], + ); + if (!mounted) return ok; + showCustomNotification( + context, + ok ? l10n.chatInfoComplaintSent : l10n.chatInfoComplaintFailed, + ); + return ok; + }, + ); + } + Widget _actionBtn( ColorScheme cs, IconData icon, - String label, [ + String label, { VoidCallback? onTap, - ]) { + IconData? slashedIcon, + bool slashed = false, + }) { return Expanded( child: GlossyPill( onTap: onTap, @@ -436,7 +1775,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, @@ -449,8 +1797,38 @@ class _ChatInfoScreenState extends State { ); } + Widget _wideActionBtn( + ColorScheme cs, + IconData icon, + String label, [ + VoidCallback? onTap, + ]) { + return GlossyPill( + onTap: onTap, + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + depth: 6, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: cs.primary, size: 22), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 13), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + Widget _buildPersistentInfo(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; final items = []; if (widget.chatType == 'DIALOG') { @@ -484,9 +1862,12 @@ class _ChatInfoScreenState extends State { items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio)); } } - } else if (widget.chatType == 'CHANNEL') { - final link = _chatInfo?.link; - if (link != null && link.isNotEmpty) { + } else { + final info = _chatInfo; + final link = info?.link; + if (link != null && + link.isNotEmpty && + (info?.canSeeInviteLink(_myId) ?? false)) { items.add(_linkCard(cs, link)); } final desc = _chatInfo?.description; @@ -497,9 +1878,11 @@ class _ChatInfoScreenState extends State { } if (items.isEmpty) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [...items, const SizedBox(height: 16)], + return SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [...items, const SizedBox(height: 16)], + ), ); } @@ -524,8 +1907,10 @@ class _ChatInfoScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), - Text( - value, + FormattedMessageText( + text: value, + ranges: const [], + entityMode: TextEntityMode.copy, style: TextStyle( color: isLink ? cs.primary : cs.onSurface, fontSize: 16, @@ -539,7 +1924,6 @@ class _ChatInfoScreenState extends State { } Widget _linkCard(ColorScheme cs, String link) { - final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), @@ -556,12 +1940,17 @@ class _ChatInfoScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), - Text(link, style: TextStyle(color: cs.primary, fontSize: 15)), + FormattedMessageText( + text: link, + ranges: const [], + entityMode: TextEntityMode.copy, + style: TextStyle(color: cs.primary, fontSize: 15), + ), ], ), ), IconButton( - icon: Icon(Icons.qr_code_2, color: cs.primary, size: 22), + icon: Icon(Symbols.qr_code_2, color: cs.primary, size: 22), onPressed: () {}, ), ], @@ -569,8 +1958,10 @@ class _ChatInfoScreenState extends State { ); } + static const Duration _descRevealDuration = Duration(milliseconds: 260); + static const Curve _descRevealCurve = Curves.easeOutCubic; + Widget _collapsibleDescCard(ColorScheme cs, String desc) { - final l10n = AppLocalizations.of(context)!; const int collapsedLines = 3; final isLong = desc.length > 120; @@ -589,21 +1980,45 @@ class _ChatInfoScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), - Text( - desc, - style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4), - maxLines: (_descExpanded || !isLong) ? null : collapsedLines, - overflow: (_descExpanded || !isLong) - ? null - : TextOverflow.ellipsis, + AnimatedSize( + duration: _descRevealDuration, + curve: _descRevealCurve, + alignment: Alignment.topLeft, + child: FormattedMessageText( + text: desc, + ranges: const [], + entityMode: TextEntityMode.copy, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + height: 1.4, + ), + maxLines: (_descExpanded || !isLong) ? null : collapsedLines, + overflow: (_descExpanded || !isLong) + ? null + : TextOverflow.ellipsis, + ), ), if (isLong) ...[ const SizedBox(height: 6), GestureDetector( - onTap: () => setState(() => _descExpanded = !_descExpanded), - child: Text( - _descExpanded ? l10n.chatInfoCollapse : l10n.chatInfoShowMore, - style: TextStyle(color: cs.primary, fontSize: 13), + behavior: HitTestBehavior.opaque, + onTap: () { + Haptics.tap(); + setState(() => _descExpanded = !_descExpanded); + }, + child: AnimatedTextSwap( + showAlternate: _descExpanded, + duration: _descRevealDuration, + curve: _descRevealCurve, + alternate: Text( + l10n.chatInfoCollapse, + style: TextStyle(color: cs.primary, fontSize: 13), + ), + child: Text( + l10n.chatInfoShowMore, + style: TextStyle(color: cs.primary, fontSize: 13), + ), ), ), ], @@ -693,7 +2108,6 @@ class _ChatInfoScreenState extends State { } Widget _tabBody(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; if (_selectedTab == 'Info') return _buildInfoTabContent(cs); if (_selectedTab == l10n.chatInfoTabMembers) { return _buildMembersTabContent(cs); @@ -704,7 +2118,7 @@ class _ChatInfoScreenState extends State { return _buildPlaceholder( cs, l10n.chatInfoEmptyGeneralChats, - Icons.group, + Symbols.group, ); } return CommonChatsTab( @@ -718,7 +2132,7 @@ class _ChatInfoScreenState extends State { cs, SharedContentKind.media, l10n.chatInfoEmptyMedia, - Icons.photo_library, + Symbols.photo_library, ); } if (_selectedTab == l10n.chatInfoTabFiles) { @@ -726,7 +2140,7 @@ class _ChatInfoScreenState extends State { cs, SharedContentKind.files, l10n.chatInfoEmptyFiles, - Icons.description, + Symbols.description, ); } if (_selectedTab == l10n.chatInfoTabVoice) { @@ -734,7 +2148,7 @@ class _ChatInfoScreenState extends State { cs, SharedContentKind.voice, l10n.chatInfoEmptyVoice, - Icons.mic, + Symbols.mic, ); } if (_selectedTab == l10n.chatInfoTabLinks) { @@ -742,7 +2156,7 @@ class _ChatInfoScreenState extends State { cs, SharedContentKind.links, l10n.chatInfoEmptyLinks, - Icons.link, + Symbols.link, ); } return const SizedBox.shrink(); @@ -761,6 +2175,7 @@ class _ChatInfoScreenState extends State { chatId: _mediaChatId, anchorMessageId: anchor, myId: _myId, + sourceName: widget.name, kind: kind, emptyLabel: emptyLabel, emptyIcon: emptyIcon, @@ -810,23 +2225,15 @@ class _ChatInfoScreenState extends State { } Widget _buildInfoTabContent(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; final items = []; - if (widget.chatType == 'CHAT') { - final desc = _chatInfo?.description; - if (desc != null && desc.isNotEmpty) { - items - ..add(_infoCard(cs, l10n.contactProfileInfoDescription, desc)) - ..add(const SizedBox(height: 8)); - } - } - items.add(_buildInfoRowsCard(cs)); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: items, + return SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: items, + ), ); } @@ -840,38 +2247,9 @@ class _ChatInfoScreenState extends State { ); } - Widget _infoCard(ColorScheme cs, String label, String value) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(14), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), - depth: 6, - child: SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - const SizedBox(height: 4), - Text( - value, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); - } - Widget _buildMembersTabContent(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; + final hasHidden = _members.length > _memberRenderLimit; + final shown = hasHidden ? _members.take(_memberRenderLimit) : _members; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -879,13 +2257,65 @@ class _ChatInfoScreenState extends State { ), child: Column( children: [ - _memberAction(cs, Icons.person_add, l10n.chatInfoAddMember, () {}), - ..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]), + _memberAction( + cs, + Symbols.person_add, + l10n.chatInfoAddMember, + _openAddMembers, + ), + if (_inviteLink != null) ...[ + _listDivider(cs), + _memberAction( + cs, + Symbols.link, + l10n.chatInfoInviteByLink, + () => _openInviteLink(_inviteLink!), + ), + ], + ...shown.expand((m) => [_listDivider(cs), _memberTile(cs, m)]), + if (hasHidden || _membersLoading || !_membersEnd) ...[ + _listDivider(cs), + _membersFooter(cs), + ], ], ), ); } + Widget _membersFooter(ColorScheme cs) { + if (_membersLoading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + return InkWell( + onTap: () { + if (!_revealMoreMembers()) _fetchMembersPage(); + }, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(Symbols.expand_more, color: cs.primary, size: 26), + const SizedBox(width: 14), + Text( + l10n.chatInfoShowMore, + style: TextStyle(color: cs.primary, fontSize: 16), + ), + ], + ), + ), + ); + } + Widget _memberAction( ColorScheme cs, IconData icon, @@ -916,18 +2346,22 @@ class _ChatInfoScreenState extends State { ); Widget _memberTile(ColorScheme cs, _MemberInfo member) { - final l10n = AppLocalizations.of(context)!; final name = + member.name ?? ContactCache.get(member.id) ?? (member.isMe ? l10n.callParticipantYou : '${member.id}'); - final avatar = ContactCache.getAvatar(member.id); + final avatar = member.avatarUrl ?? ContactCache.getAvatar(member.id); final String sublabel; - if (member.isMe) { + if (member.blocked) { + sublabel = l10n.chatInfoMemberDeleted; + } else if (member.isMe) { sublabel = l10n.callParticipantYou; - } else if (member.isOnline) { + } else if (member.presenceStatus == 1) { sublabel = l10n.contactProfileOnline; - } else if (member.seenTime != null) { + } else if (member.presenceStatus == 2 || member.presenceStatus == 3) { + sublabel = l10n.contactProfileRecentlyActive; + } else if (member.seenTime != null && member.seenTime! > 0) { sublabel = formatLastSeen(member.seenTime!); } else { sublabel = l10n.contactProfileRecentlyActive; @@ -937,63 +2371,159 @@ class _ChatInfoScreenState extends State { ? l10n.chatInfoRoleOwner : (member.isAdmin ? l10n.chatInfoRoleAdmin : null); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - (avatar != null && avatar.isNotEmpty) - ? CircleAvatar( - radius: 22, - backgroundImage: CachedNetworkImageProvider( - avatar, - maxWidth: 144, - maxHeight: 144, - ), - backgroundColor: cs.primaryContainer, - ) - : CircleAvatar( - radius: 22, - backgroundColor: cs.primaryContainer, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', + final story = member.blocked || !AppStories.current.value + ? null + : storiesModule.previewOf(member.id); + final avatarRadius = story == null ? 22.0 : 19.0; + + return InkWell( + onTap: member.isMe + ? null + : () => openContactDialogProfile( + context, + contactId: member.id, + name: name, + avatarUrl: avatar, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + if (member.blocked) + _ghostAvatar() + else + _memberAvatar( + cs, + story: story, + radius: avatarRadius, + name: name, + avatarUrl: avatar, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 16, + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, ), ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w500, + Text( + sublabel, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), - ), - Text( - sublabel, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - ], + ], + ), ), - ), - if (roleLabel != null) - Text( - roleLabel, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + if (member.alias != null) + _memberTag(cs, member.alias!) + else if (roleLabel != null) + Text( + roleLabel, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ); + } + + Widget _memberAvatar( + ColorScheme cs, { + required StoryPreview? story, + required double radius, + required String name, + String? avatarUrl, + }) { + final circle = (avatarUrl != null && avatarUrl.isNotEmpty) + ? CircleAvatar( + radius: radius, + backgroundImage: CachedNetworkImageProvider( + avatarUrl, + maxWidth: 144, + maxHeight: 144, ), - ], + backgroundColor: cs.primaryContainer, + ) + : CircleAvatar( + radius: radius, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: radius * 0.72, + ), + ), + ); + if (story == null) return circle; + return Builder( + builder: (avatarContext) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openMemberStories(avatarContext, story, name, avatarUrl), + child: StoryAvatarRing( + diameter: radius * 2, + total: story.totalCount, + read: story.readCount, + strokeWidth: 2.2, + ringGap: 3, + haloWidth: 1.5, + child: circle, + ), + ), + ); + } + + void _openMemberStories( + BuildContext avatarContext, + StoryPreview story, + String name, + String? avatarUrl, + ) { + Haptics.tap(); + unawaited( + openStoryViewer( + context, + previews: [story], + origin: storyOriginOf(avatarContext), + ownerOverrides: { + story.owner.ownerId: StoryOwnerInfo(name: name, avatarUrl: avatarUrl), + }, + ), + ); + } + + Widget _ghostAvatar({double radius = 22, double fontSize = 24}) { + return CircleAvatar( + radius: radius, + backgroundColor: const Color(0xFFD4D4D4), + child: Text('👻', style: TextStyle(fontSize: fontSize)), + ); + } + + Widget _memberTag(ColorScheme cs, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.w600, + ), ), ); } Widget _buildAllInfoRows(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; final rows = <({String label, String value})>[]; final chat = _chatInfo?.raw; if (chat == null) { @@ -1022,6 +2552,7 @@ class _ChatInfoScreenState extends State { add(l10n.chatInfoRowId, chat['id']); if (type == 'DIALOG') { + add(l10n.chatInfoRowUserId, _contactData?.raw['id'] ?? _otherId); add(l10n.chatInfoRowCreated, chat['created'], tsFormat: true); add(l10n.chatInfoRowModified, chat['modified'], tsFormat: true); add(l10n.callInfoStatus, chat['status']); @@ -1050,6 +2581,7 @@ class _ChatInfoScreenState extends State { final opts = chat['options'] as Map?; add(l10n.chatInfoRowOfficialGroup, opts?['OFFICIAL'] as bool?); add(l10n.chatInfoRowSignAdmin, opts?['SIGN_ADMIN'] as bool?); + _addChatOptions(add, opts); add(l10n.callInfoStatus, chat['status']); } @@ -1066,6 +2598,7 @@ class _ChatInfoScreenState extends State { l10n.chatInfoRowOnlyAdmin, opts?['ONLY_ADMIN_CAN_ADD_MEMBER'] as bool?, ); + _addChatOptions(add, opts); add(l10n.callInfoStatus, chat['status']); } @@ -1114,8 +2647,37 @@ class _ChatInfoScreenState extends State { ); } + void _addChatOptions( + void Function(String label, dynamic val, {bool tsFormat}) add, + Map? opts, + ) { + if (opts == null) return; + add(l10n.chatInfoRowDisableForward, opts['DISABLE_FORWARD'] as bool?); + add( + l10n.chatInfoRowCopyDisabled, + opts['MESSAGE_COPY_NOT_ALLOWED'] as bool?, + ); + add(l10n.chatInfoRowOnlyAdminCall, opts['ONLY_ADMIN_CAN_CALL'] as bool?); + add(l10n.chatInfoRowAllCanPin, opts['ALL_CAN_PIN_MESSAGE'] as bool?); + add( + l10n.chatInfoRowMembersSeeLink, + opts['MEMBERS_CAN_SEE_PRIVATE_LINK'] as bool?, + ); + add( + l10n.chatInfoRowConfirmBeforeSend, + opts['CONFIRM_BEFORE_SEND'] as bool?, + ); + add( + l10n.chatInfoRowOnlyOwnerIconTitle, + opts['ONLY_OWNER_CAN_CHANGE_ICON_TITLE'] as bool?, + ); + add( + l10n.chatInfoRowPromotedDisabled, + opts['PROMOTED_CONTENT_DISABLED'] as bool?, + ); + } + List<({String label, String value})> _buildExtraContactRows() { - final l10n = AppLocalizations.of(context)!; final c = _contactData; if (c == null) return const []; final rows = <({String label, String value})>[]; @@ -1170,7 +2732,6 @@ class _ChatInfoScreenState extends State { } Widget? _trailingFor(String label, ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; if (label != l10n.chatInfoRowId) return null; if (widget.chatType != 'DIALOG') return null; if (_contactData == null) return null; @@ -1190,6 +2751,13 @@ class _ChatInfoScreenState extends State { ); } + Future _copyInfoValue(String value) async { + await Clipboard.setData(ClipboardData(text: value)); + if (!mounted) return; + Haptics.tap(); + showCustomNotification(context, l10n.msgActionsCopied); + } + Widget _infoRow( ColorScheme cs, String label, @@ -1202,22 +2770,26 @@ class _ChatInfoScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), - ), - Text( - value, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontWeight: FontWeight.w500, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: () => unawaited(_copyInfoValue(value)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), ), - ), - ], + Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), ), ?trailing, @@ -1226,27 +2798,77 @@ class _ChatInfoScreenState extends State { ); } - Widget _avatar() { - final avatar = KometAvatar( - name: widget.name, - imageUrl: widget.imageUrl, - size: 96, - fontSize: 36, - ); - final peerId = widget.chatType == 'DIALOG' ? _otherId : null; - if (peerId == null || widget.imageUrl.isEmpty) return avatar; - return GestureDetector( - onTap: () => AvatarHistoryScreen.open( - context, - contactId: peerId, - name: widget.name, - currentAvatarUrl: widget.imageUrl, - ), - child: avatar, - ); + Future _loadAvatarHistory(int peerId) async { + if (!_headerHasPhoto || _avatarHistoryBusy) return; + _avatarHistoryBusy = true; + final cached = ContactsModule.cachedPhotos(peerId); + if (cached != null) _applyAvatarPhotos(cached); + try { + final photos = await ContactsModule.fetchPhotos(api, peerId, count: 30); + if (!mounted) return; + _avatarHistoryLoaded = true; + _applyAvatarPhotos(photos); + } catch (e) { + logger.w('Не удалось получить историю аватарок $peerId: $e'); + } finally { + _avatarHistoryBusy = false; + } } - Widget _buildShimmer(ColorScheme cs) { + void _applyAvatarPhotos(ContactPhotos photos) { + final urls = []; + if (widget.imageUrl.isNotEmpty) urls.add(widget.imageUrl); + for (final url in photos.urls) { + if (url.isNotEmpty && !urls.contains(url)) urls.add(url); + } + if (urls.isEmpty || listEquals(urls, _avatarPages)) return; + setState(() { + _avatarPages = urls; + _avatarTotal = math.max(photos.total, urls.length); + _avatarIndex = _avatarIndex.clamp(0, urls.length - 1); + }); + } + + Future _loadStories(int peerId) async { + if (!AppStories.current.value || _peerDeleted) return; + final cached = storiesModule.previewOf(peerId); + if (cached != null && !cached.isEmpty && mounted) { + setState(() { + _storyPreview = cached; + _refreshUnreadStories(); + }); + } + final fresh = await storiesModule.loadOwnerPreview( + StoryOwner(ownerId: peerId), + ); + if (!mounted) return; + setState(() { + _storyPreview = (fresh == null || fresh.isEmpty) ? null : fresh; + _refreshUnreadStories(); + }); + } + + Future _openStories() async { + final preview = _storyPreview; + if (preview == null) return; + Haptics.tap(); + final avatarContext = _avatarKey.currentContext; + await openStoryViewer( + context, + previews: [preview], + origin: avatarContext == null ? null : storyOriginOf(avatarContext), + ownerOverrides: { + preview.owner.ownerId: StoryOwnerInfo( + name: _customName, + avatarUrl: _contactData?.avatarUrl ?? widget.imageUrl, + ), + }, + ); + if (!mounted) return; + await _loadStories(preview.owner.ownerId); + } + + List _loadingBlocks(ColorScheme cs) { Widget block(double w, double h, {double r = 8}) => Container( width: w, height: h, @@ -1256,21 +2878,12 @@ class _ChatInfoScreenState extends State { ), ); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 60, 16, 0), - children: [ - Center(child: block(96, 96, r: 48)), - const SizedBox(height: 14), - Center(child: block(160, 22, r: 8)), - const SizedBox(height: 8), - Center(child: block(110, 16, r: 6)), - const SizedBox(height: 24), - Center(child: block(240, 54, r: 14)), - const SizedBox(height: 16), - block(double.infinity, 36, r: 20), - const SizedBox(height: 12), - block(double.infinity, 120, r: 14), - ], - ); + return [ + block(double.infinity, 60, r: 14), + const SizedBox(height: 16), + block(double.infinity, 36, r: 20), + const SizedBox(height: 12), + block(double.infinity, 120, r: 14), + ]; } } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index db7a196..0dc2a4f 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,17 +8,36 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; import 'search_screen.dart'; +import 'create_channel_flow.dart'; import 'create_group_flow.dart'; +import 'folder_action_sheet.dart'; +import 'folder_edit_sheet.dart'; +import '../contacts/add_contact_sheet.dart'; import '../../widgets/adaptive_shell.dart'; +import '../../../core/crypto/message_decryption_cache.dart'; +import '../../widgets/decrypted_text.dart'; +import '../../widgets/encryption_lock_badge.dart'; import '../../widgets/online_dot.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; -import '../../widgets/formatted_message_text.dart'; +import '../../widgets/springy_tap.dart'; +import '../../widgets/informer_banner_tile.dart'; +import '../../../backend/modules/share_sender.dart'; +import '../../../core/utils/logger.dart'; import '../../../core/utils/format.dart'; +import '../../../models/shared_payload.dart'; +import '../../widgets/rich_message_controller.dart'; +import 'share_composer_bar.dart'; +import '../../../core/utils/download_history.dart'; +import '../../../core/utils/link_opener.dart'; import '../../../core/utils/text_format.dart'; +import '../../../core/utils/update_checker.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../models/chat_preview_media.dart'; +import '../../../models/informer_banner.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -28,12 +47,18 @@ import '../digital_id/digital_id_web_screen.dart'; import '../../widgets/account_switcher_overlay.dart'; import 'chat/view/chat_list_shimmer.dart'; import 'chat/view/chat_list_tile.dart'; +import 'chat/view/chat_preview_line.dart'; import '../../widgets/connection_status.dart'; import '../../../backend/api.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_animations.dart'; +import '../../../core/config/app_frost.dart'; +import '../../../core/config/app_spectrum_background.dart'; +import '../../../core/config/app_nav_pill_style.dart'; +import '../../../core/cache/info_cache.dart'; +import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_stories.dart'; import '../../../core/config/app_colors.dart'; import '../../../core/config/komet_settings.dart'; @@ -46,15 +71,36 @@ import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/draft_store.dart'; import '../../../core/storage/archived_chats_store.dart'; +import '../../../core/storage/chat_encryption_store.dart'; import '../../../core/storage/token_storage.dart'; import '../../../core/storage/chat_activity_store.dart'; import '../../../main.dart' - show accountModule, api, messagesModule, storiesModule, appRouteObserver; + show + accountModule, + animojiModule, + api, + appRouteObserver, + bannersModule, + messagesModule, + storiesModule; import '../../widgets/attachment/attachment_sheet.dart'; +import '../../widgets/spectrum_background.dart'; +import '../../widgets/spectrum_tint.dart'; +import '../../widgets/update_dialog.dart'; import '../stories/story_composer_screen.dart'; import '../stories/story_owner_info.dart'; +import '../../../backend/modules/webapp.dart'; +import '../../../models/story.dart'; +import '../webapp/open_mini_app.dart'; import '../stories/story_ring.dart'; +import '../../widgets/attachment/bubbles/bubble_context.dart'; +import '../../widgets/sending_clock_icon.dart'; import '../stories/story_viewer_screen.dart'; +import '../downloads_screen.dart'; +import '../../widgets/media_playback_pill.dart'; +import '../../../core/config/app_fonts.dart'; + +const String _savedWelcomeKey = 'welcome.saved.dialog.message'; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -118,6 +164,7 @@ class ChatListScreen extends StatefulWidget { final bool forwardMode; final int forwardMessageCount; final bool archiveMode; + final SharedPayload? sharePayload; const ChatListScreen({ super.key, @@ -125,8 +172,39 @@ class ChatListScreen extends StatefulWidget { this.forwardMode = false, this.forwardMessageCount = 1, this.archiveMode = false, + this.sharePayload, }); + static _ChatListScreenState? _root; + + static bool selectTab(int index) { + final root = _root; + if (root == null || !root.mounted) return false; + root._onNavTabSelected(index); + return true; + } + + static bool selectFolder(String folderId) { + final root = _root; + if (root == null || !root.mounted) return false; + root._onNavTabSelected(0); + return root._selectFolder(folderId); + } + + static bool openSavedMessages() { + final root = _root; + if (root == null || !root.mounted) return false; + root._openSavedMessages(); + return true; + } + + static bool openSearch() { + final root = _root; + if (root == null || !root.mounted) return false; + root._openSearch(); + return true; + } + @override State createState() => _ChatListScreenState(); } @@ -134,7 +212,7 @@ class ChatListScreen extends StatefulWidget { enum _DeleteKind { personalLike, ownerGroup, blocked } class _ChatListScreenState extends State - with TickerProviderStateMixin, RouteAware { + with TickerProviderStateMixin, RouteAware, SpectrumSurface { String? _selectedFolderId; List _folders = []; @@ -181,11 +259,13 @@ class _ChatListScreenState extends State bool _reloadQueued = false; bool _reloadInFlight = false; Timer? _settleTimer; - bool get _isSelectionMode => _selectedChats.isNotEmpty; + bool get _shareMode => widget.sharePayload != null; + bool get _isSelectionMode => !_shareMode && _selectedChats.isNotEmpty; bool? _foldersListKnown; late AnimationController _navPageAnimController; late AnimationController _fabController; + final BackdropKey _frostBackdrop = BackdropKey(); late PageController _folderPageController; late AnimationController _storiesRevealController; @@ -193,6 +273,10 @@ class _ChatListScreenState extends State final List _folderChatScrollListenerFns = []; final Set _selectedChats = {}; final Set _inflightContactIds = {}; + final Map _selectedChatNames = {}; + RichMessageController? _shareCaption; + PreparedShare? _preparedShare; + bool _shareSending = false; DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); @@ -214,6 +298,7 @@ class _ChatListScreenState extends State StreamSubscription? _loginSub; StreamSubscription? _typingSub; StreamSubscription? _typingMsgSub; + String? _presentedInformerId; Widget? _cachedChatsBody; Object? _chatsBodyCacheKey; @@ -231,6 +316,7 @@ class _ChatListScreenState extends State _storiesDockedOpen, _storiesAnimClosing, _storiesOverscrollRevealArmed, + storiesModule.storiesChanged.value, _sessionState, identityHashCode(_profile), ]); @@ -254,6 +340,107 @@ class _ChatListScreenState extends State }); } + void _initShare() { + final payload = widget.sharePayload; + if (payload == null) return; + _shareCaption = RichMessageController( + text: payload.isTextOnly ? (payload.text ?? '') : '', + ); + unawaited( + PreparedShare.prepare(payload).then((prepared) { + if (mounted) setState(() => _preparedShare = prepared); + }), + ); + } + + void _toggleShareTarget(String chatId, String name) { + Haptics.selection(); + setState(() { + if (_selectedChats.remove(chatId)) { + _selectedChatNames.remove(chatId); + } else { + _selectedChats.add(chatId); + _selectedChatNames[chatId] = name; + } + }); + } + + List get _shareRecipientNames => [ + for (final id in _selectedChats) _selectedChatNames[id] ?? 'Чат', + ]; + + Future _sendShare(String caption) async { + final prepared = _preparedShare; + final myId = _profile?.id ?? 0; + if (prepared == null || myId == 0 || _selectedChats.isEmpty) return; + if (_shareSending) return; + + final targets = []; + for (final raw in _selectedChats) { + final id = int.tryParse(raw); + if (id != null) targets.add(id); + } + if (targets.isEmpty) return; + + setState(() => _shareSending = true); + Haptics.send(); + + ShareSendResult? result; + try { + result = await ShareSender.send( + accountId: myId, + chatIds: targets, + share: prepared, + caption: caption, + ); + } catch (e) { + logger.w('Поделиться: отправка не удалась: $e'); + } + + if (!mounted) return; + setState(() => _shareSending = false); + + if (result == null) { + Haptics.error(); + showCustomNotification(context, 'Не удалось отправить'); + return; + } + + final navigator = Navigator.of(context); + if (targets.length == 1) { + final chatId = targets.first; + final chat = _chats.where((c) => c.id == chatId).firstOrNull; + navigator.pop(); + unawaited( + pushSwipeable( + navigator.context, + (_) => ChatScreen( + chatId: chatId, + name: _selectedChatNames[chatId.toString()] ?? chat?.title ?? 'Чат', + imageUrl: chat?.iconUrl ?? '', + chatType: chat?.type ?? 'DIALOG', + ), + ), + ); + return; + } + navigator.pop(); + } + + Widget _buildShareComposer(ColorScheme cs) { + final prepared = _preparedShare; + final controller = _shareCaption; + if (prepared == null || controller == null) return const SizedBox.shrink(); + if (_selectedChats.isEmpty) return const SizedBox.shrink(); + return ShareComposerBar( + share: prepared, + controller: controller, + recipientNames: _shareRecipientNames, + sending: _shareSending, + onSend: _sendShare, + ); + } + void _clearSelection() { setState(() { _selectedChats.clear(); @@ -526,6 +713,10 @@ class _ChatListScreenState extends State @override void initState() { super.initState(); + if (!widget.forwardMode && !widget.archiveMode && !_shareMode) { + ChatListScreen._root = this; + } + _initShare(); _fabController = AnimationController( vsync: this, duration: const Duration(milliseconds: 350), @@ -538,7 +729,8 @@ class _ChatListScreenState extends State _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), - )..repeat(); + ); + _syncShimmer(); _storiesRevealController = AnimationController( @@ -557,6 +749,7 @@ class _ChatListScreenState extends State setState(() { _sessionState = state; }); + _syncShimmer(); if (state == SessionState.online) { _requestReload(); _maybeLoadStories(); @@ -572,11 +765,15 @@ class _ChatListScreenState extends State }); chats.chatsChanged.addListener(_onChatsChanged); ArchivedChatsStore.instance.revision.addListener(_onArchivedChanged); + ChatEncryptionStore.instance.revision.addListener(_onEncryptionChanged); DraftStore.instance.revision.addListener(_onDraftsChanged); AppStories.current.addListener(_onStoriesEnabledChanged); storiesModule.storiesChanged.addListener(_onStoriesDataChanged); KometSettings.hideAllChatsFolder.addListener(_requestReload); KometSettings.showHiddenChats.addListener(_requestReload); + ContactsModule.revision.addListener(_requestReload); + FoldersModule.revision.addListener(_requestReload); + bannersModule.activeBanner.addListener(_onActiveInformerChanged); _maybeLoadStories(); _typingSub = api.pushStream .where((p) => p.opcode == Opcode.notifTyping) @@ -616,6 +813,10 @@ class _ChatListScreenState extends State if (mounted) _requestReload(); } + void _onEncryptionChanged() { + if (mounted) setState(() {}); + } + void _onStoriesEnabledChanged() { if (!mounted) return; if (!AppStories.current.value) { @@ -657,7 +858,24 @@ class _ChatListScreenState extends State final me = _profile?.id; final self = _selfOwnerInfo(); if (me == null || self == null) return const {}; - return {me: StoryOwnerInfo(name: 'Ваша история', avatarUrl: self.avatarUrl)}; + return { + me: StoryOwnerInfo(name: 'Ваша история', avatarUrl: self.avatarUrl), + }; + } + + StoryPreview? _storyPreviewFor(int ownerId) { + if (!AppStories.current.value || ownerId == 0) return null; + final preview = storiesModule.previewFor(ownerId); + return (preview == null || preview.isEmpty) ? null : preview; + } + + void _openStoriesForOwner(int ownerId, [Offset? origin]) { + final index = storiesModule.previews.indexWhere( + (p) => p.owner.ownerId == ownerId, + ); + if (index < 0) return; + Haptics.tap(); + _openStories(index, origin); } void _openStories(int index, [Offset? origin]) { @@ -697,6 +915,7 @@ class _ChatListScreenState extends State unawaited(_runReload()); } }); + _scheduleInformerPresentation(); } void _requestReload() { @@ -736,6 +955,7 @@ class _ChatListScreenState extends State _foldersListKnown = null; _isInitialLoading = false; }); + _syncShimmer(); } return; } @@ -757,16 +977,14 @@ class _ChatListScreenState extends State } var folders = await FoldersModule.loadFolders(p.id); final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); - final contactIds = (await ContactsModule.getContacts(p.id)) - .map((c) => c.id) - .toSet(); + final contactIds = (await ContactsModule.getContacts( + p.id, + includeDeleted: true, + )).map((c) => c.id).toSet(); - final allChatsFolder = ChatFolder( - id: 'all.chat.folder', + const allChatsFolder = ChatFolder( + id: FoldersModule.allChatsFolderId, title: 'Все чаты', - filters: [], - hideEmpty: false, - widgets: [], ); if (widget.archiveMode) { @@ -831,7 +1049,9 @@ class _ChatListScreenState extends State } _isInitialLoading = false; }); + _syncShimmer(); _prefetchContactsForChats(loadedChats); + unawaited(_prefetchPresenceForChats(loadedChats)); if (widget.archiveMode) { if (filteredChats.isNotEmpty) { _archiveHadChats = true; @@ -856,6 +1076,7 @@ class _ChatListScreenState extends State _foldersListKnown = null; _isInitialLoading = false; }); + _syncShimmer(); } } finally { if (mounted) { @@ -873,6 +1094,16 @@ class _ChatListScreenState extends State return _sessionState != SessionState.disconnected; } + void _syncShimmer() { + final needed = _isInitialLoading || _showFoldersShimmer; + if (needed == _shimmerController.isAnimating) return; + if (needed) { + _shimmerController.repeat(); + } else { + _shimmerController.stop(); + } + } + int get _folderPageCount => _folders.isEmpty ? 1 : _folders.length; int get _selectedFolderIndex { @@ -895,20 +1126,42 @@ class _ChatListScreenState extends State return 0; } - Future _prefetchContactsForChats(List chats) async { + bool _presencePrefetchRunning = false; + + Set _dialogPeerIds(List chats) { final myId = _profile?.id; final ids = {}; for (final chat in chats) { - if (chat.type == 'DIALOG' && chat.id != 0) { - for (final entry in chat.participants.entries) { - if (entry.key != myId) { - ids.add(entry.key); - break; - } + if (chat.type != 'DIALOG' || chat.id == 0) continue; + for (final entry in chat.participants.entries) { + if (entry.key != myId) { + ids.add(entry.key); + break; } } + } + return ids; + } + + Future _prefetchPresenceForChats(List chats) async { + if (_presencePrefetchRunning) return; + if (_sessionState != SessionState.online) return; + final ids = _dialogPeerIds(chats); + if (ids.isEmpty) return; + _presencePrefetchRunning = true; + try { + await PresenceFetch.ensureFor(ids); + } finally { + _presencePrefetchRunning = false; + } + } + + Future _prefetchContactsForChats(List chats) async { + final myId = _profile?.id; + final ids = _dialogPeerIds(chats); + for (final chat in chats) { final senderId = chat.lastMsgSenderId; - if (senderId != null) ids.add(senderId); + if (senderId != null && senderId != myId) ids.add(senderId); } ids.removeWhere((id) => ContactCache.get(id) != null); ids.removeAll(_inflightContactIds); @@ -1211,15 +1464,21 @@ class _ChatListScreenState extends State @override void dispose() { + if (ChatListScreen._root == this) ChatListScreen._root = null; + _shareCaption?.dispose(); appRouteObserver.unsubscribe(this); _settleTimer?.cancel(); chats.chatsChanged.removeListener(_onChatsChanged); ArchivedChatsStore.instance.revision.removeListener(_onArchivedChanged); + ChatEncryptionStore.instance.revision.removeListener(_onEncryptionChanged); DraftStore.instance.revision.removeListener(_onDraftsChanged); AppStories.current.removeListener(_onStoriesEnabledChanged); storiesModule.storiesChanged.removeListener(_onStoriesDataChanged); KometSettings.hideAllChatsFolder.removeListener(_requestReload); KometSettings.showHiddenChats.removeListener(_requestReload); + ContactsModule.revision.removeListener(_requestReload); + FoldersModule.revision.removeListener(_requestReload); + bannersModule.activeBanner.removeListener(_onActiveInformerChanged); _loginSub?.cancel(); _stateSub?.cancel(); _typingSub?.cancel(); @@ -1278,6 +1537,7 @@ class _ChatListScreenState extends State _navPageAnimEnd = index.toDouble(); setState(() => _currentNavIndex = index); _navPageAnimController.forward(from: 0); + if (index == 0) _scheduleInformerPresentation(); } void _toggleFab() { @@ -1292,6 +1552,115 @@ class _ChatListScreenState extends State }); } + void _markInformerPresented(InformerBanner banner) { + if (widget.forwardMode || widget.archiveMode || _currentNavIndex != 0) { + return; + } + final route = ModalRoute.of(context); + if (route != null && !route.isCurrent) return; + if (_presentedInformerId == banner.id) return; + if (bannersModule.activeBanner.value?.id != banner.id) return; + _presentedInformerId = banner.id; + unawaited(_persistInformerPresentation(banner)); + } + + Future _persistInformerPresentation(InformerBanner banner) async { + try { + await bannersModule.markShown(banner); + } catch (_) {} + } + + void _onActiveInformerChanged() { + if (bannersModule.activeBanner.value == null) { + _presentedInformerId = null; + return; + } + _scheduleInformerPresentation(); + } + + void _scheduleInformerPresentation() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final banner = bannersModule.activeBanner.value; + if (banner != null) _markInformerPresented(banner); + }); + } + + Future _closeInformer(InformerBanner banner) async { + Haptics.tap(); + try { + await bannersModule.close(banner); + } catch (_) { + bannersModule.refresh(); + } + } + + Future _openInformer(InformerBanner banner) async { + Haptics.tap(); + try { + await bannersModule.markClicked(banner); + } catch (_) { + bannersModule.refresh(); + } + if (!mounted) return; + + final url = banner.url?.trim(); + if (url != null && url.isNotEmpty) { + await openExternalUrl(context, url); + return; + } + if (!banner.isUpdate) return; + + final result = await UpdateChecker.checkNow(); + if (!mounted) return; + switch (result.status) { + case UpdateCheckStatus.updateAvailable: + await showUpdateDialog(context, result.update!); + return; + case UpdateCheckStatus.upToDate: + showCustomNotification( + context, + AppLocalizations.of(context)!.updateUpToDate, + ); + return; + case UpdateCheckStatus.failed: + showCustomNotification( + context, + AppLocalizations.of(context)!.updateCheckFailed, + ); + return; + } + } + + Widget _buildInformerBanner() { + return ValueListenableBuilder( + valueListenable: bannersModule.activeBanner, + builder: (context, banner, _) { + return ClipRect( + child: AnimatedSize( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: banner == null + ? const SizedBox(width: double.infinity) + : InformerBannerTile( + key: ValueKey(banner.id), + banner: banner, + animojiLoader: animojiModule.fetchById, + onPresented: _markInformerPresented, + onTap: banner.isClickable + ? () => unawaited(_openInformer(banner)) + : null, + onClose: banner.hidesCloseButton + ? null + : () => unawaited(_closeInformer(banner)), + ), + ), + ); + }, + ); + } + Widget _buildPinnedChatsHeader(BuildContext context) { final cs = Theme.of(context).colorScheme; return ColoredBox( @@ -1319,64 +1688,134 @@ class _ChatListScreenState extends State child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - if (AppStories.current.value && - _pullRatio < 0.8 && - storiesModule.hasAny) - Opacity( - opacity: 1.0 - _pullRatio, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _openStories(0), - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only( - right: 8, + Expanded( + child: Row( + children: [ + if (_shareMode) + Padding( + padding: const EdgeInsets.only( + right: 4, + ), + child: IconButton( + key: const ValueKey('share-back'), + visualDensity: + VisualDensity.compact, + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + weight: 500, ), - child: FoldedStoryStack( - previews: storiesModule.previews, - opacity: 1.0 - _pullRatio, + onPressed: () => Navigator.of( + context, + ).maybePop(), + ), + ), + if (AppStories.current.value && + !_shareMode && + _pullRatio < 0.8 && + storiesModule.hasAny) + Opacity( + opacity: 1.0 - _pullRatio, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openStories(0), + child: SizedBox( + width: + (FoldedStoryStack.widthFor( + storiesModule + .previews + .length, + ) + + 8) * + (1.0 - _pullRatio), + height: + FoldedStoryStack.outerSize, + child: OverflowBox( + alignment: Alignment.centerLeft, + maxWidth: + FoldedStoryStack.widthFor( + storiesModule + .previews + .length, + ), + child: FoldedStoryStack( + previews: + storiesModule.previews, + opacity: 1.0 - _pullRatio, + ), + ), ), ), ), + Flexible( + child: Text( + _shareMode && + _selectedChats.isNotEmpty + ? '${_selectedChats.length} ' + '${pluralRu(_selectedChats.length, 'получатель', 'получателя', 'получателей')}' + : connectionStatusLabel( + _sessionState, + ) ?? + (_profile?.firstName ?? + 'Чат'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: displayFontOf(context), + ), + ), ), - Text( - connectionStatusLabel(_sessionState) ?? - (_profile?.firstName ?? 'Чат'), - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], + ], + ), ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - onSelected: _onOverflowMenuSelected, - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Избранное', - Symbols.bookmark, - ), - _buildPopupMenuItem( - 2, - 'Прочитать всё', - Symbols.done_all, + const SizedBox(width: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!widget.forwardMode && + !widget.archiveMode && + !_shareMode) + IconButton( + key: const ValueKey('downloads-button'), + tooltip: AppLocalizations.of( + context, + )!.downloadsTooltip, + icon: Icon( + Symbols.download_for_offline, + color: cs.outline, + weight: 400, + ), + onPressed: () => + unawaited(_openDownloads()), + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + onSelected: _onOverflowMenuSelected, + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Избранное', + Symbols.bookmark, + ), + _buildPopupMenuItem( + 2, + 'Прочитать всё', + Symbols.done_all, + ), + ], ), ], ), @@ -1395,12 +1834,9 @@ class _ChatListScreenState extends State padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: widget.forwardMode + onTap: (widget.forwardMode || _shareMode) ? null - : () => pushSwipeable( - context, - (_) => const SearchScreen(), - ), + : _openSearch, child: GlossyPill( color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(50), @@ -1476,10 +1912,7 @@ class _ChatListScreenState extends State children: [ for (var i = 0; i < _folders.length; i++) ...[ if (i > 0) const SizedBox(width: 8), - _buildFolderChip( - _folderChipLabel(_folders[i]), - folderId: _folders[i].id, - ), + _buildFolderChip(_folders[i]), ], ], ); @@ -1494,10 +1927,7 @@ class _ChatListScreenState extends State for (var i = 0; i < _folders.length; i++) ...[ if (i > 0) const SizedBox(width: 8), Expanded( - child: _buildFolderChip( - _folderChipLabel(_folders[i]), - folderId: _folders[i].id, - ), + child: _buildFolderChip(_folders[i]), ), ], ], @@ -1508,6 +1938,9 @@ class _ChatListScreenState extends State ), ), ), + if (!widget.forwardMode) + const MediaPlaybackPill(margin: EdgeInsets.fromLTRB(20, 6, 20, 2)), + if (!widget.forwardMode) _buildInformerBanner(), ], ), ); @@ -1622,6 +2055,7 @@ class _ChatListScreenState extends State avatar ?? "", presenceUserId: secondId, unreadCount: chat.unreadCount, + hasMention: chat.hasUnreadMention, isMuted: chat.isMuted, isVerified: isVerified, isPinned: isPinned, @@ -1633,62 +2067,72 @@ class _ChatListScreenState extends State messageRanges: isPlaceholder ? const [] : chat.lastMsgFormatRanges, + previewMessageId: isPlaceholder ? null : chat.lastMsgId, + previewCipherText: isPlaceholder + ? null + : chat.lastMsgTextOneLine, + previewMedia: isPlaceholder ? null : chat.lastMsgMedia, + titleIcon: chatKindIcon( + 'DIALOG', + isBot: _isBotDialog(secondId, chat), + ), + hasMiniApp: _hasMiniApp(secondId, chat), ), ); } else { final isPlaceholder = chat.isLastMsgDeleted; + final isSavedWelcome = + chat.id == 0 && chat.lastMsgText == _savedWelcomeKey; final sender = chat.lastMsgSenderId != null ? ContactCache.get(chat.lastMsgSenderId!) : null; - String fullMsg = ""; - List messageRanges = const []; - if (isPlaceholder) { - fullMsg = 'зайдите в чат для подгрузки'; - } else { - var prefixLen = 0; - if (sender?.isNotEmpty == true && chat.id != 0) { - final prefix = "$sender: "; - fullMsg += prefix; - prefixLen = prefix.length; - } - if (chat.lastMsgText?.isNotEmpty == true) { - fullMsg += chat.lastMsgText ?? ""; - final ranges = chat.lastMsgFormatRanges; - messageRanges = prefixLen == 0 - ? ranges - : [ - for (final r in ranges) - FormatRange( - format: r.format, - start: r.start + prefixLen, - length: r.length, - attributes: r.attributes, - ), - ]; - } - } + final senderPrefix = + !isPlaceholder && + sender?.isNotEmpty == true && + chat.id != 0 + ? "$sender: " + : ""; + final body = isPlaceholder + ? 'зайдите в чат для подгрузки' + : isSavedWelcome + ? AppLocalizations.of( + context, + )!.savedMessagesEmptyPreview + : (chat.lastMsgTextOneLine ?? ''); return _animateChatTile( chat.id.toString(), _buildChatItem( chat.id.toString(), chat.id == 0 ? "Избранное" : chat.title ?? "Чат", - fullMsg, + body, _formatTime(chat.lastMsgTime), (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) ? chat.iconUrl! : '', unreadCount: chat.unreadCount, + hasMention: chat.hasUnreadMention, isMuted: chat.isMuted, isVerified: chat.isOfficial, isPinned: isPinned, chatType: chat.type, - messageItalic: isPlaceholder, + messageItalic: isPlaceholder || isSavedWelcome, draft: chat.id == 0 ? null : _draftFor(chat.id), ownStatus: _ownStatusFor(chat, isPlaceholder), ownRead: chat.lastMsgReadByOthers, - messageRanges: messageRanges, + messageRanges: isPlaceholder || isSavedWelcome + ? const [] + : chat.lastMsgFormatRanges, + previewMessageId: isPlaceholder ? null : chat.lastMsgId, + previewPrefix: senderPrefix, + previewCipherText: isPlaceholder || isSavedWelcome + ? null + : chat.lastMsgText, + previewMedia: isPlaceholder ? null : chat.lastMsgMedia, + titleIcon: chat.id == 0 + ? null + : chatKindIcon(chat.type, isBot: false), ), ); } @@ -1831,6 +2275,7 @@ class _ChatListScreenState extends State _currentNavIndex = next; _navDragging = false; }); + if (next == 0) _scheduleInformerPresentation(); }, onHorizontalDragCancel: () { if (!_navDragging) return; @@ -1859,6 +2304,7 @@ class _ChatListScreenState extends State geometry: geometry, iconSize: 20, labelGap: 4, + backdropKey: _frostBackdrop, onTap: _onNavTabSelected, onItemLongPress: (index, pos) { if (index == 3) _openAccountSwitcher(pos); @@ -1886,6 +2332,14 @@ class _ChatListScreenState extends State if (widget.forwardMode) { return _getChatsBody(); } + if (_shareMode) { + return Column( + children: [ + Expanded(child: _getChatsBody()), + _buildShareComposer(cs), + ], + ); + } final bottomInset = MediaQuery.viewPaddingOf(context).bottom; final pageW = constraints.maxWidth; final pageH = constraints.maxHeight; @@ -1903,6 +2357,29 @@ class _ChatListScreenState extends State return Stack( children: [ + if (AppSpectrumBackground.isEnabled) + Positioned.fill( + child: AnimatedBuilder( + animation: Listenable.merge([ + _navPageAnimController, + _navDragDx, + ]), + child: const RepaintBoundary(child: SpectrumBackground()), + builder: (context, child) { + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + return Transform.translate( + offset: Offset( + -pageDisplayT * pageW * SpectrumTuning.parallax, + 0, + ), + child: child, + ); + }, + ), + ), ClipRect( child: SizedBox( width: pageW, @@ -2020,12 +2497,36 @@ class _ChatListScreenState extends State Positioned( right: 20, bottom: bottomInset + 90, - child: GlossyPill( - onTap: _toggleFab, - color: cs.primaryContainer, - borderRadius: BorderRadius.circular(28), - elevated: true, - depth: 12, + child: ValueListenableBuilder( + valueListenable: AppVisualStyle.current, + builder: (context, style, child) => + ValueListenableBuilder( + valueListenable: AppNavPillStyle.current, + builder: (context, navStyle, child) { + final liquid = + style.glossyChrome && + NavPillMaterial.isLiquid(navStyle); + final frost = + style.glossyChrome && + NavPillMaterial.isFrost(navStyle); + return GlossyPill( + onTap: _toggleFab, + color: frost || liquid + ? AppFrost.glassTint(cs) + : cs.primaryContainer, + blurSigma: frost + ? AppFrost.sigma + : null, + liquid: liquid, + backdropKey: _frostBackdrop, + borderRadius: BorderRadius.circular(28), + elevated: true, + depth: 12, + child: child!, + ); + }, + child: child, + ), child: SizedBox( width: 56, height: 56, @@ -2095,7 +2596,7 @@ class _ChatListScreenState extends State color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ], @@ -2119,10 +2620,7 @@ class _ChatListScreenState extends State return InkWell( onTap: () { if (_isSelectionMode) return; - pushSwipeable( - context, - (_) => const ChatListScreen(archiveMode: true), - ); + pushSwipeable(context, (_) => const ChatListScreen(archiveMode: true)); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), @@ -2151,10 +2649,7 @@ class _ChatListScreenState extends State if (_archivedUnread > 0) Container( margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric( - horizontal: 7, - vertical: 2, - ), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( color: cs.primary, borderRadius: BorderRadius.circular(12), @@ -2296,25 +2791,29 @@ class _ChatListScreenState extends State onSend: (photos, caption) async { if (photos.isEmpty) return; final picked = photos.first; - if (picked.item.isVideo) { - if (mounted) { - showCustomNotification( - context, - 'Видео в историях пока не поддерживается', - ); - } - return; - } + final isVideo = picked.item.isVideo; final file = picked.editedFile ?? picked.item.localFile ?? await picked.item.originFile(); if (file == null) { - if (mounted) showCustomNotification(context, 'Не удалось открыть фото'); + if (mounted) { + showCustomNotification( + context, + isVideo ? 'Не удалось открыть видео' : 'Не удалось открыть фото', + ); + } return; } if (!mounted) return; - pushSwipeable(context, (_) => StoryComposerScreen(file: file)); + pushSwipeable( + context, + (_) => StoryComposerScreen( + file: file, + isVideo: isVideo, + durationMs: picked.item.duration?.inMilliseconds, + ), + ); }, ); } @@ -2325,27 +2824,35 @@ class _ChatListScreenState extends State return f.title; } - Widget _buildFolderChip(String title, {required String folderId}) { + bool _selectFolder(String folderId) { + final target = _folders.indexWhere((f) => f.id == folderId); + if (target < 0) return false; + setState(() => _selectedFolderId = folderId); + if (_folderPageController.hasClients) { + final cur = _folderPageController.page?.round() ?? 0; + if (cur == target) return true; + if ((target - cur).abs() > 1) { + final neighbor = target > cur ? target - 1 : target + 1; + _folderPageController.jumpToPage(neighbor); + } + _folderPageController.animateToPage( + target, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ); + } + return true; + } + + Widget _buildFolderChip(ChatFolder folder) { final cs = Theme.of(context).colorScheme; + final folderId = folder.id; final isSelected = _selectedFolderId == folderId; return GestureDetector( - onTap: () { - final target = _folders.indexWhere((f) => f.id == folderId); - if (target < 0) return; - setState(() => _selectedFolderId = folderId); - if (_folderPageController.hasClients) { - final cur = _folderPageController.page?.round() ?? 0; - if (cur == target) return; - if ((target - cur).abs() > 1) { - final neighbor = target > cur ? target - 1 : target + 1; - _folderPageController.jumpToPage(neighbor); - } - _folderPageController.animateToPage( - target, - duration: const Duration(milliseconds: 280), - curve: Curves.easeOutCubic, - ); - } + onTap: () => _selectFolder(folderId), + onLongPress: () { + Haptics.medium(); + showFolderActionSheet(context, folder: folder); }, child: GlossyPill( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, @@ -2354,7 +2861,7 @@ class _ChatListScreenState extends State depth: 4, child: Center( child: Text( - title, + _folderChipLabel(folder), textAlign: TextAlign.center, style: TextStyle( color: isSelected ? cs.onPrimaryContainer : cs.primary, @@ -2374,6 +2881,8 @@ class _ChatListScreenState extends State return oneLine.isEmpty ? null : oneLine; } + static const double _ownStatusIconSize = 14; + String? _ownStatusFor(CachedChat chat, bool isPlaceholder) { if (isPlaceholder || chat.id == 0) return null; final me = _profile?.id; @@ -2382,28 +2891,19 @@ class _ChatListScreenState extends State } Widget _ownStatusIcon(ColorScheme cs, String status, bool read) { - IconData icon; - Color color; - switch (status) { - case 'sending': - case 'pending': - icon = Symbols.schedule; - color = cs.outline; - case 'error': - icon = Symbols.error; - color = Colors.redAccent; - default: - if (read) { - icon = Symbols.done_all; - color = kReadReceiptBlue; - } else { - icon = Symbols.check; - color = cs.outline; - } - } + final sending = isSendingStatus(status); + final effective = (read && !sending && status != 'error') ? 'read' : status; + final visual = messageStatusVisual(effective, dimColor: cs.outline); return Padding( padding: const EdgeInsets.only(left: 6), - child: Icon(icon, size: 16, color: color, fill: 1), + child: sending + ? SendingClockIcon(color: visual.color, size: _ownStatusIconSize) + : Icon( + visual.icon, + size: _ownStatusIconSize, + color: visual.color, + weight: 400, + ), ); } @@ -2422,8 +2922,10 @@ class _ChatListScreenState extends State String message, List messageRanges, String? draft, - bool messageItalic, - ) { + bool messageItalic, { + String prefix = '', + ChatPreviewMedia? media, + }) { if (draft != null) { return Text.rich( TextSpan( @@ -2448,29 +2950,39 @@ class _ChatListScreenState extends State overflow: TextOverflow.ellipsis, ); } - final previewStyle = TextStyle( - color: cs.outline, - fontSize: 14, - fontWeight: FontWeight.w400, - fontStyle: messageItalic ? FontStyle.italic : FontStyle.normal, - height: 1.2, + return ChatPreviewLine( + prefix: prefix, + text: message, + ranges: messageRanges, + media: media, + italic: messageItalic, + style: TextStyle( + color: cs.outline, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.2, + ), ); - if (messageRanges.isEmpty) { - return Text( - message, - style: previewStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } - return Text.rich( - FormattedMessageText.buildInlineSpan( - message, - messageRanges, - previewStyle, + } + + Widget _countBadge(ColorScheme cs, String label, {required bool muted}) { + return Container( + constraints: const BoxConstraints(minWidth: 20), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: muted ? cs.surfaceContainerHighest : cs.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: muted ? cs.outline : cs.onPrimary, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ); } @@ -2483,6 +2995,7 @@ class _ChatListScreenState extends State int presenceUserId = 0, bool isRead = false, int unreadCount = 0, + bool hasMention = false, bool isMuted = false, bool isVerified = false, bool isPinned = false, @@ -2492,22 +3005,81 @@ class _ChatListScreenState extends State String? ownStatus, bool ownRead = false, List messageRanges = const [], + int? previewMessageId, + String previewPrefix = '', + String? previewCipherText, + ChatPreviewMedia? previewMedia, + IconData? titleIcon, + bool hasMiniApp = false, }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); + final isEncrypted = ChatEncryptionStore.instance.isEnabled( + _profile?.id ?? 0, + int.tryParse(id) ?? 0, + ); final Widget? statusIcon = (ownStatus != null && draft == null) ? _ownStatusIcon(cs, ownStatus, ownRead) : null; - final Widget messageLine = _buildPreviewLine( - cs, - message, - messageRanges, - draft, - messageItalic, - ); + final canDecryptPreview = + isEncrypted && + draft == null && + previewMessageId != null && + (previewCipherText?.isNotEmpty ?? false); + final Widget messageLine = canDecryptPreview + ? DecryptedContent( + accountId: _profile?.id ?? 0, + chatId: int.tryParse(id) ?? 0, + messageId: previewMessageId.toString(), + cipherText: previewCipherText!, + builder: (decryption) => switch (decryption?.state) { + null => _buildPreviewLine( + cs, + message, + messageRanges, + draft, + messageItalic, + prefix: previewPrefix, + media: previewMedia, + ), + MessageDecryptionState.wrongKey => _buildPreviewLine( + cs, + 'неверный ключ', + const [], + draft, + true, + prefix: previewPrefix, + ), + MessageDecryptionState.decrypted => _buildPreviewLine( + cs, + decryption!.plaintext ?? '', + const [], + draft, + messageItalic, + prefix: previewPrefix, + ), + }, + ) + : _buildPreviewLine( + cs, + message, + messageRanges, + draft, + messageItalic, + prefix: previewPrefix, + media: previewMedia, + ); - final Widget avatarCircle = CircleAvatar( - radius: 24, + final storyOwnerId = chatType == 'DIALOG' + ? presenceUserId + : (int.tryParse(id) ?? 0); + final story = (_isSelectionMode || widget.forwardMode) + ? null + : _storyPreviewFor(storyOwnerId); + final avatarRadius = story == null ? 24.0 : 20.0; + + final CircleAvatar rawAvatar = CircleAvatar( + radius: avatarRadius, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty ? CachedNetworkImageProvider( @@ -2519,223 +3091,316 @@ class _ChatListScreenState extends State child: imageUrl.isEmpty ? Text( name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 20), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: story == null ? 20 : 17, + ), ) : null, ); - return InkWell( + + final Widget avatarCircle = story == null + ? rawAvatar + : Builder( + builder: (avatarContext) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openStoriesForOwner( + storyOwnerId, + storyOriginOf(avatarContext), + ), + child: StoryAvatarRing( + diameter: avatarRadius * 2, + total: story.totalCount, + read: story.readCount, + strokeWidth: 2.2, + ringGap: 4, + haloWidth: 1.5, + child: rawAvatar, + ), + ), + ); + return SpringyTap( key: ValueKey('chat_$id'), - onTap: () { - if (widget.forwardMode) { - Navigator.of(context).pop( - ForwardTarget( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), - ); - return; - } - if (_isSelectionMode) { - _toggleSelection(id); - return; - } - if (imageUrl.isNotEmpty) { - unawaited( - precacheImage( - CachedNetworkImageProvider( - imageUrl, - maxWidth: kAvatarThumbSize, - maxHeight: kAvatarThumbSize, + child: InkWell( + onTap: () { + if (widget.forwardMode) { + Navigator.of(context).pop( + ForwardTarget( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), + ); + return; + } + if (_shareMode) { + _toggleShareTarget(id, name); + return; + } + if (_isSelectionMode) { + _toggleSelection(id); + return; + } + if (imageUrl.isNotEmpty) { + unawaited( + precacheImage( + CachedNetworkImageProvider( + imageUrl, + maxWidth: kAvatarThumbSize, + maxHeight: kAvatarThumbSize, + ), + context, + ), + ); + } + if (widget.onChatSelected != null) { + widget.onChatSelected!( + DesktopChatSelection( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + ), + ); + } else { + pushSwipeable( context, - ), - ); - } - if (widget.onChatSelected != null) { - widget.onChatSelected!( - DesktopChatSelection( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), - ); - } else { - pushSwipeable( - context, - (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), - ); - } - }, - onLongPress: widget.forwardMode ? null : () => _toggleSelection(id), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - color: isSelected - ? cs.primary.withValues(alpha: 0.08) - : Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Stack( - children: [ - avatarCircle, - if (isSelected) - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - child: Icon( - Symbols.check, - color: cs.onPrimary, - size: 14, - ), - ), - ) - else if (presenceUserId != 0) - Positioned( - right: 0, - bottom: 0, - child: OnlineDot( - userId: presenceUserId, - borderColor: cs.surface, - ), - ), - ], + (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), - const SizedBox(width: 12), - Expanded( - child: SizedBox( - height: 48, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(top: 5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.1, + ); + } + }, + onLongPress: (widget.forwardMode || _shareMode) + ? null + : () => _toggleSelection(id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + color: isSelected + ? cs.primary.withValues(alpha: 0.08) + : Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + avatarCircle, + if (isEncrypted) + const Positioned( + left: -2, + bottom: -2, + child: EncryptionLockBadge(size: 18), + ), + if (isSelected) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 14, + ), + ), + ) + else if (presenceUserId != 0) + Positioned( + right: 0, + bottom: 0, + child: OnlineDot( + userId: presenceUserId, + borderColor: cs.surface, + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (titleIcon != null) ...[ + Icon( + titleIcon, + color: cs.outline, + size: 15, + weight: 500, + fill: 1, + ), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (isVerified) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, ), + if (isVerified) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], ], - ], + ), ), - ), - if (isMuted) ...[ - const SizedBox(width: 4), - Icon( - Symbols.notifications_off, - color: cs.outlineVariant, - size: 14, - weight: 400, + if (isMuted) ...[ + const SizedBox(width: 4), + Icon( + Symbols.notifications_off, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], + if (isPinned) ...[ + const SizedBox(width: 4), + Icon( + Symbols.keep, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], + const SizedBox(width: 8), + Text( + time, + style: TextStyle( + color: cs.outline, + fontSize: 12, + ), ), ], - if (isPinned) ...[ - const SizedBox(width: 4), - Icon( - Symbols.keep, - color: cs.outlineVariant, - size: 14, - weight: 400, - ), - ], - const SizedBox(width: 8), - Text( - time, - style: TextStyle(color: cs.outline, fontSize: 12), - ), - ], + ), ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: ActivitySubtitle( - chatId: int.tryParse(id) ?? 0, - child: messageLine, + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: ActivitySubtitle( + chatId: int.tryParse(id) ?? 0, + group: chatType != 'DIALOG', + child: messageLine, + ), ), - ), - ?statusIcon, - const SizedBox(width: 8), - if (unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: isMuted - ? cs.surfaceContainerHighest - : cs.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( + ?statusIcon, + const SizedBox(width: 8), + if (hasMention) ...[ + _countBadge(cs, '@', muted: isMuted), + const SizedBox(width: 4), + ], + if (unreadCount > 0) + _countBadge( + cs, unreadCount.toString(), - style: TextStyle( - color: isMuted ? cs.outline : cs.onPrimary, - fontSize: 11, - fontWeight: FontWeight.w600, - height: 1.1, - ), + muted: isMuted, + ) + else if (isRead) + Icon( + Symbols.done_all, + color: cs.primary, + size: 16, + weight: 400, ), - ) - else if (isRead) - Icon( - Symbols.done_all, - color: cs.primary, - size: 16, - weight: 400, - ), - ], + if (hasMiniApp) ...[ + const SizedBox(width: 8), + _miniAppButton( + cs, + botId: presenceUserId, + chatId: int.tryParse(id) ?? 0, + name: name, + ), + ], + ], + ), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), + ), + ), + ), + ); + } + + bool _isBotDialog(int contactId, CachedChat chat) { + if (contactId == 0 || contactId == _profile?.id) return false; + if (ContactCache.getOptions(contactId)?.contains('BOT') == true) + return true; + return chat.options.contains('BOT'); + } + + bool _hasMiniApp(int contactId, CachedChat chat) { + if (contactId == 0 || widget.forwardMode || _isSelectionMode) return false; + final options = ContactCache.getOptions(contactId); + if (options != null && options.any(kMiniAppOptions.contains)) return true; + return chat.options.any(kMiniAppOptions.contains); + } + + Widget _miniAppButton( + ColorScheme cs, { + required int botId, + required int chatId, + required String name, + }) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => unawaited( + openMiniApp(context, botId: botId, chatId: chatId, title: name), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + AppLocalizations.of(context)!.miniAppOpen, + style: TextStyle( + color: cs.onPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, ), ), ), @@ -2798,9 +3463,32 @@ class _ChatListScreenState extends State }, ), const SizedBox(height: 4), - _buildFabMenuItem(Symbols.campaign, 'Создать канал'), + _buildFabMenuItem( + Symbols.campaign, + 'Создать канал', + onTap: () { + _toggleFab(); + showCreateChannelFlow(context); + }, + ), const SizedBox(height: 4), - _buildFabMenuItem(Symbols.person_add, 'Создать контакт'), + _buildFabMenuItem( + Symbols.person_add, + 'Создать контакт', + onTap: () { + _toggleFab(); + showAddContactSheet(context); + }, + ), + const SizedBox(height: 4), + _buildFabMenuItem( + Symbols.create_new_folder, + 'Создать папку', + onTap: () { + _toggleFab(); + showFolderEditSheet(context); + }, + ), ], ); } @@ -2842,6 +3530,66 @@ class _ChatListScreenState extends State } } + Future _openDownloads() async { + final record = await pushSwipeable( + context, + (_) => const DownloadsScreen(), + ); + if (record == null || !mounted) return; + await _openDownloadedMessage(record); + } + + Future _openDownloadedMessage(DownloadRecord record) async { + final chatId = record.chatId; + final messageId = record.messageId; + if (chatId == null || messageId == null || messageId.isEmpty) return; + final profile = _profile ?? await AppDatabase.loadActiveProfile(); + if (profile == null || !mounted) return; + + CachedChat? chat; + for (final item in _chats) { + if (item.id == chatId) { + chat = item; + break; + } + } + if (chat == null) { + await chats.ensureChatCached(api, profile.id, chatId); + final cached = await chats.getChat(profile.id, chatId); + if (cached.isNotEmpty) chat = cached.first; + } + if (!mounted) return; + + final selection = DesktopChatSelection( + chatId: chatId, + name: + chat?.title ?? + (record.sourceName.trim().isEmpty ? 'Чат' : record.sourceName.trim()), + imageUrl: chat?.iconUrl ?? '', + chatType: chat?.type ?? 'CHAT', + initialMessageId: messageId, + initialMessageTime: record.messageTime, + ); + if (widget.onChatSelected != null) { + widget.onChatSelected!(selection); + return; + } + pushSwipeable( + context, + (_) => ChatScreen( + chatId: selection.chatId, + name: selection.name, + imageUrl: selection.imageUrl, + chatType: selection.chatType, + initialMessageId: selection.initialMessageId, + initialMessageTime: selection.initialMessageTime, + ), + ); + } + + void _openSearch() => + unawaited(pushSwipeable(context, (_) => const SearchScreen())); + void _openSavedMessages() { CachedChat? self; for (final c in _chats) { @@ -2915,7 +3663,6 @@ class _ChatListScreenState extends State ), ); } - } class _StoriesUi extends ChangeNotifier { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 51a8af0..6fe53a6 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert' show base64Encode; import 'dart:io' show File; import 'dart:math' as math; import 'dart:ui' as ui; @@ -10,12 +11,17 @@ import 'package:flutter/rendering.dart'; 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/file_uploader.dart'; -import 'package:komet/backend/modules/upload_notification_service.dart'; +import 'package:komet/backend/modules/comments.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'; +import 'package:komet/core/media/desktop_video_probe.dart'; +import 'package:komet/core/media/video_transcoder.dart'; import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; -import 'package:komet/frontend/screens/contacts/contact_profile_screen.dart'; +import 'package:komet/frontend/screens/contacts/open_contact_profile.dart'; import 'package:komet/frontend/screens/chats/chat_list_screen.dart'; import 'package:komet/frontend/screens/chats/poll_create_screen.dart'; import 'package:komet/frontend/widgets/animated_text_swap.dart'; @@ -26,6 +32,7 @@ import '../../../main.dart'; import '../../../l10n/app_localizations.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; +import '../../../backend/modules/contacts.dart'; import '../../../backend/modules/animoji.dart'; import '../../../models/animoji.dart'; import '../../../backend/modules/complaints.dart'; @@ -34,9 +41,15 @@ import '../../../core/media/rlottie/rlottie.dart'; import '../calls/call_screen.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; +import '../../../core/push/notification_bridge.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'; +import '../../../core/storage/chat_encryption_store.dart'; import '../../../core/storage/chat_wallpaper_store.dart'; import '../../../core/storage/draft_store.dart'; import '../../../core/storage/archived_chats_store.dart'; @@ -45,6 +58,7 @@ import '../../../core/cache/message_session_cache.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/utils/emoji_keyword_index.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/route_settle.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_colors.dart'; import '../../../core/config/app_message_actions_style.dart'; @@ -57,19 +71,26 @@ 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'; import 'chat/view/sticker_panel_view.dart'; import 'chat/view/command_panel_view.dart'; +import 'chat/view/mention_panel_view.dart'; +import 'chat/mention_panel_controller.dart'; import 'chat/view/selection_bar.dart'; import 'chat/view/chat_header.dart'; import 'chat/view/shimmer_loading.dart'; import '../../../core/config/app_commands.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_chat_chrome.dart'; +import 'package:komet/core/config/app_composer_background.dart'; +import 'package:komet/core/config/app_frost.dart'; +import 'package:komet/core/config/app_composer_style.dart'; import '../../../core/config/komet_settings.dart'; import '../../../models/attachment.dart'; +import '../../../models/contact_info.dart'; import '../../../models/sticker.dart'; import '../../commands/command_registry.dart'; import '../../commands/slash_command.dart'; @@ -78,18 +99,31 @@ import '../../../core/utils/text_format.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/message_bubble.dart'; +import '../../widgets/photo_viewer.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/lottie_image.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/sticker_pack_sheet.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_route.dart'; +import '../../widgets/directional_drag_recognizer.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/schedule_time_picker.dart'; import '../../widgets/chat_wallpaper_sheet.dart'; import '../../widgets/chat_wallpaper_view.dart'; +import '../../widgets/glossy_pill.dart'; +import '../../widgets/liquid_glass.dart'; import 'scheduled_messages_screen.dart'; +import 'chat_encryption_screen.dart'; import 'chat_wallpaper_preview_screen.dart'; +import 'chat/retain_offset_physics.dart'; +import 'profile_action_sheets.dart'; +import '../../../core/media/media_playback.dart'; +import '../../widgets/media_playback_pill.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; class _DateSeparatorItem { final DateTime date; @@ -110,20 +144,35 @@ class _UnreadSeparatorItem { class _FrostedPanel extends StatelessWidget { final Color tint; final Border? border; + final double sigma; + final BackdropKey? backdropKey; final Widget child; - const _FrostedPanel({required this.tint, this.border, required this.child}); + const _FrostedPanel({ + required this.tint, + this.border, + this.sigma = AppFrost.panelSigma, + this.backdropKey, + required this.child, + }); @override Widget build(BuildContext context) { - return ClipRect( - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24), - child: DecoratedBox( - decoration: BoxDecoration(color: tint, border: border), - child: child, + return Stack( + fit: StackFit.passthrough, + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: GlassSurface( + frostTint: tint, + frostSigma: sigma, + border: border, + backdropKey: backdropKey, + child: const SizedBox.expand(), + ), ), - ), + child, + ], ); } } @@ -173,9 +222,33 @@ class _MeasureSizeState extends State<_MeasureSize> { class ForwardRequest { final int sourceChatId; - final List optimistic; + final String sourceChatName; + final String sourceChatIconUrl; + final String sourceChatType; + final List messages; - const ForwardRequest({required this.sourceChatId, required this.optimistic}); + ForwardRequest({ + required this.sourceChatId, + required this.sourceChatName, + required this.sourceChatIconUrl, + required this.sourceChatType, + required List messages, + }) : messages = List.unmodifiable(messages); + + ForwardRequest withMessages(List value) => ForwardRequest( + sourceChatId: sourceChatId, + sourceChatName: sourceChatName, + sourceChatIconUrl: sourceChatIconUrl, + sourceChatType: sourceChatType, + messages: value, + ); +} + +class ReplyRequest { + final int sourceChatId; + final CachedMessage message; + + const ReplyRequest({required this.sourceChatId, required this.message}); } class ChatScreen extends StatefulWidget { @@ -183,11 +256,17 @@ class ChatScreen extends StatefulWidget { final String name; final String imageUrl; final String chatType; + final bool? channelSubscribed; final bool embedded; final VoidCallback? onClose; final ForwardRequest? forwardRequest; + final ReplyRequest? replyRequest; final String? initialMessageId; final int? initialMessageTime; + final String? commentPostId; + final CachedMessage? postMessage; + final String? botStartPayload; + final String? initialText; const ChatScreen({ super.key, @@ -195,19 +274,37 @@ class ChatScreen extends StatefulWidget { required this.name, required this.imageUrl, required this.chatType, + this.channelSubscribed, this.embedded = false, this.onClose, this.forwardRequest, + this.replyRequest, this.initialMessageId, this.initialMessageTime, + this.commentPostId, + this.postMessage, + this.botStartPayload, + this.initialText, }); + static final List<_ChatScreenState> _open = []; + + static bool startBotInVisibleChat(int chatId, String startPayload) { + for (final screen in _open.reversed) { + if (screen.widget.chatId != chatId) continue; + if (!screen.mounted || !screen._isRouteCurrent) continue; + unawaited(screen._sendBotStart(startPayload)); + return true; + } + return false; + } + @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State - with TickerProviderStateMixin, WidgetsBindingObserver { + with TickerProviderStateMixin, WidgetsBindingObserver, ReloadOnReconnect { final RichMessageController _messageController = RichMessageController(); final FocusNode _messageFocusNode = FocusNode(); double _keyboardReserve = 0; @@ -215,6 +312,7 @@ class _ChatScreenState extends State bool _keyboardBeforeStickers = false; final ScrollController _scrollController = ScrollController(); bool _userDidScroll = false; + int _userGestureEpoch = 0; String? _pinnedMessageId; double _pinnedAlignment = 0; int? _unreadAnchorTime; @@ -223,24 +321,38 @@ class _ChatScreenState extends State bool _initialPositionDone = false; bool _positioningInFlight = false; bool _initialTargetHandled = false; - bool _suppressHistoryAutoload = false; + int _historyAutoloadSuppressCount = 0; + bool get _historyAutoloadSuppressed => _historyAutoloadSuppressCount > 0; int _readMarkTime = 0; Timer? _readMarkTimer; final GlobalKey _listKey = GlobalKey(); + final GlobalKey _unreadSeparatorKey = GlobalKey(); + final Object _profileHeroTag = UniqueKey(); final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; + bool _encryptionEnabled = false; final ValueNotifier _showAttachmentPanel = ValueNotifier(false); late final StickerPanelController _stickers; final ValueNotifier _uploadStatus = ValueNotifier( const UploadStatus(), ); - StreamSubscription? _uploadSub; + String? _uploadStatusJobId; + ValueListenable? _uploadStatusBytes; StreamSubscription? _pushSub; StreamSubscription? _messageEventSub; + StreamSubscription>? _commentsInfoSub; + StreamSubscription? _commentSub; + final Map _commentCounts = {}; + final Set _commentCountsRequested = {}; + bool get _commentsMode => widget.commentPostId != null; + bool _commentsLoadingMore = false; + bool _commentsHasMore = true; StreamSubscription? _connSub; final Map?>> _reactionNotifiers = {}; - final Map>> _photoUploadProgress = {}; + final ValueNotifier _reactionAnimation = + ValueNotifier(null); + int _reactionAnimationToken = 0; final ValueNotifier _scheduledCount = ValueNotifier(0); late final VoiceRecordController _voiceRec = VoiceRecordController( @@ -255,10 +367,13 @@ class _ChatScreenState extends State isMounted: () => mounted, onRecorded: _sendVideoNote, formatElapsed: formatVoiceElapsed, + bottomInset: () => _composerHeight.value, ); + StreamSubscription? _uploadEventSub; + ValueListenable>? _photoProgressFor(CachedMessage m) => - _photoUploadProgress[m.id]; + UploadService.instance.progressFor(m.id); ValueNotifier?> _reactionNotifierFor(CachedMessage m) { final existing = _reactionNotifiers[m.id]; @@ -306,9 +421,26 @@ class _ChatScreenState extends State } notifier.value = result.info; _applyReactionInfoToMessage(message.id, result.info); + final appliedReaction = result.info?['yourReaction']?.toString(); + if (!isToggleOff && + appliedReaction != null && + EmojiKeywordIndex.normalize(appliedReaction) == + EmojiKeywordIndex.normalize(emoji)) { + final event = ReactionAnimationEvent( + messageId: message.id, + emoji: appliedReaction, + token: ++_reactionAnimationToken, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _reactionAnimation.value = event; + }); + } } - void _applyReactionInfoToMessage(String messageId, Map? info) { + void _applyReactionInfoToMessage( + String messageId, + Map? info, + ) { final idx = _messages.indexWhere((m) => m.id == messageId); if (idx == -1) return; final payload = {...?_messages[idx].payload}; @@ -351,7 +483,8 @@ class _ChatScreenState extends State final prev = current?['yourReaction']?.toString(); String? your; if (prev != null && - EmojiKeywordIndex.normalize(prev) == EmojiKeywordIndex.normalize(emoji)) { + EmojiKeywordIndex.normalize(prev) == + EmojiKeywordIndex.normalize(emoji)) { decrement(prev); your = null; } else { @@ -385,15 +518,23 @@ class _ChatScreenState extends State int _otherStatus = 0; int? _otherSeenTime; - int? _participantsCount; final ValueNotifier _replyTo = ValueNotifier(null); + final ValueNotifier> _pendingForwards = ValueNotifier( + const [], + ); + static const bool _crossChatReplySupported = false; + int? _replySourceChatId; + ForwardRequest? _forwardRequest; + bool _forwardSending = false; final ValueNotifier _highlightMessageId = ValueNotifier(null); Timer? _highlightTimer; final ValueNotifier _jumpCacheExtent = ValueNotifier(null); Timer? _goToMessageSettleTimer; static const double _jumpCacheExtentPx = 800.0; + late final RouteSettle _routeSettle = RouteSettle(isMounted: () => mounted); + late final ChatSearchController _search; late final AnimationController _searchAnim; final FocusNode _searchFocusNode = FocusNode(); @@ -411,14 +552,15 @@ class _ChatScreenState extends State int _tempIdCounter = 0; late final AnimationController _attachAnim; late final CommandPanelController _commandPanel; + late final MentionPanelController _mentionPanel; String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; late AnimationController _shimmerController; Timer? _shimmerStartTimer; bool _previewChat = false; - bool _forwardRequestDone = false; - + bool _subscribing = false; + String? _channelLink; final ChatController _chatController = ChatController(); List get _messages => _chatController.messages; @@ -433,8 +575,24 @@ class _ChatScreenState extends State static const double _avgMessageHeight = 72.0; static const double _historyPrefetchExtent = _avgMessageHeight * 8; + static const double _scrollDownRevealExtent = _avgMessageHeight * 30; + static const double _scrollDownRevealFactor = 0.6; + static const double _scrollDownTeleportFactor = 2.0; static const double _glossyHeaderHeight = 76.0; static const double _glossySearchHeight = 58.0; + static const double _pinnedBannerLift = 6.0; + static const double _edgeFadeHeight = 24.0; + static const double _scrollDownSize = 46.0; + static const double _materialIconSlot = 48.0; + static const double _unreadSeparatorHeight = 30.0; + static const double _unreadSeparatorInset = 72.0; + static const double _unreadAnchorFallbackAlignment = 0.3; + static const int _jumpStallLimit = 8; + static const int _jumpFrameLimit = 240; + static const double _jumpStepMaxScreens = 4.0; + + final BackdropKey _barBackdrop = BackdropKey(); + final BackdropKey _pillBackdrop = BackdropKey(); bool get _isLoadingMore => _chatController.isLoadingMore; set _isLoadingMore(bool v) => _chatController.isLoadingMore = v; bool get _hasMoreHistory => _chatController.hasMoreHistory; @@ -446,16 +604,42 @@ class _ChatScreenState extends State set _myId(int v) => _chatController.myId = v; CachedChat? chat; bool _peerIsBot = false; + bool _botStartRequested = false; ChatWallpaper? _wallpaper; + bool get _composerFrosted => + AppComposerBackground.current.value != ComposerBackground.standard; + + bool get _composerUnderlap => + AppChatChrome.current.value != ChatChromeStyle.color || _composerFrosted; + + bool get _materialComposer => + !ComposerChrome.isGlossy(AppComposerStyle.current.value); + + bool get _composerPaintsSurface { + if (!_commentsMode && + widget.chatType == 'CHANNEL' && + _pendingForwards.value.isEmpty) { + return false; + } + return _materialComposer && !_composerFrosted; + } + + bool get _liquidChrome => + AppVisualStyle.current.value.glossyChrome && + ChatChromeMaterial.isLiquid(AppChatChrome.current.value); + ChatChromeStyle get _effectiveChrome { final chrome = AppChatChrome.current.value; - if (_wallpaper != null && chrome == ChatChromeStyle.none) { - return ChatChromeStyle.blur; + if (chrome == ChatChromeStyle.liquidGlass) { + return ChatChromeStyle.transparent; } return chrome; } + bool get _chromeVignette => + _effectiveChrome == ChatChromeStyle.none && _wallpaper == null; + final ValueNotifier _composerHeight = ValueNotifier(96); final ValueNotifier _pinnedBannerHeight = ValueNotifier(0); @@ -463,25 +647,43 @@ class _ChatScreenState extends State Timer? _floatingDateTimer; late final AnimationController _floatingDateAnimController; late final CurvedAnimation _floatingDateCurved; + late final AnimationController _scrollDownAnimController; + late final CurvedAnimation _scrollDownCurved; + bool _scrollDownVisible = false; + final ValueNotifier _newMessageCount = ValueNotifier(0); + bool _clearCountScheduled = false; + bool _retainOffsetOnce = false; + late final ScrollPhysics _listPhysics = RetainOffsetScrollPhysics( + retain: _consumeRetainOffset, + ); + int _listEpoch = 0; + final List<({String id, double pixels, double alignment})> _returnStack = []; + bool _returningToAnchor = false; final Map _separatorKeys = {}; String? _lastSentId; final ValueNotifier _otherUnread = ValueNotifier(0); final ValueNotifier _animojiHold = ValueNotifier(true); final ValueNotifier> _selectedIds = ValueNotifier(const {}); + final ValueNotifier _textSelectionDrag = ValueNotifier(null); + final ValueNotifier<({String id, Offset pos})?> _textSelection = + ValueNotifier(null); late final AnimationController _selectionAnim; bool get _selectionMode => _selectedIds.value.isNotEmpty; void _prewarmQuickReactions() { if (!mounted || !RlottieEngine.instance.available) return; - final dpr = - (MediaQuery.maybeOf(context)?.devicePixelRatio ?? 2.0).clamp(1.0, 2.0); + final dpr = (MediaQuery.maybeOf(context)?.devicePixelRatio ?? 2.0).clamp( + 1.0, + 2.0, + ); final px = ((44.0 * dpr).clamp(96.0, 512.0) / 32).ceil() * 32; for (final a in animojiModule.quickAnimojis) { - final url = a.lottieUrl; - if (url != null && url.isNotEmpty) { - unawaited(RlottieEngine.instance.prewarm(url, px)); + for (final url in [a.lottieUrl, a.lottiePlayUrl]) { + if (url != null && url.isNotEmpty) { + unawaited(RlottieEngine.instance.prewarm(url, px)); + } } } } @@ -489,22 +691,38 @@ class _ChatScreenState extends State @override void initState() { super.initState(); + _previewChat = widget.channelSubscribed == false; _chatController.chatId = widget.chatId; _chatController.isMounted = () => mounted; + if (!_commentsMode) ChatScreen._open.add(this); unawaited(PushService.clearChatNotification(widget.chatId)); - unawaited(animojiModule - .ensureLoaded() - .then((_) => _prewarmQuickReactions()) - .catchError((_) {})); + if (!_commentsMode) { + unawaited(NotificationBridge.instance.setActiveChat(widget.chatId)); + } + unawaited( + animojiModule + .ensureLoaded() + .then((_) { + _prewarmQuickReactions(); + if (mounted) _bumpMessages(); + }) + .catchError((_) {}), + ); WidgetsBinding.instance.addObserver(this); + _uploadEventSub = UploadService.instance.events.listen(_onUploadEvent); + _syncUploadStatus(); chats.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); _scrollController.addListener(_onScrollForDate); _scrollController.addListener(_maybeLoadMoreHistory); _scrollController.addListener(_recordScrollPixels); _scrollController.addListener(_scheduleReadMarker); + _scrollController.addListener(_updateScrollDownVisible); + MediaPlayback.instance.enterChat(widget.chatId); AppVisualStyle.current.addListener(_onVisualStyleChanged); AppChatChrome.current.addListener(_onVisualStyleChanged); + AppComposerStyle.current.addListener(_onVisualStyleChanged); + AppComposerBackground.current.addListener(_onVisualStyleChanged); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -524,6 +742,14 @@ class _ChatScreenState extends State textOf: () => _messageController.text, onSelected: _onCommandSelected, ); + _mentionPanel = MentionPanelController( + vsync: this, + chatId: widget.chatId, + enabled: _mentionsAvailable, + selfId: () => _myId, + valueOf: () => _messageController.value, + onSelected: _onMentionSelected, + ); _selectionAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 260), @@ -538,6 +764,18 @@ class _ChatScreenState extends State chatId: widget.chatId, isMounted: () => mounted, ); + final incomingReply = widget.replyRequest; + if (incomingReply != null) { + _replyTo.value = incomingReply.message; + _replySourceChatId = incomingReply.sourceChatId == widget.chatId + ? null + : incomingReply.sourceChatId; + } + final incomingForward = widget.forwardRequest; + if (incomingForward != null) { + _forwardRequest = incomingForward; + _pendingForwards.value = incomingForward.messages; + } _pushSub = api.pushStream .where( (p) => @@ -549,14 +787,28 @@ class _ChatScreenState extends State _messageEventSub = chats.messageEvents .where((e) => e.chatId == widget.chatId) .listen(_onMessageEvent); + if (_commentsMode) { + _commentSub = commentsModule.commentStream + .where( + (e) => + e.chatId == widget.chatId && e.postId == widget.commentPostId, + ) + .listen(_onLiveComment); + } else if (widget.chatType == 'CHANNEL') { + _commentsInfoSub = commentsModule.infoStream.listen(_onCommentsInfo); + } ChatActivityStore.instance .listenable(widget.chatId) .addListener(_recomputeHeaderStatus); + ChatMembersStore.instance + .listenable(widget.chatId) + .addListener(_recomputeHeaderStatus); _connSub = api.stateStream.listen((_) { if (mounted) _recomputeHeaderStatus(); }); debugForceOffline.addListener(_recomputeHeaderStatus); PresenceFetch.revision.addListener(_onPresenceChanged); + ContactsModule.revision.addListener(_onContactsChanged); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), @@ -567,24 +819,37 @@ class _ChatScreenState extends State curve: Curves.easeOut, reverseCurve: Curves.easeIn, ); - - unawaited( - _fastPreloadCache().then((_) { - if (mounted) unawaited(_runForwardRequest()); - }), + _scrollDownAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + reverseDuration: const Duration(milliseconds: 180), ); + _scrollDownCurved = CurvedAnimation( + parent: _scrollDownAnimController, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ); + + unawaited(_fastPreloadCache()); unawaited(_loadParticipantsCount()); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); } + @override + void reloadAfterReconnect() { + if (!_historyKickedOff) return; + unawaited(_loadHistory()); + unawaited(_loadParticipantsCount()); + } + Future _loadParticipantsCount() async { + if (_commentsMode) return; if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; final info = await chats.getChatInfo(api, widget.chatId); if (!mounted) return; - final count = info?['participantsCount'] as int?; - if (count != null && count != _participantsCount) { - _participantsCount = count; - _recomputeHeaderStatus(); + if (widget.chatType == 'CHANNEL') { + final link = info?['link']; + if (link is String && link.isNotEmpty) _channelLink = link; } } @@ -593,14 +858,24 @@ class _ChatScreenState extends State final peerId = widget.chatId ^ _myId; if (peerId <= 0) return; final cached = ContactInfoFetch.peek(peerId); - if (cached != null) _applyPeerKind(cached.isBot); + if (cached != null) _applyPeerInfo(peerId, cached); final info = await ContactInfoFetch.get(peerId); - if (info != null) _applyPeerKind(info.isBot); + if (info != null) _applyPeerInfo(peerId, info); + if ((info ?? cached)?.isBot ?? false) { + unawaited(BotInfoFetch.get(peerId)); + } } - void _applyPeerKind(bool isBot) { - if (!mounted || _peerIsBot == isBot) return; - setState(() => _peerIsBot = isBot); + void _applyPeerInfo(int peerId, ContactInfo info) { + if (!mounted) return; + final avatar = info.avatarUrl; + final avatarIsNew = + avatar != null && + avatar.isNotEmpty && + ContactCache.getAvatar(peerId) != avatar; + if (avatarIsNew) ContactCache.putAvatar(peerId, avatar); + if (_peerIsBot == info.isBot && !avatarIsNew) return; + setState(() => _peerIsBot = info.isBot); } Future _fastPreloadCache() async { @@ -615,17 +890,26 @@ class _ChatScreenState extends State if (myName.isNotEmpty) ContactCache.put(p.id, myName); ContactCache.putAvatar(p.id, p.baseUrl); } + if (_commentsMode) return; _restoreDraft(); unawaited(_loadPeerKind()); unawaited(_loadWallpaper()); + unawaited(_loadEncryption()); unawaited(_refreshBadge()); try { final chatRows = await chats.getChat(_myId, widget.chatId); if (!mounted) return; if (chatRows.isNotEmpty) { + final channelSubscribed = + widget.chatType != 'CHANNEL' || + await AppDatabase.isChatInList(_myId, widget.chatId); + if (!mounted) return; setState(() { chat = chatRows.first; + if (widget.chatType == 'CHANNEL') { + _previewChat = !channelSubscribed; + } }); _bumpMessages(); _seedPresenceFromChat(); @@ -643,7 +927,9 @@ class _ChatScreenState extends State _hasMoreHistory = !cached.reachedStart; _messagesRev.value++; }); + _mergePendingMedia(); _syncReactionNotifiersFromMessages(); + _requestCommentCounts(); _revealOrHoldInitial(); return; } @@ -663,6 +949,8 @@ class _ChatScreenState extends State _messages = first; _messagesRev.value++; }); + _mergePendingMedia(); + _requestCommentCounts(); _revealOrHoldInitial(); } } @@ -716,29 +1004,11 @@ class _ChatScreenState extends State void _onFirstFrameRendered(Duration _) { if (!mounted) return; if (widget.embedded) { - _kickoffHistory(); - return; + _routeSettle.settleNow(); + } else { + _routeSettle.bind(context); } - final anim = ModalRoute.of(context)?.animation; - if (anim == null || anim.status == AnimationStatus.completed) { - _kickoffHistory(); - return; - } - Timer? safety; - void onStatus(AnimationStatus status) { - if (status != AnimationStatus.completed) return; - anim.removeStatusListener(onStatus); - safety?.cancel(); - if (!mounted) return; - _kickoffHistory(); - } - - anim.addStatusListener(onStatus); - safety = Timer(const Duration(milliseconds: 400), () { - anim.removeStatusListener(onStatus); - if (!mounted) return; - _kickoffHistory(); - }); + _routeSettle.run(_kickoffHistory); } void _kickoffHistory() { @@ -749,7 +1019,44 @@ class _ChatScreenState extends State if (!mounted || !_isLoading) return; _shimmerController.repeat(); }); - _loadHistory(); + unawaited(_loadHistory().then((_) => _sendPendingBotStart())); + } + + bool get _isRouteCurrent { + if (!mounted) return false; + final route = ModalRoute.of(context); + return route == null || route.isCurrent; + } + + Future _sendPendingBotStart() async { + final payload = widget.botStartPayload; + if (payload == null || _botStartRequested || !mounted) return; + _botStartRequested = true; + await _sendBotStart(payload); + } + + Future _sendBotStart(String startPayload) async { + if (_myId == 0) { + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + _myId = profile?.id ?? 0; + } + try { + final sent = await messagesModule.sendBotStart( + widget.chatId, + startPayload, + ); + if (!mounted) return; + if (sent == null) { + showCustomNotification(context, 'Не удалось запустить бота'); + return; + } + await _persistOutgoing( + CachedMessage.fromPushPayload(_myId, widget.chatId, sent), + ); + } catch (_) { + if (mounted) showCustomNotification(context, 'Не удалось запустить бота'); + } } void _onLoadingFinished() { @@ -768,18 +1075,41 @@ class _ChatScreenState extends State } } - void _positionToMessage(String messageId, double alignment) { + double _unreadAnchorAlignment() { + final listBox = _listKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || listBox.size.height <= 0) { + return _unreadAnchorFallbackAlignment; + } + final separator = + _unreadSeparatorKey.currentContext?.size?.height ?? + _unreadSeparatorHeight; + final glossy = AppVisualStyle.current.value.glossyChrome; + final chromeBottom = _effectiveChrome == ChatChromeStyle.color + ? 0.0 + : MediaQuery.paddingOf(context).top + + (glossy ? _glossyHeaderHeight : kToolbarHeight) + + _pinnedBannerHeight.value; + final desiredTop = chromeBottom + separator + _unreadSeparatorInset; + return (desiredTop / listBox.size.height).clamp(0.0, 0.5); + } + + void _positionToMessage(String messageId) { _pinnedMessageId = messageId; - _pinnedAlignment = alignment.clamp(0.0, 1.0); + _jumpCacheExtent.value = _jumpCacheExtentPx; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + _pinnedAlignment = _unreadAnchorAlignment(); _scrollToLoadedMessage( messageId, alignment: _pinnedAlignment, highlight: false, notifyIfMissing: false, onSettled: () { - if (mounted) setState(_markPositioned); + if (!mounted) return; + setState(_markPositioned); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _jumpCacheExtent.value = null; + }); }, ); }); @@ -801,6 +1131,15 @@ class _ChatScreenState extends State return; } if (_positioningInFlight) return; + if (_commentsMode) { + _initialPositionDone = true; + _markPositioned(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + return; + } if (_messages.isEmpty) { if (!_hasMoreHistory) _markPositioned(); return; @@ -826,7 +1165,7 @@ class _ChatScreenState extends State } if (firstUnread > 0 || !_hasMoreHistory) { _initialPositionDone = true; - _positionToMessage(_messages[firstUnread].id, 0.15); + _positionToMessage(_messages[firstUnread].id); } else { _positioningInFlight = true; unawaited(_loadUntilUnreadReady()); @@ -878,6 +1217,47 @@ class _ChatScreenState extends State }); } + void _openChatInfo({ChatInfoTab? initialTab}) { + final navigator = Navigator.of(context); + final chatRoute = ModalRoute.of(context); + navigator.push( + MaterialPageRoute( + builder: (_) => ChatInfoScreen( + chatId: widget.chatId, + name: _headerName(), + imageUrl: _headerAvatarUrl(), + chatType: widget.chatType, + heroTag: _profileHeroTag, + initialTab: initialTab, + openedFromChat: true, + onJumpToMessage: (chatRoute == null || widget.embedded) + ? null + : (messageId, time) { + navigator.popUntil((r) => r == chatRoute); + _requestGoToMessage(messageId, time); + }, + ), + ), + ); + } + + late final PhotoViewerActions _photoActions = PhotoViewerActions( + goToMessage: _requestGoToMessage, + forward: _forwardMessageById, + delete: (messageId, senderId) => + _confirmDeleteMessage(messageId, senderId == _myId), + viewAllMedia: () => _openChatInfo(initialTab: ChatInfoTab.media), + ); + + void _forwardMessageById(String messageId) { + final message = _messages.where((m) => m.id == messageId).firstOrNull; + if (message == null) { + showCustomNotification(context, 'Сообщение не загружено'); + return; + } + unawaited(_forwardMessages([message])); + } + void _requestGoToMessage(String id, int time) { if (!mounted) return; setState(_beginTargetNavigation); @@ -887,18 +1267,14 @@ class _ChatScreenState extends State } Future _loadUntilUnreadReady() async { - var guard = 0; - while (mounted && guard < 80 && _hasMoreHistory) { - if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); - final ua = _unreadAnchorTime; - if (ua != null && _messages.indexWhere((m) => m.time > ua) > 0) break; - guard++; - final before = _messages.isEmpty ? 0 : _messages.first.time; - await _loadMoreHistory(); - if (!mounted) return; - final after = _messages.isEmpty ? 0 : _messages.first.time; - if (after == before) break; - } + await _walkHistoryBack( + reached: () { + if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); + final ua = _unreadAnchorTime; + return ua != null && _messages.indexWhere((m) => m.time > ua) > 0; + }, + maxPages: 15, + ); if (!mounted) return; if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); final ua = _unreadAnchorTime; @@ -906,7 +1282,7 @@ class _ChatScreenState extends State _positioningInFlight = false; _initialPositionDone = true; if (idx >= 0) { - _positionToMessage(_messages[idx].id, 0.15); + _positionToMessage(_messages[idx].id); } else { setState(_markPositioned); } @@ -921,6 +1297,7 @@ class _ChatScreenState extends State } void _updateReadMarker() { + if (_commentsMode) return; if (!mounted || _myId == 0 || _messages.isEmpty) return; if (_awaitingPosition || !_initialPositionDone) return; if (!_scrollController.hasClients) return; @@ -948,6 +1325,7 @@ class _ChatScreenState extends State final atBottom = candidate.id == _messages.last.id; if (_unreadAnchorTime != null && + _userDidScroll && _unreadSeparatorScrolledPast( atBottom, topIndex, @@ -1011,6 +1389,67 @@ class _ChatScreenState extends State Navigator.of(context).pop(); } + bool _canShowReadBy(CachedMessage message) { + if (message.isControl || message.deleted) return false; + if (int.tryParse(message.id) == null) return false; + final type = chat?.type ?? widget.chatType; + return type == 'CHAT' || type == 'GROUP'; + } + + Future> _loadReadBy(CachedMessage message) async { + final marks = await chats.getReadMarks(api, _myId, widget.chatId); + final reactions = await messagesModule.getDetailedReactions( + widget.chatId, + message.id, + ); + + final readerIds = { + ...marks.entries.where((e) => e.value >= message.time).map((e) => e.key), + ...reactions.keys, + }..removeAll({_myId, message.senderId}); + if (readerIds.isEmpty || !mounted) return const []; + + await messagesModule.ensureContactNames(readerIds); + await animojiModule.ensureLoaded(); + if (!mounted) return const []; + + final animojiByEmoji = { + for (final animoji in animojiModule.animojis) + EmojiKeywordIndex.normalize(animoji.emoji): animoji, + }; + final unknownName = AppLocalizations.of( + context, + )!.msgActionsReadByUnknownUser; + + final readers = readerIds.map((id) { + final emoji = reactions[id]; + final animoji = emoji == null + ? null + : animojiByEmoji[EmojiKeywordIndex.normalize(emoji)]; + final name = ContactCache.get(id); + return MessageReader( + id: id, + name: name == null || name.isEmpty ? unknownName : name, + avatarUrl: ContactCache.getAvatar(id), + reaction: emoji == null + ? null + : ReactionEmoji( + emoji: emoji, + animationUrl: animoji?.lottieUrl, + staticUrl: animoji?.iconUrl, + ), + ); + }).toList(); + + readers.sort((a, b) { + final aReacted = a.reaction != null; + final bReacted = b.reaction != null; + if (aReacted != bReacted) return aReacted ? -1 : 1; + return (marks[b.id] ?? 0).compareTo(marks[a.id] ?? 0); + }); + return readers; + } + bool _canPinMessage(CachedMessage message) { if (message.isControl) return false; if (int.tryParse(message.id) == null) return false; @@ -1099,35 +1538,14 @@ class _ChatScreenState extends State void _jumpToPinnedMessage() { final id = chat?.pinnedMsgId; - final time = chat?.pinnedMsgTime; if (id == null) return; - unawaited(_openPinnedMessage(id.toString(), time ?? 0)); - } - - Future _openPinnedMessage(String messageId, int time) async { - if (!_messages.any((m) => m.id == messageId)) { - var guard = 0; - while (mounted && - guard < 60 && - _hasMoreHistory && - !_messages.any((m) => m.id == messageId) && - (_messages.isEmpty || _messages.first.time > time)) { - guard++; - final before = _messages.isEmpty ? 0 : _messages.first.time; - await _loadMoreHistory(); - if (!mounted) return; - final after = _messages.isEmpty ? 0 : _messages.first.time; - if (after == before) break; - } - if (!mounted) return; - await WidgetsBinding.instance.endOfFrame; - if (!mounted) return; - } + final messageId = id.toString(); if (_messages.any((m) => m.id == messageId)) { _scrollToLoadedMessage(messageId); - } else { - showCustomNotification(context, 'Сообщение не загружено'); + return; } + setState(_beginTargetNavigation); + unawaited(_runGoToMessage(messageId, chat?.pinnedMsgTime ?? 0)); } bool _badgeRefreshing = false; @@ -1192,6 +1610,10 @@ class _ChatScreenState extends State if (!mounted) return; _myId = activeProfile?.id ?? 0; } + if (_commentsMode) { + await _loadCommentsHistory(); + return; + } if (widget.chatType == 'DIALOG') { unawaited(_loadOtherPresence()); } @@ -1214,8 +1636,20 @@ class _ChatScreenState extends State void _maybeLoadMoreHistory() { if (!_scrollController.hasClients) return; - if (_suppressHistoryAutoload) return; - if (_isLoading || _isLoadingMore || !_hasMoreHistory) return; + if (_historyAutoloadSuppressed) return; + if (_isLoading) return; + if (_commentsMode) { + if (_commentsLoadingMore || !_commentsHasMore || _messages.isEmpty) { + return; + } + final pos = _scrollController.position; + if (pos.pixels - pos.minScrollExtent <= _historyPrefetchExtent) { + unawaited(_loadMoreComments()); + } + return; + } + _maybeFillGap(); + if (_isLoadingMore || !_hasMoreHistory) return; if (_messages.isEmpty) return; final pos = _scrollController.position; if (pos.maxScrollExtent <= 0) return; @@ -1224,14 +1658,221 @@ class _ChatScreenState extends State } } - Future _loadMoreHistory() async { + void _maybeFillGap() { + final controller = _chatController; + if (!controller.hasGap || controller.loadingGap) return; + final oldestRendered = _oldestRenderedMessageTime(); + for (final gap in controller.gaps) { + if (!ChatController.gapFillLeavesViewportInPlace(gap, oldestRendered)) { + continue; + } + unawaited(_fillGapForward(gap)); + return; + } + } + + int? _oldestRenderedMessageTime() { + for (final message in _messages) { + final box = _messageKeys[message.id]?.currentContext?.findRenderObject(); + if (box is RenderBox && box.attached) return message.time; + } + return null; + } + + Future _fillGapForward(HistoryGap gap) async { + String? anchorId; + double? anchorAt; + double? anchorAlignment; + final added = await _chatController.fillGapForward( + gap, + beforeApply: () { + final id = _viewportAnchorId(); + anchorId = id; + if (id == null) return; + anchorAt = _messageContentOffset(id); + anchorAlignment = _messageAlignmentInList(id); + }, + ); + if (!mounted || added == 0) return; + + _syncReactionNotifiersFromMessages(); + _bumpMessages(); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + final id = anchorId; + final at = anchorAt; + final alignment = anchorAlignment; + if (id != null && at != null && !_restoreContentOffset(id, at)) { + _historyAutoloadSuppressCount++; + _alignLoadedMessage( + id, + alignment ?? 0, + 0, + epoch: _userGestureEpoch, + onSettled: () => _historyAutoloadSuppressCount--, + ); + } + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + } + + bool _consumeRetainOffset() { + if (!_retainOffsetOnce) return false; + _retainOffsetOnce = false; + return true; + } + + void _retainOffsetForNextLayout() { + _retainOffsetOnce = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _retainOffsetOnce = false; + }); + } + + String? _viewportAnchorId() { + final listBox = _listKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || !listBox.attached) return null; + final height = listBox.size.height; + String? newest; + for (final message in _messages) { + final box = _messageKeys[message.id]?.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.attached) continue; + final dy = box.localToGlobal(Offset.zero, ancestor: listBox).dy; + if (dy >= 0 && dy <= height) newest = message.id; + } + return newest; + } + + Future _holdScrollAfterAppend( + String? anchorId, + double? anchorAt, + ) async { + if (anchorId == null || anchorAt == null) return; + await WidgetsBinding.instance.endOfFrame; + if (!mounted || !_scrollController.hasClients) return; + if (_scrollController.position.userScrollDirection != + ScrollDirection.idle) { + return; + } + _restoreContentOffset(anchorId, anchorAt); + } + + double? _messageOffsetInList(String messageId) { + final listBox = _listKey.currentContext?.findRenderObject(); + final box = _keyForMessage(messageId).currentContext?.findRenderObject(); + if (listBox is! RenderBox || box is! RenderBox || !box.attached) { + return null; + } + return box.localToGlobal(Offset.zero, ancestor: listBox).dy; + } + + double? _messageContentOffset(String messageId) { + if (!_scrollController.hasClients) return null; + final dy = _messageOffsetInList(messageId); + if (dy == null) return null; + return dy - _scrollController.position.pixels; + } + + double? _messageAlignmentInList(String messageId) { + final listBox = _listKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || listBox.size.height <= 0) return null; + final dy = _messageOffsetInList(messageId); + if (dy == null) return null; + return (dy / listBox.size.height).clamp(0.0, 1.0); + } + + bool _restoreContentOffset(String messageId, double before) { + if (!_scrollController.hasClients) return false; + final after = _messageContentOffset(messageId); + if (after == null) return false; + final delta = before - after; + if (delta.abs() <= 0.5) return true; + final pos = _scrollController.position; + final target = (pos.pixels + delta).clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + if ((target - pos.pixels).abs() <= 0.5) return true; + _scrollController.jumpTo(target); + return true; + } + + Future _loadMessageWindow(String messageId, int targetTime) async { + if (targetTime <= 0) { + await _walkHistoryBack( + reached: () => _messages.any((m) => m.id == messageId), + maxPages: 10, + ); + return; + } + + _historyAutoloadSuppressCount++; + try { + await _chatController.loadMessageWindow( + targetId: messageId, + targetTime: targetTime, + ); + } finally { + _historyAutoloadSuppressCount--; + } + if (!mounted) return; + _syncReactionNotifiersFromMessages(); + _bumpMessages(); + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + } + + Future _walkHistoryBack({ + required bool Function() reached, + required int maxPages, + int targetTime = 0, + }) async { + if (reached()) return; + _historyAutoloadSuppressCount++; + try { + var page = 0; + while (mounted && + page < maxPages && + _hasMoreHistory && + !reached() && + (_messages.isEmpty || _messages.first.time > targetTime)) { + page++; + final before = _messages.isEmpty ? 0 : _messages.first.time; + await _loadMoreHistory( + resolveSenderNames: false, + pageSize: ChatController.historyWalkPageSize, + persist: false, + ); + if (!mounted) return; + final after = _messages.isEmpty ? 0 : _messages.first.time; + if (after == before) break; + } + } finally { + _historyAutoloadSuppressCount--; + } + if (!mounted) return; + _chatController.persistSessionCache(); + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + } + + Future _loadMoreHistory({ + bool resolveSenderNames = true, + int? pageSize, + bool persist = true, + }) async { await _chatController.loadMoreHistory( + pageSize: pageSize, + persist: persist, onLoadingStarted: _bumpMessages, onLoaded: (added) { if (added > 0) _syncReactionNotifiersFromMessages(); _bumpMessages(); - _loadForwardedSenderNames(); - _loadGroupSenderNames(); + if (resolveSenderNames) { + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + } }, onError: (_) { if (mounted) { @@ -1247,6 +1888,7 @@ class _ChatScreenState extends State bool markLoaded = false, }) { final changed = _chatController.mergeMessages(decodedDesc); + _requestCommentCounts(); if (!changed && !markLoaded) return; @@ -1264,6 +1906,169 @@ class _ChatScreenState extends State } } + void _requestCommentCounts() { + if (_commentsMode) return; + if ((chat?.type ?? widget.chatType) != 'CHANNEL') return; + final pending = []; + for (final m in _messages) { + if (m.isControl) continue; + if (_commentCountsRequested.contains(m.id)) continue; + _commentCountsRequested.add(m.id); + pending.add(m.id); + } + if (pending.isEmpty) return; + unawaited( + commentsModule.fetchInfo( + accountId: _myId, + chatId: widget.chatId, + postIds: pending, + ), + ); + } + + void _onCommentsInfo(Map info) { + if (!mounted) return; + var changed = false; + for (final m in _messages) { + final count = info[m.id]?.totalCount; + if (count == null) continue; + if (_commentCounts[m.id] != count) { + _commentCounts[m.id] = count; + changed = true; + } + } + if (changed) setState(() {}); + } + + String _commentsLabelFor(String postId) { + final l10n = AppLocalizations.of(context)!; + final count = _commentCounts[postId]; + if (count == null || count == 0) return l10n.commentsWrite; + return l10n.commentsCount(count); + } + + void _openComments(CachedMessage post) { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => ChatScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: 'CHANNEL', + commentPostId: post.id, + postMessage: _stripInlineKeyboard(post), + ), + ), + ) + .then((_) => _refreshCommentCount(post.id)); + } + + void _refreshCommentCount(String postId) { + if (!mounted) return; + _commentCountsRequested.remove(postId); + _requestCommentCounts(); + } + + CachedMessage _stripInlineKeyboard(CachedMessage post) { + final attaches = post.attachments; + if (attaches == null || attaches.isEmpty) return post; + final filtered = attaches + .where((a) => a.type != AttachmentType.inlineKeyboard) + .toList(); + if (filtered.length == attaches.length) return post; + return post.copyWith(attachments: filtered); + } + + Future _loadCommentsHistory() async { + final post = widget.postMessage; + final loaded = await commentsModule.fetchHistory( + _myId, + widget.chatId, + widget.commentPostId!, + fromTime: post?.time ?? DateTime.now().millisecondsSinceEpoch, + forward: 30, + backward: 0, + ); + if (!mounted) return; + final comments = [...loaded]..sort((a, b) => a.time.compareTo(b.time)); + _messages = post != null ? [post, ...comments] : comments; + _commentsHasMore = comments.isNotEmpty; + _syncReactionNotifiersFromMessages(); + unawaited(_resolveCommentNames(comments)); + _bumpMessages(); + setState(() { + _isLoading = false; + _onLoadingFinished(); + }); + } + + Future _loadMoreComments() async { + if (_commentsLoadingMore || !_commentsHasMore || _messages.isEmpty) return; + _commentsLoadingMore = true; + final newest = _messages.last; + try { + final more = await commentsModule.fetchHistory( + _myId, + widget.chatId, + widget.commentPostId!, + fromTime: newest.time, + forward: 30, + backward: 0, + ); + if (!mounted) return; + final existing = _messages.map((m) => m.id).toSet(); + final fresh = more.where((c) => !existing.contains(c.id)).toList(); + if (fresh.isEmpty) { + _commentsHasMore = false; + } else { + _messages = [..._messages, ...fresh] + ..sort((a, b) => a.time.compareTo(b.time)); + _syncReactionNotifiersFromMessages(); + unawaited(_resolveCommentNames(fresh)); + _bumpMessages(); + } + } finally { + _commentsLoadingMore = false; + } + } + + void _onLiveComment(CommentAddedEvent event) { + if (!mounted) return; + final comment = event.comment; + if (comment.senderId == _myId) return; + if (_messages.any((m) => m.id == comment.id)) return; + final nearBottom = _isNearListBottom(); + final anchorId = nearBottom ? null : _viewportAnchorId(); + final anchorAt = anchorId == null ? null : _messageContentOffset(anchorId); + if (!nearBottom) _retainOffsetForNextLayout(); + _messages.add(comment); + _syncReactionNotifiersFromMessages(); + _bumpMessages(); + unawaited(_resolveCommentNames([comment])); + if (nearBottom) { + _scrollToBottom(); + } else { + _noteMissedMessage(); + unawaited(_holdScrollAfterAppend(anchorId, anchorAt)); + } + } + + bool _isNearListBottom() { + if (!_scrollController.hasClients) return true; + return _scrollController.position.pixels <= _historyPrefetchExtent; + } + + Future _resolveCommentNames(List list) async { + final ids = list + .map((m) => m.senderId) + .where((id) => id != 0 && ContactCache.get(id) == null) + .toSet(); + if (ids.isEmpty) return; + final resolved = await messagesModule.ensureContactNames(ids); + if (resolved && mounted) _bumpMessages(); + } + void _syncReactionNotifiersFromMessages() { for (final m in _messages) { if (_reactionNotifiers.containsKey(m.id)) continue; @@ -1283,15 +2088,16 @@ class _ChatScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || - state == AppLifecycleState.inactive) { + state == AppLifecycleState.hidden || + state == AppLifecycleState.detached) { if (_voiceRec.isRecording.value) { unawaited(_voiceRec.stop(cancel: true)); } if (_note.isRecording.value) { unawaited(_note.stop(cancel: true)); } - _saveDraft(); } + if (state != AppLifecycleState.resumed) _saveDraft(); super.didChangeAppLifecycleState(state); } @@ -1308,11 +2114,16 @@ class _ChatScreenState extends State @override void dispose() { + ChatScreen._open.remove(this); + if (!_commentsMode) { + unawaited(NotificationBridge.instance.clearActiveChat(widget.chatId)); + } _chatController.persistSessionCache(); if (_previewChat) { unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false)); } WidgetsBinding.instance.removeObserver(this); + _uploadEventSub?.cancel(); chats.chatsChanged.removeListener(_onChatsBump); _otherUnread.dispose(); _animojiHold.dispose(); @@ -1322,22 +2133,31 @@ class _ChatScreenState extends State _scrollController.removeListener(_maybeLoadMoreHistory); _scrollController.removeListener(_recordScrollPixels); _scrollController.removeListener(_scheduleReadMarker); + _scrollController.removeListener(_updateScrollDownVisible); _readMarkTimer?.cancel(); AppVisualStyle.current.removeListener(_onVisualStyleChanged); + MediaPlayback.instance.leaveChat(widget.chatId); AppChatChrome.current.removeListener(_onVisualStyleChanged); + AppComposerStyle.current.removeListener(_onVisualStyleChanged); + AppComposerBackground.current.removeListener(_onVisualStyleChanged); _composerHeight.dispose(); _pinnedBannerHeight.dispose(); _floatingDateTimer?.cancel(); _floatingDateCurved.dispose(); _floatingDateAnimController.dispose(); _floatingDate.dispose(); + _scrollDownCurved.dispose(); + _scrollDownAnimController.dispose(); + _newMessageCount.dispose(); _hasText.dispose(); _scheduledCount.dispose(); _showAttachmentPanel.removeListener(_onAttachPanelToggle); _showAttachmentPanel.dispose(); - _uploadSub?.cancel(); + _detachUploadStatus(); _pushSub?.cancel(); _messageEventSub?.cancel(); + _commentsInfoSub?.cancel(); + _commentSub?.cancel(); _connSub?.cancel(); _voiceRec.dispose(); _note.dispose(); @@ -1346,17 +2166,22 @@ class _ChatScreenState extends State n.dispose(); } _reactionNotifiers.clear(); - for (final n in _photoUploadProgress.values) { - n.dispose(); - } - _photoUploadProgress.clear(); + _reactionAnimation.dispose(); ChatActivityStore.instance .listenable(widget.chatId) .removeListener(_recomputeHeaderStatus); + ChatMembersStore.instance + .listenable(widget.chatId) + .removeListener(_recomputeHeaderStatus); PresenceFetch.revision.removeListener(_onPresenceChanged); + ContactsModule.revision.removeListener(_onContactsChanged); if (_wallpaperListening) { - ChatWallpaperStore.instance.revision - .removeListener(_applyEffectiveWallpaper); + ChatWallpaperStore.instance.revision.removeListener( + _applyEffectiveWallpaper, + ); + } + if (_encryptionListening) { + ChatEncryptionStore.instance.revision.removeListener(_applyEncryption); } _headerStatusNotifier.dispose(); _otherReadTime.dispose(); @@ -1365,11 +2190,14 @@ class _ChatScreenState extends State _uploadStatus.dispose(); _attachAnim.dispose(); _commandPanel.dispose(); + _mentionPanel.dispose(); _selectionAnim.dispose(); _searchAnim.dispose(); _searchFocusNode.dispose(); _search.dispose(); _selectedIds.dispose(); + _textSelection.dispose(); + _textSelectionDrag.dispose(); _messageController.dispose(); _messageFocusNode.dispose(); _stickers.dispose(); @@ -1377,10 +2205,12 @@ class _ChatScreenState extends State _shimmerStartTimer?.cancel(); _shimmerController.dispose(); _replyTo.dispose(); + _pendingForwards.dispose(); _highlightTimer?.cancel(); _highlightMessageId.dispose(); _goToMessageSettleTimer?.cancel(); _jumpCacheExtent.dispose(); + _routeSettle.dispose(); _messageKeys.clear(); super.dispose(); } @@ -1391,6 +2221,21 @@ class _ChatScreenState extends State _hasText.value = newHasText; } _commandPanel.update(); + _mentionPanel.update(); + } + + bool _mentionsAvailable() => + !_commentsMode && (chat?.type ?? widget.chatType) == 'CHAT'; + + void _onMentionSelected(MentionCandidate candidate, MentionQuery query) { + _messageController.insertMention( + userId: candidate.id, + name: candidate.name, + start: query.start, + end: query.end, + ); + _mentionPanel.update(); + _messageFocusNode.requestFocus(); } void _onCommandSelected(SlashCommand c) { @@ -1403,8 +2248,13 @@ class _ChatScreenState extends State } void _restoreDraft() { - if (_myId == 0 || _messageController.text.isNotEmpty) return; - final draft = DraftStore.instance.get(_myId, widget.chatId); + if (_myId == 0 || _commentsMode || _messageController.text.isNotEmpty) { + return; + } + final shared = widget.initialText?.trim(); + final draft = (shared != null && shared.isNotEmpty) + ? shared + : DraftStore.instance.get(_myId, widget.chatId); if (draft == null || draft.isEmpty) return; _messageController.text = draft; _messageController.selection = TextSelection.collapsed( @@ -1413,7 +2263,7 @@ class _ChatScreenState extends State } void _saveDraft() { - if (_myId == 0) return; + if (_myId == 0 || _commentsMode) return; unawaited( DraftStore.instance.set( _myId, @@ -1450,7 +2300,11 @@ class _ChatScreenState extends State String? _effectiveStatus(CachedMessage msg) { if (msg.senderId != _myId) return null; - if (msg.status == 'sending' || msg.status == 'error') return msg.status; + if (msg.status == 'sending' || + msg.status == 'pending' || + msg.status == 'error') { + return msg.status; + } return 'sent'; } @@ -1512,15 +2366,30 @@ class _ChatScreenState extends State if (!next.remove(message.id)) next.add(message.id); Haptics.selection(); _selectedIds.value = next; + if (!next.contains(message.id)) _exitTextSelection(message.id); _syncSelectionAnim(); } void _clearSelection() { + _exitTextSelection(); if (_selectedIds.value.isEmpty) return; _selectedIds.value = const {}; _syncSelectionAnim(); } + void _startTextSelection(CachedMessage message, Offset globalPosition) { + if (message.isControl || message.selectableText == null) return; + _textSelectionDrag.value = null; + _textSelection.value = (id: message.id, pos: globalPosition); + } + + void _exitTextSelection([String? onlyId]) { + final current = _textSelection.value; + if (current == null) return; + if (onlyId != null && current.id != onlyId) return; + _textSelection.value = null; + } + void _syncSelectionAnim() { if (_selectedIds.value.isEmpty) { _selectionAnim.reverse(); @@ -1533,17 +2402,10 @@ class _ChatScreenState extends State List _selectedMessages(Set ids) => _messages.where((m) => ids.contains(m.id)).toList(); - CachedMessage? _singleCopyableText(Set ids) { - CachedMessage? found; - var textCount = 0; - for (final m in _messages) { - if (!ids.contains(m.id)) continue; - if ((m.text ?? '').isEmpty) continue; - if (++textCount > 1) return null; - found = m; - } - return found; - } + List _copyableSelection(Set ids) => [ + for (final m in _messages) + if (ids.contains(m.id) && (m.selectableText ?? '').isNotEmpty) m, + ]; CachedMessage? _singleEditable(Set ids) { if (ids.length != 1) return null; @@ -1552,9 +2414,9 @@ class _ChatScreenState extends State return _canEditMessage(list.first) ? list.first : null; } - void _copySelected(CachedMessage message) { - final text = message.text; - if (text == null || text.isEmpty) return; + void _copySelected(List messages) { + if (messages.isEmpty) return; + final text = messages.map((m) => m.selectableText!).join('\n\n'); Clipboard.setData(ClipboardData(text: text)); Haptics.tap(); showCustomNotification(context, 'Скопировано'); @@ -1615,7 +2477,9 @@ class _ChatScreenState extends State } Future _forwardMessages(List msgs) async { - final forwardable = msgs.where((m) => !m.id.startsWith('temp_')).toList(); + final forwardable = msgs + .where((message) => int.tryParse(message.id) != null) + .toList(); if (forwardable.isEmpty) { showCustomNotification(context, 'Нечего пересылать'); return; @@ -1627,21 +2491,20 @@ class _ChatScreenState extends State ); if (target == null || !mounted) return; - if (api.state != SessionState.online) { - showCustomNotification(context, 'Нет соединения'); - return; - } - final ordered = [...forwardable]..sort((a, b) => a.time.compareTo(b.time)); + final request = ForwardRequest( + sourceChatId: widget.chatId, + sourceChatName: widget.name, + sourceChatIconUrl: widget.imageUrl, + sourceChatType: widget.chatType, + messages: ordered, + ); if (target.chatId == widget.chatId) { - await _forwardIntoCurrentChat(ordered); + _setForwardRequest(request); return; } - final optimistic = await _seedForwardsToChat(target, ordered); - if (!mounted) return; - Haptics.send(); pushSwipeable( context, (_) => ChatScreen( @@ -1649,158 +2512,127 @@ class _ChatScreenState extends State name: target.name, imageUrl: target.imageUrl, chatType: target.chatType, - forwardRequest: ForwardRequest( - sourceChatId: widget.chatId, - optimistic: optimistic, - ), + forwardRequest: request, ), ); } - Future _forwardIntoCurrentChat(List sources) async { - final now = DateTime.now().millisecondsSinceEpoch; - final optimistic = []; - var i = 0; - for (final src in sources) { - final msg = MessagesModule.buildForwardMessage( + void _setForwardRequest(ForwardRequest request) { + _cancelReply(); + _forwardRequest = request; + _pendingForwards.value = request.messages; + } + + void _cancelForward() { + _forwardRequest = null; + _pendingForwards.value = const []; + } + + Future _sendForwardRequest() async { + var request = _forwardRequest; + if (request == null) return true; + if (api.state != SessionState.online) { + showCustomNotification(context, 'Нет соединения'); + return false; + } + Haptics.send(); + while (request != null && request.messages.isNotEmpty) { + if (!identical(_forwardRequest, request)) return false; + final source = request.messages.first; + final optimistic = MessagesModule.buildForwardMessage( myId: _myId, targetChatId: widget.chatId, - sourceChatId: widget.chatId, - source: src, + sourceChatId: request.sourceChatId, + source: source, tempId: _nextTempId(), - time: now + i, + time: DateTime.now().millisecondsSinceEpoch, status: 'sending', + sourceChatName: request.sourceChatName, + sourceChatIconUrl: request.sourceChatIconUrl, + sourceChatType: request.sourceChatType, ); - optimistic.add(msg); - _messages.add(msg); - unawaited(_persistOutgoing(msg)); - i++; - } - _bumpMessages(); - Haptics.send(); - _scrollToBottom(); - if (optimistic.isNotEmpty) { - final last = optimistic.last; - unawaited( - chats.applyOutgoing( - _myId, - widget.chatId, - messageId: last.id, - time: last.time, - text: MessagesModule.forwardPreviewText(last), - status: 'sending', - ), - ); - } - for (final opt in optimistic) { - await _sendOneForward(opt, widget.chatId); + _messages.add(optimistic); + _bumpMessages(); + _scrollToBottom(); + await _syncForwardOutgoing(optimistic); + final sent = await _sendOneForward(optimistic, request.sourceChatId); + if (!sent || !mounted) return false; + if (!identical(_forwardRequest, request)) return false; + final remaining = request.messages.skip(1).toList(growable: false); + if (remaining.isEmpty) { + _cancelForward(); + return true; + } + request = request.withMessages(remaining); + _forwardRequest = request; + _pendingForwards.value = request.messages; } + _cancelForward(); + return true; } - Future> _seedForwardsToChat( - ForwardTarget target, - List sources, - ) async { - await chats.ensureChatCached(api, _myId, target.chatId); - final now = DateTime.now().millisecondsSinceEpoch; - final optimistic = []; - var i = 0; - for (final src in sources) { - final msg = MessagesModule.buildForwardMessage( - myId: _myId, - targetChatId: target.chatId, - sourceChatId: widget.chatId, - source: src, - tempId: _nextTempId(), - time: now + i, - status: 'sending', - ); - optimistic.add(msg); - await AppDatabase.saveMessages([msg.toDbRow()]); - i++; - } - final cached = MessageSessionCache.get(_myId, target.chatId); - if (cached != null) { - MessageSessionCache.save(_myId, target.chatId, [ - ...cached.messages, - ...optimistic, - ], reachedStart: cached.reachedStart); - } - if (optimistic.isNotEmpty) { - final last = optimistic.last; - unawaited( - chats.applyOutgoing( - _myId, - target.chatId, - messageId: last.id, - time: last.time, - text: MessagesModule.forwardPreviewText(last), - status: 'sending', - ), - ); - } - return optimistic; - } - - Future _runForwardRequest() async { - final req = widget.forwardRequest; - if (req == null || _forwardRequestDone) return; - _forwardRequestDone = true; - for (final opt in req.optimistic) { - await _sendOneForward(opt, req.sourceChatId); - } - } - - Future _sendOneForward( + Future _sendOneForward( CachedMessage optimistic, int sourceChatId, ) async { final link = optimistic.payload?['link']; final rawWireId = link is Map ? link['messageId'] : null; final wireId = rawWireId is int ? rawWireId : null; - if (wireId == null) return; + if (wireId == null) return false; try { final realId = await messagesModule.forwardMessage( widget.chatId, sourceChatId, wireId, ); - if (!mounted) return; final sent = MessagesModule.reidentifyMessage( optimistic, realId.isNotEmpty ? realId : optimistic.id, status: 'sent', ); - final index = _messages.indexWhere((m) => m.id == optimistic.id); - if (index != -1) { - _messages[index] = sent; - _bumpMessages(); + if (mounted) { + final index = _messages.indexWhere((m) => m.id == optimistic.id); + if (index != -1) { + _messages[index] = sent; + _bumpMessages(); + } } - unawaited(_persistOutgoing(sent, removeId: optimistic.id)); - unawaited( - chats.applyOutgoing( - _myId, - widget.chatId, - messageId: sent.id, - time: sent.time, - text: MessagesModule.forwardPreviewText(sent), - status: 'sent', - ), - ); + await _syncForwardOutgoing(sent, removeId: optimistic.id); + return true; } catch (_) { final index = _messages.indexWhere((m) => m.id == optimistic.id); if (index != -1 && mounted) { _messages.removeAt(index); _bumpMessages(); } - unawaited(AppDatabase.deleteMessage(_myId, widget.chatId, optimistic.id)); + try { + await AppDatabase.deleteMessage(_myId, widget.chatId, optimistic.id); + } catch (_) {} if (mounted) { Haptics.error(); showCustomNotification(context, 'Не удалось переслать'); } + return false; } } + Future _syncForwardOutgoing( + CachedMessage message, { + String? removeId, + }) async { + await _persistOutgoing(message, removeId: removeId); + try { + await chats.applyOutgoing( + _myId, + widget.chatId, + messageId: message.id, + time: message.time, + text: MessagesModule.forwardPreviewText(message), + status: message.status ?? 'sending', + ); + } catch (_) {} + } + Widget _buildComposerArea(BuildContext context) { final cs = Theme.of(context).colorScheme; final content = Column( @@ -1857,30 +2689,50 @@ class _ChatScreenState extends State ); }, ), - ComposerInputBar( - chatType: widget.chatType, - chrome: _effectiveChrome, - attachAnim: _attachAnim, - replyTo: _replyTo, - myId: _myId, - hasText: _hasText, - uploadStatus: _uploadStatus, - messageController: _messageController, - messageFocusNode: _messageFocusNode, - voiceRec: _voiceRec, - note: _note, - onToggleStickerPanel: _toggleStickerPanel, - onSendText: _sendMessage, - onScheduleMessage: _scheduleMessage, - onOpenAttach: _openAttachmentSheet, - onOpenAttachScheduled: _openAttachmentSheetScheduled, - onSendHistory: _sendHistoryFile, - onCancelReply: _cancelReply, - formatElapsed: formatVoiceElapsed, - contextMenuBuilder: (ctx, state) => - _formatContextMenu(_messageController, ctx, state), - isMuted: chat?.isMuted ?? false, - onToggleMute: _toggleChatMute, + AnimatedBuilder( + animation: _stickers.anim, + builder: (context, _) => ComposerInputBar( + bottomSafe: _stickers.anim.value == 0, + chatType: _commentsMode ? 'CHAT' : widget.chatType, + chrome: _effectiveChrome, + vignette: _chromeVignette, + style: AppComposerStyle.current.value, + background: AppComposerBackground.current.value, + backdropKey: _pillBackdrop, + attachAnim: _attachAnim, + replyTo: _replyTo, + forwardMessages: _pendingForwards, + myId: _myId, + hasText: _hasText, + uploadStatus: _uploadStatus, + messageController: _messageController, + messageFocusNode: _messageFocusNode, + voiceRec: _voiceRec, + note: _note, + onToggleStickerPanel: _toggleStickerPanel, + onSendText: _sendMessage, + onScheduleMessage: _scheduleMessage, + onOpenAttach: _openAttachmentSheet, + onOpenAttachScheduled: _openAttachmentSheetScheduled, + onSendHistory: _sendHistoryFile, + onCancelReply: _cancelReply, + onCancelForward: _cancelForward, + onPickReplyChat: _commentsMode || !_crossChatReplySupported + ? null + : () => unawaited(_pickReplyChat()), + formatElapsed: formatVoiceElapsed, + contextMenuBuilder: (ctx, state) => + _formatContextMenu(_messageController, ctx, state), + isMuted: chat?.isMuted ?? false, + onToggleMute: _toggleChatMute, + channelSubscribed: !_previewChat, + channelSubscribing: _subscribing, + onSubscribe: _subscribeChannel, + showStickerButton: !_commentsMode, + showAttachButton: !_commentsMode, + forceSend: _commentsMode, + hintText: _commentsMode ? 'Комментарий' : 'Message', + ), ), StickerPanelView( stickers: _stickers, @@ -1912,21 +2764,30 @@ class _ChatScreenState extends State selected: selected, onReply: _replySelected, onForward: _forwardSelected, + allowForward: !(chat?.forwardDisabled ?? false), ), ), ), ], ); Widget wrapChrome(Widget child) { + if (_composerFrosted) { + if (ComposerChrome.isGlossy(AppComposerStyle.current.value)) { + return child; + } + return _FrostedPanel( + sigma: AppFrost.sigma, + tint: AppFrost.glassTint(cs), + border: Border(top: AppFrost.hairline(cs)), + backdropKey: _barBackdrop, + child: child, + ); + } if (_effectiveChrome != ChatChromeStyle.blur) return child; return _FrostedPanel( - tint: cs.surfaceContainerHigh.withValues(alpha: 0.55), - border: Border( - top: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - width: 0.5, - ), - ), + tint: AppFrost.blurPanelTint(cs), + border: Border(top: AppFrost.hairline(cs)), + backdropKey: _barBackdrop, child: child, ); } @@ -1963,75 +2824,29 @@ class _ChatScreenState extends State Future _startEditMessage(CachedMessage message) async { final cs = Theme.of(context).colorScheme; - final controller = RichMessageController(text: message.text ?? '') - ..setFormatRanges(message.formatRanges); - final saved = await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (sheetContext) => Padding( - padding: EdgeInsets.only( - left: 20, - right: 20, - top: 20, - bottom: MediaQuery.viewInsetsOf(sheetContext).bottom + 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Изменить сообщение', - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - const SizedBox(height: 16), - TextField( - controller: controller, - autofocus: true, - minLines: 1, - maxLines: 6, - style: TextStyle(color: cs.onSurface), - contextMenuBuilder: (ctx, state) => - _formatContextMenu(controller, ctx, state), - decoration: InputDecoration( - hintText: 'Текст сообщения', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, - ), - ), - ), - const SizedBox(height: 20), - FilledButton( - onPressed: () => Navigator.of(sheetContext).pop(true), - child: const Text('Сохранить'), - ), - ], - ), - ), - ); + final content = + await showModalBottomSheet< + ({String text, List> elements}) + >( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => _EditMessageSheet( + text: message.text ?? '', + formatRanges: message.formatRanges, + contextMenuBuilder: _formatContextMenu, + ), + ); - if (saved != true || !mounted) { - controller.dispose(); - return; - } + if (content == null || !mounted) return; - final content = controller.buildContent(); final rawText = content.text; final newText = rawText.trim(); final elements = _trimmedElements(content.elements, rawText, newText); - controller.dispose(); final oldElements = serializeFormatElements( message.formatRanges.where((r) => composerFormats.contains(r.format)), @@ -2084,12 +2899,12 @@ class _ChatScreenState extends State Haptics.send(); } - Future _confirmDeleteMessage(CachedMessage message, bool isMe) async { - final isLocalOnly = message.id.startsWith('temp_'); + Future _confirmDeleteMessage(String messageId, bool isMe) async { + final isLocalOnly = messageId.startsWith('temp_'); final canForEveryone = isMe && !isLocalOnly; if (isLocalOnly) { - _startDeleteAnimation(message.id); + _startDeleteAnimation(messageId); return; } @@ -2097,7 +2912,7 @@ class _ChatScreenState extends State if (forEveryone == null || !mounted) return; final ok = await messagesModule.deleteMessages(widget.chatId, [ - message.id, + messageId, ], forEveryone: forEveryone); if (!mounted) return; if (!ok) { @@ -2105,7 +2920,7 @@ class _ChatScreenState extends State showCustomNotification(context, 'Не удалось удалить сообщение'); return; } - _startDeleteAnimation(message.id); + _startDeleteAnimation(messageId); } void _startDeleteAnimation(String messageId) { @@ -2139,6 +2954,7 @@ class _ChatScreenState extends State builder: (ctx, setLocalState) { return AlertDialog( backgroundColor: cs.surfaceContainerHigh, + shape: AppShape.dialogBorder, title: const Text('Удалить сообщение'), content: Column( mainAxisSize: MainAxisSize.min, @@ -2200,11 +3016,17 @@ class _ChatScreenState extends State void _onMessageEvent(MessageEvent event) { if (!mounted) return; + 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(); + final anchorAt = anchorId == null + ? null + : _messageContentOffset(anchorId); + if (!nearBottom) _retainOffsetForNextLayout(); _lastSentId = message.id; _messages.add(message); _bumpMessages(); @@ -2214,6 +3036,8 @@ class _ChatScreenState extends State _scrollToBottom(); _scheduleReadMarker(); } else { + _noteMissedMessage(); + unawaited(_holdScrollAfterAppend(anchorId, anchorAt)); _reapplyPinIfNeeded(); } _prank.checkTrigger(message); @@ -2275,29 +3099,57 @@ class _ChatScreenState extends State } } + void _onContactsChanged() { + if (mounted) setState(() {}); + } + + String _headerAvatarUrl() { + if (!_commentsMode && widget.chatType == 'DIALOG') { + final otherId = _resolveOtherId(); + if (otherId != null) { + final cached = ContactCache.getAvatar(otherId); + if (cached != null && cached.isNotEmpty) return cached; + } + } + return widget.imageUrl; + } + + String _headerName() { + if (_commentsMode) return AppLocalizations.of(context)!.commentsTitle; + if (widget.chatType == 'DIALOG') { + final otherId = _resolveOtherId(); + if (otherId != null) { + final cached = ContactCache.get(otherId); + if (cached != null && cached.isNotEmpty) return cached; + } + } + return widget.name; + } + PreferredSizeWidget _buildAppBar(ColorScheme cs) { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final glossy = AppVisualStyle.current.value.glossyChrome; final searchT = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0)); final height = glossy ? ui.lerpDouble(_glossyHeaderHeight, _glossySearchHeight, searchT)! : kToolbarHeight; final chrome = _effectiveChrome; + final barExtent = MediaQuery.paddingOf(context).top + height; + final fadeStop = ((barExtent - _edgeFadeHeight) / barExtent).clamp( + 0.0, + 1.0, + ); return AppBar( backgroundColor: chrome == ChatChromeStyle.color ? (glossy ? Colors.transparent : cs.surfaceContainerHigh) : Colors.transparent, flexibleSpace: chrome == ChatChromeStyle.blur ? _FrostedPanel( - tint: cs.surfaceContainerHigh.withValues(alpha: 0.55), - border: Border( - bottom: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - width: 0.5, - ), - ), + tint: AppFrost.blurPanelTint(cs), + border: Border(bottom: AppFrost.hairline(cs)), + backdropKey: _barBackdrop, child: const SizedBox.expand(), ) - : (chrome == ChatChromeStyle.none && !glossy) + : (_chromeVignette && !glossy) ? IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( @@ -2309,29 +3161,19 @@ class _ChatScreenState extends State cs.surface, cs.surface.withValues(alpha: 0.0), ], - stops: const [0.0, 0.72, 1.0], + stops: [0.0, fadeStop, 1.0], ), ), child: const SizedBox.expand(), ), ) : (chrome == ChatChromeStyle.transparent && !glossy) - ? IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - cs.surface.withValues(alpha: 0.72), - cs.surface.withValues(alpha: 0.45), - cs.surface.withValues(alpha: 0.0), - ], - stops: const [0.0, 0.62, 1.0], - ), - ), - child: const SizedBox.expand(), - ), + ? _FrostedPanel( + sigma: AppFrost.sigma, + tint: AppFrost.glassTint(cs), + border: Border(bottom: AppFrost.hairline(cs)), + backdropKey: _barBackdrop, + child: const SizedBox.expand(), ) : null, foregroundColor: cs.onSurface, @@ -2367,49 +3209,34 @@ class _ChatScreenState extends State offset: Offset(0, -height * 0.4 * t), child: ChatHeaderRow( glossy: glossy, + frosted: + glossy && chrome == ChatChromeStyle.transparent, + backdropVisible: t == 0 && s == 0, + liquid: _liquidChrome, + backdropKey: _pillBackdrop, cs: cs, embedded: widget.embedded, chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, + heroTag: _profileHeroTag, + name: _headerName(), + imageUrl: _headerAvatarUrl(), chatType: widget.chatType, isOfficial: chat?.isOfficial ?? false, + encrypted: _encryptionEnabled, myId: _myId, headerStatus: _headerStatusNotifier, scheduledCount: _scheduledCount, otherUnread: _otherUnread, showCall: - widget.chatType == 'DIALOG' && !_peerIsBot, + !_commentsMode && + widget.chatType == 'DIALOG' && + widget.chatId != 0 && + !_peerIsBot, onClose: widget.onClose, - onOpenInfo: () { - final navigator = Navigator.of(context); - final chatRoute = ModalRoute.of(context); - navigator.push( - MaterialPageRoute( - builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType, - onJumpToMessage: - (chatRoute == null || widget.embedded) - ? null - : (messageId, time) { - navigator.popUntil( - (r) => r == chatRoute, - ); - _requestGoToMessage( - messageId, - time, - ); - }, - ), - ), - ); - }, + onOpenInfo: _commentsMode ? () {} : _openChatInfo, onOpenScheduled: _openScheduledMessages, onCall: _startCall, - onMenu: _openChatMenu, + onMenu: _commentsMode ? (_) {} : _openChatMenu, ), ), ), @@ -2425,7 +3252,7 @@ class _ChatScreenState extends State cs: cs, selected: selected, glossy: glossy, - copyMsg: _singleCopyableText(selected), + copyMsgs: _copyableSelection(selected), editMsg: _singleEditable(selected), onClear: _clearSelection, onCopy: _copySelected, @@ -2458,6 +3285,25 @@ class _ChatScreenState extends State ); } + bool get _hasMiniApp { + if (widget.chatType != 'DIALOG' || _commentsMode) return false; + final peerId = _resolveOtherId(); + if (peerId == null) return false; + if (hasMiniAppOption(ContactCache.getOptions(peerId))) return true; + return hasMiniAppOption(chat?.options); + } + + Future _openMiniApp() async { + final peerId = _resolveOtherId(); + if (peerId == null) return; + await openMiniApp( + context, + botId: peerId, + chatId: widget.chatId, + title: _headerName(), + ); + } + void _openChatMenu(BuildContext btnContext) { final box = btnContext.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; @@ -2466,6 +3312,13 @@ class _ChatScreenState extends State context: context, anchorRect: anchorRect, items: [ + if (_hasMiniApp) + ChatMenuItem( + icon: Symbols.apps, + label: AppLocalizations.of(context)!.miniAppOpen, + dividerAfter: true, + onTap: () => unawaited(_openMiniApp()), + ), ChatMenuItem( icon: (chat?.isMuted ?? false) ? Symbols.volume_off @@ -2487,6 +3340,11 @@ class _ChatScreenState extends State label: 'Очистить историю', onTap: _clearHistory, ), + ChatMenuItem( + icon: _encryptionEnabled ? Symbols.lock : Symbols.lock_open, + label: 'Шифрование сообщений', + onTap: _openEncryptionSettings, + ), ChatMenuItem( icon: Symbols.delete, label: 'Удалить чат', @@ -2496,6 +3354,41 @@ class _ChatScreenState extends State ); } + Future _subscribeChannel() async { + if (_subscribing) return; + setState(() => _subscribing = true); + try { + var link = _channelLink; + if (link == null || link.isEmpty) { + final info = await chats.getChatInfo(api, widget.chatId); + link = info?['link'] as String?; + } + if (link == null || link.isEmpty) { + throw const PacketError('Не удалось получить ссылку канала'); + } + final result = await chats.joinChannel(api, link, _myId); + if (!mounted) return; + setState(() { + _previewChat = false; + _subscribing = false; + chat = result.chat; + }); + ChatMembersStore.instance.setCount( + widget.chatId, + result.subscribersCount, + ); + _recomputeHeaderStatus(); + showCustomNotification(context, 'Вы подписались на канал'); + } catch (e) { + if (!mounted) return; + setState(() => _subscribing = false); + showCustomNotification( + context, + e is PacketError ? e.message : 'Не удалось подписаться', + ); + } + } + Future _toggleChatMute() async { final current = chat; if (current == null) return; @@ -2518,6 +3411,43 @@ class _ChatScreenState extends State ); } + bool _encryptionListening = false; + + Future _loadEncryption() async { + await ChatEncryptionStore.instance.load(); + if (!mounted) return; + if (!_encryptionListening) { + _encryptionListening = true; + ChatEncryptionStore.instance.revision.addListener(_applyEncryption); + } + _applyEncryption(); + } + + void _applyEncryption() { + if (!mounted) return; + final enabled = ChatEncryptionStore.instance.isEnabled( + _myId, + widget.chatId, + ); + if (enabled != _encryptionEnabled) { + setState(() => _encryptionEnabled = enabled); + } + if (enabled && _myId != 0) { + unawaited(ChatCryptoService.instance.warmKey(_myId, widget.chatId)); + } + } + + Future _openEncryptionSettings() async { + if (_myId == 0) return; + await pushSwipeable( + context, + (context) => + ChatEncryptionScreen(accountId: _myId, chatId: widget.chatId), + ); + if (!mounted) return; + _applyEncryption(); + } + bool _wallpaperListening = false; Future _loadWallpaper() async { @@ -2525,7 +3455,9 @@ class _ChatScreenState extends State if (!mounted) return; if (!_wallpaperListening) { _wallpaperListening = true; - ChatWallpaperStore.instance.revision.addListener(_applyEffectiveWallpaper); + ChatWallpaperStore.instance.revision.addListener( + _applyEffectiveWallpaper, + ); } _applyEffectiveWallpaper(); } @@ -2533,7 +3465,8 @@ class _ChatScreenState extends State void _applyEffectiveWallpaper() { if (!mounted) return; final store = ChatWallpaperStore.instance; - final wp = store.get(_myId, widget.chatId) ?? + final wp = + store.get(_myId, widget.chatId) ?? store.get(_myId, kGlobalWallpaperChatId); if (!identical(wp, _wallpaper)) setState(() => _wallpaper = wp); } @@ -2593,20 +3526,27 @@ class _ChatScreenState extends State } Future _clearHistory() async { - final confirmed = await showConfirmDialog( + final current = chat; + final canClearForAll = + (widget.chatType == 'CHAT' || widget.chatType == 'CHANNEL') && + (current?.iAmAdmin(_myId) ?? false); + final choice = await showBlurredConfirm( context, title: 'Очистить историю', message: 'Все сообщения в этом чате будут удалены без возможности ' 'восстановления.', confirmLabel: 'Очистить', + cancelLabel: 'Отмена', destructive: true, + checkboxLabel: canClearForAll ? 'Для всех' : null, ); - if (!mounted || !confirmed) return; + if (!mounted || !choice.confirmed) return; final err = await chats.clearHistory( api, chatId: widget.chatId, - lastEventTime: chat?.lastEventTime ?? 0, + lastEventTime: current?.lastEventTime ?? 0, + forAll: canClearForAll && choice.checked, ); if (!mounted) return; if (err != null) { @@ -2731,20 +3671,34 @@ class _ChatScreenState extends State } void _recomputeHeaderStatus() { + if (_commentsMode) { + _headerStatusNotifier.value = ''; + return; + } _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 'В сети'; @@ -2765,6 +3719,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) { @@ -2789,6 +3751,8 @@ class _ChatScreenState extends State static String _formatLabel(TextFormat format) { switch (format) { + case TextFormat.heading: + return 'Заголовок'; case TextFormat.strong: return 'Жирный'; case TextFormat.emphasized: @@ -2805,6 +3769,8 @@ class _ChatScreenState extends State return 'Ссылка'; case TextFormat.animoji: return 'Animoji'; + case TextFormat.userMention: + return 'Упоминание'; } } @@ -2878,7 +3844,54 @@ class _ChatScreenState extends State return result; } + Future _encryptOutgoing(String text) async { + if (!_encryptionEnabled || _myId == 0) return text; + final result = await ChatCryptoService.instance.encrypt( + _myId, + widget.chatId, + text, + ); + if (result.isOk) { + if (result.text!.length > kMaxEncryptedMessageLength) { + if (mounted) { + showCustomNotification( + context, + 'Слишком длинное сообщение. Разделите на несколько', + ); + } + return null; + } + return result.text; + } + if (mounted) { + showCustomNotification( + context, + result.failure == CryptoFailure.noKey + ? 'Не задан ключ шифрования' + : 'Не удалось зашифровать сообщение', + ); + } + return null; + } + Future _sendMessage() async { + if (_forwardRequest == null) { + await _sendTextMessage(); + return; + } + if (_forwardSending || _myId == 0) return; + _forwardSending = true; + try { + final forwarded = await _sendForwardRequest(); + if (!forwarded || !mounted) return; + if (_messageController.text.trim().isEmpty) return; + await _sendTextMessage(); + } finally { + _forwardSending = false; + } + } + + Future _sendTextMessage() async { final content = _messageController.buildContent(); final rawText = content.text; final text = rawText.trim(); @@ -2901,18 +3914,33 @@ class _ChatScreenState extends State } } + if (chat?.confirmBeforeSend ?? false) { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showConfirmDialog( + context, + message: l10n.chatSendConfirmMessage, + confirmLabel: l10n.chatSendConfirmAction, + ); + if (!confirmed || !mounted) return; + } + + final wireText = await _encryptOutgoing(text); + if (wireText == null || !mounted) return; + final encrypted = wireText != text; + final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; final online = api.state == SessionState.online; final reply = _replyTo.value; final int? replyId = reply == null ? null : int.tryParse(reply.id); + final int? replySourceChatId = replyId == null ? null : _replySourceChatId; Map? replyPayload; if (reply != null && replyId != null) { replyPayload = { 'link': { 'type': 'REPLY', - 'chatId': widget.chatId, + 'chatId': replySourceChatId ?? widget.chatId, 'message': { 'id': replyId, 'sender': reply.senderId, @@ -2924,8 +3952,11 @@ class _ChatScreenState extends State }; } _replyTo.value = null; + _replySourceChatId = null; - final elements = _trimmedElements(content.elements, rawText, text); + final elements = encrypted + ? const >[] + : _trimmedElements(content.elements, rawText, text); final Map? composedPayload = (replyPayload == null && elements.isEmpty) ? null @@ -2936,32 +3967,36 @@ class _ChatScreenState extends State accountId: _myId, chatId: widget.chatId, senderId: _myId, - text: text, + text: wireText, time: now, status: online ? 'sending' : 'pending', payload: composedPayload, ); + if (encrypted) MessageDecryptionCache.instance.seed(tempId, text); _hasText.value = false; _lastSentId = tempId; _messages.add(composed); _messageController.clear(); - if (DraftStore.instance.get(_myId, widget.chatId) != null) { + if (!_commentsMode && + DraftStore.instance.get(_myId, widget.chatId) != null) { unawaited(DraftStore.instance.clear(_myId, widget.chatId)); } _bumpMessages(); - unawaited(_persistOutgoing(composed)); - unawaited( - chats.applyOutgoing( - _myId, - widget.chatId, - messageId: tempId, - time: now, - text: text, - status: composed.status ?? 'sending', - elements: elements, - ), - ); + if (!_commentsMode) { + unawaited(_persistOutgoing(composed)); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: wireText, + status: composed.status ?? 'sending', + elements: elements, + ), + ); + } // Instant tactile "whoosh" the moment the message leaves the composer, // not after the network round-trip — feedback must feel immediate. @@ -2973,13 +4008,23 @@ class _ChatScreenState extends State if (!online) return; try { - final actualId = await messagesModule.sendMessage( - _myId, - widget.chatId, - text, - replyToMessageId: replyId, - elements: elements, - ); + final actualId = _commentsMode + ? await commentsModule.sendComment( + _myId, + widget.chatId, + widget.commentPostId!, + wireText, + replyToMessageId: replyId, + elements: elements, + ) + : await messagesModule.sendMessage( + _myId, + widget.chatId, + wireText, + replyToMessageId: replyId, + replySourceChatId: replySourceChatId, + elements: elements, + ); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { @@ -2988,28 +4033,33 @@ class _ChatScreenState extends State accountId: _myId, chatId: widget.chatId, senderId: _myId, - text: text, + text: wireText, time: now, status: 'sent', payload: composedPayload, ); + if (encrypted) { + MessageDecryptionCache.instance.adopt(tempId, sent.id); + } _messages[index] = sent; _bumpMessages(); - unawaited(_persistOutgoing(sent, removeId: tempId)); - unawaited( - chats.applyOutgoing( - _myId, - widget.chatId, - messageId: sent.id, - time: now, - text: text, - status: 'sent', - elements: elements, - ), - ); + if (!_commentsMode) { + unawaited(_persistOutgoing(sent, removeId: tempId)); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: sent.id, + time: now, + text: wireText, + status: 'sent', + elements: elements, + ), + ); + } } - if (chat == null) { + if (!_commentsMode && chat == null) { unawaited( chats.refreshChats(api, [widget.chatId]).then((list) { if (!mounted || list.isEmpty) return; @@ -3020,6 +4070,23 @@ class _ChatScreenState extends State ); } } catch (e) { + if (replySourceChatId != null) { + logger.w('Cross-chat reply rejected: $e'); + final index = _messages.indexWhere((m) => m.id == tempId); + if (index != -1 && mounted) { + _messages.removeAt(index); + _bumpMessages(); + } + unawaited(AppDatabase.deleteMessage(_myId, widget.chatId, tempId)); + if (mounted) { + Haptics.error(); + showCustomNotification(context, e.toString()); + } + return; + } + final failed = isPermanentSendFailure(e); + final status = failed ? 'error' : 'pending'; + if (failed) logger.w('Отправка отклонена сервером: $e'); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { final queued = CachedMessage( @@ -3029,29 +4096,32 @@ class _ChatScreenState extends State senderId: _myId, text: text, time: now, - status: 'pending', + status: status, payload: composedPayload, ); _messages[index] = queued; _bumpMessages(); - unawaited(_persistOutgoing(queued)); - unawaited( - chats.applyOutgoing( - _myId, - widget.chatId, - messageId: tempId, - time: now, - text: text, - status: 'pending', - elements: elements, - ), - ); + if (!_commentsMode) { + unawaited(_persistOutgoing(queued)); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: status, + elements: elements, + ), + ); + } } } } int? _resolveOtherId() { if (widget.chatType != 'DIALOG' || _myId == 0) return null; + if (widget.chatId == 0) return null; final id = widget.chatId ^ _myId; return id > 0 ? id : null; } @@ -3206,6 +4276,8 @@ class _ChatScreenState extends State }, postMessage: _postCommandMessage, updateMessage: _updateCommandMessage, + sendPhotos: _sendPhotos, + sendVideoNote: _sendVideoNote, ); Future _scheduleMessage() async { @@ -3287,7 +4359,8 @@ class _ChatScreenState extends State if (msg.attachments != null) { for (final a in msg.attachments!) { if (a is ForwardedMessageAttachment) { - if (a.originalSenderName == null && + if (a.originalSenderId != 0 && + a.originalSenderName == null && ContactCache.get(a.originalSenderId) == null) { forwardIds.add(a.originalSenderId); } @@ -3323,10 +4396,12 @@ class _ChatScreenState extends State originalSenderId: a.originalSenderId, originalSenderName: r.name, originalSenderAvatar: r.avatar, + originalType: a.originalType, originalMessageId: a.originalMessageId, originalTime: a.originalTime, originalText: a.originalText, originalChatId: a.originalChatId, + originalFormatRanges: a.originalFormatRanges, originalAttachments: a.originalAttachments, originalContact: a.originalContact, ); @@ -3345,14 +4420,167 @@ class _ChatScreenState extends State } void _scrollToBottom() { + _returnStack.clear(); + _newMessageCount.value = 0; WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.minScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); + if (!_scrollController.hasClients) return; + final pos = _scrollController.position; + final runway = pos.viewportDimension; + final teleport = pos.pixels > runway * _scrollDownTeleportFactor; + if (teleport) { + _pinnedMessageId = null; + _listEpoch++; + _jumpCacheExtent.value = _jumpCacheExtentPx; + _bumpMessages(); + _scrollController.jumpTo(runway); } + unawaited( + _scrollController + .animateTo( + pos.minScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ) + .whenComplete(() { + if (!teleport) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _jumpCacheExtent.value = null; + }); + }), + ); + }); + } + + void _updateScrollDownVisible() { + if (!_scrollController.hasClients) { + _setScrollDownVisible(_newMessageCount.value > 0); + return; + } + final pos = _scrollController.position; + final atBottom = _isNearBottom(); + if (_returnStack.isNotEmpty && + atBottom && + pos.userScrollDirection != ScrollDirection.idle) { + _returnStack.clear(); + } + if (atBottom && _newMessageCount.value > 0) _clearNewMessageCountSoon(); + final reveal = math.min( + _scrollDownRevealExtent, + pos.viewportDimension * _scrollDownRevealFactor, + ); + _setScrollDownVisible( + pos.pixels >= reveal || + _returnStack.isNotEmpty || + _newMessageCount.value > 0, + ); + } + + void _setScrollDownVisible(bool show) { + if (show == _scrollDownVisible) return; + _scrollDownVisible = show; + if (show) { + _scrollDownAnimController.forward(); + } else { + _scrollDownAnimController.reverse(); + } + } + + void _noteMissedMessage() { + _newMessageCount.value++; + _updateScrollDownVisible(); + } + + void _clearNewMessageCountSoon() { + if (_clearCountScheduled) return; + _clearCountScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _clearCountScheduled = false; + if (!mounted || !_isNearBottom()) return; + _newMessageCount.value = 0; + _updateScrollDownVisible(); + }); + } + + void _pushReturnAnchor(String messageId) { + if (!_scrollController.hasClients) return; + final listBox = _listKey.currentContext?.findRenderObject(); + final dy = _messageOffsetInList(messageId); + final viewportH = listBox is RenderBox ? listBox.size.height : 0.0; + final alignment = viewportH > 0 && dy != null + ? (dy / viewportH).clamp(0.0, 1.0) + : 0.5; + _returnStack.add(( + id: messageId, + pixels: _scrollController.position.pixels, + alignment: alignment.toDouble(), + )); + if (!_scrollDownVisible) { + _scrollDownVisible = true; + _scrollDownAnimController.forward(); + } + } + + void _onScrollDownTap() { + if (_returningToAnchor || _navigatingToTarget) return; + if (!_scrollController.hasClients) { + _scrollToBottom(); + return; + } + final pixels = _scrollController.position.pixels; + while (_returnStack.isNotEmpty) { + final anchor = _returnStack.removeLast(); + if (anchor.pixels < pixels && _messages.any((m) => m.id == anchor.id)) { + _returningToAnchor = true; + unawaited( + _returnToAnchor( + anchor, + ).whenComplete(() => _returningToAnchor = false), + ); + return; + } + } + _scrollToBottom(); + } + + Future _returnToAnchor( + ({String id, double pixels, double alignment}) anchor, + ) async { + final pos = _scrollController.position; + final runway = pos.viewportDimension; + final target = anchor.pixels.clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + final distance = (pos.pixels - target).abs(); + final far = distance > runway * _scrollDownTeleportFactor; + + if (!far) { + await _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + if (!mounted) return; + await _scrollToMessagePrecise(anchor.id, alignment: anchor.alignment); + return; + } + + _jumpCacheExtent.value = _jumpCacheExtentPx; + if (target + runway < distance) { + _listEpoch++; + _bumpMessages(); + _scrollController.jumpTo(target + runway); + await _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + if (!mounted) return; + } + await _scrollToMessagePrecise(anchor.id, alignment: anchor.alignment); + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _jumpCacheExtent.value = null; }); } @@ -3362,24 +4590,112 @@ class _ChatScreenState extends State } void _startReply(CachedMessage message) { + _cancelForward(); _replyTo.value = message; + _replySourceChatId = null; _messageFocusNode.requestFocus(); } void _cancelReply() { _replyTo.value = null; + _replySourceChatId = null; + } + + Future _pickReplyChat() async { + final reply = _replyTo.value; + if (reply == null) return; + if (reply.id.startsWith('temp_')) { + showCustomNotification(context, 'Сообщение ещё не отправлено'); + return; + } + + final sourceChatId = _replySourceChatId ?? widget.chatId; + final target = await openForwardScreen(context: context); + if (target == null || !mounted) return; + + if (target.chatId == widget.chatId) { + _replySourceChatId = sourceChatId == widget.chatId ? null : sourceChatId; + _messageFocusNode.requestFocus(); + return; + } + + await chats.ensureChatCached(api, _myId, target.chatId); + if (!mounted) return; + pushSwipeable( + context, + (_) => ChatScreen( + chatId: target.chatId, + name: target.name, + imageUrl: target.imageUrl, + chatType: target.chatType, + replyRequest: ReplyRequest(sourceChatId: sourceChatId, message: reply), + ), + ); } void _openSenderProfile(int senderId) { if (senderId == 0 || senderId == _myId) return; - Navigator.push( + unawaited( + openContactDialogProfile( + context, + contactId: senderId, + name: ContactCache.get(senderId) ?? 'User #$senderId', + avatarUrl: ContactCache.getAvatar(senderId), + ), + ); + } + + void _openForwardedSource(ForwardedMessageAttachment forwarded) { + if (forwarded.isChannel) { + unawaited(_openForwardedChannel(forwarded)); + return; + } + final senderId = forwarded.originalSenderId; + if (senderId == 0 || senderId == _myId) return; + unawaited( + openContactDialogProfile( + context, + contactId: senderId, + name: + forwarded.originalSenderName ?? + ContactCache.get(senderId) ?? + 'User #$senderId', + avatarUrl: + forwarded.originalSenderAvatar ?? ContactCache.getAvatar(senderId), + ), + ); + } + + Future _openForwardedChannel( + ForwardedMessageAttachment forwarded, + ) async { + final sourceChatId = forwarded.originalChatId; + if (sourceChatId == null) { + showCustomNotification(context, 'Канал недоступен'); + return; + } + final sourceMessageId = forwarded.originalMessageId; + if (sourceChatId == widget.chatId) { + if (sourceMessageId == null) return; + _beginTargetNavigation(); + await _runGoToMessage(sourceMessageId, forwarded.originalTime ?? 0); + return; + } + + await chats.ensureChatCached(api, _myId, sourceChatId); + if (!mounted) return; + final cached = await chats.getChat(_myId, sourceChatId); + if (!mounted) return; + final channel = cached.isEmpty ? null : cached.first; + pushSwipeable( context, - MaterialPageRoute( - builder: (_) => ContactProfileScreen( - contactId: senderId, - initialName: ContactCache.get(senderId), - initialAvatarUrl: ContactCache.getAvatar(senderId), - ), + (_) => ChatScreen( + chatId: sourceChatId, + name: channel?.title ?? forwarded.originalSenderName ?? 'Канал', + imageUrl: channel?.iconUrl ?? forwarded.originalSenderAvatar ?? '', + chatType: channel?.type ?? 'CHANNEL', + initialMessageId: sourceMessageId, + initialMessageTime: forwarded.originalTime, ), ); } @@ -3397,13 +4713,15 @@ class _ChatScreenState extends State ); } - void _jumpToMessage(String messageId) { + void _jumpToMessage(String messageId, {String? fromId}) { final index = _messages.indexWhere((m) => m.id == messageId); if (index == -1) { showCustomNotification(context, 'Сообщение не загружено'); return; } + if (fromId != null) _pushReturnAnchor(fromId); + final key = _keyForMessage(messageId); final ctx = key.currentContext; if (ctx != null) { @@ -3413,6 +4731,8 @@ class _ChatScreenState extends State curve: Curves.easeOut, alignment: 0.4, ); + } else { + unawaited(_scrollToMessagePrecise(messageId, alignment: 0.4)); } _highlightTimer?.cancel(); @@ -3460,19 +4780,7 @@ class _ChatScreenState extends State if (!mounted) return; if (!_messages.any((m) => m.id == id)) { - var guard = 0; - while (mounted && - guard < 80 && - _hasMoreHistory && - !_messages.any((m) => m.id == id) && - (_messages.isEmpty || _messages.first.time > targetTime)) { - guard++; - final before = _messages.isEmpty ? 0 : _messages.first.time; - await _loadMoreHistory(); - if (!mounted) return; - final after = _messages.isEmpty ? 0 : _messages.first.time; - if (after == before) break; - } + await _loadMessageWindow(id, targetTime); if (!mounted) return; await WidgetsBinding.instance.endOfFrame; if (!mounted) return; @@ -3520,11 +4828,13 @@ class _ChatScreenState extends State if (!mounted || !_scrollController.hasClients) return; if (_messages.indexWhere((m) => m.id == id) == -1) return; - _suppressHistoryAutoload = true; + final epoch = _userGestureEpoch; + _historyAutoloadSuppressCount++; try { var stable = 0; for (var iter = 0; iter < 120; iter++) { if (!mounted || !_scrollController.hasClients) return; + if (_userGestureEpoch != epoch) return; final listObj = _listKey.currentContext?.findRenderObject(); final boxObj = _keyForMessage(id).currentContext?.findRenderObject(); final p = _scrollController.position; @@ -3576,35 +4886,20 @@ class _ChatScreenState extends State await WidgetsBinding.instance.endOfFrame; } } finally { - _suppressHistoryAutoload = false; + _historyAutoloadSuppressCount--; } } Future _openSearchResult(MessageSearchResult result) async { _closeSearch(); - await WidgetsBinding.instance.endOfFrame; - if (!mounted) return; - - if (!_messages.any((m) => m.id == result.id)) { - var guard = 0; - while (mounted && - guard < 60 && - _hasMoreHistory && - !_messages.any((m) => m.id == result.id) && - (_messages.isEmpty || _messages.first.time > result.time)) { - guard++; - final before = _messages.isEmpty ? 0 : _messages.first.time; - await _loadMoreHistory(); - if (!mounted) return; - final after = _messages.isEmpty ? 0 : _messages.first.time; - if (after == before) break; - } - if (!mounted) return; + if (_messages.any((m) => m.id == result.id)) { await WidgetsBinding.instance.endOfFrame; if (!mounted) return; + _scrollToLoadedMessage(result.id); + return; } - - _scrollToLoadedMessage(result.id); + setState(_beginTargetNavigation); + await _runGoToMessage(result.id, result.time); } void _scrollToLoadedMessage( @@ -3634,12 +4929,7 @@ class _ChatScreenState extends State messageId, ).currentContext?.findRenderObject(); if (laidOut is! RenderBox || !laidOut.attached) { - var below = 0.0; - for (var i = pos + 1; i < items.length; i++) { - below += items[i] is _MessageItem ? _avgMessageHeight : 44.0; - } - final maxExtent = _scrollController.position.maxScrollExtent; - _scrollController.jumpTo(below.clamp(0.0, maxExtent).toDouble()); + _jumpNearMessage(messageId); } if (highlight) { @@ -3657,28 +4947,102 @@ class _ChatScreenState extends State ); } + ({int oldest, int newest}) _visibleItemRange( + List items, + RenderBox listBox, + ) { + var oldest = -1; + var newest = -1; + final viewportBottom = listBox.size.height; + for (var i = 0; i < items.length; i++) { + final item = items[i]; + if (item is! _MessageItem) continue; + final box = _messageKeys[item.message.id]?.currentContext + ?.findRenderObject(); + if (box is! RenderBox || !box.attached) { + if (oldest != -1) break; + continue; + } + final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy; + if (top + box.size.height <= 0 || top >= viewportBottom) { + if (oldest != -1) break; + continue; + } + if (oldest == -1) oldest = i; + newest = i; + } + return (oldest: oldest, newest: newest); + } + + double _jumpStepScreens(int index, ({int oldest, int newest}) visible) { + if (visible.oldest == -1) return 1; + final perScreen = visible.newest - visible.oldest + 1; + if (perScreen <= 0) return 1; + final away = index < visible.oldest + ? visible.oldest - index + : index - visible.newest; + return (away / perScreen).clamp(1.0, _jumpStepMaxScreens); + } + + bool _jumpNearMessage(String messageId) { + if (!_scrollController.hasClients) return false; + final listBox = _listKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || listBox.size.height <= 0) return false; + final items = _buildCombinedItems(); + final index = items.indexWhere( + (it) => it is _MessageItem && it.message.id == messageId, + ); + if (index == -1) return false; + + final visible = _visibleItemRange(items, listBox); + final position = _scrollController.position; + final step = position.viewportDimension * _jumpStepScreens(index, visible); + final double next; + if (visible.oldest == -1 || index < visible.oldest) { + next = position.pixels + step; + } else if (index > visible.newest) { + next = position.pixels - step; + } else { + return false; + } + final clamped = next.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((clamped - position.pixels).abs() < 0.5) return false; + _scrollController.jumpTo(clamped); + return true; + } + void _alignLoadedMessage( String messageId, double alignment, int attempt, { + int frames = 0, + int? epoch, VoidCallback? onSettled, }) { - if (!mounted || !_scrollController.hasClients) { + if (!mounted || + !_scrollController.hasClients || + (epoch != null && epoch != _userGestureEpoch)) { onSettled?.call(); return; } final listBox = _listKey.currentContext?.findRenderObject(); final box = _keyForMessage(messageId).currentContext?.findRenderObject(); if (listBox is! RenderBox || box is! RenderBox || !box.attached) { - if (attempt >= 8) { + if (attempt >= _jumpStallLimit || frames >= _jumpFrameLimit) { onSettled?.call(); return; } + final moved = _jumpNearMessage(messageId); WidgetsBinding.instance.addPostFrameCallback( (_) => _alignLoadedMessage( messageId, alignment, - attempt + 1, + moved ? 0 : attempt + 1, + frames: frames + 1, + epoch: epoch, onSettled: onSettled, ), ); @@ -3698,7 +5062,8 @@ class _ChatScreenState extends State if (viewportHeight <= 0 || delta.abs() <= 0.5 || (target - pos.pixels).abs() <= 0.5 || - attempt >= 8) { + attempt >= _jumpStallLimit || + frames >= _jumpFrameLimit) { onSettled?.call(); return; } @@ -3709,6 +5074,8 @@ class _ChatScreenState extends State messageId, alignment, attempt + 1, + frames: frames + 1, + epoch: epoch, onSettled: onSettled, ), ); @@ -3911,6 +5278,7 @@ class _ChatScreenState extends State final cs = Theme.of(context).colorScheme; final accent = cs.primary; return Padding( + key: _unreadSeparatorKey, padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), child: Row( children: [ @@ -3964,13 +5332,22 @@ class _ChatScreenState extends State ? math.max(mq.viewInsets.bottom, _keyboardReserve) : mq.viewInsets.bottom; return ListenableBuilder( - listenable: Listenable.merge([_selectedIds, _search.searchMode]), + listenable: Listenable.merge([ + _selectedIds, + _search.searchMode, + _textSelection, + ]), builder: (context, child) => PopScope( - canPop: _selectedIds.value.isEmpty && !_search.searchMode.value, + canPop: + _selectedIds.value.isEmpty && + !_search.searchMode.value && + _textSelection.value == null, onPopInvokedWithResult: (didPop, _) { if (didPop) return; if (_search.searchMode.value) { _closeSearch(); + } else if (_textSelection.value != null) { + _exitTextSelection(); } else { _clearSelection(); } @@ -4012,13 +5389,46 @@ class _ChatScreenState extends State ); } - Widget? _buildPinnedBanner({required bool floating}) { + Widget _buildPinnedAndPill() { + return ValueListenableBuilder( + valueListenable: MediaPlayback.instance.primary, + builder: (context, kind, _) { + final merged = kind != null; + final banner = _buildPinnedBanner( + floating: true, + borderRadius: merged + ? const BorderRadius.vertical(top: Radius.circular(16)) + : null, + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ?banner, + MediaPlaybackPill( + borderRadius: banner == null + ? BorderRadius.circular(16) + : const BorderRadius.vertical(bottom: Radius.circular(16)), + ), + ], + ); + }, + ); + } + + Widget? _buildPinnedBanner({ + required bool floating, + BorderRadius? borderRadius, + }) { final pinned = chat; if (pinned == null || !pinned.hasPinnedMessage) return null; return _PinnedMessageBanner( text: pinned.pinnedMsgText, isPreview: pinned.pinnedMsgIsPreview, floating: floating, + borderRadius: borderRadius, + frosted: _effectiveChrome == ChatChromeStyle.transparent, + liquid: _liquidChrome, + backdropKey: _pillBackdrop, onTap: _jumpToPinnedMessage, onUnpin: pinned.canPinMessages(_myId) ? () => unawaited(_unpinCurrentMessage()) @@ -4029,6 +5439,11 @@ class _ChatScreenState extends State Widget _buildColorBody() { final cs = Theme.of(context).colorScheme; final banner = _buildPinnedBanner(floating: false); + final frosted = _composerFrosted; + final composer = _MeasureSize( + onHeight: (value) => _composerHeight.value = value, + child: _buildComposerArea(context), + ); return Column( children: [ ?banner, @@ -4041,12 +5456,24 @@ class _ChatScreenState extends State child: ChatWallpaperView(wallpaper: _wallpaper!), ), Positioned.fill(child: _buildMessagesArea()), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: CommandPanelView(commandPanel: _commandPanel), + ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, _) => Positioned( + left: 0, + right: 0, + bottom: frosted ? height : 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MentionPanelView(mentionPanel: _mentionPanel), + CommandPanelView(commandPanel: _commandPanel), + ], + ), + ), ), + VideoNoteRecordingLayer(controller: _note), + if (frosted) + Positioned(left: 0, right: 0, bottom: 0, child: composer), SearchOverlay( cs: cs, searchAnim: _searchAnim, @@ -4058,32 +5485,22 @@ class _ChatScreenState extends State ], ), ), - _buildComposerArea(context), + if (!frosted) composer, ], ); } double _pinnedBannerTop() { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final glossy = AppVisualStyle.current.value.glossyChrome; return MediaQuery.paddingOf(context).top + - (glossy ? _glossyHeaderHeight : kToolbarHeight); - } - - void _resetPinnedBannerHeight() { - if (_pinnedBannerHeight.value == 0) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && chat?.hasPinnedMessage != true) { - _pinnedBannerHeight.value = 0; - } - }); + (glossy ? _glossyHeaderHeight : kToolbarHeight) - + _pinnedBannerLift; } Widget _buildUnderlapBody() { final cs = Theme.of(context).colorScheme; - final vignette = _effectiveChrome == ChatChromeStyle.none; + final vignette = _chromeVignette; final bannerTop = _pinnedBannerTop(); - final banner = _buildPinnedBanner(floating: true); - if (banner == null) _resetPinnedBannerHeight(); return Stack( fit: StackFit.expand, children: [ @@ -4099,41 +5516,55 @@ class _ChatScreenState extends State senderAvatar: _searchSenderAvatar, ), if (vignette) ...[ - Positioned( - top: 0, - left: 0, - right: 0, - child: _buildEdgeVignette(cs, top: true), - ), - ValueListenableBuilder( - valueListenable: _composerHeight, - builder: (context, height, _) => Positioned( + if (AppVisualStyle.current.value.glossyChrome) + Positioned( + top: 0, left: 0, right: 0, - bottom: 0, - child: _buildEdgeVignette(cs, top: false, height: height), + child: _buildEdgeVignette(cs, top: true), ), + ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, _) => _composerPaintsSurface + ? Positioned( + left: 0, + right: 0, + bottom: height, + child: _buildEdgeFade(cs), + ) + : Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildEdgeVignette(cs, top: false, height: height), + ), ), ], - if (banner != null) - Positioned( - top: bannerTop, - left: 8, - right: 8, - child: _MeasureSize( - onHeight: (value) => _pinnedBannerHeight.value = value, - child: banner, - ), + Positioned( + top: bannerTop, + left: 8, + right: 8, + child: _MeasureSize( + onHeight: (value) => _pinnedBannerHeight.value = value, + child: _buildPinnedAndPill(), ), + ), ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, _) => Positioned( left: 0, right: 0, bottom: height, - child: CommandPanelView(commandPanel: _commandPanel), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MentionPanelView(mentionPanel: _mentionPanel), + CommandPanelView(commandPanel: _commandPanel), + ], + ), ), ), + VideoNoteRecordingLayer(controller: _note), Positioned( left: 0, right: 0, @@ -4153,6 +5584,21 @@ class _ChatScreenState extends State ); } + Widget _buildEdgeFade(ColorScheme cs) { + return IgnorePointer( + child: Container( + height: _edgeFadeHeight, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [cs.surface, cs.surface.withValues(alpha: 0.0)], + ), + ), + ), + ); + } + Widget _buildEdgeVignette( ColorScheme cs, { required bool top, @@ -4162,7 +5608,7 @@ class _ChatScreenState extends State if (height != null) { resolved = height; } else { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + final glossy = AppVisualStyle.current.value.glossyChrome; resolved = MediaQuery.paddingOf(context).top + (glossy ? _glossyHeaderHeight : kToolbarHeight); @@ -4190,9 +5636,14 @@ class _ChatScreenState extends State children: [ Opacity( opacity: showShimmer ? 0.0 : 1.0, - child: NotificationListener( - onNotification: (_) { - _updateReadMarker(); + child: NotificationListener( + onNotification: (notification) { + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + _userGestureEpoch++; + } else if (notification is ScrollEndNotification) { + _updateReadMarker(); + } return false; }, child: _buildMessagesList(), @@ -4216,32 +5667,21 @@ class _ChatScreenState extends State } double _floatingDateTop(double pinnedHeight) { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; if (AppChatChrome.current.value == ChatChromeStyle.color) { + final glossy = AppVisualStyle.current.value.glossyChrome; return glossy ? 2 : 4; } if (chat?.hasPinnedMessage == true && pinnedHeight > 0) { return _pinnedBannerTop() + pinnedHeight + 2; } - return MediaQuery.paddingOf(context).top + - (glossy ? _glossyHeaderHeight : kToolbarHeight) + - 2; + return _pinnedBannerTop() + 2; } Widget _buildLoadMoreIndicator() { final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: cs.onSurfaceVariant, - ), - ), - ), + child: Center(child: SmallSpinner(size: 22, color: cs.onSurfaceVariant)), ); } @@ -4272,182 +5712,245 @@ class _ChatScreenState extends State jumpExtent != null && jumpExtent < userCacheExtent ? jumpExtent : userCacheExtent; - return ListView.builder( + return CustomScrollView( controller: _scrollController, reverse: true, - padding: _messagesListPadding(context), + physics: _listPhysics, cacheExtent: cacheExtent, - itemCount: items.length + 1 + (_isLoadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (index == 0) { - return ValueListenableBuilder( - valueListenable: _composerHeight, - builder: (context, height, _) => SizedBox( - height: - AppChatChrome.current.value == - ChatChromeStyle.color - ? 0 - : height, - ), - ); - } - if (index > items.length) { - return _buildLoadMoreIndicator(); - } - final item = items[items.length - index]; + slivers: [ + SliverPadding( + padding: _messagesListPadding(context), + sliver: SliverList( + key: ValueKey(_listEpoch), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + return ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, _) => SizedBox( + height: _composerUnderlap ? height : 0, + ), + ); + } + if (index > items.length) { + return _buildLoadMoreIndicator(); + } + final item = items[items.length - index]; - if (item is _DateSeparatorItem) { - return _buildDateSeparatorWidget( - context, - item.date, - key: item.key, - ); - } - - if (item is _UnreadSeparatorItem) { - return _buildUnreadSeparatorWidget(context); - } - - final msgItem = item as _MessageItem; - final message = msgItem.message; - final msgIndex = msgItem.index; - final isMe = message.senderId == _myId; - final prevMessage = msgIndex > 0 - ? _messages[msgIndex - 1] - : null; - final nextMessage = msgIndex < _messages.length - 1 - ? _messages[msgIndex + 1] - : null; - - final bubble = MessageBubble( - message: message, - isMe: isMe, - myId: _myId, - prevMessage: prevMessage, - nextMessage: nextMessage, - chatType: chat?.type ?? 'CHAT', - overrideStatus: _effectiveStatus(message), - otherReadTime: _otherReadTime, - reactionsListenable: _reactionNotifierFor(message), - uploadProgress: _photoProgressFor(message), - onReplyTap: _jumpToMessage, - onAvatarTap: _openSenderProfile, - onStickerTap: _openStickerPack, - onReactionTap: message.isControl - ? null - : (emoji) => _reactToMessage(message, emoji), - peerName: widget.name, - peerAvatarUrl: widget.imageUrl, - ); - - final canReport = !isMe && !message.isControl; - final reportTypeId = _complaintTypeId( - chat?.type ?? widget.chatType, - ); - - final pressable = _SelectableMessageRow( - message: message, - isMe: isMe, - selectedIds: _selectedIds, - selectionAnim: _selectionAnim, - isSelectionActive: () => _selectionMode, - onToggleSelection: () => _toggleSelection(message), - onEnterSelection: () => _enterSelection(message), - onDelete: () => _confirmDeleteMessage(message, isMe), - onEdit: _canEditMessage(message) - ? () => _startEditMessage(message) - : null, - onReply: message.isControl - ? null - : () => _startReply(message), - onForward: message.isControl - ? null - : () => _forwardMessages([message]), - onMarkUnread: message.isControl - ? null - : () => _markMessageUnread(message), - onPin: _canPinMessage(message) - ? () => _togglePinMessage(message) - : null, - isPinned: () => - chat?.pinnedMsgId == int.tryParse(message.id), - loadReportReasons: canReport - ? () => _loadReportReasons(reportTypeId) - : null, - onReport: canReport - ? (reasonId) => _reportMessage( - message, - reportTypeId, - reasonId, - ) - : null, - onReact: message.isControl - ? null - : (emoji) => _reactToMessage(message, emoji), - reactions: _reactionNotifierFor(message), - child: bubble, - ); - - final isChannel = - (chat?.type ?? widget.chatType) == 'CHANNEL'; - final swipeable = (message.isControl || isChannel) - ? pressable - : _SwipeToReply( - isMe: isMe, - onReply: () => _startReply(message), - child: pressable, - ); - - final Widget child; - if (_deletingIds.contains(message.id)) { - child = _DeletingMessageAnimation( - key: ValueKey('del_${message.id}'), - onComplete: () => _finalizeDelete(message.id), - child: IgnorePointer(child: swipeable), - ); - } else if (message.id == _lastSentId) { - child = _SentMessageAnimation( - key: ValueKey('anim_${message.id}'), - onComplete: () { - if (mounted) { - _lastSentId = null; - _bumpMessages(); - } - }, - child: swipeable, - ); - } else { - child = swipeable; - } - - final highlightable = ValueListenableBuilder( - valueListenable: _highlightMessageId, - builder: (context, hl, c) => AnimatedContainer( - duration: const Duration(milliseconds: 250), - color: hl == message.id - ? Theme.of( + if (item is _DateSeparatorItem) { + return _buildDateSeparatorWidget( context, - ).colorScheme.primary.withValues(alpha: 0.12) - : Colors.transparent, - child: c, - ), - child: child, - ); + item.date, + key: item.key, + ); + } - final builtItem = RepaintBoundary( - key: ValueKey('msg_${message.id}'), - child: KeyedSubtree( - key: _keyForMessage(message.id), - child: highlightable, + if (item is _UnreadSeparatorItem) { + return _buildUnreadSeparatorWidget(context); + } + + final msgItem = item as _MessageItem; + final message = msgItem.message; + final msgIndex = msgItem.index; + final isMe = message.senderId == _myId; + final prevMessage = msgIndex > 0 + ? _messages[msgIndex - 1] + : null; + final nextMessage = + msgIndex < _messages.length - 1 + ? _messages[msgIndex + 1] + : null; + + final bool isChannelPost = + !_commentsMode && + (chat?.type ?? widget.chatType) == + 'CHANNEL' && + !message.isControl; + final bool isCommentedPost = + _commentsMode && + message.id == widget.commentPostId; + + final bubble = MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + chatType: _commentsMode + ? 'CHAT' + : (chat?.type ?? 'CHAT'), + chatId: widget.chatId, + photoActions: _photoActions, + overrideStatus: _effectiveStatus(message), + otherReadTime: _otherReadTime, + reactionsListenable: _reactionNotifierFor( + message, + ), + reactionAnimation: _reactionAnimation, + uploadProgress: _photoProgressFor(message), + onReplyTap: (id) => + _jumpToMessage(id, fromId: message.id), + onAvatarTap: _openSenderProfile, + onForwardedSourceTap: _openForwardedSource, + onStickerTap: _openStickerPack, + onReactionTap: message.isControl + ? null + : (emoji) => + _reactToMessage(message, emoji), + peerName: widget.name, + peerAvatarUrl: widget.imageUrl, + senderNameOverride: isCommentedPost + ? widget.name + : null, + senderAvatarOverride: isCommentedPost + ? widget.imageUrl + : null, + textSelection: _textSelection, + textSelectionDrag: _textSelectionDrag, + onExitTextSelection: _exitTextSelection, + commentsLabel: isChannelPost + ? _commentsLabelFor(message.id) + : null, + onCommentsTap: isChannelPost + ? () => _openComments(message) + : null, + ); + + final canReport = !isMe && !message.isControl; + final reportTypeId = _complaintTypeId( + chat?.type ?? widget.chatType, + ); + + final pressable = _SelectableMessageRow( + message: message, + isMe: isMe, + selectedIds: _selectedIds, + selectionAnim: _selectionAnim, + isSelectionActive: () => _selectionMode, + onToggleSelection: () => + _toggleSelection(message), + onEnterSelection: () => + _enterSelection(message), + onStartTextSelection: (pos) => + _startTextSelection(message, pos), + onDragTextSelection: (pos) => + _textSelectionDrag.value = pos, + onDelete: () => + _confirmDeleteMessage(message.id, isMe), + onEdit: _canEditMessage(message) + ? () => _startEditMessage(message) + : null, + onReply: message.isControl + ? null + : () => _startReply(message), + onForward: + message.isControl || + (chat?.forwardDisabled ?? false) + ? null + : () => _forwardMessages([message]), + allowCopy: !(chat?.copyDisabled ?? false), + onMarkUnread: message.isControl + ? null + : () => _markMessageUnread(message), + onPin: _canPinMessage(message) + ? () => _togglePinMessage(message) + : null, + isPinned: () => + chat?.pinnedMsgId == + int.tryParse(message.id), + loadReadBy: _canShowReadBy(message) + ? () => _loadReadBy(message) + : null, + onReaderTap: _openSenderProfile, + loadReportReasons: canReport + ? () => _loadReportReasons(reportTypeId) + : null, + onReport: canReport + ? (reasonId) => _reportMessage( + message, + reportTypeId, + reasonId, + ) + : null, + onReact: message.isControl + ? null + : (emoji) => + _reactToMessage(message, emoji), + reactions: _reactionNotifierFor(message), + child: bubble, + ); + + final isChannel = + (chat?.type ?? widget.chatType) == 'CHANNEL'; + final swipeable = (message.isControl || isChannel) + ? pressable + : _SwipeToReply( + isMe: isMe, + onReply: () => _startReply(message), + child: pressable, + ); + + final Widget child; + if (_deletingIds.contains(message.id)) { + child = _DeletingMessageAnimation( + key: ValueKey('del_${message.id}'), + onComplete: () => _finalizeDelete(message.id), + child: IgnorePointer(child: swipeable), + ); + } else if (message.id == _lastSentId) { + child = _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) { + _lastSentId = null; + _bumpMessages(); + } + }, + child: swipeable, + ); + } else { + child = swipeable; + } + + final highlightable = + ValueListenableBuilder( + valueListenable: _highlightMessageId, + builder: (context, hl, c) => + AnimatedContainer( + duration: const Duration( + milliseconds: 250, + ), + color: hl == message.id + ? Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.12) + : Colors.transparent, + child: c, + ), + child: child, + ); + + final builtItem = RepaintBoundary( + key: ValueKey('msg_${message.id}'), + child: KeyedSubtree( + key: _keyForMessage(message.id), + child: highlightable, + ), + ); + return message.id == _prank.bubbleId + ? KeyedSubtree( + key: _prank.bubbleKey, + child: builtItem, + ) + : builtItem; + }, + childCount: + items.length + 1 + (_isLoadingMore ? 1 : 0), + ), ), - ); - return message.id == _prank.bubbleId - ? KeyedSubtree( - key: _prank.bubbleKey, - child: builtItem, - ) - : builtItem; - }, + ), + ], ); }, ), @@ -4487,10 +5990,109 @@ class _ChatScreenState extends State ), ), ), + _buildScrollDownButton(), ], ); } + Widget _buildScrollDownButton() { + final cs = Theme.of(context).colorScheme; + final frosted = _effectiveChrome == ChatChromeStyle.transparent; + return ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, child) => Positioned( + right: _materialComposer + ? (_materialIconSlot - _scrollDownSize) / 2 + : 16, + bottom: (_composerUnderlap ? height : 0) + 12, + child: child!, + ), + child: AnimatedBuilder( + animation: _scrollDownCurved, + builder: (context, child) { + final t = _scrollDownCurved.value; + if (t == 0) return const SizedBox.shrink(); + final backdropVisible = t >= 1; + return Opacity( + opacity: t, + child: Transform.scale( + scale: 0.82 + 0.18 * t, + child: SizedBox( + width: _scrollDownSize, + height: _scrollDownSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: GlossyPill( + color: frosted || _liquidChrome + ? AppFrost.glassTint(cs) + : null, + blurSigma: frosted && !_liquidChrome && backdropVisible + ? AppFrost.sigma + : null, + liquid: _liquidChrome, + backdropKey: _pillBackdrop, + elevated: true, + onTap: _onScrollDownTap, + child: child!, + ), + ), + Positioned( + top: -5, + right: -3, + child: ValueListenableBuilder( + valueListenable: _newMessageCount, + builder: (context, count, _) => count <= 0 + ? const SizedBox.shrink() + : AnimatedValueSwap( + value: count > 99 ? 100 : count, + alignment: Alignment.centerRight, + builder: (context, value) => + _unreadBadge(cs, value), + ), + ), + ), + ], + ), + ), + ), + ); + }, + child: Center( + child: Icon( + Symbols.keyboard_arrow_down, + color: cs.onSurface, + weight: 500, + size: 26, + ), + ), + ), + ); + } + + Widget _unreadBadge(ColorScheme cs, int count) { + return Container( + constraints: const BoxConstraints(minWidth: 21), + height: 21, + padding: const EdgeInsets.symmetric(horizontal: 6), + alignment: Alignment.center, + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(11), + ), + child: Text( + count > 99 ? '99+' : '$count', + style: TextStyle( + color: cs.onPrimary, + fontSize: 12, + height: 1, + fontWeight: FontWeight.w700, + ), + ), + ); + } + Uint8List _buildWave(List amps, {int bars = 80}) { final out = Uint8List(bars); if (amps.isEmpty) return out; @@ -4517,77 +6119,24 @@ class _ChatScreenState extends State return; } final wave = _buildWave(amps); - final tempId = _nextTempId(); - final progress = ValueNotifier>(const [0]); - _photoUploadProgress[tempId] = progress; - _messages.add( - CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - time: DateTime.now().millisecondsSinceEpoch, - status: 'sending', - attachments: [AudioAttachment(duration: durationMs)], + final placeholder = _addOptimisticMediaMessage( + AudioAttachment( + duration: durationMs, + waveform: String.fromCharCodes(wave), ), ); - _lastSentId = tempId; - _bumpMessages(); - Haptics.send(); - _scrollToBottom(); - try { - final info = await messagesModule.requestAudioUploadUrl(); - if (info == null || info.url.isEmpty) throw Exception('no_url'); - - final ok = await fileUploader.uploadMediaFile( - Uri.parse(info.url), - file, - onProgress: (sent, total) { - if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)]; - }, - ); - if (!ok) throw Exception('upload_failed'); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - - final serverMsg = await messagesModule.sendAudioMessage( - widget.chatId, - info.token, - duration: durationMs, + unawaited( + UploadService.instance.sendVoice( + accountId: _myId, + chatId: widget.chatId, + tempId: placeholder.id, + file: file, + durationMs: durationMs, wave: wave, - ); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - if (serverMsg == null) throw Exception('send_failed'); - - final real = CachedMessage.fromPushPayload( - _myId, - widget.chatId, - serverMsg, - ); - final idx = _messages.indexWhere((m) => m.id == tempId); - if (idx != -1) { - _messages[idx] = real; - _bumpMessages(); - unawaited(_persistOutgoing(real, removeId: tempId)); - } - _disposePhotoProgress(tempId); - } catch (_) { - if (mounted) { - _failPhotoMessage(tempId); - } else { - _disposePhotoProgress(tempId); - } - } finally { - try { - await file.delete(); - } catch (_) {} - } + placeholder: placeholder, + ), + ); } Future _sendVideoNote(File file, int durationMs) async { @@ -4597,76 +6146,23 @@ class _ChatScreenState extends State } catch (_) {} return; } - final tempId = _nextTempId(); - final progress = ValueNotifier>(const [0]); - _photoUploadProgress[tempId] = progress; - _messages.add( - CachedMessage( - id: tempId, + final placeholder = _addOptimisticMediaMessage( + VideoAttachment(duration: durationMs, videoType: 1, localPath: file.path), + ); + + unawaited( + UploadService.instance.sendVideoNote( accountId: _myId, chatId: widget.chatId, - senderId: _myId, - time: DateTime.now().millisecondsSinceEpoch, - status: 'sending', - attachments: [VideoAttachment(duration: durationMs, videoType: 1)], + tempId: placeholder.id, + file: file, + durationMs: durationMs, + placeholder: placeholder, ), ); - _lastSentId = tempId; - _bumpMessages(); - Haptics.send(); - _scrollToBottom(); - - try { - final info = await messagesModule.requestVideoNoteUploadUrl(); - if (info == null || info.url.isEmpty) throw Exception('no_url'); - final ok = await fileUploader.uploadMediaFile( - Uri.parse(info.url), - file, - onProgress: (sent, total) { - if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)]; - }, - ); - if (!ok) throw Exception('upload_failed'); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - final serverMsg = await messagesModule.sendVideoNoteMessage( - widget.chatId, - info.token, - duration: durationMs, - ); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - if (serverMsg == null) throw Exception('send_failed'); - final real = CachedMessage.fromPushPayload( - _myId, - widget.chatId, - serverMsg, - ); - final idx = _messages.indexWhere((m) => m.id == tempId); - if (idx != -1) { - _messages[idx] = real; - _bumpMessages(); - unawaited(_persistOutgoing(real, removeId: tempId)); - } - _disposePhotoProgress(tempId); - } catch (_) { - if (mounted) { - _failPhotoMessage(tempId); - } else { - _disposePhotoProgress(tempId); - } - } finally { - try { - await file.delete(); - } catch (_) {} - } } - String _addOptimisticFileMessage(FileAttachment attachment) { + CachedMessage _addOptimisticMediaMessage(MessageAttachment attachment) { final now = DateTime.now().millisecondsSinceEpoch; final tempId = _nextTempId(); final msg = CachedMessage( @@ -4683,20 +6179,21 @@ class _ChatScreenState extends State _bumpMessages(); Haptics.send(); _scrollToBottom(); - return tempId; + return msg; } void _updateFileMessageStatus( String tempId, String status, { FileAttachment? attachment, + String? realId, }) { if (!mounted) return; final idx = _messages.indexWhere((m) => m.id == tempId); if (idx == -1) return; final old = _messages[idx]; _messages[idx] = CachedMessage( - id: tempId, + id: realId != null && realId.isNotEmpty ? realId : tempId, accountId: old.accountId, chatId: old.chatId, senderId: old.senderId, @@ -4710,37 +6207,47 @@ class _ChatScreenState extends State } Future _sendHistoryFile(FileHistoryEntry entry) async { - final tempId = _addOptimisticFileMessage( + final tempId = _addOptimisticMediaMessage( FileAttachment( fileId: entry.fileId, fileToken: entry.token, name: entry.filename, size: entry.size, ), - ); + ).id; _showAttachmentPanel.value = false; try { - final ok = await messagesModule.sendFileMessage( + final realId = await messagesModule.sendFileMessage( widget.chatId, entry.fileId, token: entry.token, ); - _updateFileMessageStatus(tempId, ok ? 'sent' : 'error'); + _updateFileMessageStatus( + tempId, + realId != null ? 'sent' : 'error', + realId: realId, + ); } catch (_) { _updateFileMessageStatus(tempId, 'error'); } } Future _sendFileById(int fileId) async { - final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId)); + final tempId = _addOptimisticMediaMessage( + FileAttachment(fileId: fileId), + ).id; try { - final ok = await messagesModule.sendFileMessage(widget.chatId, fileId); + final realId = await messagesModule.sendFileMessage( + widget.chatId, + fileId, + ); + final ok = realId != null; if (!mounted) return ok; if (ok) { FileHistoryCache.add( FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()), ); - _updateFileMessageStatus(tempId, 'sent'); + _updateFileMessageStatus(tempId, 'sent', realId: realId); _showAttachmentPanel.value = false; } else { _updateFileMessageStatus(tempId, 'error'); @@ -4774,11 +6281,20 @@ class _ChatScreenState extends State ? _sendPhotos : (picked, caption) => _sendScheduledPhotos(picked, caption, scheduledTime), - onPickFile: scheduledTime == null - ? _pickAndUploadFile - : () => _pickAndUploadFile(scheduledTime: scheduledTime), - onShareLocation: _shareLocation, - onCreatePoll: _createPoll, + onPickFile: _encryptionEnabled + ? () => _refuseUnencrypted('Файлы') + : (scheduledTime == null + ? _pickAndUploadFile + : () => _pickAndUploadFile(scheduledTime: scheduledTime)), + onShareLocation: _encryptionEnabled + ? () => _refuseUnencrypted('Геолокацию') + : _shareLocation, + onCreatePoll: _encryptionEnabled + ? () => _refuseUnencrypted('Опросы') + : _createPoll, + onSendContact: _encryptionEnabled + ? (_) => _refuseUnencrypted('Контакты') + : _sendContact, ); if (!mounted || !hadKeyboard) return; _messageFocusNode.requestFocus(); @@ -4788,6 +6304,7 @@ class _ChatScreenState extends State Future _sendPhotos(List picked, String caption) async { if (_myId == 0) return; + if (_encryptionEnabled) return _sendEncryptedPhotos(picked, caption); final videos = picked.where((ph) => ph.item.isVideo).toList(); final photos = picked.where((ph) => !ph.item.isVideo).toList(); if (photos.isEmpty && videos.isEmpty) return; @@ -4798,7 +6315,7 @@ class _ChatScreenState extends State } if (photos.isEmpty) return; - final files = []; + final jobs = <({File file, GalleryItem? item})>[]; final attachments = []; for (final photo in photos) { final edited = photo.editedFile; @@ -4808,88 +6325,41 @@ class _ChatScreenState extends State final dim = edited != null ? await imageFileDimensions(edited) : await photo.item.dimensions(); - files.add(file); + jobs.add((file: file, item: edited == null ? photo.item : null)); attachments.add( PhotoAttachment(localPath: file.path, width: dim?.$1, height: dim?.$2), ); } - if (files.isEmpty || !mounted) return; + if (jobs.isEmpty || !mounted) return; final tempId = _nextTempId(); - final now = DateTime.now().millisecondsSinceEpoch; - final progress = ValueNotifier>( - List.filled(files.length, 0), + final placeholder = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: caption.isEmpty ? null : caption, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sending', + attachments: attachments, ); - _photoUploadProgress[tempId] = progress; - _messages.add( - CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: caption.isEmpty ? null : caption, - time: now, - status: 'sending', - attachments: attachments, - ), - ); + _messages.add(placeholder); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); - try { - final tokens = await Future.wait( - List.generate( - files.length, - (i) => _uploadOnePhoto(files[i], i, progress), - ), - ); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - if (tokens.any((t) => t == null)) { - _failPhotoMessage(tempId); - return; - } - - progress.value = List.filled(files.length, 1); - - final serverMsg = await messagesModule.sendPhotoMessage( - widget.chatId, - tokens.cast(), - caption: caption.isEmpty ? null : caption, - ); - if (!mounted) { - _disposePhotoProgress(tempId); - return; - } - if (serverMsg == null) { - _failPhotoMessage(tempId); - return; - } - - final real = CachedMessage.fromPushPayload( - _myId, - widget.chatId, - serverMsg, - ); - final idx = _messages.indexWhere((m) => m.id == tempId); - if (idx != -1) { - _messages[idx] = real; - _bumpMessages(); - unawaited(_persistOutgoing(real)); - } - _disposePhotoProgress(tempId); - } catch (e) { - if (mounted) { - _failPhotoMessage(tempId); - } else { - _disposePhotoProgress(tempId); - } - } + unawaited( + UploadService.instance.sendPhotos( + accountId: _myId, + chatId: widget.chatId, + tempId: tempId, + jobs: jobs, + caption: caption, + placeholder: placeholder, + ), + ); } Future _sendVideo( @@ -4904,103 +6374,192 @@ class _ChatScreenState extends State await video.item.originFile(); if (file == null || !mounted) return; - final scheduled = scheduledTime != null; - final durationMs = video.item.duration?.inMilliseconds; + final edited = video.editedFile; + var durationMs = video.item.duration?.inMilliseconds; + var dims = await video.item.dimensions(); + Uint8List? thumbBytes; + if (edited != null) { + final info = await VideoTranscoder.probe(edited.path); + if (info != null) { + if (info.durationMs > 0) durationMs = info.durationMs; + if (info.width > 0 && info.height > 0) dims = (info.width, info.height); + } + final frames = await VideoTranscoder.frames(edited.path, const [ + 0, + ], size: 512); + if (frames.isNotEmpty) thumbBytes = frames.first; + } + if (durationMs == null && DesktopVideoProbe.supported) { + durationMs = (await DesktopVideoProbe.duration( + file.path, + ))?.inMilliseconds; + } + if (thumbBytes == null) { + try { + thumbBytes = await video.item.thumbnail(512); + } catch (_) {} + } + if (!mounted) return; + final thumbData = thumbBytes == null || thumbBytes.isEmpty + ? null + : 'data:image/jpeg;base64,${base64Encode(thumbBytes)}'; - String? tempId; - ValueNotifier>? progress; - if (scheduled) { + final tempId = _nextTempId(); + CachedMessage? placeholder; + + if (scheduledTime != null) { showCustomNotification(context, 'Загрузка…'); } else { - tempId = _nextTempId(); - progress = ValueNotifier>(const [0]); - _photoUploadProgress[tempId] = progress; - _messages.add( - CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: caption.isEmpty ? null : caption, - time: DateTime.now().millisecondsSinceEpoch, - status: 'sending', - attachments: [VideoAttachment(duration: durationMs)], - ), + placeholder = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: caption.isEmpty ? null : caption, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sending', + attachments: [ + VideoAttachment( + duration: durationMs, + localPath: file.path, + previewData: thumbData, + width: dims?.$1, + height: dims?.$2, + ), + ], ); + _messages.add(placeholder); _lastSentId = tempId; _bumpMessages(); Haptics.send(); _scrollToBottom(); } - final progressNotifier = progress; - try { - final info = await messagesModule.requestVideoUploadUrl(); - if (info == null || info.url.isEmpty) throw Exception('no_url'); - - final ok = await fileUploader.uploadVideoFile( - Uri.parse(info.url), - file, - onProgress: progressNotifier == null - ? null - : (sent, total) { - if (total > 0) { - progressNotifier.value = [(sent / total).clamp(0.0, 1.0)]; - } - }, - ); - if (!ok) throw Exception('upload_failed'); - if (!mounted) { - if (tempId != null) _disposePhotoProgress(tempId); - return; - } - - final serverMsg = await messagesModule.sendVideoMessage( - widget.chatId, - info.token, - caption: caption.isEmpty ? null : caption, + unawaited( + UploadService.instance.sendVideo( + accountId: _myId, + chatId: widget.chatId, + tempId: tempId, + file: file, + caption: caption, + placeholder: placeholder, scheduledTime: scheduledTime, - ); - if (!mounted) { - if (tempId != null) _disposePhotoProgress(tempId); - return; - } - if (serverMsg == null) throw Exception('send_failed'); + ), + ); + } - if (scheduled) { + void _onUploadEvent(UploadJobEvent event) { + if (!mounted || event.chatId != widget.chatId) return; + _syncUploadStatus(); + if (event is UploadJobDone) { + if (event.scheduled) { Haptics.send(); _markHasScheduled(); + final at = event.scheduledTime; showCustomNotification( context, - 'Запланировано на ' - '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}', + at == null + ? 'Запланировано' + : 'Запланировано на ' + '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(at))}', ); - } else { - final real = CachedMessage.fromPushPayload( - _myId, - widget.chatId, - serverMsg, - ); - final idx = _messages.indexWhere((m) => m.id == tempId); - if (idx != -1) { - _messages[idx] = real; - _bumpMessages(); - unawaited(_persistOutgoing(real, removeId: tempId)); - } - _disposePhotoProgress(tempId!); - } - } catch (_) { - if (!mounted) { - if (tempId != null) _disposePhotoProgress(tempId); return; } - if (scheduled) { + final real = event.message; + if (real == null) return; + final idx = _messages.indexWhere((m) => m.id == event.tempId); + if (idx != -1) { + _messages[idx] = real; + _bumpMessages(); + } + } else if (event is UploadJobFailed) { + if (event.scheduled) { Haptics.error(); - showCustomNotification(context, 'Не удалось запланировать видео'); - } else { - _failPhotoMessage(tempId!); + showCustomNotification(context, 'Не удалось запланировать'); + return; + } + _failPhotoMessage(event.tempId); + final text = _uploadFailureText(event.kind, event.reason); + if (text != null) showCustomNotification(context, text); + } + } + + String? _uploadFailureText(UploadKind kind, String reason) { + final detail = switch (reason) { + 'no_upload_url' => 'сервер не выдал ссылку', + 'upload_failed' => 'загрузка отклонена', + 'send_failed' => 'сервер не принял сообщение', + _ => reason, + }; + return switch (kind) { + UploadKind.file => 'Ошибка: $reason', + UploadKind.videoNote => 'Кружок не отправлен: $detail', + UploadKind.voice => 'Голосовое не отправлено: $detail', + UploadKind.photo || UploadKind.video => null, + }; + } + + 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() { + _syncUploadStatus(); + final service = UploadService.instance; + var changed = false; + + for (var i = _messages.length - 1; i >= 0; i--) { + final msg = _messages[i]; + if (!isSendingStatus(msg.status)) continue; + final done = service.completedFor(msg.id); + if (done != null) { + if (done.id != msg.id && _messages.any((m) => m.id == done.id)) { + _messages.removeAt(i); + } else { + _messages[i] = done; + } + changed = true; + continue; + } + if (service.didFail(msg.id)) { + _messages[i] = msg.copyWith(status: 'error'); + changed = true; } } + + for (final msg in service.pendingFor(widget.chatId)) { + if (_messages.any((m) => m.id == msg.id)) continue; + _messages.add(msg); + changed = true; + } + + if (changed) _bumpMessages(); } Future _sendScheduledPhotos( @@ -5019,58 +6578,28 @@ class _ChatScreenState extends State } if (photos.isEmpty) return; - final files = []; + final jobs = <({File file, GalleryItem? item})>[]; for (final photo in photos) { final edited = photo.editedFile; final file = edited ?? photo.item.localFile ?? await photo.item.originFile(); - if (file != null) files.add(file); + if (file != null) { + jobs.add((file: file, item: edited == null ? photo.item : null)); + } } - if (files.isEmpty || !mounted) return; + if (jobs.isEmpty || !mounted) return; showCustomNotification(context, 'Загрузка…'); - final progress = ValueNotifier>( - List.filled(files.length, 0), - ); - try { - final tokens = await Future.wait( - List.generate( - files.length, - (i) => _uploadOnePhoto(files[i], i, progress), - ), - ); - if (!mounted) return; - if (tokens.any((t) => t == null)) { - showCustomNotification(context, 'Не удалось загрузить фото'); - return; - } - - final result = await messagesModule.sendPhotoMessage( - widget.chatId, - tokens.cast(), - caption: caption.isEmpty ? null : caption, + unawaited( + UploadService.instance.sendPhotos( + accountId: _myId, + chatId: widget.chatId, + tempId: _nextTempId(), + jobs: jobs, + caption: caption, scheduledTime: scheduledTime, - ); - if (!mounted) return; - if (result != null) { - Haptics.send(); - _markHasScheduled(); - showCustomNotification( - context, - 'Запланировано на ' - '${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}', - ); - } else { - showCustomNotification(context, 'Не удалось запланировать'); - } - } catch (_) { - if (mounted) { - Haptics.error(); - showCustomNotification(context, 'Ошибка при загрузке'); - } - } finally { - progress.dispose(); - } + ), + ); } Future _sendAttachMessage( @@ -5129,13 +6658,12 @@ class _ChatScreenState extends State } final keyboard = MediaQuery.viewInsetsOf(context).bottom; _keyboardBeforeStickers = keyboard > 120 || _messageFocusNode.hasFocus; - if (keyboard > 120) _stickers.panelHeight = keyboard; + if (keyboard > 120) _stickers.setBaseHeight(keyboard); FocusManager.instance.primaryFocus?.unfocus(); _stickers.showPanel.value = true; } Future _sendSticker(StickerItem sticker) async { - _stickers.hide(); await _sendAttachMessage([ StickerAttachment( stickerId: sticker.id.toString(), @@ -5191,6 +6719,22 @@ class _ChatScreenState extends State } } + Future _sendContact(CachedContact contact) async { + final last = contact.lastName; + final fullName = (last != null && last.isNotEmpty) + ? '${contact.firstName} $last' + : contact.firstName; + await _sendAttachMessage([ + ContactAttachment( + contactId: contact.id, + firstName: contact.firstName, + lastName: last, + name: fullName, + photoUrl: contact.baseUrl, + ), + ], () => messagesModule.sendContactMessage(widget.chatId, contact.id)); + } + Future _createPoll() async { final draft = await showCreatePollSheet(context); if (draft == null || !mounted) return; @@ -5206,186 +6750,117 @@ class _ChatScreenState extends State ); } - Future _uploadOnePhoto( - File file, - int index, - ValueNotifier> progress, - ) async { - final url = await messagesModule.requestPhotoUploadUrl(); - if (url == null || url.isEmpty) return null; - return fileUploader.uploadPhoto( - Uri.parse(url), - file, - filename: _photoFilename(file), - onProgress: (sent, total) { - if (total <= 0) return; - final next = List.from(progress.value); - if (index < next.length) { - next[index] = (sent / total).clamp(0.0, 1.0); - 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 _failPhotoMessage(String tempId) { final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { - final old = _messages[idx]; - _messages[idx] = CachedMessage( - id: old.id, - accountId: old.accountId, - chatId: old.chatId, - senderId: old.senderId, - text: old.text, - time: old.time, - status: 'error', - attachments: old.attachments, - ); + _messages[idx] = _messages[idx].copyWith(status: 'error'); _bumpMessages(); } - _disposePhotoProgress(tempId); Haptics.error(); } - void _disposePhotoProgress(String tempId) { - _photoUploadProgress.remove(tempId)?.dispose(); + void _refuseUnencrypted(String what) { + if (!mounted) return; + _showAttachmentPanel.value = false; + showCustomNotification(context, '$what пока нельзя зашифровать'); + } + + Future _sendEncryptedPhotos( + List picked, + String caption, + ) async { + final photos = picked.where((ph) => !ph.item.isVideo).toList(); + if (photos.length != picked.length && mounted) { + showCustomNotification(context, 'Видео пока нельзя зашифровать'); + } + if (photos.isEmpty) return; + + for (final photo in photos) { + final source = + photo.editedFile ?? + photo.item.localFile ?? + await photo.item.originFile(); + if (source == null || !mounted) continue; + + _showAttachmentPanel.value = false; + _uploadStatus.value = const UploadStatus(active: true); + final stamp = DateTime.now().microsecondsSinceEpoch.toString(); + final prepared = await prepareEncryptedPhoto( + accountId: _myId, + chatId: widget.chatId, + source: source, + stamp: stamp, + ); + if (!mounted) return; + if (!prepared.isOk) { + _uploadStatus.value = const UploadStatus(); + showCustomNotification( + context, + prepared.failure == CryptoFailure.noKey + ? 'Не задан ключ шифрования' + : 'Не удалось зашифровать фото', + ); + return; + } + + final encrypted = prepared.file!; + await _uploadAsFile( + source: encrypted, + filename: 'photo_$stamp$kEncryptedPhotoExtension', + size: await encrypted.length(), + ); + if (!mounted) return; + } + + if (caption.isNotEmpty) { + final wire = await _encryptOutgoing(caption); + if (wire != null && mounted) { + await messagesModule.sendMessage(_myId, widget.chatId, wire); + } + } } Future _pickAndUploadFile({int? scheduledTime}) async { final result = await FilePicker.platform.pickFiles(); if (result == null || result.files.isEmpty) return; - final file = result.files.first; - if (file.path == null) return; + final picked = result.files.first; + if (picked.path == null) return; + await _uploadAsFile( + source: File(picked.path!), + filename: picked.name, + size: picked.size, + scheduledTime: scheduledTime, + ); + } + + Future _uploadAsFile({ + required File source, + required String filename, + required int size, + int? scheduledTime, + }) async { + 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), + : _addOptimisticMediaMessage( + FileAttachment(name: filename, size: size), ); - 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: File(file.path!), - 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, - ); - 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): - 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 { - _updateFileMessageStatus( - tempId!, - 'sent', - attachment: FileAttachment( - fileId: fileId, - fileToken: token, - name: file.name, - size: file.size, - ), - ); - } - case UploadError(:final message): - stopNotif(); - showCustomNotification(context, 'Ошибка: $message'); - if (tempId != null) _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') { - _updateFileMessageStatus(tempId, 'error'); - } - } - _uploadStatus.value = const UploadStatus(); - _uploadSub = null; - }, - onError: (Object e) { - if (!mounted) return; - stopNotif(); - showCustomNotification(context, 'Ошибка: $e'); - if (tempId != null) _updateFileMessageStatus(tempId, 'error'); - _uploadStatus.value = const UploadStatus(); - _uploadSub = null; - }, - ); + 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; } } @@ -5446,6 +6921,12 @@ class _SwipeToReplyState extends State<_SwipeToReply> void _onDragEnd(DragEndDetails d) { if (_triggered) widget.onReply(); + _settle(); + } + + void _onDragCancel() => _settle(); + + void _settle() { _triggered = false; _springFrom = _dragX; _springBack.forward(from: 0); @@ -5455,10 +6936,20 @@ class _SwipeToReplyState extends State<_SwipeToReply> Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0); - return GestureDetector( + return RawGestureDetector( behavior: HitTestBehavior.opaque, - onHorizontalDragUpdate: _onDragUpdate, - onHorizontalDragEnd: _onDragEnd, + gestures: { + LeftwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LeftwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _onDragCancel; + }, + ), + }, child: Stack( alignment: Alignment.centerRight, children: [ @@ -5492,6 +6983,10 @@ class _PinnedMessageBanner extends StatelessWidget { final VoidCallback onTap; final VoidCallback? onUnpin; final bool floating; + final bool frosted; + final bool liquid; + final BorderRadius? borderRadius; + final BackdropKey? backdropKey; const _PinnedMessageBanner({ required this.text, @@ -5499,16 +6994,24 @@ class _PinnedMessageBanner extends StatelessWidget { required this.onTap, this.onUnpin, this.floating = false, + this.borderRadius, + this.frosted = false, + this.liquid = false, + this.backdropKey, }); + BorderRadius get _radius => borderRadius ?? BorderRadius.circular(16); + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final content = Material( - color: floating + color: frosted + ? AppFrost.glassTint(cs) + : floating ? cs.surfaceContainerHigh.withValues(alpha: 0.92) : cs.surfaceContainerHigh, - borderRadius: floating ? BorderRadius.circular(16) : null, + borderRadius: floating ? _radius : null, clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, @@ -5562,16 +7065,22 @@ class _PinnedMessageBanner extends StatelessWidget { ), ); + final bottomBorder = Border(bottom: AppFrost.hairline(cs)); + + if (frosted) { + return GlassSurface( + liquid: liquid, + borderRadius: floating ? BorderRadius.circular(16) : BorderRadius.zero, + frostTint: Colors.transparent, + border: floating ? null : bottomBorder, + backdropKey: backdropKey, + child: content, + ); + } + if (!floating) { return DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - width: 0.5, - ), - ), - ), + decoration: BoxDecoration(border: bottomBorder), child: content, ); } @@ -5664,13 +7173,18 @@ class _SelectableMessageRow extends StatefulWidget { final bool Function() isSelectionActive; final VoidCallback onToggleSelection; final VoidCallback onEnterSelection; + final void Function(Offset globalPosition) onStartTextSelection; + final void Function(Offset? globalPosition) onDragTextSelection; final VoidCallback onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; + final bool allowCopy; final VoidCallback? onMarkUnread; final VoidCallback? onPin; final bool Function() isPinned; + final Future> Function()? loadReadBy; + final void Function(int userId)? onReaderTap; final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; final void Function(String emoji)? onReact; @@ -5685,13 +7199,18 @@ class _SelectableMessageRow extends StatefulWidget { required this.isSelectionActive, required this.onToggleSelection, required this.onEnterSelection, + required this.onStartTextSelection, + required this.onDragTextSelection, required this.onDelete, this.onEdit, this.onReply, this.onForward, + this.allowCopy = true, this.onMarkUnread, this.onPin, required this.isPinned, + this.loadReadBy, + this.onReaderTap, this.loadReportReasons, this.onReport, this.onReact, @@ -5745,16 +7264,20 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { tapPoint: _lastTapDown ?? rect.center, isMe: widget.isMe, messageText: widget.message.text, + copyText: widget.message.selectableText, controller: controller, style: AppMessageActionsStyle.current.value, interaction: MessageActionsInteraction.tap, editHistory: widget.message.editHistory, + loadReadBy: widget.loadReadBy, + onReaderTap: widget.onReaderTap, loadReportReasons: widget.loadReportReasons, onReport: widget.onReport, onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, onForward: widget.onForward, + allowCopy: widget.allowCopy, onMarkUnread: widget.onMarkUnread, onPin: widget.onPin, isPinned: _isPinnedNow(), @@ -5811,16 +7334,20 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { tapPoint: details.globalPosition, isMe: widget.isMe, messageText: widget.message.text, + copyText: widget.message.selectableText, controller: controller, style: MessageActionsStyle.list, interaction: MessageActionsInteraction.click, editHistory: widget.message.editHistory, + loadReadBy: widget.loadReadBy, + onReaderTap: widget.onReaderTap, loadReportReasons: widget.loadReportReasons, onReport: widget.onReport, onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, onForward: widget.onForward, + allowCopy: widget.allowCopy, onMarkUnread: widget.onMarkUnread, onPin: widget.onPin, isPinned: _isPinnedNow(), @@ -5847,11 +7374,32 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { }); } - void _handleLongPress() { - if (widget.isSelectionActive()) { - widget.onToggleSelection(); - } else { + bool _textSelectionPress = false; + + void _handleLongPressMove(Offset globalPosition) { + if (!_textSelectionPress) return; + widget.onDragTextSelection(globalPosition); + } + + void _handleLongPressEnd() { + if (!_textSelectionPress) return; + _textSelectionPress = false; + widget.onDragTextSelection(null); + } + + void _handleLongPressStart(Offset globalPosition) { + _textSelectionPress = false; + if (!widget.isSelectionActive()) { widget.onEnterSelection(); + return; + } + final selected = widget.selectedIds.value.contains(widget.message.id); + final hasText = widget.message.selectableText != null; + if (selected && hasText && !widget.message.isControl) { + _textSelectionPress = true; + widget.onStartTextSelection(globalPosition); + } else { + widget.onToggleSelection(); } } @@ -5896,7 +7444,11 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { behavior: HitTestBehavior.opaque, onTapDown: (d) => _lastTapDown = d.globalPosition, onTap: _handleTap, - onLongPress: _handleLongPress, + onLongPressStart: (d) => _handleLongPressStart(d.globalPosition), + onLongPressMoveUpdate: (d) => + _handleLongPressMove(d.globalPosition), + onLongPressEnd: (_) => _handleLongPressEnd(), + onLongPressCancel: _handleLongPressEnd, onSecondaryTapDown: active ? null : _onSecondaryTapDown, child: ColoredBox( color: isSelected @@ -6082,3 +7634,94 @@ class _ChatMessageListState extends State<_ChatMessageList> { ); } } + +class _EditMessageSheet extends StatefulWidget { + final String text; + final Iterable formatRanges; + final Widget Function( + RichMessageController controller, + BuildContext context, + EditableTextState editableState, + ) + contextMenuBuilder; + + const _EditMessageSheet({ + required this.text, + required this.formatRanges, + required this.contextMenuBuilder, + }); + + @override + State<_EditMessageSheet> createState() => _EditMessageSheetState(); +} + +class _EditMessageSheetState extends State<_EditMessageSheet> { + late final RichMessageController _controller; + + @override + void initState() { + super.initState(); + _controller = RichMessageController(text: widget.text) + ..setFormatRanges(widget.formatRanges); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Изменить сообщение', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: displayFontOf(context), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + minLines: 1, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + style: TextStyle(color: cs.onSurface), + contextMenuBuilder: (ctx, state) => + widget.contextMenuBuilder(_controller, ctx, state), + decoration: InputDecoration( + hintText: 'Текст сообщения', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => + Navigator.of(context).pop(_controller.buildContent()), + child: const Text('Сохранить'), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_wallpaper_preview_screen.dart b/lib/frontend/screens/chats/chat_wallpaper_preview_screen.dart index ede2389..aaa342c 100644 --- a/lib/frontend/screens/chats/chat_wallpaper_preview_screen.dart +++ b/lib/frontend/screens/chats/chat_wallpaper_preview_screen.dart @@ -6,6 +6,8 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/storage/chat_wallpaper_store.dart'; import 'package:komet/frontend/widgets/chat_wallpaper_view.dart'; +import '../../../core/config/app_frost.dart'; +import '../../../core/config/app_fonts.dart'; class ChatWallpaperPreviewScreen extends StatefulWidget { final Uint8List imageBytes; @@ -100,13 +102,13 @@ class _ChatWallpaperPreviewScreenState icon: const Icon(Symbols.arrow_back, color: Colors.white), onPressed: () => Navigator.pop(context), ), - const Text( + Text( 'Обои', style: TextStyle( color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ], @@ -158,7 +160,10 @@ class _Frosted extends StatelessWidget { return ClipRRect( borderRadius: BorderRadius.circular(radius), child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24), + filter: ui.ImageFilter.blur( + sigmaX: AppFrost.panelSigma, + sigmaY: AppFrost.panelSigma, + ), child: DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.14), @@ -258,7 +263,7 @@ class _DimLabel extends StatelessWidget { color: color, fontSize: 16, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -279,7 +284,8 @@ class _RevealClipper extends CustomClipper { const _RevealClipper(this.fraction); @override - Rect getClip(Size size) => Rect.fromLTWH(0, 0, size.width * fraction, size.height); + Rect getClip(Size size) => + Rect.fromLTWH(0, 0, size.width * fraction, size.height); @override bool shouldReclip(_RevealClipper oldClipper) => @@ -324,11 +330,11 @@ class _ToggleChip extends StatelessWidget { const SizedBox(width: 10), Text( label, - style: const TextStyle( + style: TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ], @@ -350,7 +356,7 @@ class _ApplyButton extends StatelessWidget { onTap: onTap, child: _Frosted( radius: 26, - child: const SizedBox( + child: SizedBox( height: 52, child: Center( child: Text( @@ -359,7 +365,7 @@ class _ApplyButton extends StatelessWidget { color: Colors.white, fontSize: 17, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), @@ -384,6 +390,7 @@ class _SamplePreview extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _bubble( + context, text: 'Как насчёт новых обоев для этого чата?', color: cs.surfaceContainerHighest.withValues(alpha: 0.92), textColor: cs.onSurface, @@ -391,6 +398,7 @@ class _SamplePreview extends StatelessWidget { ), const SizedBox(height: 8), _bubble( + context, text: 'Отличная идея.', color: cs.primary, textColor: cs.onPrimary, @@ -402,7 +410,8 @@ class _SamplePreview extends StatelessWidget { ); } - Widget _bubble({ + Widget _bubble( + BuildContext context, { required String text, required Color color, required Color textColor, @@ -423,7 +432,7 @@ class _SamplePreview extends StatelessWidget { style: TextStyle( color: textColor, fontSize: 15, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), diff --git a/lib/frontend/screens/chats/create_channel_flow.dart b/lib/frontend/screens/chats/create_channel_flow.dart new file mode 100644 index 0000000..c4e7438 --- /dev/null +++ b/lib/frontend/screens/chats/create_channel_flow.dart @@ -0,0 +1,275 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/chats.dart'; +import '../../../core/utils/image_utils.dart'; +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; +import '../../widgets/swipe_route.dart'; +import 'chat_screen.dart'; + +Future showCreateChannelFlow(BuildContext context) async { + final cs = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => const _CreateChannelFlow(), + ); +} + +class _CreateChannelFlow extends StatefulWidget { + const _CreateChannelFlow(); + + @override + State<_CreateChannelFlow> createState() => _CreateChannelFlowState(); +} + +class _CreateChannelFlowState extends State<_CreateChannelFlow> { + final TextEditingController _title = TextEditingController(); + File? _avatar; + bool _creating = false; + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + Future _pickAvatar() async { + if (_creating) return; + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result == null || result.files.isEmpty) return; + final path = result.files.first.path; + if (path == null) return; + final file = File(path); + final size = await file.length(); + if (size > kMaxAvatarBytes) { + if (!mounted) return; + showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); + return; + } + if (!mounted) return; + setState(() => _avatar = file); + } + + Future _create() async { + final title = _title.text.trim(); + if (title.isEmpty || _creating) return; + setState(() => _creating = true); + final navigator = Navigator.of(context, rootNavigator: true); + try { + final chat = await chats.createChannel(api, title: title); + if (!mounted) return; + if (chat == null) { + showCustomNotification(context, 'Не удалось создать канал'); + setState(() => _creating = false); + return; + } + + if (_avatar != null) { + final url = await chats.requestChatPhotoUploadUrl(api); + if (url != null) { + final bytes = await compressAvatar(await _avatar!.readAsBytes()); + if (bytes == null) { + if (mounted) { + showCustomNotification(context, 'Не удалось обработать аватарку'); + } + } else { + final token = await fileUploader.uploadImage( + Uri.parse(url), + bytes, + filename: 'avatar.jpg', + ); + if (token != null) { + await chats.setChatPhoto(api, chatId: chat.id, photoToken: token); + } else if (mounted) { + showCustomNotification(context, 'Не удалось загрузить аватарку'); + } + } + } + } + + if (!mounted) return; + navigator.pop(); + navigator.push( + SwipeRoute( + builder: (_) => ChatScreen( + chatId: chat.id, + name: chat.title ?? title, + imageUrl: chat.iconUrl ?? '', + chatType: chat.type, + ), + ), + ); + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _creating = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final viewInsets = MediaQuery.of(context).viewInsets; + final canCreate = _title.text.trim().isNotEmpty && !_creating; + + return Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + 'Создать канал', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: _creating ? null : () => Navigator.pop(context), + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + GestureDetector( + onTap: _pickAvatar, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: _avatar != null + ? Image.file(_avatar!, fit: BoxFit.cover) + : Icon( + Symbols.add_a_photo, + color: cs.onSurfaceVariant, + size: 22, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _title, + onChanged: (_) => setState(() {}), + enabled: !_creating, + autofocus: true, + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + hintText: 'Название канала', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'В канале публикуете только вы, участники читают. Пригласить их можно после создания.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Expanded( + child: _PillButton( + label: 'Отменить', + filled: false, + onTap: _creating ? null : () => Navigator.pop(context), + cs: cs, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _PillButton( + label: _creating ? 'Создаю...' : 'Создать', + filled: true, + onTap: canCreate ? _create : null, + cs: cs, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _PillButton extends StatelessWidget { + final String label; + final bool filled; + final VoidCallback? onTap; + final ColorScheme cs; + const _PillButton({ + required this.label, + required this.filled, + required this.onTap, + required this.cs, + }); + + @override + Widget build(BuildContext context) { + final disabled = onTap == null; + return GestureDetector( + onTap: onTap, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled + ? (disabled ? cs.primary.withValues(alpha: 0.4) : cs.primary) + : cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(22), + ), + child: Text( + label, + style: TextStyle( + color: filled + ? cs.onPrimary + : (disabled + ? cs.onSurface.withValues(alpha: 0.4) + : cs.onSurface), + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index b7566fd..82e3a81 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -13,6 +13,7 @@ import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_route.dart'; import 'chat_screen.dart'; @@ -295,7 +296,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { child: _loading ? const Padding( padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator()), + child: Center(child: SmallSpinner(size: 36)), ) : ListView.builder( padding: EdgeInsets.zero, @@ -376,20 +377,18 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { child: Row( children: [ Expanded( - child: _SheetButton( + child: SheetButton( label: 'Отменить', filled: false, onTap: () => Navigator.pop(context), - cs: cs, ), ), const SizedBox(width: 12), Expanded( - child: _SheetButton( + child: SheetButton( label: 'Далее', filled: true, onTap: () => setState(() => _step = _Step.groupDetails), - cs: cs, ), ), ], @@ -481,20 +480,18 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { child: Row( children: [ Expanded( - child: _SheetButton( + child: SheetButton( label: 'Отменить', filled: false, onTap: _creating ? null : () => Navigator.pop(context), - cs: cs, ), ), const SizedBox(width: 12), Expanded( - child: _SheetButton( + child: SheetButton( label: _creating ? 'Создаю...' : 'Создать', filled: true, onTap: canCreate ? _create : null, - cs: cs, ), ), ], @@ -551,46 +548,3 @@ class _SelectedChip extends StatelessWidget { ); } } - -class _SheetButton extends StatelessWidget { - final String label; - final bool filled; - final VoidCallback? onTap; - final ColorScheme cs; - const _SheetButton({ - required this.label, - required this.filled, - required this.onTap, - required this.cs, - }); - - @override - Widget build(BuildContext context) { - final disabled = onTap == null; - return GestureDetector( - onTap: onTap, - child: Container( - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: filled - ? (disabled ? cs.primary.withValues(alpha: 0.4) : cs.primary) - : cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(22), - ), - child: Text( - label, - style: TextStyle( - color: filled - ? cs.onPrimary - : (disabled - ? cs.onSurface.withValues(alpha: 0.4) - : cs.onSurface), - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - ); - } -} diff --git a/lib/frontend/screens/chats/folder_action_sheet.dart b/lib/frontend/screens/chats/folder_action_sheet.dart new file mode 100644 index 0000000..2bc72a6 --- /dev/null +++ b/lib/frontend/screens/chats/folder_action_sheet.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/models/chat_folder.dart'; +import '../../../backend/modules/folders.dart'; +import '../../../core/protocol/packet.dart'; +import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../main.dart'; +import '../../widgets/confirm_dialog.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; +import 'folder_edit_sheet.dart'; + +enum _FolderAction { edit, create, delete } + +Future showFolderActionSheet( + BuildContext context, { + required ChatFolder folder, +}) async { + final cs = Theme.of(context).colorScheme; + final isAllChats = folder.id == FoldersModule.allChatsFolderId; + final canEdit = !isAllChats && (folder.canEditTitle || folder.canEditFilters); + final canDelete = !isAllChats && folder.canDelete; + + final action = await showModalBottomSheet<_FolderAction>( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SheetGrabber(), + Padding( + padding: const EdgeInsets.fromLTRB(24, 6, 24, 10), + child: Text( + folder.title, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (canEdit) + _ActionRow( + icon: Symbols.edit, + label: 'Изменить', + onTap: () => Navigator.pop(ctx, _FolderAction.edit), + ), + _ActionRow( + icon: Symbols.create_new_folder, + label: 'Новая папка', + onTap: () => Navigator.pop(ctx, _FolderAction.create), + ), + if (canDelete) + _ActionRow( + icon: Symbols.delete, + label: 'Удалить', + color: cs.error, + onTap: () => Navigator.pop(ctx, _FolderAction.delete), + ), + const SizedBox(height: 12), + ], + ), + ), + ); + + if (action == null || !context.mounted) return; + + switch (action) { + case _FolderAction.edit: + await showFolderEditSheet(context, folder: folder); + case _FolderAction.create: + await showFolderEditSheet(context); + case _FolderAction.delete: + await _confirmDelete(context, folder); + } +} + +Future _confirmDelete(BuildContext context, ChatFolder folder) async { + final confirmed = await showConfirmDialog( + context, + message: 'Удалить папку «${folder.title}»? Чаты останутся на месте.', + confirmLabel: 'Удалить', + destructive: true, + ); + if (!confirmed || !context.mounted) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null || !context.mounted) return; + + try { + await FoldersModule.deleteFolders(api, accountId, [folder.id]); + Haptics.success(); + } catch (e) { + Haptics.error(); + if (!context.mounted) return; + showCustomNotification( + context, + e is PacketError ? e.message : 'Не удалось удалить папку', + ); + } +} + +class _ActionRow extends StatelessWidget { + final IconData icon; + final String label; + final Color? color; + final VoidCallback onTap; + + const _ActionRow({ + required this.icon, + required this.label, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tint = color ?? cs.onSurface; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + children: [ + Icon(icon, color: tint, size: 22), + const SizedBox(width: 16), + Text( + label, + style: TextStyle( + color: tint, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/folder_edit_sheet.dart b/lib/frontend/screens/chats/folder_edit_sheet.dart new file mode 100644 index 0000000..a00c89c --- /dev/null +++ b/lib/frontend/screens/chats/folder_edit_sheet.dart @@ -0,0 +1,637 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/models/chat_folder.dart'; +import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/cloud_storage.dart'; +import '../../../backend/modules/folders.dart'; +import '../../../backend/modules/messages.dart'; +import '../../../core/protocol/packet.dart'; +import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../main.dart'; +import '../../widgets/confirm_dialog.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; + +typedef _ChatType = ({int filter, IconData icon, String label}); + +const List<_ChatType> _chatTypes = [ + (filter: FolderFilter.contact, icon: Symbols.person, label: 'Контакты'), + ( + filter: FolderFilter.notContact, + icon: Symbols.person_off, + label: 'Не в контактах', + ), + (filter: FolderFilter.chat, icon: Symbols.group, label: 'Группы'), + (filter: FolderFilter.channel, icon: Symbols.campaign, label: 'Каналы'), + (filter: FolderFilter.bot, icon: Symbols.smart_toy, label: 'Боты'), +]; + +const Set _editableFilters = { + FolderFilter.contact, + FolderFilter.notContact, + FolderFilter.chat, + FolderFilter.channel, + FolderFilter.bot, + FolderFilter.unread, + FolderFilter.notMuted, +}; + +Future showFolderEditSheet( + BuildContext context, { + ChatFolder? folder, +}) async { + final cs = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => _FolderEditSheet(folder: folder), + ); +} + +class _FolderEditSheet extends StatefulWidget { + final ChatFolder? folder; + + const _FolderEditSheet({this.folder}); + + @override + State<_FolderEditSheet> createState() => _FolderEditSheetState(); +} + +class _FolderEditSheetState extends State<_FolderEditSheet> { + final TextEditingController _title = TextEditingController(); + final TextEditingController _search = TextEditingController(); + + final Set _types = {}; + final Set _chatIds = {}; + List _preservedFilters = const []; + bool _onlyUnread = false; + bool _onlyNotMuted = false; + + List _chats = []; + int _myId = 0; + bool _loading = true; + bool _busy = false; + + bool get _isNew => widget.folder == null; + + @override + void initState() { + super.initState(); + final folder = widget.folder; + if (folder != null) { + _title.text = folder.title; + _types.addAll( + folder.filters.where((f) => _chatTypes.any((t) => t.filter == f)), + ); + _chatIds.addAll(folder.include); + _onlyUnread = folder.filters.contains(FolderFilter.unread); + _onlyNotMuted = folder.filters.contains(FolderFilter.notMuted); + _preservedFilters = folder.filters + .where((f) => !_editableFilters.contains(f)) + .toList(); + } + _load(); + } + + @override + void dispose() { + _title.dispose(); + _search.dispose(); + super.dispose(); + } + + Future _load() async { + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + if (mounted) setState(() => _loading = false); + return; + } + final list = await chats.getChats(accountId); + list.removeWhere(CloudStorageModule.isCloudStorageGroup); + if (!mounted) return; + setState(() { + _myId = accountId; + _chats = list; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + int _peerId(CachedChat chat) { + for (final entry in chat.participants.entries) { + if (entry.key != _myId) return entry.key; + } + return _myId; + } + + String _chatTitle(CachedChat chat) { + if (chat.id == 0) return 'Избранное'; + if (chat.type == 'DIALOG') { + return ContactCache.get(_peerId(chat)) ?? chat.title ?? 'Пользователь'; + } + return chat.title ?? 'Чат'; + } + + String? _chatAvatar(CachedChat chat) { + if (chat.type == 'DIALOG' && chat.id != 0) { + return ContactCache.getAvatar(_peerId(chat)) ?? chat.iconUrl; + } + return chat.iconUrl; + } + + List _buildFilters() => [ + ..._types, + if (_onlyUnread) FolderFilter.unread, + if (_onlyNotMuted) FolderFilter.notMuted, + ..._preservedFilters, + ]; + + bool get _canSubmit => + !_busy && + _title.text.trim().isNotEmpty && + (_types.isNotEmpty || _chatIds.isNotEmpty); + + Future _submit() async { + if (!_canSubmit) return; + if (_myId == 0) { + showCustomNotification(context, 'Нет активного аккаунта'); + return; + } + setState(() => _busy = true); + final navigator = Navigator.of(context); + try { + final title = _title.text.trim(); + final folder = widget.folder; + if (folder == null) { + await FoldersModule.createFolder( + api, + _myId, + title: title, + include: _chatIds.toList(), + filters: _buildFilters(), + ); + } else { + await FoldersModule.updateFolder( + api, + _myId, + folder, + title: title, + include: _chatIds.toList(), + filters: _buildFilters(), + ); + } + Haptics.success(); + if (mounted) navigator.pop(); + } catch (e) { + Haptics.error(); + if (!mounted) return; + setState(() => _busy = false); + showCustomNotification( + context, + e is PacketError ? e.message : 'Не удалось сохранить папку', + ); + } + } + + Future _delete() async { + final folder = widget.folder; + if (folder == null || _busy) return; + if (_myId == 0) { + showCustomNotification(context, 'Нет активного аккаунта'); + return; + } + final confirmed = await showConfirmDialog( + context, + message: 'Удалить папку «${folder.title}»? Чаты останутся на месте.', + confirmLabel: 'Удалить', + destructive: true, + ); + if (!confirmed || !mounted) return; + + setState(() => _busy = true); + final navigator = Navigator.of(context); + try { + await FoldersModule.deleteFolders(api, _myId, [folder.id]); + Haptics.success(); + if (mounted) navigator.pop(); + } catch (e) { + Haptics.error(); + if (!mounted) return; + setState(() => _busy = false); + showCustomNotification( + context, + e is PacketError ? e.message : 'Не удалось удалить папку', + ); + } + } + + void _clearSelection() { + setState(() { + _types.clear(); + _chatIds.clear(); + _onlyUnread = false; + _onlyNotMuted = false; + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final viewInsets = MediaQuery.of(context).viewInsets; + final query = _search.text.trim().toLowerCase(); + final types = query.isEmpty + ? _chatTypes + : _chatTypes + .where((t) => t.label.toLowerCase().contains(query)) + .toList(); + final visibleChats = query.isEmpty + ? _chats + : _chats + .where((c) => _chatTitle(c).toLowerCase().contains(query)) + .toList(); + + return Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(cs), + Flexible( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + physics: const BouncingScrollPhysics(), + children: [ + _buildTitleCard(cs), + const SizedBox(height: 12), + _buildPickerCard(cs, types, visibleChats), + if (widget.folder?.canEditFilters ?? true) ...[ + const SizedBox(height: 12), + _buildShowOnlyCard(cs), + ], + ], + ), + ), + _buildActions(cs), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(ColorScheme cs) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + _isNew ? 'Новая папка' : 'Изменение папки', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: _busy ? null : () => Navigator.pop(context), + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + ), + ], + ), + ); + + Widget _buildCard(ColorScheme cs, Widget child) => Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: child, + ); + + Widget _buildTitleCard(ColorScheme cs) { + final canEditTitle = widget.folder?.canEditTitle ?? true; + return _buildCard( + cs, + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _title, + enabled: canEditTitle && !_busy, + onChanged: (_) => setState(() {}), + maxLength: FoldersModule.titleMaxLength, + inputFormatters: [ + LengthLimitingTextInputFormatter( + FoldersModule.titleMaxLength, + ), + ], + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + hintText: 'Название папки', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + counterText: '', + ), + ), + ), + const SizedBox(width: 12), + Text( + '${_title.text.characters.length}/${FoldersModule.titleMaxLength}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ); + } + + Widget _buildPickerCard( + ColorScheme cs, + List<_ChatType> types, + List visibleChats, + ) { + final canEditFilters = widget.folder?.canEditFilters ?? true; + return _buildCard( + cs, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + child: TextField( + controller: _search, + onChanged: (_) => setState(() {}), + style: TextStyle(color: cs.onSurface, fontSize: 14), + decoration: InputDecoration( + hintText: 'Найти по имени', + hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + prefixIcon: Icon( + Symbols.search, + color: cs.onSurfaceVariant, + size: 20, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 48, + minHeight: 0, + ), + isDense: true, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + if (types.isNotEmpty && canEditFilters) ...[ + _buildSectionLabel(cs, 'ТИПЫ ЧАТОВ'), + for (final type in types) + _buildRow( + cs, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + shape: BoxShape.circle, + ), + child: Icon(type.icon, color: cs.onSurface, size: 20), + ), + title: type.label, + selected: _types.contains(type.filter), + onTap: () => setState(() { + if (!_types.remove(type.filter)) _types.add(type.filter); + Haptics.selection(); + }), + ), + ], + if (_loading) ...[ + _buildSectionLabel(cs, 'ЧАТЫ И КАНАЛЫ'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Center(child: SmallSpinner(size: 28)), + ), + ] else if (visibleChats.isNotEmpty) ...[ + _buildSectionLabel(cs, 'ЧАТЫ И КАНАЛЫ'), + for (final chat in visibleChats) + _buildRow( + cs, + leading: KometAvatar( + name: _chatTitle(chat), + size: 40, + imageUrl: _chatAvatar(chat), + ), + title: _chatTitle(chat), + subtitle: chat.id == 0 ? 'Сообщения себе' : null, + selected: _chatIds.contains(chat.id), + onTap: () => setState(() { + if (!_chatIds.remove(chat.id)) _chatIds.add(chat.id); + Haptics.selection(); + }), + ), + ], + if (!_loading && types.isEmpty && visibleChats.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + child: Text( + 'Ничего не найдено', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ), + ], + ), + ); + } + + Widget _buildSectionLabel(ColorScheme cs, String text) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), + child: Text( + text, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + ), + ), + ); + + Widget _buildRow( + ColorScheme cs, { + required Widget leading, + required String title, + String? subtitle, + required bool selected, + required VoidCallback onTap, + }) => InkWell( + onTap: _busy ? null : onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + leading, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.8), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + AnimatedScale( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutBack, + scale: selected ? 1 : 0, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: Icon(Symbols.check, color: cs.onPrimary, size: 16), + ), + ), + ], + ), + ), + ); + + Widget _buildShowOnlyCard(ColorScheme cs) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionLabel(cs, 'ПОКАЗЫВАТЬ ТОЛЬКО'), + _buildCard( + cs, + Column( + children: [ + _buildToggle( + cs, + icon: Symbols.notifications, + title: 'Чаты с уведомлениями', + value: _onlyNotMuted, + onChanged: (v) => setState(() => _onlyNotMuted = v), + ), + _buildToggle( + cs, + icon: Symbols.mark_chat_unread, + title: 'Непрочитанные чаты', + value: _onlyUnread, + onChanged: (v) => setState(() => _onlyUnread = v), + ), + ], + ), + ), + ], + ); + + Widget _buildToggle( + ColorScheme cs, { + required IconData icon, + required String title, + required bool value, + required ValueChanged onChanged, + }) => Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 8, 4), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + ), + Switch( + value: value, + onChanged: _busy + ? null + : (v) { + Haptics.selection(); + onChanged(v); + }, + ), + ], + ), + ); + + Widget _buildActions(ColorScheme cs) { + final folder = widget.folder; + final hasSelection = + _types.isNotEmpty || + _chatIds.isNotEmpty || + _onlyUnread || + _onlyNotMuted; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + Expanded( + child: folder == null + ? SheetButton( + label: 'Очистить выбор', + filled: false, + onTap: hasSelection && !_busy ? _clearSelection : null, + ) + : SheetButton( + label: 'Удалить папку', + filled: false, + color: cs.error, + onTap: folder.canDelete && !_busy ? _delete : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SheetButton( + label: _isNew ? 'Создать папку' : 'Сохранить', + filled: true, + onTap: _canSubmit ? _submit : null, + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/chats/group_invite_sheets.dart b/lib/frontend/screens/chats/group_invite_sheets.dart new file mode 100644 index 0000000..a1c0be0 --- /dev/null +++ b/lib/frontend/screens/chats/group_invite_sheets.dart @@ -0,0 +1,485 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/main.dart'; +import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/backend/modules/messages.dart' show ContactCache; +import 'package:komet/core/storage/app_database.dart'; +import 'package:komet/core/storage/token_storage.dart'; +import 'package:komet/frontend/screens/contacts/contact_sheet_common.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +class _Candidate { + final int id; + final String name; + final String? avatarUrl; + + const _Candidate({required this.id, required this.name, this.avatarUrl}); +} + +Future showAddMembersSheet( + BuildContext context, { + required int chatId, + required Set excludeIds, +}) { + return showBlurredCard( + context, + (host) => _AddMembersCard( + chatId: chatId, + excludeIds: excludeIds, + hostContext: host, + ), + ); +} + +class _AddMembersCard extends StatefulWidget { + final int chatId; + final Set excludeIds; + final BuildContext hostContext; + + const _AddMembersCard({ + required this.chatId, + required this.excludeIds, + required this.hostContext, + }); + + @override + State<_AddMembersCard> createState() => _AddMembersCardState(); +} + +class _AddMembersCardState extends State<_AddMembersCard> { + final TextEditingController _searchCtrl = TextEditingController(); + final Set _selected = {}; + List<_Candidate> _all = []; + String _query = ''; + bool _loading = true; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + if (mounted) setState(() => _loading = false); + return; + } + final byId = {}; + + final contacts = await ContactsModule.getContacts(accountId); + for (final c in contacts) { + if (c.id == accountId || widget.excludeIds.contains(c.id)) continue; + final name = [ + c.firstName, + c.lastName, + ].where((s) => s != null && s.trim().isNotEmpty).join(' ').trim(); + byId[c.id] = _Candidate( + id: c.id, + name: name.isEmpty ? '${c.id}' : name, + avatarUrl: c.baseUrl, + ); + } + + final dialogs = await AppDatabase.loadDialogChats(accountId); + for (final row in dialogs) { + final chat = CachedChat.fromDbRow(row); + for (final pid in chat.participants.keys) { + if (pid == accountId || + widget.excludeIds.contains(pid) || + byId.containsKey(pid)) { + continue; + } + final name = chat.title ?? ContactCache.get(pid) ?? '$pid'; + byId[pid] = _Candidate( + id: pid, + name: name, + avatarUrl: chat.iconUrl ?? ContactCache.getAvatar(pid), + ); + } + } + + final list = byId.values.toList() + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + if (mounted) { + setState(() { + _all = list; + _loading = false; + }); + } + } + + List<_Candidate> get _filtered { + final q = _query.trim().toLowerCase(); + if (q.isEmpty) return _all; + return _all.where((c) => c.name.toLowerCase().contains(q)).toList(); + } + + Future _submit() async { + if (_selected.isEmpty || _submitting) return; + setState(() => _submitting = true); + final ok = await chats.addMembers( + api, + chatId: widget.chatId, + userIds: _selected.toList(), + ); + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + if (ok) { + Navigator.of(context).pop(true); + if (widget.hostContext.mounted) { + showCustomNotification(widget.hostContext, l10n.chatInfoMembersAdded); + } + } else { + setState(() => _submitting = false); + showCustomNotification(context, l10n.chatInfoAddMembersError); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final width = MediaQuery.sizeOf(context).width; + final maxListHeight = MediaQuery.sizeOf(context).height * 0.5; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: Container( + width: width > 420 ? 380 : double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), + child: Row( + children: [ + Expanded( + child: Text( + l10n.chatInfoAddMember, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: TextField( + controller: _searchCtrl, + onChanged: (v) => setState(() => _query = v), + style: TextStyle(color: cs.onSurface, fontSize: 14), + decoration: InputDecoration( + isDense: true, + constraints: const BoxConstraints(maxWidth: 150), + prefixIcon: Icon( + Symbols.search, + size: 18, + color: cs.onSurfaceVariant, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 34, + ), + border: InputBorder.none, + hintText: l10n.chatInfoMembersSearchHint, + hintStyle: TextStyle( + color: cs.outline, + fontSize: 14, + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + ), + ), + ), + ], + ), + ), + Divider(height: 1, thickness: 0.5, color: cs.outlineVariant), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxListHeight), + child: _buildList(cs, l10n), + ), + Divider(height: 1, thickness: 0.5, color: cs.outlineVariant), + _buildAddButton(cs, l10n), + ], + ), + ), + ), + ), + ); + } + + Widget _buildList(ColorScheme cs, AppLocalizations l10n) { + if (_loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + final items = _filtered; + if (items.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Text( + l10n.chatInfoAddMembersEmpty, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), + ), + ); + } + return ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: items.length, + itemBuilder: (_, i) => _candidateRow(cs, items[i]), + ); + } + + Widget _candidateRow(ColorScheme cs, _Candidate c) { + final selected = _selected.contains(c.id); + return InkWell( + onTap: () => setState(() { + if (selected) { + _selected.remove(c.id); + } else { + _selected.add(c.id); + } + }), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + KometAvatar(name: c.name, imageUrl: c.avatarUrl, size: 42), + const SizedBox(width: 14), + Expanded( + child: Text( + c.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + selected + ? Symbols.check_circle + : Symbols.radio_button_unchecked, + fill: selected ? 1 : 0, + color: selected ? cs.primary : cs.outline, + size: 24, + ), + ], + ), + ), + ); + } + + Widget _buildAddButton(ColorScheme cs, AppLocalizations l10n) { + final enabled = _selected.isNotEmpty && !_submitting; + return InkWell( + onTap: enabled ? _submit : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_submitting) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Text( + _selected.isEmpty + ? l10n.chatInfoAddMembersAction + : '${l10n.chatInfoAddMembersAction} · ${_selected.length}', + style: TextStyle( + color: enabled ? cs.primary : cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +Future showInviteLinkSheet( + BuildContext context, { + required String link, + required String title, + String? avatarUrl, +}) { + return showBlurredCard( + context, + (host) => _InviteLinkCard( + link: link, + title: title, + avatarUrl: avatarUrl, + hostContext: host, + ), + ); +} + +class _InviteLinkCard extends StatelessWidget { + final String link; + final String title; + final String? avatarUrl; + final BuildContext hostContext; + + const _InviteLinkCard({ + required this.link, + required this.title, + this.avatarUrl, + required this.hostContext, + }); + + String get _shortLink => link.replaceFirst(RegExp(r'^https?://'), ''); + + Future _copy(BuildContext context) async { + final message = AppLocalizations.of(context)!.sharedLinkCopied; + await Clipboard.setData(ClipboardData(text: link)); + if (!context.mounted) return; + Navigator.of(context).pop(); + if (hostContext.mounted) showCustomNotification(hostContext, message); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final width = MediaQuery.sizeOf(context).width; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: Container( + width: width > 420 ? 380 : double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.chatInfoInviteLink.toUpperCase(), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), + child: Row( + children: [ + KometAvatar(name: title, imageUrl: avatarUrl, size: 40), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _shortLink, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + IconButton( + icon: Icon(Symbols.content_copy, color: cs.primary), + onPressed: () => _copy(context), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + l10n.chatInfoInviteLinkHint, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _copy(context), + icon: const Icon(Symbols.content_copy, size: 20), + label: Text(l10n.sharedCopyLink), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/profile_action_sheets.dart b/lib/frontend/screens/chats/profile_action_sheets.dart new file mode 100644 index 0000000..f6cfd2a --- /dev/null +++ b/lib/frontend/screens/chats/profile_action_sheets.dart @@ -0,0 +1,398 @@ +import 'package:flutter/material.dart'; + +import '../contacts/contact_sheet_common.dart'; +import '../../../core/config/app_fonts.dart'; + +class ConfirmChoice { + final bool confirmed; + final bool checked; + + const ConfirmChoice({required this.confirmed, required this.checked}); + + static const cancelled = ConfirmChoice(confirmed: false, checked: false); +} + +Future showBlurredConfirm( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, + required String cancelLabel, + bool destructive = false, + String? checkboxLabel, + bool checkboxInitial = false, +}) async { + final result = await showBlurredCard( + context, + (_) => _ConfirmCard( + title: title, + message: message, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + destructive: destructive, + checkboxLabel: checkboxLabel, + checkboxInitial: checkboxInitial, + ), + ); + return result ?? ConfirmChoice.cancelled; +} + +Future showComplaintCard( + BuildContext context, { + required String title, + required String subtitle, + required String sendLabel, + required String closeLabel, + required String emptyLabel, + required Future> Function() loadReasons, + required Future Function(int reasonId) onSend, +}) { + return showBlurredCard( + context, + (_) => _ComplaintCard( + title: title, + subtitle: subtitle, + sendLabel: sendLabel, + closeLabel: closeLabel, + emptyLabel: emptyLabel, + loadReasons: loadReasons, + onSend: onSend, + ), + ); +} + +class _CardShell extends StatelessWidget { + final Widget child; + + const _CardShell({required this.child}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final width = MediaQuery.sizeOf(context).width; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: Container( + width: width > 420 ? 380 : double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 16), + child: child, + ), + ), + ), + ), + ); + } +} + +class _ConfirmCard extends StatefulWidget { + final String title; + final String message; + final String confirmLabel; + final String cancelLabel; + final bool destructive; + final String? checkboxLabel; + final bool checkboxInitial; + + const _ConfirmCard({ + required this.title, + required this.message, + required this.confirmLabel, + required this.cancelLabel, + required this.destructive, + required this.checkboxLabel, + required this.checkboxInitial, + }); + + @override + State<_ConfirmCard> createState() => _ConfirmCardState(); +} + +class _ConfirmCardState extends State<_ConfirmCard> { + late bool _checked = widget.checkboxInitial; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final checkboxLabel = widget.checkboxLabel; + + return _CardShell( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + const SizedBox(height: 8), + Text( + widget.message, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + height: 1.35, + ), + ), + if (checkboxLabel != null) ...[ + const SizedBox(height: 12), + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => setState(() => _checked = !_checked), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Checkbox( + value: _checked, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (v) => setState(() => _checked = v ?? false), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + checkboxLabel, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + ), + ], + ), + ), + ), + ], + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => + Navigator.of(context).pop(ConfirmChoice.cancelled), + child: Text( + widget.cancelLabel, + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + const SizedBox(width: 8), + FilledButton.tonal( + style: widget.destructive + ? FilledButton.styleFrom( + backgroundColor: cs.errorContainer, + foregroundColor: cs.onErrorContainer, + ) + : null, + onPressed: () => Navigator.of( + context, + ).pop(ConfirmChoice(confirmed: true, checked: _checked)), + child: Text(widget.confirmLabel), + ), + ], + ), + ], + ), + ); + } +} + +class _ComplaintCard extends StatefulWidget { + final String title; + final String subtitle; + final String sendLabel; + final String closeLabel; + final String emptyLabel; + final Future> Function() loadReasons; + final Future Function(int reasonId) onSend; + + const _ComplaintCard({ + required this.title, + required this.subtitle, + required this.sendLabel, + required this.closeLabel, + required this.emptyLabel, + required this.loadReasons, + required this.onSend, + }); + + @override + State<_ComplaintCard> createState() => _ComplaintCardState(); +} + +class _ComplaintCardState extends State<_ComplaintCard> { + List<({int id, String title})>? _reasons; + int? _selected; + bool _sending = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + List<({int id, String title})> loaded; + try { + loaded = await widget.loadReasons(); + } catch (_) { + loaded = const []; + } + if (!mounted) return; + setState(() => _reasons = loaded); + } + + Future _send() async { + final reasonId = _selected; + if (reasonId == null || _sending) return; + setState(() => _sending = true); + final ok = await widget.onSend(reasonId); + if (!mounted) return; + if (ok) { + Navigator.of(context).pop(); + } else { + setState(() => _sending = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final reasons = _reasons; + + return _CardShell( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + const SizedBox(height: 6), + Text( + widget.subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 12), + if (reasons == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (reasons.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 22), + child: Center( + child: Text( + widget.emptyLabel, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ), + ) + else + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.42, + ), + child: SingleChildScrollView( + child: RadioGroup( + groupValue: _selected, + onChanged: (v) { + if (_sending) return; + setState(() => _selected = v); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final reason in reasons) + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _sending + ? null + : () => setState(() => _selected = reason.id), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Radio( + value: reason.id, + visualDensity: VisualDensity.compact, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + reason.title, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _sending ? null : () => Navigator.of(context).pop(), + child: Text( + widget.closeLabel, + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + const SizedBox(width: 8), + FilledButton.tonal( + style: FilledButton.styleFrom( + backgroundColor: cs.errorContainer, + foregroundColor: cs.onErrorContainer, + ), + onPressed: _selected == null || _sending ? null : _send, + child: _sending + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(widget.sendLabel), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/chats/scheduled_messages_screen.dart b/lib/frontend/screens/chats/scheduled_messages_screen.dart index 2e98c14..783b174 100644 --- a/lib/frontend/screens/chats/scheduled_messages_screen.dart +++ b/lib/frontend/screens/chats/scheduled_messages_screen.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart' + show ExpressiveRefreshIndicator; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart'; @@ -15,6 +17,9 @@ import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/schedule_time_picker.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; +import '../../widgets/reload_on_reconnect.dart'; +import '../../../core/config/app_fonts.dart'; class ScheduledMessagesScreen extends StatefulWidget { final int chatId; @@ -33,7 +38,8 @@ class ScheduledMessagesScreen extends StatefulWidget { _ScheduledMessagesScreenState(); } -class _ScheduledMessagesScreenState extends State { +class _ScheduledMessagesScreenState extends State + with ReloadOnReconnect { final List _messages = []; StreamSubscription? _pushSub; bool _loading = true; @@ -58,6 +64,9 @@ class _ScheduledMessagesScreenState extends State { super.dispose(); } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { final list = await messagesModule.fetchDelayedMessages( widget.accountId, @@ -109,7 +118,7 @@ class _ScheduledMessagesScreenState extends State { color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), const SizedBox(height: 16), @@ -241,7 +250,7 @@ class _ScheduledMessagesScreenState extends State { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), Text( @@ -258,10 +267,10 @@ class _ScheduledMessagesScreenState extends State { ), ), body: _loading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : _messages.isEmpty ? _empty(cs) - : RefreshIndicator( + : ExpressiveRefreshIndicator( onRefresh: _load, child: ListView.separated( padding: const EdgeInsets.all(16), diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart index 90c9683..30b0671 100644 --- a/lib/frontend/screens/chats/search_screen.dart +++ b/lib/frontend/screens/chats/search_screen.dart @@ -6,12 +6,14 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/contacts.dart'; +import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/storage/app_database.dart'; import '../../../core/utils/debouncer.dart'; import '../../../core/utils/names.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_route.dart'; -import '../contacts/contact_profile_screen.dart'; +import '../contacts/open_contact_profile.dart'; import 'chat_screen.dart'; class SearchScreen extends StatefulWidget { @@ -127,6 +129,11 @@ class _SearchScreenState extends State { } String _contactName(Map row) { + final id = row['id']; + if (id is int) { + final cached = ContactCache.get(id); + if (cached != null && cached.isNotEmpty) return cached; + } return displayName( row['first_name'], row['last_name'], @@ -134,6 +141,37 @@ class _SearchScreenState extends State { ); } + String _phoneResultName(PhoneLookupResult result) { + return ContactCache.get(result.id) ?? result.name ?? 'User #${result.id}'; + } + + ({String name, String? avatar, String type}) _chatIdentity( + int chatId, + String? type, + String? title, + String? iconUrl, + ) { + final fallbackType = type ?? 'CHAT'; + if (chatId == 0) { + return (name: 'Избранное', avatar: iconUrl, type: fallbackType); + } + final me = _accountId ?? 0; + final peer = me == 0 ? 0 : chatId ^ me; + if ((type != null && type != 'DIALOG') || peer <= 0) { + return (name: title ?? '', avatar: iconUrl, type: fallbackType); + } + final cachedName = ContactCache.get(peer); + final cachedAvatar = ContactCache.getAvatar(peer); + final known = cachedName != null && cachedName.isNotEmpty; + return ( + name: known ? cachedName : (title ?? ''), + avatar: (cachedAvatar != null && cachedAvatar.isNotEmpty) + ? cachedAvatar + : iconUrl, + type: known ? 'DIALOG' : fallbackType, + ); + } + void _openChat(int chatId, String name, String? avatarUrl, String type) { pushSwipeable( context, @@ -147,13 +185,12 @@ class _SearchScreenState extends State { } void _openContact(Map row) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ContactProfileScreen( - contactId: row['id'] as int, - initialName: _contactName(row), - initialAvatarUrl: row['base_url'] as String?, - ), + unawaited( + openContactDialogProfile( + context, + contactId: row['id'] as int, + name: _contactName(row), + avatarUrl: row['base_url'] as String?, ), ); } @@ -166,13 +203,12 @@ class _SearchScreenState extends State { } void _openPhoneResult(PhoneLookupResult result) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ContactProfileScreen( - contactId: result.id, - initialName: result.name, - initialAvatarUrl: result.avatarUrl, - ), + unawaited( + openContactDialogProfile( + context, + contactId: result.id, + name: _phoneResultName(result), + avatarUrl: result.avatarUrl, ), ); } @@ -234,7 +270,7 @@ class _SearchScreenState extends State { } if (!hasResults) { if (_loading) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } return _buildHint(cs, Symbols.search_off, 'Ничего не найдено'); } @@ -246,7 +282,7 @@ class _SearchScreenState extends State { if (phoneResult != null) ...[ _sectionHeader(cs, 'По номеру'), _ResultTile( - name: phoneResult.name ?? '', + name: _phoneResultName(phoneResult), imageUrl: phoneResult.avatarUrl, subtitle: query, onTap: () => _openPhoneResult(phoneResult), @@ -264,17 +300,7 @@ class _SearchScreenState extends State { ], if (_chats.isNotEmpty) ...[ _sectionHeader(cs, 'Чаты'), - for (final row in _chats) - _ResultTile( - name: (row['title'] as String?) ?? '', - imageUrl: row['icon_url'] as String?, - onTap: () => _openChat( - row['id'] as int, - (row['title'] as String?) ?? '', - row['icon_url'] as String?, - (row['type'] as String?) ?? 'CHAT', - ), - ), + for (final row in _chats) _localChatTile(row), ], if (_messages.isNotEmpty) ...[ _sectionHeader(cs, 'Сообщения'), @@ -296,16 +322,36 @@ class _SearchScreenState extends State { onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type), ); + Widget _localChatTile(Map row) { + final chatId = row['id'] as int; + final identity = _chatIdentity( + chatId, + (row['type'] as String?) ?? 'CHAT', + row['title'] as String?, + row['icon_url'] as String?, + ); + return _ResultTile( + name: identity.name, + imageUrl: identity.avatar, + onTap: () => + _openChat(chatId, identity.name, identity.avatar, identity.type), + ); + } + Widget _messageTile(MessageSearchHit hit) { final meta = _msgChatMeta[hit.chatId]; - final title = (meta?['title'] as String?) ?? 'Чат'; - final icon = meta?['icon_url'] as String?; - final type = (meta?['type'] as String?) ?? 'CHAT'; + final identity = _chatIdentity( + hit.chatId, + meta?['type'] as String?, + meta?['title'] as String?, + meta?['icon_url'] as String?, + ); + final name = identity.name.isEmpty ? 'Чат' : identity.name; return _ResultTile( - name: title, - imageUrl: icon, + name: name, + imageUrl: identity.avatar, subtitle: hit.text?.trim(), - onTap: () => _openChat(hit.chatId, title, icon, type), + onTap: () => _openChat(hit.chatId, name, identity.avatar, identity.type), ); } diff --git a/lib/frontend/screens/chats/share_composer_bar.dart b/lib/frontend/screens/chats/share_composer_bar.dart new file mode 100644 index 0000000..5b52386 --- /dev/null +++ b/lib/frontend/screens/chats/share_composer_bar.dart @@ -0,0 +1,355 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/share_sender.dart'; +import '../../../core/media/share_thumbnail.dart'; +import '../../../core/share/share_labels.dart'; +import '../../../main.dart'; +import '../../../models/animoji.dart'; +import '../../../models/shared_payload.dart'; +import '../../widgets/emoji_panel.dart'; +import '../../widgets/rich_message_controller.dart'; +import '../../widgets/small_spinner.dart'; +import '../../widgets/springy_tap.dart'; + +class ShareComposerBar extends StatefulWidget { + const ShareComposerBar({ + super.key, + required this.share, + required this.controller, + required this.recipientNames, + required this.onSend, + this.sending = false, + }); + + final PreparedShare share; + final RichMessageController controller; + final List recipientNames; + final Future Function(String caption) onSend; + final bool sending; + + @override + State createState() => _ShareComposerBarState(); +} + +class _ShareComposerBarState extends State { + static const double _emojiPanelHeight = 280; + static const Duration _panelDuration = Duration(milliseconds: 220); + + final FocusNode _focus = FocusNode(); + bool _emojiOpen = false; + + RichMessageController get _controller => widget.controller; + + @override + void initState() { + super.initState(); + _focus.addListener(_onFocus); + } + + @override + void dispose() { + _focus.removeListener(_onFocus); + _focus.dispose(); + super.dispose(); + } + + void _onFocus() { + if (_focus.hasFocus && _emojiOpen) setState(() => _emojiOpen = false); + } + + void _toggleEmoji() { + if (_emojiOpen) { + setState(() => _emojiOpen = false); + return; + } + _focus.unfocus(); + setState(() => _emojiOpen = true); + } + + void _insertAnimoji(Animoji animoji) { + _controller.insertAnimoji(animoji); + unawaited(animojiModule.noteUsed(animoji)); + } + + Future _send() async { + if (widget.sending) return; + await widget.onSend(_controller.buildContent().text.trim()); + } + + String get _title { + final share = widget.share; + return shareTitleFor( + photos: share.photos.length, + videos: share.videos.length, + documents: share.documents.length, + textOnly: share.isTextOnly, + ); + } + + String get _subtitle => shareSubtitleFor(widget.recipientNames); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + final safeBottom = MediaQuery.paddingOf(context).bottom; + + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only(bottom: _emojiOpen ? 0 : bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!widget.share.isTextOnly) _buildPreviewRow(cs), + _buildInputRow(cs), + AnimatedSize( + duration: _panelDuration, + curve: Curves.easeOutCubic, + child: _emojiOpen + ? SizedBox( + height: _emojiPanelHeight + safeBottom, + child: Padding( + padding: EdgeInsets.only(bottom: safeBottom), + child: EmojiPanel(onEmojiTap: _insertAnimoji), + ), + ) + : SizedBox(height: bottomInset > 0 ? 0 : safeBottom), + ), + ], + ), + ); + } + + Widget _buildPreviewRow(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + Icon(Symbols.forward, color: cs.primary, size: 22, weight: 500), + const SizedBox(width: 12), + _ShareThumbStack(files: widget.share.files), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _title, + style: TextStyle( + color: cs.primary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInputRow(ColorScheme cs) { + final count = widget.recipientNames.length; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 12, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IconButton( + onPressed: _toggleEmoji, + icon: Icon( + Symbols.mood, + color: _emojiOpen ? cs.primary : cs.onSurfaceVariant, + size: 26, + fill: _emojiOpen ? 1 : 0, + ), + ), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 120), + child: TextField( + controller: _controller, + focusNode: _focus, + minLines: 1, + maxLines: null, + textCapitalization: TextCapitalization.sentences, + keyboardType: TextInputType.multiline, + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: widget.share.isTextOnly + ? 'Сообщение' + : 'Добавить подпись...', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + _SendButton( + count: count, + sending: widget.sending, + onTap: count == 0 ? null : _send, + ), + ], + ), + ); + } +} + +class _ShareThumbStack extends StatelessWidget { + const _ShareThumbStack({required this.files}); + + final List files; + + static const double _size = 40; + static const double _step = 9; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final visible = files.take(3).toList(); + final width = _size + _step * (visible.length - 1).clamp(0, 2); + + return SizedBox( + width: width, + height: _size, + child: Stack( + children: [ + for (var i = visible.length - 1; i >= 0; i--) + Positioned( + left: i * _step, + child: _ShareThumb(file: visible[i], size: _size, cs: cs), + ), + ], + ), + ); + } +} + +class _ShareThumb extends StatelessWidget { + const _ShareThumb({required this.file, required this.size, required this.cs}); + + final PreparedShareFile file; + final double size; + final ColorScheme cs; + + @override + Widget build(BuildContext context) { + final provider = decodeSharedThumb(file.thumbDataUri); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.surfaceContainerHigh, width: 2), + ), + clipBehavior: Clip.antiAlias, + child: provider != null + ? Image(image: provider, fit: BoxFit.cover) + : Icon( + file.kind == SharedFileKind.video + ? Symbols.movie + : Symbols.description, + size: 20, + color: cs.onSurfaceVariant, + ), + ); + } +} + +class _SendButton extends StatelessWidget { + const _SendButton({ + required this.count, + required this.sending, + required this.onTap, + }); + + final int count; + final bool sending; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final enabled = onTap != null && !sending; + + return SpringyTap( + child: GestureDetector( + onTap: enabled ? onTap : null, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: enabled ? cs.primary : cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: sending + ? Padding( + padding: const EdgeInsets.all(14), + child: SmallSpinner(size: 24, color: cs.onPrimary), + ) + : Icon( + Symbols.send, + color: enabled ? cs.onPrimary : cs.onSurfaceVariant, + size: 24, + fill: 1, + ), + ), + if (count > 0 && !sending) + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + constraints: const BoxConstraints(minWidth: 20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.primary, width: 1.5), + ), + child: Text( + '$count', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/contacts/add_contact_sheet.dart b/lib/frontend/screens/contacts/add_contact_sheet.dart new file mode 100644 index 0000000..4645c53 --- /dev/null +++ b/lib/frontend/screens/contacts/add_contact_sheet.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/core/config/countries.dart'; +import 'package:komet/frontend/screens/auth/phone_input_formatter.dart'; +import 'package:komet/frontend/screens/auth/select_country_screen.dart'; +import 'package:komet/frontend/screens/contacts/contact_sheet_common.dart'; +import 'package:komet/frontend/screens/contacts/open_contact_profile.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/main.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +Future showAddContactSheet(BuildContext context) { + return showBlurredCard( + context, + (host) => _AddContactCard(hostContext: host), + ); +} + +class _AddContactCard extends StatefulWidget { + final BuildContext hostContext; + + const _AddContactCard({required this.hostContext}); + + @override + State<_AddContactCard> createState() => _AddContactCardState(); +} + +class _AddContactCardState extends State<_AddContactCard> { + final TextEditingController _phoneCtrl = TextEditingController(); + final TextEditingController _firstCtrl = TextEditingController(); + final TextEditingController _lastCtrl = TextEditingController(); + + late CountryName _country; + bool _loading = false; + String? _notFoundPhone; + + @override + void initState() { + super.initState(); + final allowed = api.registrationCountries; + _country = + countriesByCode['RU'] ?? + (allowed.isNotEmpty ? allowed.first : allCountries.first); + if (allowed.isNotEmpty && !allowed.any((c) => c.code == _country.code)) { + _country = allowed.first; + } + } + + @override + void dispose() { + _phoneCtrl.dispose(); + _firstCtrl.dispose(); + _lastCtrl.dispose(); + super.dispose(); + } + + String get _digits => _phoneCtrl.text.replaceAll(RegExp(r'\D'), ''); + bool get _phoneValid => _digits.length == _country.phoneDigits; + bool get _canSave => + _phoneValid && _firstCtrl.text.trim().isNotEmpty && !_loading; + + Future _pickCountry() async { + final picked = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SelectCountryScreen( + selectedCountry: _country, + countries: api.registrationCountries, + ), + ), + ); + if (picked != null) { + setState(() { + _country = picked; + _phoneCtrl.clear(); + }); + } + } + + Future _save() async { + if (!_canSave) return; + setState(() => _loading = true); + final phone = '${_country.phoneCode}$_digits'; + final result = await ContactsModule.addContactByPhone( + api, + phone: phone, + firstName: _firstCtrl.text.trim(), + lastName: _lastCtrl.text.trim(), + ); + if (!mounted) return; + setState(() => _loading = false); + + switch (result.status) { + case AddContactStatus.added: + final contact = result.contact; + Navigator.of(context).pop(); + if (contact == null) return; + final name = (contact.lastName != null && contact.lastName!.isNotEmpty) + ? '${contact.firstName} ${contact.lastName}' + : contact.firstName; + if (!widget.hostContext.mounted) return; + await openContactDialogProfile( + widget.hostContext, + contactId: contact.id, + name: name, + avatarUrl: contact.baseUrl, + ); + case AddContactStatus.notFound: + setState(() => _notFoundPhone = phone); + case AddContactStatus.error: + showCustomNotification( + context, + AppLocalizations.of(context)!.addContactError, + ); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final width = MediaQuery.sizeOf(context).width; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: Container( + width: width > 420 ? 380 : double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: _notFoundPhone != null + ? _buildNotFound(cs) + : _buildForm(cs), + ), + ), + ), + ), + ); + } + + Widget _buildForm(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.addContactTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 20), + _buildPhoneRow(cs), + contactSheetDivider(cs), + _buildTextRow( + cs, + controller: _firstCtrl, + hint: l10n.addContactFirstName, + ), + contactSheetDivider(cs), + _buildTextRow( + cs, + controller: _lastCtrl, + hint: l10n.addContactLastName, + ), + contactSheetDivider(cs), + _buildSaveButton(cs, l10n), + ], + ), + ); + } + + Widget _buildPhoneRow(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + InkWell( + onTap: _loading ? null : _pickCountry, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + contactFlagEmoji(_country.code), + style: const TextStyle(fontSize: 22), + ), + const SizedBox(width: 6), + Text( + _country.phoneCode, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + Icon( + Symbols.keyboard_arrow_down, + size: 20, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + key: ValueKey(_country.code), + controller: _phoneCtrl, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + PhoneInputFormatter(_country), + ], + onChanged: (_) => setState(() {}), + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: _country.phoneMask.replaceAll('#', '0'), + hintStyle: TextStyle(color: cs.outline, fontSize: 16), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTextRow( + ColorScheme cs, { + required TextEditingController controller, + required String hint, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + maxLength: 60, + onChanged: (_) => setState(() {}), + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + counterText: '', + hintText: hint, + hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + ), + ), + ), + const SizedBox(width: 8), + Text( + '${controller.text.characters.length}/60', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ); + } + + Widget _buildSaveButton(ColorScheme cs, AppLocalizations l10n) { + return SizedBox( + width: double.infinity, + child: TextButton( + onPressed: _canSave ? _save : null, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + foregroundColor: cs.primary, + disabledForegroundColor: cs.onSurfaceVariant.withValues(alpha: 0.5), + ), + child: _loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ) + : Text( + l10n.addContactSave, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + Widget _buildNotFound(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.addContactNotFound(_notFoundPhone ?? ''), + style: TextStyle( + color: cs.onSurface, + fontSize: 21, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + l10n.addContactNotFoundSubtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: () => setState(() => _notFoundPhone = null), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text(l10n.addContactSearchOther), + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart deleted file mode 100644 index d853608..0000000 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ /dev/null @@ -1,420 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; - -import '../../../core/cache/info_cache.dart'; -import '../../../core/storage/app_database.dart'; -import '../../../core/storage/token_storage.dart'; -import '../../../core/utils/format.dart'; -import '../../../l10n/app_localizations.dart'; -import '../../../models/contact_info.dart'; -import '../../widgets/avatar_history_screen.dart'; -import '../../widgets/custom_notification.dart'; -import '../../widgets/glossy_pill.dart'; -import '../../widgets/komet_avatar.dart'; -import '../../widgets/connection_status.dart'; -import '../../widgets/swipe_route.dart'; -import '../chats/chat_screen.dart'; - -class ContactProfileScreen extends StatefulWidget { - final int contactId; - final String? initialName; - final String? initialAvatarUrl; - - const ContactProfileScreen({ - super.key, - required this.contactId, - this.initialName, - this.initialAvatarUrl, - }); - - @override - State createState() => _ContactProfileScreenState(); -} - -class _ContactProfileScreenState extends State { - bool _loading = true; - ContactInfo? _contact; - int? _seenTime; - int _presenceStatus = 0; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - try { - final contactFuture = ContactInfoFetch.get(widget.contactId); - final presenceFuture = PresenceFetch.get(widget.contactId); - final contact = await contactFuture; - final presence = await presenceFuture; - if (!mounted) return; - if (contact != null) { - _contact = contact; - } - if (presence != null) { - _seenTime = presence['seen'] as int?; - _presenceStatus = (presence['status'] as int?) ?? 0; - } - } catch (e) { - if (mounted) { - showCustomNotification( - context, - AppLocalizations.of(context)!.contactProfileLoadError(e.toString()), - ); - } - } finally { - if (mounted) setState(() => _loading = false); - } - } - - String _displayName() { - return _contact?.displayName ?? - widget.initialName ?? - 'User #${widget.contactId}'; - } - - String? _avatarUrl() { - return _contact?.avatarUrl ?? widget.initialAvatarUrl; - } - - Set _options() { - return _contact?.options.toSet() ?? const {}; - } - - bool get _isBot => _options().contains('BOT'); - bool get _isVerified => _options().contains('OFFICIAL'); - - String _subtitle() { - final l10n = AppLocalizations.of(context)!; - if (_isBot) return l10n.contactProfileBot; - if (_presenceStatus == 1) return l10n.contactProfileOnline; - if (_presenceStatus == 2 || _presenceStatus == 3) return l10n.contactProfileRecentlyActive; - if (_seenTime != null && _seenTime! > 0) return formatLastSeen(_seenTime!); - return ''; - } - - Future _openChat() async { - final accountId = await TokenStorage.getActiveAccountId(); - if (accountId == null) return; - final existing = await AppDatabase.findDialogChatByParticipant( - accountId, - widget.contactId, - ); - final chatId = existing ?? (accountId ^ widget.contactId); - if (!mounted) return; - pushSwipeable( - context, - (_) => ChatScreen( - chatId: chatId, - name: _displayName(), - imageUrl: _avatarUrl() ?? '', - chatType: 'DIALOG', - ), - ); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: cs.surface, - floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, - floatingActionButton: const ConnectionSpinner(), - body: SafeArea( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _buildBody(cs), - ), - ); - } - - Widget _buildBody(ColorScheme cs) { - return CustomScrollView( - slivers: [ - SliverAppBar( - backgroundColor: Colors.transparent, - elevation: 0, - floating: true, - leading: IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: () => Navigator.pop(context), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - GestureDetector( - onTap: () => AvatarHistoryScreen.open( - context, - contactId: widget.contactId, - name: _displayName(), - currentAvatarUrl: _avatarUrl(), - ), - child: KometAvatar( - name: _displayName(), - imageUrl: _avatarUrl(), - size: 96, - fontSize: 36, - ), - ), - const SizedBox(height: 14), - _buildNameRow(cs), - const SizedBox(height: 4), - Text( - _subtitle(), - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - const SizedBox(height: 20), - _buildActions(cs), - const SizedBox(height: 16), - _buildInfoCard(cs), - const SizedBox(height: 40), - ], - ), - ), - ), - ], - ); - } - - Widget _buildNameRow(ColorScheme cs) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - _displayName(), - style: TextStyle( - color: cs.onSurface, - fontSize: 22, - fontWeight: FontWeight.w700, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - if (_isVerified) ...[ - const SizedBox(width: 6), - Icon(Symbols.verified, color: cs.primary, size: 20, fill: 1), - ], - ], - ); - } - - Widget _buildActions(ColorScheme cs) { - final l10n = AppLocalizations.of(context)!; - final actions = <({IconData icon, String label, VoidCallback? onTap})>[ - ( - icon: Symbols.chat_bubble, - label: l10n.contactProfileActionChat, - onTap: _openChat, - ), - ( - icon: Symbols.notifications, - label: l10n.contactProfileActionSound, - onTap: null, - ), - if (!_isBot) - (icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null), - ]; - return Row( - children: [ - for (var i = 0; i < actions.length; i++) ...[ - Expanded( - child: GestureDetector( - onTap: actions[i].onTap, - child: GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(14), - padding: const EdgeInsets.symmetric(vertical: 12), - depth: 6, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(actions[i].icon, color: cs.primary, size: 22), - const SizedBox(height: 4), - Text( - actions[i].label, - style: TextStyle(color: cs.onSurface, fontSize: 12), - ), - ], - ), - ), - ), - ), - if (i < actions.length - 1) const SizedBox(width: 8), - ], - ], - ); - } - - Widget _buildInfoCard(ColorScheme cs) { - final c = _contact; - if (c == null) return const SizedBox.shrink(); - - final l10n = AppLocalizations.of(context)!; - final rows = []; - - final phoneStr = formatPhone(c.raw['phone']); - if (phoneStr != null) { - rows.add( - _infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr), - ); - } - - final country = c.raw['country'] as String?; - if (country != null && country.isNotEmpty) { - rows.add( - _infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country), - ); - } - - final genderStr = formatGender(c.raw['gender']); - if (genderStr != null) { - rows.add( - _infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr), - ); - } - - final regTime = c.raw['registrationTime'] as int?; - if (regTime != null && regTime > 0) { - rows.add( - _infoRow( - cs, - Symbols.event, - l10n.contactProfileInfoRegistration, - formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(regTime)), - ), - ); - } - - final updateTime = c.raw['updateTime'] as int?; - if (updateTime != null && updateTime > 0) { - rows.add( - _infoRow( - cs, - Symbols.update, - l10n.contactProfileInfoUpdated, - formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(updateTime)), - ), - ); - } - - final accountStatus = c.raw['accountStatus']; - if (accountStatus is int && accountStatus != 0) { - rows.add( - _infoRow( - cs, - Symbols.account_circle, - l10n.contactProfileInfoAccountStatus, - accountStatus.toString(), - ), - ); - } - - final desc = (c.raw['description'] as String?)?.trim(); - if (desc != null && desc.isNotEmpty) { - rows.add( - _infoRow( - cs, - Symbols.info, - l10n.contactProfileInfoDescription, - desc, - multiline: true, - ), - ); - } - - final link = c.raw['link'] as String?; - if (link != null && link.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.link, l10n.contactProfileInfoLink, link)); - } - - final webApp = c.raw['webApp'] as String?; - if (webApp != null && webApp.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.web, 'Web app', webApp)); - } - - final opts = _options(); - if (opts.isNotEmpty) { - rows.add( - _infoRow( - cs, - Symbols.label, - l10n.contactProfileInfoFlags, - opts.join(', '), - multiline: true, - ), - ); - } - - rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString())); - - if (rows.isEmpty) return const SizedBox.shrink(); - - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - depth: 6, - child: SizedBox( - width: double.infinity, - child: Column( - children: [ - for (var i = 0; i < rows.length; i++) ...[ - if (i > 0) - Divider( - height: 1, - color: cs.outlineVariant.withValues(alpha: 0.3), - ), - rows[i], - ], - ], - ), - ), - ); - } - - Widget _infoRow( - ColorScheme cs, - IconData icon, - String label, - String value, { - bool multiline = false, - }) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: cs.onSurfaceVariant, size: 20), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), - ), - const SizedBox(height: 2), - Text( - value, - style: TextStyle(color: cs.onSurface, fontSize: 14), - maxLines: multiline ? null : 1, - overflow: multiline ? null : TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/frontend/screens/contacts/contact_sheet_common.dart b/lib/frontend/screens/contacts/contact_sheet_common.dart new file mode 100644 index 0000000..4ae2861 --- /dev/null +++ b/lib/frontend/screens/contacts/contact_sheet_common.dart @@ -0,0 +1,45 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../../../core/config/app_frost.dart'; + +Future showBlurredCard( + BuildContext context, + Widget Function(BuildContext hostContext) builder, +) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: AppFrost.scrim(), + transitionDuration: const Duration(milliseconds: 260), + pageBuilder: (_, _, _) => builder(context), + transitionBuilder: (_, anim, _, child) { + final t = Curves.easeOutCubic.transform(anim.value); + return BackdropFilter( + filter: ImageFilter.blur( + sigmaX: AppFrost.overlaySigma * t, + sigmaY: AppFrost.overlaySigma * t, + ), + child: Opacity( + opacity: anim.value, + child: Transform.scale(scale: 0.94 + 0.06 * t, child: child), + ), + ); + }, + ); +} + +Widget contactSheetDivider(ColorScheme cs) => + Divider(height: 1, thickness: 0.5, color: cs.outlineVariant); + +String contactFlagEmoji(String code) { + if (code.length != 2) return '🏳️'; + final upper = code.toUpperCase(); + final a = upper.codeUnitAt(0); + final b = upper.codeUnitAt(1); + if (a < 65 || a > 90 || b < 65 || b > 90) return '🏳️'; + return String.fromCharCode(0x1F1E6 + (a - 65)) + + String.fromCharCode(0x1F1E6 + (b - 65)); +} diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 694dcb2..3194469 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,44 +1,29 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/config/debug_test.dart'; +import '../../../core/contacts/device_contacts_service.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../backend/modules/contacts.dart'; +import '../../../backend/modules/messages.dart' show ContactCache; import '../../../main.dart'; import '../../../models/contact_info.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; +import '../../widgets/spectrum_tint.dart'; +import '../../widgets/springy_tap.dart'; import '../chats/chat_info_screen.dart'; import 'nfc_exchange_sheet.dart'; +import 'open_contact_profile.dart'; +import '../../../core/config/app_frost.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; -Future openContactDialogProfile( - BuildContext context, { - required int contactId, - required String name, - String? avatarUrl, -}) async { - final accountId = await TokenStorage.getActiveAccountId(); - final existing = accountId == null - ? null - : await AppDatabase.findDialogChatByParticipant(accountId, contactId); - final chatId = existing ?? ((accountId ?? 0) ^ contactId); - if (!context.mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ChatInfoScreen( - chatId: chatId, - name: name, - imageUrl: avatarUrl ?? '', - chatType: 'DIALOG', - dialogPeerId: contactId, - ), - ), - ); -} +enum _SearchMode { phone, id } class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); @@ -47,7 +32,7 @@ class ContactsTab extends StatefulWidget { State createState() => _ContactsTabState(); } -class _ContactsTabState extends State { +class _ContactsTabState extends State with SpectrumSurface { List _contacts = []; bool _isLoading = true; @@ -56,6 +41,12 @@ class _ContactsTabState extends State { super.initState(); _loadContacts(); ContactsModule.revision.addListener(_loadContacts); + _loadDeviceContacts(); + } + + Future _loadDeviceContacts() async { + final changed = await DeviceContactsService.ensureLoadedInteractive(); + if (changed && mounted) setState(() {}); } @override @@ -69,7 +60,7 @@ class _ContactsTabState extends State { context: context, barrierDismissible: true, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, - barrierColor: Colors.black54, + barrierColor: AppFrost.scrim(), transitionDuration: const Duration(milliseconds: 320), pageBuilder: (_, _, _) => const Align( alignment: Alignment.topCenter, @@ -138,85 +129,89 @@ class _ContactsTabState extends State { final fullName = '${contact.firstName}${contact.lastName != null ? ' ${contact.lastName}' : ''}' .trim(); - final nameToDisplay = fullName.isEmpty ? '+${contact.phone}' : fullName; + final book = DeviceContactsService.nameForPhone(contact.phone); + final nameToDisplay = + book ?? (fullName.isEmpty ? '+${contact.phone}' : fullName); - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => openContactDialogProfile( - context, - contactId: contact.id, - name: nameToDisplay, - avatarUrl: contact.baseUrl, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: cs.primary.withValues(alpha: 0.1), - width: 1, + return SpringyTap( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => openContactDialogProfile( + context, + contactId: contact.id, + name: nameToDisplay, + avatarUrl: contact.baseUrl, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.1), + width: 1, + ), + ), + child: KometAvatar( + name: nameToDisplay, + imageUrl: contact.baseUrl, + size: 48, ), ), - child: KometAvatar( - name: nameToDisplay, - imageUrl: contact.baseUrl, - size: 48, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - nameToDisplay, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + nameToDisplay, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (contact.isVerified) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, ), + if (contact.isVerified) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], ], - ], - ), - const SizedBox(height: 4), - Text( - contact.updateTime > 0 - ? 'Был(а) недавно' - : '+${contact.phone}', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + const SizedBox(height: 4), + Text( + contact.updateTime > 0 + ? 'Был(а) недавно' + : '+${contact.phone}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), - ), - ], + ], + ), ), ), ), @@ -228,7 +223,7 @@ class _ContactsTabState extends State { final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: cs.surface, + backgroundColor: spectrumSurfaceColor(cs), body: SafeArea( bottom: false, child: Column( @@ -248,7 +243,7 @@ class _ContactsTabState extends State { color: cs.onSurface, fontSize: 24, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), const ConnectionStatusLine(), @@ -268,7 +263,7 @@ class _ContactsTabState extends State { ), Expanded( child: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : _contacts.isEmpty ? Center( child: Text( @@ -305,6 +300,7 @@ class _SearchContactSheet extends StatefulWidget { class _SearchContactSheetState extends State<_SearchContactSheet> { final _controller = TextEditingController(); + _SearchMode _mode = _SearchMode.phone; bool _loading = false; String? _error; @@ -314,7 +310,82 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { super.dispose(); } + void _setMode(_SearchMode mode) { + if (_mode == mode || _loading) return; + setState(() { + _mode = mode; + _error = null; + }); + } + Future _submit() async { + if (_mode == _SearchMode.phone) { + await _submitPhone(); + } else { + await _submitId(); + } + } + + String? _phoneCandidate(String query) { + if (!RegExp(r'^[+\d\s\-()]+$').hasMatch(query)) return null; + final digits = query.replaceAll(RegExp(r'[^\d]'), ''); + if (digits.length < 5) return null; + return query; + } + + Future _submitPhone() async { + final query = _phoneCandidate(_controller.text.trim()); + if (query == null) { + setState(() => _error = 'Введите корректный номер телефона'); + return; + } + setState(() { + _loading = true; + _error = null; + }); + try { + final result = await ContactsModule.findByPhone(api, query); + if (!mounted) return; + if (result == null) { + setState(() { + _loading = false; + _error = 'Контакт с таким номером не найден'; + }); + return; + } + final navigator = Navigator.of(context); + final accountId = await TokenStorage.getActiveAccountId(); + final existing = accountId == null + ? null + : await AppDatabase.findDialogChatByParticipant(accountId, result.id); + final chatId = existing ?? ((accountId ?? 0) ^ result.id); + if (!mounted) return; + navigator.pop(); + navigator.push( + MaterialPageRoute( + builder: (_) => ChatInfoScreen( + chatId: chatId, + name: + ContactCache.get(result.id) ?? + result.name ?? + 'User #${result.id}', + imageUrl: result.avatarUrl ?? '', + chatType: 'DIALOG', + dialogPeerId: result.id, + ), + ), + ); + } catch (e) { + if (mounted) { + setState(() { + _loading = false; + _error = 'Ошибка: $e'; + }); + } + } + } + + Future _submitId() async { final raw = _controller.text.trim(); final id = int.tryParse(raw); if (id == null) { @@ -341,6 +412,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { } final raw = Map.from(contacts.first as Map); final info = ContactInfo.fromMap(raw); + ContactsModule.primeContactCache(raw); if (!mounted) return; final navigator = Navigator.of(context); final accountId = await TokenStorage.getActiveAccountId(); @@ -354,7 +426,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { MaterialPageRoute( builder: (_) => ChatInfoScreen( chatId: chatId, - name: info.displayName ?? 'User #$id', + name: ContactCache.get(id) ?? info.displayName ?? 'User #$id', imageUrl: info.avatarUrl ?? '', chatType: 'DIALOG', dialogPeerId: id, @@ -395,7 +467,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { children: [ Expanded( child: Text( - 'Поиск по ID', + 'Найти контакт', style: TextStyle( color: cs.onSurface, fontSize: 18, @@ -409,11 +481,32 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 12), + SegmentedButton<_SearchMode>( + segments: const [ + ButtonSegment( + value: _SearchMode.phone, + label: Text('Номер'), + icon: Icon(Symbols.call, size: 18), + ), + ButtonSegment( + value: _SearchMode.id, + label: Text('ID'), + icon: Icon(Symbols.tag, size: 18), + ), + ], + selected: {_mode}, + onSelectionChanged: (s) => _setMode(s.first), + showSelectedIcon: false, + style: ButtonStyle(visualDensity: VisualDensity.compact), + ), + const SizedBox(height: 12), TextField( controller: _controller, autofocus: true, - keyboardType: TextInputType.number, + keyboardType: _mode == _SearchMode.phone + ? TextInputType.phone + : TextInputType.number, enabled: !_loading, onSubmitted: (_) => _submit(), onChanged: (_) { @@ -421,13 +514,15 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { }, style: TextStyle(color: cs.onSurface, fontSize: 16), decoration: InputDecoration( - hintText: 'Введите ID контакта', + hintText: _mode == _SearchMode.phone + ? 'Введите номер телефона' + : 'Введите ID контакта', hintStyle: TextStyle( color: cs.onSurfaceVariant, fontSize: 16, ), prefixIcon: Icon( - Symbols.tag, + _mode == _SearchMode.phone ? Symbols.call : Symbols.tag, color: cs.onSurfaceVariant, size: 20, ), @@ -476,17 +571,11 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { FilledButton( onPressed: _loading ? null : _submit, style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: AppShape.buttonBorder, padding: const EdgeInsets.symmetric(vertical: 14), ), child: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 20) : const Text('Найти'), ), ], diff --git a/lib/frontend/screens/contacts/edit_contact_sheet.dart b/lib/frontend/screens/contacts/edit_contact_sheet.dart new file mode 100644 index 0000000..09cde8b --- /dev/null +++ b/lib/frontend/screens/contacts/edit_contact_sheet.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; + +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/frontend/screens/contacts/contact_sheet_common.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/main.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/config/app_shape.dart'; + +enum EditContactAction { updated, removed } + +class EditContactResult { + final EditContactAction action; + final String firstName; + final String lastName; + + const EditContactResult( + this.action, { + this.firstName = '', + this.lastName = '', + }); +} + +Future showEditContactSheet( + BuildContext context, { + required int contactId, + required String avatarUrl, + required String customFirst, + required String customLast, + required String onemeFirst, + required String onemeLast, +}) { + return showBlurredCard( + context, + (_) => _EditContactCard( + contactId: contactId, + avatarUrl: avatarUrl, + customFirst: customFirst, + customLast: customLast, + onemeFirst: onemeFirst, + onemeLast: onemeLast, + ), + ); +} + +class _EditContactCard extends StatefulWidget { + final int contactId; + final String avatarUrl; + final String customFirst; + final String customLast; + final String onemeFirst; + final String onemeLast; + + const _EditContactCard({ + required this.contactId, + required this.avatarUrl, + required this.customFirst, + required this.customLast, + required this.onemeFirst, + required this.onemeLast, + }); + + @override + State<_EditContactCard> createState() => _EditContactCardState(); +} + +class _EditContactCardState extends State<_EditContactCard> { + late final TextEditingController _firstCtrl; + late final TextEditingController _lastCtrl; + + bool _saving = false; + bool _deleting = false; + + @override + void initState() { + super.initState(); + _firstCtrl = TextEditingController(text: widget.customFirst); + _lastCtrl = TextEditingController(text: widget.customLast); + } + + @override + void dispose() { + _firstCtrl.dispose(); + _lastCtrl.dispose(); + super.dispose(); + } + + bool get _busy => _saving || _deleting; + + bool get _dirty => + _firstCtrl.text.trim() != widget.customFirst.trim() || + _lastCtrl.text.trim() != widget.customLast.trim(); + + Future _save() async { + if (!_dirty || _busy) return; + setState(() => _saving = true); + + final first = _firstCtrl.text.trim(); + final last = _lastCtrl.text.trim(); + final sendFirst = first.isEmpty ? widget.onemeFirst : first; + final sendLast = last.isEmpty ? widget.onemeLast : last; + + final updated = await ContactsModule.updateContact( + api, + contactId: widget.contactId, + firstName: sendFirst, + lastName: sendLast, + ); + if (!mounted) return; + + if (updated == null) { + setState(() => _saving = false); + showCustomNotification( + context, + AppLocalizations.of(context)!.editContactError, + ); + return; + } + + Navigator.of(context).pop( + EditContactResult( + EditContactAction.updated, + firstName: updated.firstName, + lastName: updated.lastName ?? '', + ), + ); + } + + Future _delete() async { + if (_busy) return; + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: AppShape.dialogBorder, + title: Text(l10n.editContactDeleteConfirmTitle), + content: Text(l10n.editContactDeleteConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.editContactDeleteCancel), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.editContactDelete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _deleting = true); + final ok = await ContactsModule.removeContact(api, widget.contactId); + if (!mounted) return; + + if (!ok) { + setState(() => _deleting = false); + showCustomNotification(context, l10n.editContactError); + return; + } + + Navigator.of( + context, + ).pop(const EditContactResult(EditContactAction.removed)); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final width = MediaQuery.sizeOf(context).width; + final avatarName = widget.customFirst.isNotEmpty + ? widget.customFirst + : widget.onemeFirst; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: Container( + width: width > 420 ? 380 : double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: KometAvatar( + name: avatarName, + size: 88, + imageUrl: widget.avatarUrl.isEmpty + ? null + : widget.avatarUrl, + ), + ), + const SizedBox(height: 20), + _inputRow( + cs, + controller: _firstCtrl, + hint: l10n.editContactFirstName, + ), + contactSheetDivider(cs), + _inputRow( + cs, + controller: _lastCtrl, + hint: l10n.editContactLastName, + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + child: _dirty + ? Padding( + padding: const EdgeInsets.only(top: 12), + child: _saveButton(cs, l10n), + ) + : const SizedBox(width: double.infinity), + ), + const SizedBox(height: 4), + _deleteButton(cs, l10n), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _inputRow( + ColorScheme cs, { + required TextEditingController controller, + required String hint, + }) { + final hasText = controller.text.isNotEmpty; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + maxLength: 60, + enabled: !_busy, + onChanged: (_) => setState(() {}), + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + counterText: '', + hintText: hint, + hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + ), + ), + ), + if (hasText) + InkWell( + onTap: _busy ? null : () => setState(() => controller.clear()), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Symbols.close, + size: 18, + color: cs.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + + Widget _saveButton(ColorScheme cs, AppLocalizations l10n) { + return SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _busy ? null : _save, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + l10n.editContactSave, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + Widget _deleteButton(ColorScheme cs, AppLocalizations l10n) { + return SizedBox( + width: double.infinity, + child: TextButton( + onPressed: _busy ? null : _delete, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + foregroundColor: cs.error, + ), + child: _deleting + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.error, + ), + ) + : Text( + l10n.editContactDelete, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index ab35f3e..7aa094e 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/contacts.dart'; import '../../../core/cache/info_cache.dart'; +import '../../../core/contacts/device_contacts_service.dart'; import '../../../core/nfc/nfc_exchange_service.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; @@ -15,6 +16,8 @@ import '../../../main.dart'; import '../../../models/contact_info.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/small_spinner.dart'; +import '../../../core/config/app_shape.dart'; enum _Stage { checking, @@ -129,6 +132,11 @@ class _NfcExchangeSheetState extends State String _peerName() { final l10n = AppLocalizations.of(context)!; + final phone = _peerPhone; + if (phone != null) { + final book = DeviceContactsService.nameForPhone(phone); + if (book != null) return book; + } return _peerInfo?.displayName ?? l10n.nfcPeerNameFallback('${_peerId ?? ''}'); } @@ -152,7 +160,10 @@ class _NfcExchangeSheetState extends State ); if (!mounted) return; setState(() => _stage = _Stage.added); - showCustomNotification(context, AppLocalizations.of(context)!.nfcContactAdded); + showCustomNotification( + context, + AppLocalizations.of(context)!.nfcContactAdded, + ); await Future.delayed(const Duration(milliseconds: 700)); if (mounted) Navigator.pop(context); } catch (e) { @@ -243,7 +254,7 @@ class _NfcExchangeSheetState extends State case _Stage.checking: return const Padding( padding: EdgeInsets.symmetric(vertical: 40), - child: CircularProgressIndicator(), + child: SmallSpinner(size: 36), ); case _Stage.unsupported: return _message(cs, Symbols.nfc, l10n.nfcUnsupported); @@ -405,7 +416,8 @@ class _NfcExchangeSheetState extends State ), const SizedBox(height: 4), Text( - formatPhone(_peerPhone) ?? l10n.nfcPeerIdFallback('${_peerId ?? ''}'), + formatPhone(_peerPhone) ?? + l10n.nfcPeerIdFallback('${_peerId ?? ''}'), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 24), @@ -414,17 +426,11 @@ class _NfcExchangeSheetState extends State child: FilledButton( onPressed: (_stage == _Stage.adding || loading) ? null : _add, style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: AppShape.buttonBorder, padding: const EdgeInsets.symmetric(vertical: 14), ), child: _stage == _Stage.adding - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 20) : Text( _stage == _Stage.added ? l10n.nfcAdded diff --git a/lib/frontend/screens/contacts/open_contact_profile.dart b/lib/frontend/screens/contacts/open_contact_profile.dart new file mode 100644 index 0000000..e93a707 --- /dev/null +++ b/lib/frontend/screens/contacts/open_contact_profile.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; +import '../chats/chat_info_screen.dart'; + +Future openContactDialogProfile( + BuildContext context, { + required int contactId, + required String name, + String? avatarUrl, +}) async { + final accountId = await TokenStorage.getActiveAccountId(); + final existing = accountId == null + ? null + : await AppDatabase.findDialogChatByParticipant(accountId, contactId); + final chatId = existing ?? ((accountId ?? 0) ^ contactId); + if (!context.mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ChatInfoScreen( + chatId: chatId, + name: name, + imageUrl: avatarUrl ?? '', + chatType: 'DIALOG', + dialogPeerId: contactId, + ), + ), + ); +} diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart index 9998de7..e8fdb80 100644 --- a/lib/frontend/screens/digital_id/digital_id_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart' + show ExpressiveRefreshIndicator; import 'package:material_symbols_icons/symbols.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../backend/modules/digital_id.dart'; -import '../../../backend/modules/webapp.dart'; import '../../../core/utils/webview_support.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show digitalIdModule; import '../../../models/digital_id.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/error_view.dart'; -import '../webapp/web_app_screen.dart'; +import '../../widgets/small_spinner.dart'; String _documentLabel(AppLocalizations l10n, String type) { return switch (type) { @@ -38,7 +41,8 @@ class DigitalIdScreen extends StatefulWidget { State createState() => _DigitalIdScreenState(); } -class _DigitalIdScreenState extends State { +class _DigitalIdScreenState extends State + with ReloadOnReconnect { bool _loading = true; bool _busy = false; String? _error; @@ -53,6 +57,9 @@ class _DigitalIdScreenState extends State { _load(); } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { setState(() { _loading = true; @@ -71,7 +78,12 @@ class _DigitalIdScreenState extends State { rethrow; } } - final cards = await digitalIdModule.getCardsList(passStatus: 'active'); + List cards; + try { + cards = await digitalIdModule.getCardsList(passStatus: 'active'); + } catch (_) { + cards = const []; + } if (!mounted) return; setState(() { _biometry = biometry; @@ -114,17 +126,13 @@ class _DigitalIdScreenState extends State { ); return; } - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => WebAppScreen( - title: AppLocalizations.of(context)!.digitalIdGosuslugiTitle, - loader: () async => WebAppLaunch(url: link.url), - ), - ), - ); - if (!mounted) return; - await _load(); + // Госуслуги (ЕСИА) открываем во ВНЕШНЕМ браузере — их антифрод режет + // встроенный webview. Возврат придёт диплинком max.ru?externalCallback=1 + // (deep_link_service -> опкод 105), после чего экран перезагрузится. + final uri = Uri.tryParse(link.url); + if (uri != null) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } } on DigitalIdException catch (e) { if (mounted) showCustomNotification(context, e.message); } catch (e) { @@ -198,7 +206,7 @@ class _DigitalIdScreenState extends State { Widget _buildBody(ColorScheme cs) { if (_loading) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } if (_error != null) { return ErrorView(message: _error!, onRetry: _load); @@ -206,7 +214,7 @@ class _DigitalIdScreenState extends State { if (_docs == null) { return _buildOnboarding(cs); } - return RefreshIndicator( + return ExpressiveRefreshIndicator( onRefresh: _load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -278,11 +286,7 @@ class _DigitalIdScreenState extends State { child: FilledButton.icon( onPressed: _busy ? null : _linkGosuslugi, icon: _busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 18) : const Icon(Symbols.link, size: 18), label: Text( l10n.digitalIdLinkGosuslugiButton, diff --git a/lib/frontend/screens/digital_id/digital_id_web_screen.dart b/lib/frontend/screens/digital_id/digital_id_web_screen.dart index 7a4e326..a217062 100644 --- a/lib/frontend/screens/digital_id/digital_id_web_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_web_screen.dart @@ -2,7 +2,9 @@ import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import '../../../main.dart' show webAppModule, digitalIdModule; +import '../../../backend/modules/webapp.dart' show WebAppLaunch; +import '../../../core/utils/logger.dart'; +import '../../../main.dart' show digitalIdModule, webAppModule; import '../webapp/web_app_screen.dart'; Future resetDigitalIdWebData() async { @@ -19,187 +21,37 @@ Future resetDigitalIdSession() async { } catch (_) {} } -const String _kBridge = r''' -(function(){ - var sawOpenLink = false; - var DBG = !!window.__KOMET_DID_DEBUG; - function log(m){ if (!DBG) return; try { console.log('[BRIDGE] ' + m); } catch(e){} } - if (DBG) { - try { - var origFetch = window.fetch; - window.fetch = function(){ - var u; - try { u = (typeof arguments[0] === 'string') ? arguments[0] : (arguments[0] && arguments[0].url); } catch(e){} - var watched = ('' + u).indexOf('ext-api') >= 0; - return origFetch.apply(this, arguments).then(function(r){ - if (watched) { - try { r.clone().text().then(function(t){ log('FETCH ' + r.status + ' ' + u + ' :: ' + t.slice(0, 200)); }); } catch(e){} - } - return r; - }).catch(function(err){ if (watched) log('FETCH ERR ' + u + ' ' + err); throw err; }); - }; - } catch(e){} - } - function ssKey(k){ return 'komet_did_ss_' + k; } - function userId(){ - try { - var h = decodeURIComponent(decodeURIComponent(location.hash || '')); - var m = h.match(/"id"\s*:\s*(\d+)/); - if (m) return m[1]; - } catch(e){} - try { - var m2 = (location.hash || '').match(/id\W{1,8}?(\d{4,})/); - if (m2) return m2[1]; - } catch(e){} - return 'anon'; - } - try { - var uid = userId(); - if (localStorage.getItem('komet_did_owner') !== uid) { - try { localStorage.clear(); } catch(e){} - try { sessionStorage.clear(); } catch(e){} - try { - if (window.indexedDB && indexedDB.databases) { - indexedDB.databases().then(function(dbs){ - (dbs || []).forEach(function(db){ try { indexedDB.deleteDatabase(db.name); } catch(e){} }); - }); - } - } catch(e){} - localStorage.setItem('komet_did_owner', uid); - } - } catch(e){} - function reply(type, data){ - setTimeout(function(){ - try { window.WebApp.receiveEvent(type, data); } catch(e){} - }, 0); - } - function bioToken(){ - try { - var k = 'komet_did_bio_token'; - var v = localStorage.getItem(k); - if (!v) { - v = ''; - for (var i = 0; i < 32; i++) v += Math.floor(Math.random() * 16).toString(16); - localStorage.setItem(k, v); - } - return v; - } catch(e){ return 'komet-did-fallback-token'; } - } - function tokenSaved(){ - try { return !!localStorage.getItem('komet_did_bio_token'); } catch(e){ return false; } - } - function handle(type, dataStr){ - var data = {}; - try { data = JSON.parse(dataStr || '{}'); } catch(e){} - log('recv ' + type + ' ' + dataStr); - var requestId = data.requestId; - switch (type) { - case 'WebAppBiometryGetInfo': - reply(type, { - requestId: requestId, available: true, - access_requested: tokenSaved(), accessRequested: tokenSaved(), - access_granted: tokenSaved(), accessGranted: tokenSaved(), - token_saved: tokenSaved(), tokenSaved: tokenSaved(), - device_id: 'komet-device', deviceId: 'komet-device', - type: 'face', biometricType: 'face' - }); - return; - case 'WebAppBiometryRequestAccess': - reply(type, { requestId: requestId, granted: true, access_granted: true, accessGranted: true, status: 'granted' }); - return; - case 'WebAppBiometryAuthenticate': - reply(type, { requestId: requestId, token: bioToken(), success: true, status: 'authenticated' }); - return; - case 'WebAppBiometryUpdateToken': - case 'WebAppBiometryUpdateBiometricToken': - reply(type, { requestId: requestId, success: true, status: 'updated' }); - return; - case 'WebAppOpenLink': - sawOpenLink = true; - if (data && data.url) { - setTimeout(function(){ - try { window.location.assign(data.url); } catch(e){} - }, 0); - } - return; - case 'WebAppClose': - if (!sawOpenLink) { - try { window.flutter_inappwebview.callHandler('closeWebApp'); } catch(e){} - } - return; - default: - if (type.indexOf('SecureStorage') >= 0 || type.indexOf('DeviceStorage') >= 0) { - var key = data.key; - if (/Set|Save|Put/i.test(type)) { - try { localStorage.setItem(ssKey(key), JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){} - reply(type, { requestId: requestId, success: true }); - } else if (/Remove|Delete|Clear/i.test(type)) { - try { localStorage.removeItem(ssKey(key)); } catch(e){} - reply(type, { requestId: requestId, success: true }); - } else { - var val = null; - try { - var raw = localStorage.getItem(ssKey(key)); - val = (raw == null) ? null : JSON.parse(raw); - } catch(e){} - reply(type, { requestId: requestId, value: val, data: val }); - } - return; - } - if (requestId != null) reply(type, { requestId: requestId }); - } - } - try { - window.WebViewHandler = { - postEvent: function(type, dataStr){ - try { handle(type, dataStr); } catch(e){} - } - }; - } catch(e){} -})(); -'''; - class DigitalIdWebScreen extends StatelessWidget { - const DigitalIdWebScreen({super.key}); + final WebAppLaunch? initialLaunch; + + const DigitalIdWebScreen({super.key, this.initialLaunch}); @override Widget build(BuildContext context) { return WebAppScreen( title: 'Цифровой ID', - loader: () => webAppModule.fetchDigitalId(), - extraUserScripts: [ - UserScript( - source: 'window.__KOMET_DID_DEBUG=$kDebugMode;', - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - UserScript( - source: _kBridge, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - ], - onWebViewCreated: (controller) { - controller.addJavaScriptHandler( - handlerName: 'closeWebApp', - callback: (args) { - if (context.mounted) Navigator.of(context).maybePop(); - return null; - }, - ); + preferSystemUserAgent: true, + privateChannel: true, + mobileIdVerifier: digitalIdModule.fetchMobileIdVerification, + loader: () async => initialLaunch ?? await webAppModule.fetchDigitalId(), + onExternalCallback: webAppModule.handleExternalCallback, + onConsoleMessage: (controller, consoleMessage) { + final msg = '[DID] ${consoleMessage.message}'; + final lvl = consoleMessage.messageLevel.toString().toUpperCase(); + if (lvl.contains('ERROR')) { + logger.e(msg); + } else if (lvl.contains('WARNING')) { + logger.w(msg); + } else { + logger.i(msg); + } }, - onConsoleMessage: kDebugMode - ? (controller, consoleMessage) { - debugPrint('[KOMET-DID] ${consoleMessage.message}'); - } - : null, - onLoadStart: kDebugMode - ? (controller, url) { - final u = url?.toString() ?? ''; - debugPrint( - '[KOMET-DID] loadStart: ${u.length > 160 ? u.substring(0, 160) : u}', - ); - } - : null, - shouldOverrideUrlLoading: (controller, action, currentUrl) async { + onLoadStart: (controller, url) { + if (url != null) { + logger.i('[DID] loadStart: ${url.scheme}://${url.host}${url.path}'); + } + }, + shouldOverrideUrlLoading: (_, action, _) async { final uri = action.request.url; final url = uri?.toString() ?? ''; final scheme = uri?.scheme ?? ''; @@ -208,19 +60,7 @@ class DigitalIdWebScreen extends StatelessWidget { '[KOMET-DID] nav: ${url.length > 140 ? url.substring(0, 140) : url}', ); } - final isCallback = - url.contains('?externalCallback=') || - url.contains('&externalCallback='); - if (isCallback || (scheme != 'http' && scheme != 'https')) { - final launchUrl = currentUrl ?? 'https://digital-id.max.ru'; - final hashIdx = launchUrl.indexOf('#'); - final base = hashIdx >= 0 - ? launchUrl.substring(0, hashIdx) - : launchUrl; - final frag = hashIdx >= 0 ? launchUrl.substring(hashIdx) : ''; - final query = uri?.query ?? ''; - final target = query.isEmpty ? launchUrl : '$base?$query$frag'; - controller.loadUrl(urlRequest: URLRequest(url: WebUri(target))); + if (scheme != 'http' && scheme != 'https') { return NavigationActionPolicy.CANCEL; } return NavigationActionPolicy.ALLOW; diff --git a/lib/frontend/screens/downloads_screen.dart b/lib/frontend/screens/downloads_screen.dart new file mode 100644 index 0000000..1a77791 --- /dev/null +++ b/lib/frontend/screens/downloads_screen.dart @@ -0,0 +1,439 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path/path.dart' as p; + +import '../../core/utils/download_history.dart'; +import '../../core/utils/format.dart'; +import '../../core/utils/save_file_as.dart'; +import '../../l10n/app_localizations.dart'; +import '../widgets/chat_menu_overlay.dart'; +import '../widgets/confirm_dialog.dart'; +import '../widgets/custom_notification.dart'; +import '../widgets/small_spinner.dart'; +import '../widgets/sheet_helpers.dart'; + +class DownloadsScreen extends StatefulWidget { + const DownloadsScreen({super.key}); + + @override + State createState() => _DownloadsScreenState(); +} + +class _DownloadsScreenState extends State { + late bool _loading = DownloadHistory.records.value.isEmpty; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + if (!_loading) { + DownloadHistory.refresh().ignore(); + return; + } + await DownloadHistory.load(); + if (mounted) setState(() => _loading = false); + await DownloadHistory.refresh(); + } + + Future _open(DownloadRecord record) async { + final file = await DownloadHistory.fileFor(record); + if (!mounted) return; + if (file == null) { + await DownloadHistory.remove(record.cacheName); + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.downloadsOpenFailed, + ); + } + return; + } + final result = await OpenFilex.open(file.path); + if (!mounted || result.type == ResultType.done) return; + showCustomNotification( + context, + AppLocalizations.of(context)!.downloadsOpenFailed, + ); + } + + Future _saveAs(DownloadRecord record) async { + final file = await DownloadHistory.fileFor(record); + if (!mounted) return; + if (file == null) { + await DownloadHistory.remove(record.cacheName); + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.downloadsOpenFailed, + ); + } + return; + } + final result = await saveFileAs( + source: file, + fileName: _saveName(record), + dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs, + ); + if (!mounted || result.cancelled) return; + showCustomNotification( + context, + result.saved ? 'Файл сохранён' : 'Не удалось сохранить файл', + ); + } + + void _goToMessage(DownloadRecord record) { + if (record.chatId == null || record.messageId?.isNotEmpty != true) return; + Navigator.of(context).pop(record); + } + + String _saveName(DownloadRecord record) { + final name = record.name.trim(); + if (name.isNotEmpty) return p.basename(name); + final extension = p.extension(record.cacheName); + final stamp = record.downloadedAt > 0 + ? record.downloadedAt + : DateTime.now().millisecondsSinceEpoch; + return switch (record.kind) { + DownloadKind.photo => + 'IMG_$stamp${extension.isEmpty ? '.jpg' : extension}', + DownloadKind.video => + 'VID_$stamp${extension.isEmpty ? '.mp4' : extension}', + DownloadKind.gif => 'GIF_$stamp${extension.isEmpty ? '.gif' : extension}', + DownloadKind.audio => + 'AUD_$stamp${extension.isEmpty ? '.ogg' : extension}', + DownloadKind.file => p.basename(record.cacheName), + }; + } + + Future _settings() async { + final l10n = AppLocalizations.of(context)!; + final clear = await showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + shape: kSheetShape, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: ListTile( + leading: Icon( + Symbols.delete_sweep, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text(l10n.downloadsClearHistory), + onTap: () => Navigator.pop(sheetContext, true), + ), + ), + ), + ); + if (clear != true || !mounted) return; + final confirmed = await showConfirmDialog( + context, + title: l10n.downloadsClearTitle, + message: l10n.downloadsClearBody, + confirmLabel: l10n.downloadsClearConfirm, + cancelLabel: MaterialLocalizations.of(context).cancelButtonLabel, + destructive: true, + ); + if (!confirmed || !mounted) return; + await DownloadHistory.clear(); + if (mounted) showCustomNotification(context, l10n.downloadsHistoryCleared); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + titleSpacing: 4, + title: Text( + l10n.downloadsTitle, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + ), + actions: [ + TextButton( + key: const ValueKey('downloads-settings'), + onPressed: _settings, + child: Text(l10n.downloadsSettings), + ), + const SizedBox(width: 8), + ], + ), + body: _loading + ? Center(child: SmallSpinner(size: 28, color: cs.primary)) + : ValueListenableBuilder>( + valueListenable: DownloadHistory.records, + builder: (context, records, _) { + if (records.isEmpty) { + return _DownloadsEmpty(label: l10n.downloadsEmpty); + } + return ListView.separated( + key: const ValueKey('downloads-list'), + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 32), + itemCount: records.length, + separatorBuilder: (_, _) => Divider( + height: 1, + indent: 92, + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + itemBuilder: (context, index) { + final record = records[index]; + return _DownloadTile( + record: record, + onTap: () => _open(record), + onSaveAs: () => _saveAs(record), + onGoToMessage: + record.chatId != null && + record.messageId?.isNotEmpty == true + ? () => _goToMessage(record) + : null, + ); + }, + ); + }, + ), + ); + } +} + +class _DownloadsEmpty extends StatelessWidget { + final String label; + + const _DownloadsEmpty({required this.label}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.download, + size: 52, + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + ), + const SizedBox(height: 12), + Text( + label, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), + ], + ), + ), + ); + } +} + +class _DownloadTile extends StatelessWidget { + final DownloadRecord record; + final VoidCallback onTap; + final VoidCallback onSaveAs; + final VoidCallback? onGoToMessage; + + const _DownloadTile({ + required this.record, + required this.onTap, + required this.onSaveAs, + required this.onGoToMessage, + }); + + void _openMenu(BuildContext context) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final l10n = AppLocalizations.of(context)!; + showChatMenu( + context: context, + anchorRect: box.localToGlobal(Offset.zero) & box.size, + items: [ + if (onGoToMessage != null) + ChatMenuItem( + icon: Symbols.visibility, + label: l10n.sharedGoToMessage, + onTap: onGoToMessage, + ), + ChatMenuItem( + icon: Symbols.download, + label: l10n.photoViewerSaveAs, + onTap: onSaveAs, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final source = record.sourceName.trim().isEmpty + ? l10n.downloadsUnknownSource + : record.sourceName.trim(); + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 10, 16, 10), + child: Row( + children: [ + _DownloadPreview(record: record), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _title(l10n), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + height: 1.15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 5), + Text( + '${formatBytes(record.size)} · $source', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + ), + ), + Builder( + builder: (buttonContext) => IconButton( + key: ValueKey('download-more-${record.cacheName}'), + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + icon: Icon(Symbols.more_vert, color: cs.onSurfaceVariant), + onPressed: () => _openMenu(buttonContext), + ), + ), + ], + ), + ), + ); + } + + String _title(AppLocalizations l10n) { + if (record.name.trim().isNotEmpty) return record.name.trim(); + return switch (record.kind) { + DownloadKind.photo => l10n.downloadsPhoto, + DownloadKind.video => l10n.downloadsVideo, + DownloadKind.gif => l10n.downloadsGif, + DownloadKind.audio => l10n.downloadsAudio, + DownloadKind.file => l10n.downloadsFile, + }; + } +} + +class _DownloadPreview extends StatefulWidget { + final DownloadRecord record; + + const _DownloadPreview({required this.record}); + + @override + State<_DownloadPreview> createState() => _DownloadPreviewState(); +} + +class _DownloadPreviewState extends State<_DownloadPreview> { + late Future _file = DownloadHistory.fileFor( + widget.record, + touch: false, + ); + + @override + void didUpdateWidget(_DownloadPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.record.cacheName != widget.record.cacheName) { + _file = DownloadHistory.fileFor(widget.record, touch: false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final preview = widget.record.thumbnailUrl; + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 58, + height: 58, + child: preview != null && preview.isNotEmpty + ? CachedNetworkImage( + imageUrl: preview, + fit: BoxFit.cover, + memCacheWidth: 160, + errorWidget: (_, _, _) => _fallback(cs), + ) + : FutureBuilder( + future: _file, + builder: (context, snapshot) { + final file = snapshot.data; + if (file != null && + (widget.record.kind == DownloadKind.photo || + widget.record.kind == DownloadKind.gif)) { + return Image.file( + file, + fit: BoxFit.cover, + cacheWidth: 160, + errorBuilder: (_, _, _) => _fallback(cs), + ); + } + return _fallback(cs); + }, + ), + ), + ); + } + + Widget _fallback(ColorScheme cs) { + final extension = p + .extension(widget.record.name) + .replaceFirst('.', '') + .toUpperCase(); + final (color, icon) = switch (widget.record.kind) { + DownloadKind.photo => (const Color(0xFF3CA95E), Symbols.image), + DownloadKind.video => (const Color(0xFF4A8FE7), Symbols.movie), + DownloadKind.gif => (const Color(0xFFE684AE), Symbols.gif_box), + DownloadKind.audio => (const Color(0xFF8C68D8), Symbols.audio_file), + DownloadKind.file => (const Color(0xFFF2B735), Symbols.description), + }; + return ColoredBox( + color: color, + child: Stack( + alignment: Alignment.center, + children: [ + Icon(icon, color: Colors.white, size: 30), + if (extension.isNotEmpty && widget.record.kind == DownloadKind.file) + Positioned( + bottom: 4, + child: Text( + extension.length > 5 ? extension.substring(0, 5) : extension, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/profile/app_icon_screen.dart b/lib/frontend/screens/profile/app_icon_screen.dart index 31a667b..b41c5a6 100644 --- a/lib/frontend/screens/profile/app_icon_screen.dart +++ b/lib/frontend/screens/profile/app_icon_screen.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../widgets/connection_status.dart'; import '../../../core/config/app_icon.dart'; import '../../../core/utils/haptics.dart'; import '../../widgets/custom_notification.dart'; -import '../../widgets/glossy_pill.dart'; import '../../widgets/settings_radio_tile.dart'; +import '../../widgets/settings_card.dart'; class AppIconScreen extends StatefulWidget { const AppIconScreen({super.key}); @@ -38,7 +39,8 @@ class _AppIconScreenState extends State { showCustomNotification(context, 'Иконка изменена на «${icon.title}»'); } catch (e) { if (!mounted) return; - showCustomNotification(context, 'Не удалось сменить иконку: $e'); + final reason = e is PlatformException ? (e.message ?? e.code) : '$e'; + showCustomNotification(context, 'Не удалось сменить иконку: $reason'); } } @@ -57,11 +59,8 @@ class _AppIconScreenState extends State { physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ - GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), + SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), - depth: 6, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index 3c19088..31e2222 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -10,12 +10,18 @@ import '../../../core/config/app_bubble_shape.dart'; import '../../../core/config/app_pill_gradient.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_chat_chrome.dart'; +import '../../../core/config/app_composer_background.dart'; +import '../../../core/config/app_composer_style.dart'; +import '../../../core/config/app_nav_pill_style.dart'; +import '../../../core/config/app_spectrum_background.dart'; import '../../../core/utils/bubble_radius.dart'; import '../../../core/utils/debouncer.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; -import '../../widgets/glossy_pill.dart'; +import '../../widgets/liquid_glass.dart'; +import '../../widgets/settings_card.dart'; +import '../../../core/config/app_shape.dart'; class AppearanceScreen extends StatefulWidget { const AppearanceScreen({super.key}); @@ -119,7 +125,13 @@ class _AppearanceScreenState extends State { const SizedBox(height: 12), const _ChatChromeCard(), const SizedBox(height: 12), + const _ComposerBarCard(), + const SizedBox(height: 12), + const _NavPillStyleCard(), + const SizedBox(height: 12), const _GradientToggleCard(), + const SizedBox(height: 12), + const _SpectrumToggleCard(), ], ), ), @@ -127,6 +139,27 @@ class _AppearanceScreenState extends State { } } +void _applyVisualStyle(VisualStyle style) { + AppVisualStyle.save(style); + if (style == VisualStyle.liquidGlass) { + if (AppNavPillStyle.current.value != NavPillStyle.auto) { + AppNavPillStyle.save(NavPillStyle.liquidGlass); + } + AppComposerBackground.save(ComposerBackground.liquidGlass); + AppChatChrome.save(ChatChromeStyle.liquidGlass); + return; + } + if (AppNavPillStyle.current.value == NavPillStyle.liquidGlass) { + AppNavPillStyle.save(NavPillStyle.frostBlur); + } + if (AppComposerBackground.current.value == ComposerBackground.liquidGlass) { + AppComposerBackground.save(ComposerBackground.frostBlur); + } + if (AppChatChrome.current.value == ChatChromeStyle.liquidGlass) { + AppChatChrome.save(ChatChromeStyle.transparent); + } +} + class _VisualStyleCard extends StatelessWidget { const _VisualStyleCard(); @@ -134,11 +167,7 @@ class _VisualStyleCard extends StatelessWidget { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), - depth: 6, + return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -159,7 +188,12 @@ class _VisualStyleCard extends StatelessWidget { ValueListenableBuilder( valueListenable: AppVisualStyle.current, builder: (context, current, _) { + final selectable = + current == VisualStyle.liquidGlass && !LiquidGlass.isSupported + ? VisualStyle.glossy + : current; return SegmentedButton( + showSelectedIcon: false, segments: [ ButtonSegment( value: VisualStyle.materialYou, @@ -169,12 +203,17 @@ class _VisualStyleCard extends StatelessWidget { value: VisualStyle.glossy, label: Text(l10n.appearanceVisualStyleGlossy), ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: VisualStyle.liquidGlass, + label: Text(l10n.appearanceVisualStyleLiquidGlass), + ), ], - selected: {current}, + selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); - AppVisualStyle.save(set.first); + _applyVisualStyle(set.first); } }, ); @@ -193,11 +232,7 @@ class _ChatChromeCard extends StatelessWidget { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), - depth: 6, + return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -218,30 +253,143 @@ class _ChatChromeCard extends StatelessWidget { ValueListenableBuilder( valueListenable: AppChatChrome.current, builder: (context, current, _) { - return SegmentedButton( + final selectable = + current == ChatChromeStyle.liquidGlass && + !LiquidGlass.isSupported + ? ChatChromeStyle.transparent + : current; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: ChatChromeStyle.color, + label: Text(l10n.appearanceChatChromeColor), + ), + ButtonSegment( + value: ChatChromeStyle.blur, + label: Text(l10n.appearanceChatChromeBlur), + ), + ButtonSegment( + value: ChatChromeStyle.none, + label: Text(l10n.appearanceChatChromeNone), + ), + ButtonSegment( + value: ChatChromeStyle.transparent, + label: Text(l10n.appearanceChatChromeTransparent), + ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: ChatChromeStyle.liquidGlass, + label: Text(l10n.appearanceGlassMaterial), + ), + ], + selected: {selectable}, + onSelectionChanged: (set) { + if (set.isNotEmpty) { + Haptics.selection(); + AppChatChrome.save(set.first); + } + }, + ), + ); + }, + ), + ], + ), + ); + } +} + +class _ComposerBarCard extends StatelessWidget { + const _ComposerBarCard(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return SettingsPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.appearanceComposerTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + l10n.appearanceComposerSubtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: AppComposerStyle.current, + builder: (context, current, _) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: ComposerStyle.auto, + label: Text(l10n.appearanceStyleAuto), + ), + ButtonSegment( + value: ComposerStyle.glossy, + label: Text(l10n.appearanceVisualStyleGlossy), + ), + ButtonSegment( + value: ComposerStyle.materialYou, + label: Text(l10n.appearanceVisualStyleMaterialYou), + ), + ], + selected: {current}, + onSelectionChanged: (set) { + if (set.isNotEmpty) { + Haptics.selection(); + AppComposerStyle.save(set.first); + } + }, + ), + ); + }, + ), + const SizedBox(height: 10), + ValueListenableBuilder( + valueListenable: AppComposerBackground.current, + builder: (context, current, _) { + final selectable = + current == ComposerBackground.liquidGlass && + !LiquidGlass.isSupported + ? ComposerBackground.frostBlur + : current; + return SegmentedButton( + showSelectedIcon: false, segments: [ ButtonSegment( - value: ChatChromeStyle.color, - label: Text(l10n.appearanceChatChromeColor), + value: ComposerBackground.standard, + label: Text(l10n.appearanceComposerBackgroundStandard), ), ButtonSegment( - value: ChatChromeStyle.blur, - label: Text(l10n.appearanceChatChromeBlur), - ), - ButtonSegment( - value: ChatChromeStyle.none, - label: Text(l10n.appearanceChatChromeNone), - ), - ButtonSegment( - value: ChatChromeStyle.transparent, - label: Text(l10n.appearanceChatChromeTransparent), + value: ComposerBackground.frostBlur, + label: Text(l10n.appearanceComposerBackgroundFrost), ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: ComposerBackground.liquidGlass, + label: Text(l10n.appearanceGlassMaterial), + ), ], - selected: {current}, + selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); - AppChatChrome.save(set.first); + AppComposerBackground.save(set.first); } }, ); @@ -253,6 +401,79 @@ class _ChatChromeCard extends StatelessWidget { } } +class _NavPillStyleCard extends StatelessWidget { + const _NavPillStyleCard(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return SettingsPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.appearanceNavPillTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + l10n.appearanceNavPillSubtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: AppNavPillStyle.current, + builder: (context, current, _) { + final selectable = + current == NavPillStyle.liquidGlass && + !LiquidGlass.isSupported + ? NavPillStyle.frostBlur + : current; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: NavPillStyle.auto, + label: Text(l10n.appearanceStyleAuto), + ), + ButtonSegment( + value: NavPillStyle.glossy, + label: Text(l10n.appearanceNavPillGlossy), + ), + ButtonSegment( + value: NavPillStyle.frostBlur, + label: Text(l10n.appearanceNavPillFrost), + ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: NavPillStyle.liquidGlass, + label: Text(l10n.appearanceGlassMaterial), + ), + ], + selected: {selectable}, + onSelectionChanged: (set) { + if (set.isNotEmpty) { + Haptics.selection(); + AppNavPillStyle.save(set.first); + } + }, + ), + ); + }, + ), + ], + ), + ); + } +} + class _GradientToggleCard extends StatelessWidget { const _GradientToggleCard(); @@ -260,11 +481,8 @@ class _GradientToggleCard extends StatelessWidget { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), + return SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), - depth: 6, child: Row( children: [ Icon(Symbols.blur_on, color: cs.onSurface, size: 24, weight: 500), @@ -305,6 +523,55 @@ class _GradientToggleCard extends StatelessWidget { } } +class _SpectrumToggleCard extends StatelessWidget { + const _SpectrumToggleCard(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return SettingsPanel( + padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), + child: Row( + children: [ + Icon(Symbols.graphic_eq, color: cs.onSurface, size: 24, weight: 500), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.appearanceSpectrumTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + l10n.appearanceSpectrumSubtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: AppSpectrumBackground.current, + builder: (context, value, _) => Switch( + value: value, + onChanged: (v) { + Haptics.selection(); + AppSpectrumBackground.save(v); + }, + ), + ), + ], + ), + ); + } +} + class _PreviewSection extends StatefulWidget { final ValueNotifier color; final ValueNotifier isSystem; @@ -404,11 +671,9 @@ class _ChatPreview extends StatelessWidget { builder: (context, _) { final style = AppBubbleShape.current.value; final behavior = AppBubbleBehavior.current.value; - return GlossyPill( + return SettingsPanel( color: cs.surfaceContainerLow, - borderRadius: BorderRadius.circular(28), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - depth: 6, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -502,13 +767,15 @@ class _ColorPickerCard extends StatelessWidget { ); } - Widget _buildBody(ColorScheme cs, AppLocalizations l10n, Color col, bool sys) { + Widget _buildBody( + ColorScheme cs, + AppLocalizations l10n, + Color col, + bool sys, + ) { final swatchColor = sys ? cs.primary : col; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - depth: 6, + return SettingsPanel( child: Column( children: [ InkWell( @@ -587,9 +854,7 @@ class _ColorPickerCard extends StatelessWidget { child: FilledButton.tonal( onPressed: sys ? null : onReset, style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: AppShape.buttonBorder, ), child: Row( mainAxisSize: MainAxisSize.min, @@ -630,11 +895,7 @@ class _BubbleShapeCard extends StatelessWidget { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), - depth: 6, + return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -691,11 +952,7 @@ class _BubbleBehaviorCard extends StatelessWidget { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), - depth: 6, + return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/frontend/screens/profile/blacklist_screen.dart b/lib/frontend/screens/profile/blacklist_screen.dart new file mode 100644 index 0000000..aaf86e8 --- /dev/null +++ b/lib/frontend/screens/profile/blacklist_screen.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/account.dart' show BlockedContact; +import '../../../backend/modules/contacts.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; +import '../../../core/utils/names.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; +import '../../widgets/connection_status.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/glossy_pill.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/reload_on_reconnect.dart'; +import '../../widgets/small_spinner.dart'; +import '../contacts/open_contact_profile.dart'; + +class BlacklistScreen extends StatefulWidget { + const BlacklistScreen({super.key, this.initialContacts}); + + final List? initialContacts; + + @override + State createState() => _BlacklistScreenState(); +} + +class _BlacklistScreenState extends State + with ReloadOnReconnect { + late List _contacts = widget.initialContacts ?? const []; + late bool _isLoading = widget.initialContacts == null; + final Set _pending = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void reloadAfterReconnect() => _load(); + + Future _load() async { + try { + final contacts = await accountModule.getBlockedContacts(); + if (!mounted) return; + setState(() { + _contacts = contacts; + _isLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _isLoading = false); + showCustomNotification( + context, + AppLocalizations.of(context)!.blacklistLoadError, + ); + } + } + + String _nameOf(BlockedContact contact) => displayName( + contact.firstName, + contact.lastName, + fallback: 'ID ${contact.id}', + ); + + Future _unblock(BlockedContact contact) async { + if (_pending.contains(contact.id)) return; + final l10n = AppLocalizations.of(context)!; + setState(() => _pending.add(contact.id)); + final ok = await ContactsModule.setBlocked(api, contact.id, false); + if (!mounted) return; + setState(() { + _pending.remove(contact.id); + if (ok) _contacts = _contacts.where((c) => c.id != contact.id).toList(); + }); + showCustomNotification( + context, + ok ? l10n.chatInfoUnblockDone : l10n.chatInfoBlockFailed, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Column( + children: [ + _buildAppBar(cs), + Expanded(child: _buildBody(cs)), + ], + ), + ), + ); + } + + Widget _buildAppBar(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + ConnectionTitleText( + AppLocalizations.of(context)!.securityBlacklistTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + ], + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_isLoading) return const Center(child: SmallSpinner(size: 36)); + if (_contacts.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.block, size: 48, color: cs.outline, weight: 400), + const SizedBox(height: 12), + Text( + AppLocalizations.of(context)!.blacklistEmpty, + style: TextStyle(color: cs.outline, fontSize: 15), + ), + ], + ), + ); + } + return ListView.separated( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), + itemCount: _contacts.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) => _buildTile(cs, _contacts[index]), + ); + } + + Widget _buildTile(ColorScheme cs, BlockedContact contact) { + final name = _nameOf(contact); + final busy = _pending.contains(contact.id); + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: AppShape.cardRadius, + depth: 6, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => openContactDialogProfile( + context, + contactId: contact.id, + name: name, + avatarUrl: contact.baseUrl, + ), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + KometAvatar(name: name, size: 44, imageUrl: contact.baseUrl), + const SizedBox(width: 14), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + busy + ? SmallSpinner(size: 20, color: cs.primary) + : TextButton( + onPressed: () => _unblock(contact), + child: Text( + AppLocalizations.of(context)!.chatInfoMenuUnblock, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/chat_background_screen.dart b/lib/frontend/screens/profile/chat_background_screen.dart index 0a10d4a..bde3f98 100644 --- a/lib/frontend/screens/profile/chat_background_screen.dart +++ b/lib/frontend/screens/profile/chat_background_screen.dart @@ -8,6 +8,8 @@ import '../../widgets/chat_wallpaper_sheet.dart'; import '../../widgets/chat_wallpaper_view.dart'; import '../../widgets/custom_notification.dart'; import '../chats/chat_wallpaper_preview_screen.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; class ChatBackgroundScreen extends StatefulWidget { const ChatBackgroundScreen({super.key}); @@ -33,8 +35,10 @@ class _ChatBackgroundScreenState extends State { if (!mounted) return; setState(() { _accountId = profile?.id ?? 0; - _wallpaper = ChatWallpaperStore.instance - .get(_accountId, kGlobalWallpaperChatId); + _wallpaper = ChatWallpaperStore.instance.get( + _accountId, + kGlobalWallpaperChatId, + ); _ready = true; }); } @@ -42,8 +46,10 @@ class _ChatBackgroundScreenState extends State { void _refresh() { if (!mounted) return; setState(() { - _wallpaper = ChatWallpaperStore.instance - .get(_accountId, kGlobalWallpaperChatId); + _wallpaper = ChatWallpaperStore.instance.get( + _accountId, + kGlobalWallpaperChatId, + ); }); } @@ -109,12 +115,12 @@ class _ChatBackgroundScreenState extends State { appBar: AppBar( backgroundColor: cs.surface, surfaceTintColor: Colors.transparent, - title: const Text( + title: Text( 'Фон чатов', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), @@ -134,7 +140,7 @@ class _ChatBackgroundScreenState extends State { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: ClipRRect( - borderRadius: BorderRadius.circular(28), + borderRadius: AppShape.cardRadius, child: Stack( fit: StackFit.expand, children: [ @@ -222,7 +228,7 @@ class _ChatBackgroundScreenState extends State { color: cs.onPrimary, fontSize: 16, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), @@ -250,6 +256,7 @@ class _SampleBubbles extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _bubble( + context, text: 'Единый фон для всех чатов', color: cs.surfaceContainerHighest.withValues(alpha: 0.94), textColor: cs.onSurface, @@ -257,6 +264,7 @@ class _SampleBubbles extends StatelessWidget { ), const SizedBox(height: 8), _bubble( + context, text: 'Красиво ✨', color: cs.primary, textColor: cs.onPrimary, @@ -268,7 +276,8 @@ class _SampleBubbles extends StatelessWidget { ); } - Widget _bubble({ + Widget _bubble( + BuildContext context, { required String text, required Color color, required Color textColor, @@ -289,7 +298,7 @@ class _SampleBubbles extends StatelessWidget { style: TextStyle( color: textColor, fontSize: 15, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 4016087..979ab41 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -9,15 +9,18 @@ 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'; import '../../../main.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; +import '../../../core/config/app_shape.dart'; enum _EnvState { loading, notConfigured, ready } @@ -29,7 +32,7 @@ class CloudStorageScreen extends StatefulWidget { } class _CloudStorageScreenState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, ReloadOnReconnect { static const _translateFactor = 0.7; static const _horizontalPadding = 32.0; static const _hintSidePadding = 35.0; @@ -50,6 +53,8 @@ class _CloudStorageScreenState extends State bool _isUploading = false; final ValueNotifier _uploadProgress = ValueNotifier(0); bool _animateNewCard = false; + StreamSubscription? _uploadEventSub; + UploadJob? _uploadJob; @override void initState() { @@ -58,47 +63,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(); @@ -184,6 +215,17 @@ class _CloudStorageScreenState extends State } } + @override + void reloadAfterReconnect() { + final accountId = _accountId; + final groupId = _envGroupId; + if (accountId == null || groupId == null) { + _checkEnv(); + return; + } + unawaited(_loadFiles(accountId, groupId)); + } + Future _loadFiles(int accountId, int chatId) async { final files = await CloudStorageModule.fetchFiles( messagesModule, @@ -192,6 +234,7 @@ class _CloudStorageScreenState extends State ); if (!mounted) return; setState(() => _files = files.reversed.toList()); + _syncUploadJob(); } void _prependFile(CloudFile file) { @@ -255,13 +298,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() { @@ -275,8 +322,8 @@ class _CloudStorageScreenState extends State backgroundColor: Colors.transparent, builder: (_) => _SendByIdSheet( onSend: (id) async { - final ok = await messagesModule.sendFileMessage(chatId, id); - if (!ok) return false; + final sentId = await messagesModule.sendFileMessage(chatId, id); + if (sentId == null) return false; final newest = await CloudStorageModule.fetchLatestFile( messagesModule, accountId, @@ -333,7 +380,7 @@ class _CloudStorageScreenState extends State ), ), body: switch (_envState) { - _EnvState.loading => const Center(child: CircularProgressIndicator()), + _EnvState.loading => const Center(child: SmallSpinner(size: 36)), _EnvState.notConfigured => _buildNotConfigured(cs), _EnvState.ready => _buildReady(cs), }, @@ -370,19 +417,10 @@ class _CloudStorageScreenState extends State horizontal: 32, vertical: 14, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: AppShape.buttonBorder, ), child: _isCreatingEnv - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) + ? SmallSpinner(size: 18, color: cs.onPrimary) : Text( l10n.cloudStorageStart, style: const TextStyle( @@ -569,9 +607,7 @@ class _CloudStorageScreenState extends State horizontal: 32, vertical: 14, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: AppShape.buttonBorder, ), child: Text( l10n.cloudStorageUpload, @@ -998,7 +1034,9 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { return Container( decoration: BoxDecoration( color: cs.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppShape.sheet), + ), ), padding: EdgeInsets.fromLTRB( 24, @@ -1053,14 +1091,7 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { ), const SizedBox(width: 8), _loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), - ) + ? SmallSpinner(size: 20, color: cs.primary) : IconButton( icon: Icon( isExpired ? Symbols.add_link : Symbols.content_copy, @@ -1167,7 +1198,9 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { return Container( decoration: BoxDecoration( color: cs.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppShape.sheet), + ), ), padding: EdgeInsets.fromLTRB( 24, @@ -1216,19 +1249,10 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { onPressed: _sending ? null : _submit, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: AppShape.buttonBorder, ), child: _sending - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) + ? SmallSpinner(size: 18, color: cs.onPrimary) : Text( l10n.cloudStorageSend, style: const TextStyle(fontWeight: FontWeight.w600), diff --git a/lib/frontend/screens/profile/customization_section.dart b/lib/frontend/screens/profile/customization_section.dart index 1073de2..b0aac73 100644 --- a/lib/frontend/screens/profile/customization_section.dart +++ b/lib/frontend/screens/profile/customization_section.dart @@ -40,7 +40,7 @@ class _CustomizationSectionState extends State { builder: (context) => const ThemeSettingsScreen(), ), _CustomizationCategory( - icon: Symbols.palette, + icon: Symbols.styler, title: 'Внешний вид', builder: (context) => const AppearanceScreen(), ), diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 81fa819..9a8b61d 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,8 +1,3 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; @@ -10,8 +5,6 @@ import '../../../core/calls/call_controller.dart'; import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; -import '../../../core/transport/traffic_monitor.dart'; -import '../../../core/utils/debug_session_log.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/media_cache.dart'; @@ -20,6 +13,8 @@ import '../../debug/cache_section.dart'; import '../../debug/feature_toggles_section.dart'; import '../../debug/header_section.dart'; import '../../debug/id_search_section.dart'; +import '../../debug/log_export.dart'; +import '../../debug/lottie_polygon_section.dart'; import '../../debug/network_section.dart'; import '../../debug/previews_section.dart'; import '../../debug/quick_actions_section.dart'; @@ -68,38 +63,6 @@ class _DebugMenuScreenState extends State { if (mounted) setState(() => _cacheSize = size); } - Future _exportDebugLog() async { - final content = await DebugSessionLog.instance.buildExport( - endpoint: TrafficMonitor.instance.activeEndpoint, - ); - if (content == null) { - if (mounted) showCustomNotification(context, 'Лог пуст'); - return; - } - final bytes = Uint8List.fromList(utf8.encode(content)); - final fileName = 'komet_debug_${formatFileStamp(DateTime.now())}.txt'; - final isMobile = Platform.isAndroid || Platform.isIOS; - try { - final path = await FilePicker.platform.saveFile( - dialogTitle: 'Сохранить отладочный лог', - fileName: fileName, - type: FileType.any, - bytes: isMobile ? bytes : null, - ); - if (path == null) return; - if (!isMobile) { - await File(path).writeAsBytes(bytes); - } - if (mounted) { - showCustomNotification(context, 'Лог сохранён: $path'); - } - } catch (e) { - if (mounted) { - showCustomNotification(context, 'Не удалось сохранить лог: $e'); - } - } - } - Future _clearCache() async { if (_clearingCache) return; setState(() => _clearingCache = true); @@ -255,8 +218,11 @@ class _DebugMenuScreenState extends State { ), ), SliverToBoxAdapter( - child: DebugQuickActionsSection(onExportLog: _exportDebugLog), + child: DebugQuickActionsSection( + onExportLog: () => exportDebugLog(context), + ), ), + const SliverToBoxAdapter(child: DebugLottiePolygonSection()), SliverToBoxAdapter(child: DebugNetworkSection(appState: appState)), const SliverToBoxAdapter(child: DebugFeatureTogglesSection()), SliverToBoxAdapter( diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 53a15f3..eb50260 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -12,10 +12,13 @@ import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/prompt_dialog.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/web_qr_login.dart'; import 'web_qr_scan_screen.dart'; +import '../../../core/config/app_fonts.dart'; class DevicesScreen extends StatefulWidget { const DevicesScreen({super.key}); @@ -25,7 +28,7 @@ class DevicesScreen extends StatefulWidget { } class _DevicesScreenState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, ReloadOnReconnect { bool _isLoading = true; List _sessions = []; final Map> _ipDetails = {}; @@ -49,6 +52,9 @@ class _DevicesScreenState extends State super.dispose(); } + @override + void reloadAfterReconnect() => _loadSessions(); + Future _loadSessions() async { try { final sessions = await accountModule.getSessions(); @@ -207,7 +213,7 @@ class _DevicesScreenState extends State title: ConnectionTitleText( l10n.devicesTitle, style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontSize: 20, fontWeight: FontWeight.w600, color: cs.onSurface, @@ -259,7 +265,7 @@ class _DevicesScreenState extends State Text( l10n.devicesPromoTitle, style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontSize: 18, fontWeight: FontWeight.w700, color: cs.onSurface, @@ -282,7 +288,7 @@ class _DevicesScreenState extends State label: Text( l10n.devicesScanQrButton, style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontSize: 15, fontWeight: FontWeight.w600, ), @@ -458,7 +464,7 @@ class _DevicesScreenState extends State Text( title, style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontSize: 16, fontWeight: FontWeight.w700, color: cs.onSurface, @@ -533,14 +539,10 @@ class _DevicesScreenState extends State child: Container( padding: const EdgeInsets.all(4), child: isLoading - ? SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onSurfaceVariant.withValues( - alpha: 0.5, - ), + ? SmallSpinner( + size: 14, + color: cs.onSurfaceVariant.withValues( + alpha: 0.5, ), ) : Icon( diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index 1bc23c6..dd2eb7a 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -8,6 +8,7 @@ import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/small_spinner.dart'; class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -180,11 +181,7 @@ class _EditProfileScreenState extends State { TextButton( onPressed: _isLoading || _isSaving ? null : _saveName, child: _isSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 16) : Text( l10n?.editProfileSave ?? 'Save', style: TextStyle( @@ -196,7 +193,7 @@ class _EditProfileScreenState extends State { ], ), body: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : ListView( padding: const EdgeInsets.all(16), children: [ diff --git a/lib/frontend/screens/profile/font_settings_screen.dart b/lib/frontend/screens/profile/font_settings_screen.dart index 5e255f8..af84f86 100644 --- a/lib/frontend/screens/profile/font_settings_screen.dart +++ b/lib/frontend/screens/profile/font_settings_screen.dart @@ -9,8 +9,8 @@ import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; -import '../../widgets/glossy_pill.dart'; import '../../widgets/prompt_dialog.dart'; +import '../../widgets/settings_card.dart'; class FontSettingsScreen extends StatefulWidget { const FontSettingsScreen({super.key}); @@ -198,11 +198,8 @@ class _PreviewCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), + return SettingsPanel( padding: const EdgeInsets.all(24), - depth: 6, child: SizedBox( width: double.infinity, child: Column( @@ -337,11 +334,8 @@ class _FontSizeControl extends StatelessWidget { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDefault = (scale - AppFonts.defaultScale).abs() < 0.001; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), + return SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 16, 12, 16), - depth: 6, child: Column( children: [ Row( diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index b528f40..aea4395 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -9,6 +9,7 @@ import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/small_spinner.dart'; class InfoScreen extends StatefulWidget { const InfoScreen({super.key}); @@ -68,7 +69,7 @@ class _InfoScreenState extends State { ), ), body: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : _info == null ? Center( child: Text( @@ -82,9 +83,12 @@ class _InfoScreenState extends State { Widget _buildContent(ColorScheme cs, AppLocalizations l10n) { final info = _info!; - final server = info['server'] as Map?; - final user = info['user'] as Map?; - final yMap = server?['y-map'] as Map?; + final chats = _asStringMap(info['chats']); + final server = _asStringMap(info['server']); + final user = _asStringMap(info['user']); + final experiments = _asStringMap(info['experiments']); + final chatSettings = _asStringMap(info['chatSettings']); + final yMap = _asStringMap(server?['y-map']); final accountKeys = { 'registrationTime': l10n.infoRegistrationTime, @@ -92,7 +96,36 @@ class _InfoScreenState extends State { 'videoChatHistory': l10n.infoVideoChatHistory, 'updateTime': l10n.infoUpdateTime, 'id': l10n.infoId, + 'phone': l10n.infoPhone, + 'photoId': l10n.infoPhotoId, + 'accountStatus': l10n.infoAccountStatus, + 'contactOptions': l10n.infoContactOptions, + 'profileOptions': l10n.infoProfileOptions, + 'names': l10n.infoNames, + 'baseUrl': l10n.infoBaseUrl, + 'baseRawUrl': l10n.infoBaseRawUrl, + }; + + final packetKeys = { 'chatMarker': l10n.infoChatMarker, + 'time': l10n.infoServerTime, + 'updates': l10n.infoUpdates, + 'messagesCount': l10n.infoMessagesCount, + 'contactsCount': l10n.infoContactsCount, + 'presenceCount': l10n.infoPresenceCount, + 'configHash': l10n.infoConfigHash, + }; + + final chatKeys = { + 'count': l10n.infoChatsCount, + 'active': l10n.infoChatsActive, + 'hidden': l10n.infoChatsHidden, + 'dialogs': l10n.infoChatsDialogs, + 'groups': l10n.infoChatsGroups, + 'channels': l10n.infoChatsChannels, + 'unread': l10n.infoChatsUnread, + 'newMessages': l10n.infoChatsNewMessages, + 'messages': l10n.infoChatsMessages, }; final serverKeys = { @@ -116,66 +149,153 @@ class _InfoScreenState extends State { 'reactions-enabled': l10n.infoReactionsEnabled, }; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - SectionHeader(l10n.infoAccountSection), - ...accountKeys.entries.map( - (e) => - _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs), - ), + return SelectionArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + SectionHeader(l10n.infoAccountSection), + ...accountKeys.entries.map( + (entry) => + _buildDataRow(entry.key, entry.value, info[entry.key], cs), + ), - const SizedBox(height: 16), - SectionHeader(l10n.infoServerSection), - ...serverKeys.entries.map( - (e) => _buildRow( - e.key, - e.value, - _formatValue(server?[e.key], e.key), + const SizedBox(height: 16), + SectionHeader(l10n.infoPacketSection), + ...packetKeys.entries.map( + (entry) => + _buildDataRow(entry.key, entry.value, info[entry.key], cs), + ), + + const SizedBox(height: 16), + SectionHeader(l10n.infoChatsSection), + ...chatKeys.entries.map( + (entry) => _buildDataRow( + 'chats.${entry.key}', + entry.value, + chats?[entry.key], + cs, + ), + ), + + const SizedBox(height: 16), + SectionHeader(l10n.infoServerSection), + ...serverKeys.entries.map( + (entry) => + _buildDataRow(entry.key, entry.value, server?[entry.key], cs), + ), + ..._buildDynamicRows( + server, + cs, + excludedKeys: { + ...serverKeys.keys, + 'y-map', + 'file-upload-unsupported-types', + 'white-list-links', + }, + ), + + const SizedBox(height: 8), + SectionHeader(l10n.infoYMapSection), + _buildDataRow('y-map.tile', l10n.infoTile, yMap?['tile'], cs), + _buildDataRow( + 'y-map.geocoder', + l10n.infoGeocoder, + yMap?['geocoder'], cs, ), - ), + _buildDataRow('y-map.static', l10n.infoStatic, yMap?['static'], cs), - const SizedBox(height: 8), - SectionHeader(l10n.infoYMapSection), - _buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs), - _buildRow( - 'geocoder', - l10n.infoGeocoder, - yMap?['geocoder']?.toString() ?? '-', - cs, - ), - _buildRow( - 'static', - l10n.infoStatic, - yMap?['static']?.toString() ?? '-', - cs, - ), + const SizedBox(height: 8), + SectionHeader(l10n.infoFileUploadTypes), + _buildListValueRow( + 'file-upload-unsupported-types', + l10n.infoFileUploadTypes, + server?['file-upload-unsupported-types'] as List?, + cs, + showLabel: false, + ), - const SizedBox(height: 8), - SectionHeader(l10n.infoFileUploadTypes), - _buildListRow(server?['file-upload-unsupported-types'] as List?, cs), + const SizedBox(height: 8), + SectionHeader(l10n.infoWhiteListLinks), + _buildListValueRow( + 'white-list-links', + l10n.infoWhiteListLinks, + server?['white-list-links'] as List?, + cs, + showLabel: false, + ), - const SizedBox(height: 8), - SectionHeader(l10n.infoWhiteListLinks), - _buildListRow(server?['white-list-links'] as List?, cs), + if (chatSettings != null && chatSettings.isNotEmpty) ...[ + const SizedBox(height: 8), + SectionHeader(l10n.infoChatSettingsSection), + ..._buildDynamicRows(chatSettings, cs), + ], - const SizedBox(height: 8), - SectionHeader(l10n.infoUserSection), - if (user != null) - ...user.entries - .where((e) => e.value != null) - .map((e) => _buildRow(e.key, e.key, e.value.toString(), cs)), + if (experiments != null && experiments.isNotEmpty) ...[ + const SizedBox(height: 8), + SectionHeader(l10n.infoExperimentsSection), + ..._buildDynamicRows(experiments, cs), + ], - const SizedBox(height: 120), - ], + const SizedBox(height: 8), + SectionHeader(l10n.infoUserSection), + ..._buildDynamicRows(user, cs), + + const SizedBox(height: 120), + ], + ), ); } + List _buildDynamicRows( + Map? values, + ColorScheme cs, { + Set excludedKeys = const {}, + }) { + if (values == null) return []; + final entries = >[]; + for (final entry in values.entries) { + if (excludedKeys.contains(entry.key) || entry.value == null) continue; + _flattenEntry(entry.key, entry.value, entries); + } + entries.sort((a, b) => a.key.compareTo(b.key)); + return entries + .map((entry) => _buildDataRow(entry.key, entry.key, entry.value, cs)) + .toList(); + } + + void _flattenEntry( + String key, + dynamic value, + List> target, + ) { + final map = _asStringMap(value); + if (map == null || map.isEmpty) { + target.add(MapEntry(key, value)); + return; + } + for (final entry in map.entries) { + _flattenEntry('$key.${entry.key}', entry.value, target); + } + } + + Widget _buildDataRow( + String key, + String label, + dynamic value, + ColorScheme cs, + ) { + if (value is List) { + return _buildListValueRow(key, label, value, cs); + } + return _buildRow(key, label, _formatValue(value, key), cs); + } + Widget _buildRow(String key, String label, String value, ColorScheme cs) { return Padding( padding: const EdgeInsets.only(bottom: 1), child: GlossyPill( + key: ValueKey('info-$key'), color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), @@ -212,42 +332,75 @@ class _InfoScreenState extends State { ); } - Widget _buildListRow(List? items, ColorScheme cs) { - if (items == null || items.isEmpty) { - return GlossyPill( + Widget _buildListValueRow( + String key, + String label, + List? items, + ColorScheme cs, { + bool showLabel = true, + }) { + final values = items ?? const []; + final simple = values.every( + (item) => item == null || item is String || item is num || item is bool, + ); + return Padding( + padding: const EdgeInsets.only(bottom: 1), + child: GlossyPill( + key: ValueKey('info-$key'), color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + padding: const EdgeInsets.all(16), depth: 6, - child: Text('-', style: TextStyle(color: cs.onSurfaceVariant)), - ); - } - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - padding: const EdgeInsets.all(16), - depth: 6, - child: Wrap( - spacing: 8, - runSpacing: 4, - children: items - .map( - (item) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - item.toString(), - style: TextStyle(fontSize: 13, color: cs.onSurface), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showLabel) ...[ + Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, ), ), - ) - .toList(), + const SizedBox(height: 8), + ], + if (values.isEmpty) + Text('-', style: TextStyle(color: cs.onSurfaceVariant)) + else if (simple) + Wrap( + spacing: 8, + runSpacing: 4, + children: values + .map( + (item) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _formatValue(item, key), + style: TextStyle(fontSize: 13, color: cs.onSurface), + ), + ), + ) + .toList(), + ) + else + Text( + const JsonEncoder.withIndent(' ').convert(values), + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), ); } @@ -258,6 +411,7 @@ class _InfoScreenState extends State { final ts = value['chatMarker'] as int?; return ts != null ? _formatTs(ts) : '-'; } + if (key == 'phone' && value is num && value > 0) return '+$value'; if (value is int && value > 1000000000000) return _formatTs(value); if (key == 'edit-timeout' && value is int && value > 0) { final weeks = value ~/ 604800; @@ -279,4 +433,9 @@ class _InfoScreenState extends State { return '${dt.year}-${pad2(dt.month)}-${pad2(dt.day)} ' '${pad2(dt.hour)}:${pad2(dt.minute)}:${pad2(dt.second)}'; } + + Map? _asStringMap(dynamic value) { + if (value is! Map) return null; + return value.map((key, item) => MapEntry(key.toString(), item)); + } } diff --git a/lib/frontend/screens/profile/lottie_polygon_screen.dart b/lib/frontend/screens/profile/lottie_polygon_screen.dart new file mode 100644 index 0000000..a9fa429 --- /dev/null +++ b/lib/frontend/screens/profile/lottie_polygon_screen.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_animations.dart'; +import '../../../core/config/app_fonts.dart'; + +class LottiePolygonScreen extends StatelessWidget { + const LottiePolygonScreen({super.key}); + + static const List<_PolygonEntry> _entries = [ + _PolygonEntry('call', AppAnimations.call), + _PolygonEntry('settings', AppAnimations.settings), + _PolygonEntry('search', AppAnimations.search), + _PolygonEntry('clock', AppAnimations.clock), + _PolygonEntry('chat', AppAnimations.chat), + _PolygonEntry('contacts', AppAnimations.contacts), + ]; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Symbols.chevron_left, size: 28), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Lottie полигон', + style: TextStyle( + fontFamily: displayFontOf(context), + fontSize: 20, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + centerTitle: true, + ), + body: SafeArea( + top: false, + child: GridView.count( + padding: const EdgeInsets.all(16), + crossAxisCount: 3, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + children: [for (final entry in _entries) _PolygonTile(entry: entry)], + ), + ), + ); + } +} + +class _PolygonEntry { + final String label; + final String asset; + + const _PolygonEntry(this.label, this.asset); +} + +class _PolygonTile extends StatefulWidget { + final _PolygonEntry entry; + + const _PolygonTile({required this.entry}); + + @override + State<_PolygonTile> createState() => _PolygonTileState(); +} + +class _PolygonTileState extends State<_PolygonTile> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _controller.forward(from: 0), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: 56, + child: Lottie.asset( + widget.entry.asset, + controller: _controller, + fit: BoxFit.contain, + delegates: LottieDelegates( + values: [ + ValueDelegate.color(const ['**'], value: cs.onSurface), + ValueDelegate.strokeColor(const [ + '**', + ], value: cs.onSurface), + ], + ), + onLoaded: (composition) { + _controller.duration = composition.duration; + }, + ), + ), + const SizedBox(height: 10), + Text( + widget.entry.label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/message_actions_screen.dart b/lib/frontend/screens/profile/message_actions_screen.dart index b6591e7..39714de 100644 --- a/lib/frontend/screens/profile/message_actions_screen.dart +++ b/lib/frontend/screens/profile/message_actions_screen.dart @@ -5,8 +5,8 @@ import '../../widgets/connection_status.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/utils/haptics.dart'; -import '../../widgets/glossy_pill.dart'; import '../../widgets/settings_radio_tile.dart'; +import '../../widgets/settings_card.dart'; class MessageActionsScreen extends StatelessWidget { const MessageActionsScreen({super.key}); @@ -53,11 +53,8 @@ class _StyleCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), + return SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), - depth: 6, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index bb8fd25..5197ee6 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -1,13 +1,21 @@ +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/push/fkm_bridge.dart'; +import '../../../core/push/fkm_controller.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, isOnemeFlavor; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/section_header.dart'; import '../../widgets/settings_card.dart'; +import '../../widgets/small_spinner.dart'; +import 'web_push_screen.dart'; class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); @@ -16,9 +24,11 @@ class NotificationsScreen extends StatefulWidget { State createState() => _NotificationsScreenState(); } -class _NotificationsScreenState extends State { +class _NotificationsScreenState extends State + with ReloadOnReconnect { bool _loading = true; bool _saving = false; + bool _fkmBusy = false; bool _allNotifications = true; bool _messagePreview = true; @@ -33,6 +43,9 @@ class _NotificationsScreenState extends State { _load(); } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { final config = await accountModule.getPrivacyConfig(); if (!mounted) return; @@ -77,16 +90,63 @@ class _NotificationsScreenState extends State { if (mounted) setState(() => _hapticsEnabled = value); } - void _onFkmTap() { - final l10n = AppLocalizations.of(context)!; - showCustomNotification( - context, - isOnemeFlavor - ? l10n.notificationsFkmAlreadyHasFcm - : l10n.notificationsFkmDownloadFcm, + void _openWebPush() { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const WebPushScreen()), ); } + Future _onFkmChanged(bool value) async { + final l10n = AppLocalizations.of(context)!; + if (!FkmController.instance.isSupported) { + showCustomNotification( + context, + Platform.isIOS + ? l10n.notificationsFkmIosUnsupported + : l10n.notificationsFkmUnsupported, + ); + return; + } + if (_fkmBusy) return; + + if (value && isOnemeFlavor) { + final confirmed = await showConfirmDialog( + context, + title: l10n.notificationsFkmAlreadyHasFcm, + message: l10n.notificationsFkmConfirmMessage, + confirmLabel: l10n.notificationsFkmConfirmAction, + ); + if (!confirmed) return; + } + + setState(() => _fkmBusy = true); + try { + final applied = await FkmController.instance.setEnabled(value); + if (!mounted) return; + if (!applied) { + showCustomNotification(context, l10n.notificationsFkmPermissionDenied); + return; + } + if (value) await _offerBatteryExemption(); + } finally { + if (mounted) setState(() => _fkmBusy = false); + } + } + + Future _offerBatteryExemption() async { + if (await FkmBridge.instance.isIgnoringBatteryOptimizations()) return; + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final confirmed = await showConfirmDialog( + context, + title: l10n.notificationsFkmBatteryTitle, + message: l10n.notificationsFkmBatteryMessage, + confirmLabel: l10n.notificationsFkmBatteryAction, + ); + if (!confirmed) return; + await FkmBridge.instance.requestIgnoreBatteryOptimizations(); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -101,11 +161,24 @@ class _NotificationsScreenState extends State { body: SafeArea( top: false, child: _loading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : ListView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ + if (Platform.isIOS) ...[ + SettingsCard( + children: [ + SettingsNavTile( + icon: Symbols.install_mobile, + label: l10n.webPushTitle, + onTap: _openWebPush, + isLast: true, + ), + ], + ), + const SizedBox(height: 20), + ], SectionHeader( l10n.notificationsFkmSectionTitle, padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), @@ -113,12 +186,16 @@ class _NotificationsScreenState extends State { ), SettingsCard( children: [ - SettingsToggleTile( - icon: Symbols.notifications_active, - label: l10n.notificationsFkmEnableLabel, - subtitle: l10n.notificationsFkmEnableSubtitle, - value: false, - onChanged: (_) => _onFkmTap(), + ValueListenableBuilder( + valueListenable: FkmController.instance.enabled, + builder: (context, fkmEnabled, _) => SettingsToggleTile( + icon: Symbols.notifications_active, + label: l10n.notificationsFkmEnableLabel, + subtitle: l10n.notificationsFkmEnableSubtitle, + value: fkmEnabled, + enabled: !_fkmBusy, + onChanged: _onFkmChanged, + ), ), ], ), diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index cfca998..b389ab9 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -1,12 +1,22 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; 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'; +import '../../widgets/small_spinner.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; +import '../../../backend/modules/account/account_models.dart'; + + +String _passwordErrorText(Object error, AppLocalizations l10n) => + error is WrongPasswordException + ? l10n.passwordEntryWrongPassword + : l10n.devicesGenericError('$error'); class PasswordEntryScreen extends StatefulWidget { const PasswordEntryScreen({super.key}); @@ -52,12 +62,13 @@ class _PasswordEntryScreenState extends State { _details = details; }); _passwordController.clear(); - } catch (_) { + } catch (e) { if (mounted) { setState( - () => _errorMessage = AppLocalizations.of( - context, - )!.passwordEntryWrongPassword, + () => _errorMessage = _passwordErrorText( + e, + AppLocalizations.of(context)!, + ), ); } } finally { @@ -72,6 +83,7 @@ class _PasswordEntryScreenState extends State { return await showDialog( context: context, builder: (ctx) => AlertDialog( + shape: AppShape.dialogBorder, title: Text(l10n.passwordEntryConfirmTitle), content: TextField( controller: controller, @@ -142,7 +154,7 @@ class _PasswordEntryScreenState extends State { if (_isLoading) { return Scaffold( backgroundColor: cs.surface, - body: Center(child: CircularProgressIndicator(color: cs.primary)), + body: Center(child: SmallSpinner(size: 36, color: cs.primary)), ); } @@ -187,7 +199,7 @@ class _PasswordEntryScreenState extends State { color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ], @@ -388,7 +400,7 @@ class _PasswordEntryScreenState extends State { ), _buildActionRow( cs, - icon: Icons.email_outlined, + icon: Symbols.mail, label: l10n.passwordEntryChangeEmailAction, isLast: false, onTap: () => _openWithPassword( @@ -401,7 +413,7 @@ class _PasswordEntryScreenState extends State { ), _buildActionRow( cs, - icon: Icons.delete_outline, + icon: Symbols.delete, label: l10n.passwordEntryDeleteAction, isLast: true, textColor: cs.error, @@ -476,7 +488,9 @@ class _PasswordEntryScreenState extends State { child: InkWell( onTap: onTap, borderRadius: isLast - ? const BorderRadius.vertical(bottom: Radius.circular(20)) + ? const BorderRadius.vertical( + bottom: Radius.circular(AppShape.card), + ) : BorderRadius.zero, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), @@ -614,7 +628,14 @@ class _TwoFactorSetupScreenState extends State { break; } } catch (e) { - if (mounted) setState(() => _errorMessage = e.toString()); + if (mounted) { + setState( + () => _errorMessage = _passwordErrorText( + e, + AppLocalizations.of(context)!, + ), + ); + } } finally { if (mounted) { _isLoading.value = false; @@ -1004,7 +1025,14 @@ class _TwoFactorPasswordChangeScreenState Navigator.popUntil(context, ModalRoute.withName('SecurityScreen')); } } catch (e) { - if (mounted) setState(() => _errorMessage = e.toString()); + if (mounted) { + setState( + () => _errorMessage = _passwordErrorText( + e, + AppLocalizations.of(context)!, + ), + ); + } } finally { if (mounted) _isLoading.value = false; } @@ -1175,7 +1203,14 @@ class _TwoFactorEmailChangeScreenState break; } } catch (e) { - if (mounted) setState(() => _errorMessage = e.toString()); + if (mounted) { + setState( + () => _errorMessage = _passwordErrorText( + e, + AppLocalizations.of(context)!, + ), + ); + } } finally { if (mounted) _isLoading.value = false; } @@ -1328,7 +1363,14 @@ class _TwoFactorRemoveScreenState extends State { Navigator.popUntil(context, ModalRoute.withName('SecurityScreen')); } } catch (e) { - if (mounted) setState(() => _errorMessage = e.toString()); + if (mounted) { + setState( + () => _errorMessage = _passwordErrorText( + e, + AppLocalizations.of(context)!, + ), + ); + } } finally { if (mounted) _isLoading.value = false; } @@ -1439,8 +1481,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/performance_screen.dart b/lib/frontend/screens/profile/performance_screen.dart index 47fc4ac..8edd49f 100644 --- a/lib/frontend/screens/profile/performance_screen.dart +++ b/lib/frontend/screens/profile/performance_screen.dart @@ -4,7 +4,7 @@ import '../../widgets/connection_status.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/utils/haptics.dart'; import '../../widgets/confirm_dialog.dart'; -import '../../widgets/glossy_pill.dart'; +import '../../widgets/settings_card.dart'; class PerformanceScreen extends StatefulWidget { const PerformanceScreen({super.key}); @@ -103,11 +103,7 @@ class _PerformanceScreenState extends State { physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ - GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), - depth: 6, + SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 8feed08..fac77a6 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -10,9 +10,17 @@ import '../../../l10n/app_localizations.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; +import 'blacklist_screen.dart'; import 'password_entry_screen.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; + +const bool _showFamilyProtection = false; +const bool _showSafeMode = false; class SecurityScreen extends StatefulWidget { const SecurityScreen({super.key}); @@ -22,7 +30,7 @@ class SecurityScreen extends StatefulWidget { } class _SecurityScreenState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, ReloadOnReconnect { bool _isLoading = true; bool _isSaving = false; bool _is2faEnabled = false; @@ -46,6 +54,9 @@ class _SecurityScreenState extends State super.dispose(); } + @override + void reloadAfterReconnect() => _loadData(); + Future _loadData() async { try { final results = await Future.wait([ @@ -157,9 +168,9 @@ class _SecurityScreenState extends State child: Column( children: [ _buildAppBar(context, cs), - _buildShimmerSection(cs, height: 104), + _buildShimmerSection(cs, height: _showFamilyProtection ? 104 : 56), const SizedBox(height: 12), - _buildShimmerSection(cs, height: 280), + _buildShimmerSection(cs, height: _showSafeMode ? 280 : 232), const SizedBox(height: 20), _buildShimmerSection(cs, height: 220), const SizedBox(height: 12), @@ -212,21 +223,14 @@ class _SecurityScreenState extends State color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), const Spacer(), if (_isSaving) Padding( padding: const EdgeInsets.only(right: 16), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), - ), + child: SmallSpinner(size: 20, color: cs.primary), ), ], ), @@ -252,20 +256,21 @@ class _SecurityScreenState extends State final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), + borderRadius: AppShape.cardRadius, depth: 6, child: Column( children: [ _buildPasswordRow(cs), - _settingsRow( - cs, - icon: Symbols.shield, - label: l10n.securityFamilyProtection, - subtitle: _privacyConfig?.familyProtection == 'ON' - ? l10n.securityEnabledFem - : l10n.securityDisabledFem, - isLast: true, - ), + if (_showFamilyProtection) + _settingsRow( + cs, + icon: Symbols.shield, + label: l10n.securityFamilyProtection, + subtitle: _privacyConfig?.familyProtection == 'ON' + ? l10n.securityEnabledFem + : l10n.securityDisabledFem, + isLast: true, + ), ], ), ); @@ -336,14 +341,15 @@ class _SecurityScreenState extends State ), ), ), - Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), + if (_showFamilyProtection) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), ), - ), ], ); } @@ -353,63 +359,65 @@ class _SecurityScreenState extends State final isSafeMode = _privacyConfig?.safeMode ?? false; return GlossyPill( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), + borderRadius: AppShape.cardRadius, depth: 6, child: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), - child: Row( - children: [ - Icon( - Symbols.lock, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.securityModeTitle, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - l10n.securityModeSubtitle, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: isSafeMode, - onChanged: (v) => showCustomNotification( - context, - l10n.securitySettingsUnavailable, - ), - ), - ], - ), - ), - if (isSafeMode) ...[ + if (_showSafeMode) Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.lock, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.securityModeTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + l10n.securityModeSubtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: isSafeMode, + onChanged: (v) => showCustomNotification( + context, + l10n.securitySettingsUnavailable, + ), + ), + ], ), ), + if (isSafeMode) ...[ + if (_showSafeMode) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), _settingsRow( cs, label: l10n.securityFindByPhone, @@ -464,14 +472,15 @@ class _SecurityScreenState extends State ), ], if (!isSafeMode) ...[ - Padding( - padding: const EdgeInsets.only(left: 20), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), + if (_showSafeMode) + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), ), - ), _settingsRow( cs, icon: Symbols.phone, @@ -534,7 +543,7 @@ class _SecurityScreenState extends State ), _settingsRow( cs, - icon: Icons.visibility_off_outlined, + icon: Symbols.visibility_off, label: l10n.securityShowOnlineStatus, trailingText: _privacyConfig?.hidden == true ? l10n.securityPrivacyNobody @@ -715,7 +724,7 @@ class _SecurityScreenState extends State _privacyConfig?.audioTranscriptionEnabled ?? true; return GlossyPill( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), + borderRadius: AppShape.cardRadius, depth: 6, child: Column( children: [ @@ -760,7 +769,7 @@ class _SecurityScreenState extends State ), _settingsRow( cs, - icon: Icons.mic_none_outlined, + icon: Symbols.mic, label: l10n.securityAudioTranscription, trailingWidget: Switch( value: audioTranscription, @@ -780,20 +789,31 @@ class _SecurityScreenState extends State ); } + Future _openBlacklist() async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BlacklistScreen(initialContacts: _blockedContacts), + ), + ); + if (!mounted) return; + try { + final contacts = await accountModule.getBlockedContacts(); + if (mounted) setState(() => _blockedContacts = contacts); + } catch (_) {} + } + Widget _buildBlacklistSection(ColorScheme cs) { final l10n = AppLocalizations.of(context)!; final count = _blockedContacts.length; return GlossyPill( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), + borderRadius: AppShape.cardRadius, depth: 6, child: Material( color: Colors.transparent, child: InkWell( - onTap: () => showCustomNotification( - context, - l10n.securityBlacklistNotification('$count'), - ), + onTap: _openBlacklist, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), @@ -875,7 +895,9 @@ class _SecurityScreenState extends State child: InkWell( onTap: onTap ?? () => showCustomNotification(context, label), borderRadius: isLast - ? const BorderRadius.vertical(bottom: Radius.circular(20)) + ? const BorderRadius.vertical( + bottom: Radius.circular(AppShape.card), + ) : BorderRadius.zero, child: Padding( padding: EdgeInsets.symmetric( diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 72833f4..07fbf27 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,6 +1,10 @@ import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show HapticFeedback; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/cache/self_presence.dart'; @@ -9,32 +13,42 @@ import '../../../core/config/komet_settings.dart'; import '../../../core/config/app_show_extra_info.dart'; import '../../../core/storage/app_database.dart'; 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'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/profile_header_scroll.dart'; import '../../widgets/settings_card.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/update_dialog.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; import '../../../core/config/app_digital_id_mode.dart'; import '../../../core/utils/webview_support.dart'; import '../digital_id/digital_id_screen.dart'; import '../digital_id/digital_id_web_screen.dart'; +import '../webapp/web_app_bridge.dart'; import '../webapp/web_app_screen.dart'; import 'cloud_storage_screen.dart'; import 'customization_section.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; +import '../../widgets/spectrum_tint.dart'; import 'edit_profile_screen.dart'; import 'info_screen.dart'; import 'komet_settings_screen.dart'; import 'notifications_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; +import '../../widgets/media_playback_pill.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; class SettingsTab extends StatefulWidget { const SettingsTab({super.key}); @@ -43,11 +57,19 @@ class SettingsTab extends StatefulWidget { State createState() => _SettingsTabState(); } -class _SettingsTabState extends State { +class _SettingsTabState extends State with SpectrumSurface { ProfileData? _profile; bool _isPhoneVisible = false; + ScrollController? _scrollController; + double _headerDelta = 0; + bool _headerEverExpanded = false; + bool _expandArmed = false; + bool _headerDragging = false; + bool _zoneHapticFired = false; + bool _pastCommitPoint = false; String? _appVersionLabel; bool _debugMenuVisible = false; + bool _isCheckingForUpdates = false; int _versionSecretTapCount = 0; Timer? _versionSecretTapResetTimer; StreamSubscription? _profileUpdateSub; @@ -69,9 +91,84 @@ class _SettingsTabState extends State { void dispose() { _versionSecretTapResetTimer?.cancel(); _profileUpdateSub?.cancel(); + _scrollController?.dispose(); super.dispose(); } + void _syncHeaderDelta(double delta) { + if (_scrollController == null) { + _scrollController = ScrollController(initialScrollOffset: delta); + _headerDelta = delta; + return; + } + if (_headerDelta == delta) return; + final prev = _headerDelta; + _headerDelta = delta; + WidgetsBinding.instance.addPostFrameCallback((_) { + final c = _scrollController; + if (!mounted || c == null || !c.hasClients) return; + final target = (c.offset + (delta - prev)).clamp( + 0.0, + c.position.maxScrollExtent, + ); + c.jumpTo(target); + }); + } + + bool _handleScrollNotification(ScrollNotification n, double delta) { + if (n.depth != 0) return false; + if (n is ScrollStartNotification) { + _headerDragging = n.dragDetails != null; + if (n.dragDetails != null) { + final px = n.metrics.pixels; + _expandArmed = delta > 0 && px <= delta + 8; + _zoneHapticFired = px < delta; + _pastCommitPoint = px < delta / 2; + } + } else if (n is ScrollUpdateNotification) { + if (n.dragDetails != null && delta > 0) { + final px = n.metrics.pixels; + if (px < delta) { + if (!_zoneHapticFired) { + _zoneHapticFired = true; + HapticFeedback.lightImpact(); + } + } else { + _zoneHapticFired = false; + } + final pastCommit = px < delta / 2; + if (pastCommit != _pastCommitPoint) { + _pastCommitPoint = pastCommit; + HapticFeedback.mediumImpact(); + } + } + } else if (n is ScrollEndNotification) { + if (_headerDragging) { + _headerDragging = false; + _snapHeader(delta); + } + } + return false; + } + + void _snapHeader(double delta) { + final c = _scrollController; + if (c == null || !c.hasClients || delta <= 0) return; + final collapsed = math.min(delta, c.position.maxScrollExtent); + final offset = c.offset; + if (offset <= 0 || offset >= collapsed) return; + final target = offset < collapsed / 2 ? 0.0 : collapsed; + if ((target - offset).abs() < 1) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !c.hasClients) return; + c.animateTo( + target, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + }); + } + void _scheduleVersionSecretTapReset() { _versionSecretTapResetTimer?.cancel(); _versionSecretTapResetTimer = Timer(const Duration(seconds: 2), () { @@ -104,6 +201,33 @@ class _SettingsTabState extends State { }); } + Future _checkForUpdates() async { + if (_isCheckingForUpdates) return; + setState(() => _isCheckingForUpdates = true); + + final result = await UpdateChecker.checkNow(); + if (!mounted) return; + setState(() => _isCheckingForUpdates = false); + + switch (result.status) { + case UpdateCheckStatus.updateAvailable: + await showUpdateDialog(context, result.update!); + return; + case UpdateCheckStatus.upToDate: + showCustomNotification( + context, + AppLocalizations.of(context)!.updateUpToDate, + ); + return; + case UpdateCheckStatus.failed: + showCustomNotification( + context, + AppLocalizations.of(context)!.updateCheckFailed, + ); + return; + } + } + Future _openCloudStorage(BuildContext context) async { final cs = Theme.of(context).colorScheme; final ok = await showInfoActionSheet( @@ -176,9 +300,7 @@ class _SettingsTabState extends State { backgroundColor: cs.error, foregroundColor: cs.onError, padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: AppShape.buttonBorder, ), child: const Text('Выйти'), ), @@ -220,287 +342,342 @@ class _SettingsTabState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; if (_profile == null) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } final String fullName = '${_profile!.firstName}${_profile!.lastName != null ? ' ${_profile!.lastName}' : ''}'; - final String phone = '+${_profile!.phone}'; + final String phone = _profile!.phone == 0 + ? l10n.profilePhoneRegenFailed + : '+${_profile!.phone}'; - return Scaffold( - backgroundColor: cs.surface, - body: SafeArea( - bottom: false, - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: _buildHeader(context, cs, fullName, phone), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: ValueListenableBuilder( - valueListenable: AppShowExtraInfo.current, - builder: (context, showExtraInfo, _) { - return _buildSection( + final size = MediaQuery.sizeOf(context); + final topPad = MediaQuery.paddingOf(context).top; + final hasPhoto = (_profile!.baseUrl ?? '').isNotEmpty; + + return ValueListenableBuilder( + valueListenable: KometSettings.selfOnlineCheck, + builder: (context, statusEnabled, _) { + final collapsedH = topPad + (statusEnabled ? 268.0 : 242.0); + final expandedH = hasPhoto + ? math.max(collapsedH, math.min(size.width, size.height * 0.65)) + : collapsedH; + final delta = expandedH - collapsedH; + _syncHeaderDelta(delta); + return Scaffold( + backgroundColor: spectrumSurfaceColor(cs), + body: NotificationListener( + onNotification: (n) => _handleScrollNotification(n, delta), + child: CustomScrollView( + key: ValueKey(delta), + controller: _scrollController ??= ScrollController( + initialScrollOffset: delta, + ), + physics: HeaderPullScrollPhysics( + delta: delta, + isArmed: () => _expandArmed, + parent: const BouncingScrollPhysics(), + ), + slivers: [ + SliverPersistentHeader( + delegate: MorphHeaderDelegate( + collapsedExtent: collapsedH, + expandedExtent: expandedH, + headerBuilder: (ctx, t) => + _buildHeader(ctx, cs, fullName, phone, t), + ), + ), + const SliverToBoxAdapter( + child: MediaPlaybackPill( + margin: EdgeInsets.fromLTRB(16, 8, 16, 0), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppShowExtraInfo.current, + builder: (context, showExtraInfo, _) { + return _buildSection( + context, + items: [ + _SettingsItem( + icon: Symbols.badge, + label: 'Цифровой ID', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + AppDigitalIdNative.current.value || + !webViewSupported + ? const DigitalIdScreen() + : const DigitalIdWebScreen(), + ), + ); + }, + ), + _SettingsItem( + icon: Symbols.language, + label: 'Войти в Сферум', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WebAppScreen( + title: 'Сферум', + entryPoint: WebAppEntryPoint.settings, + loader: () => webAppModule.fetchSferum(), + ), + ), + ); + }, + ), + if (showExtraInfo) + _SettingsItem( + icon: Symbols.info, + label: 'Info', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const InfoScreen(), + ), + ); + }, + ), + ], + ); + }, + ), + ), + ), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 0), + child: CustomizationSection(), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( context, items: [ _SettingsItem( - icon: Symbols.badge, - label: 'Цифровой ID', + icon: Symbols.notifications_active, + label: 'Уведомления', onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => - AppDigitalIdNative.current.value || - !webViewSupported - ? const DigitalIdScreen() - : const DigitalIdWebScreen(), + const NotificationsScreen(), ), ); }, ), _SettingsItem( - icon: Symbols.language, - label: 'Войти в Сферум', + icon: Symbols.cloud, + label: 'Облачное хранилище [BETA]', + onTap: () => _openCloudStorage(context), + ), + _SettingsItem( + icon: Symbols.vpn_lock, + label: 'Прокси', + onTap: () { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) { + return SafeArea( + child: const ProxySettingsSheet(), + ); + }, + ); + }, + ), + _SettingsItem( + icon: Symbols.shield_lock, + label: AppLocalizations.of(context)!.profileMenuSpoof, onTap: () { Navigator.push( context, MaterialPageRoute( - builder: (context) => WebAppScreen( - title: 'Сферум', - loader: () => webAppModule.fetchSferum(), - ), + builder: (context) => const SpoofScreen(), ), ); }, ), - if (showExtraInfo) - _SettingsItem( - icon: Symbols.info, - label: 'Info', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const InfoScreen(), + _SettingsItem( + icon: Symbols.lock, + label: 'Безопасность', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings( + name: 'SecurityScreen', ), - ); - }, - ), - ], - ); - }, - ), - ), - ), - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB(16, 12, 16, 0), - child: CustomizationSection(), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _buildSection( - context, - items: [ - _SettingsItem( - icon: Symbols.notifications_active, - label: 'Уведомления', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const NotificationsScreen(), - ), - ); - }, - ), - _SettingsItem( - icon: Symbols.cloud, - label: 'Облачное хранилище [BETA]', - onTap: () => _openCloudStorage(context), - ), - _SettingsItem( - icon: Symbols.vpn_lock, - label: 'Прокси', - onTap: () { - final cs = Theme.of(context).colorScheme; - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: cs.surfaceContainerHigh, - shape: kSheetShape, - builder: (_) { - return SafeArea(child: const ProxySettingsSheet()); - }, - ); - }, - ), - _SettingsItem( - icon: Symbols.shield_lock, - label: AppLocalizations.of(context)!.profileMenuSpoof, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const SpoofScreen(), - ), - ); - }, - ), - _SettingsItem( - icon: Symbols.lock, - label: 'Безопасность', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings( - name: 'SecurityScreen', - ), - builder: (context) => const SecurityScreen(), - ), - ); - }, - ), - _SettingsItem( - icon: Symbols.devices, - label: 'Устройства', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const DevicesScreen(), - ), - ); - }, - ), - ], - ), - ), - ), - SliverToBoxAdapter( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 340), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - return ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: animation.value.clamp(0.0, 1.0), - child: FadeTransition(opacity: animation, child: child), - ), - ); - }, - layoutBuilder: (currentChild, previousChildren) { - return Stack( - alignment: Alignment.topCenter, - clipBehavior: Clip.none, - children: [...previousChildren, ?currentChild], - ); - }, - child: _debugMenuVisible - ? KeyedSubtree( - key: const ValueKey('developers_settings_row'), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _buildSection( - context, - items: [ - _SettingsItem( - icon: Symbols.construction, - label: 'Для разработчиков', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const DebugMenuScreen(), - ), - ); - }, + builder: (context) => const SecurityScreen(), ), - ], - ), + ); + }, ), - ) - : const SizedBox.shrink( - key: ValueKey('developers_settings_hidden'), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _buildSection( - context, - items: [ - _SettingsItem( - leading: Image.asset( - 'assets/komet.png', - width: 22, - height: 22, - color: cs.onSurfaceVariant, - ), - label: 'Komet', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const KometSettingsScreen(), - ), - ); - }, + _SettingsItem( + icon: Symbols.devices, + label: 'Устройства', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DevicesScreen(), + ), + ); + }, + ), + ], ), - _SettingsItem( - icon: Symbols.logout, - label: 'Выйти из аккаунта', - tintColor: cs.error, - onTap: _confirmLogout, - ), - ], + ), ), - ), - ), - if (_appVersionLabel != null) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 28, 16, 12), - child: Center( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _onVersionLabelTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 8, + SliverToBoxAdapter( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 340), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: animation.value.clamp(0.0, 1.0), + child: FadeTransition( + opacity: animation, + child: child, + ), ), - child: Text( - _appVersionLabel!, - textAlign: TextAlign.center, - style: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.75), - fontSize: 13, - fontWeight: FontWeight.w400, + ); + }, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topCenter, + clipBehavior: Clip.none, + children: [...previousChildren, ?currentChild], + ); + }, + child: _debugMenuVisible + ? KeyedSubtree( + key: const ValueKey('developers_settings_row'), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( + context, + items: [ + _SettingsItem( + icon: Symbols.construction, + label: 'Для разработчиков', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const DebugMenuScreen(), + ), + ); + }, + ), + ], + ), + ), + ) + : const SizedBox.shrink( + key: ValueKey('developers_settings_hidden'), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( + context, + items: [ + _SettingsItem( + icon: Symbols.system_update, + label: _isCheckingForUpdates + ? l10n.updateChecking + : l10n.updateCheck, + onTap: _isCheckingForUpdates + ? null + : _checkForUpdates, + ), + _SettingsItem( + leading: Image.asset( + 'assets/komet.png', + width: 22, + height: 22, + color: cs.onSurfaceVariant, + ), + label: 'Komet', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const KometSettingsScreen(), + ), + ); + }, + ), + _SettingsItem( + icon: Symbols.logout, + label: 'Выйти из аккаунта', + tintColor: cs.error, + onTap: _confirmLogout, + ), + ], + ), + ), + ), + if (_appVersionLabel != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 28, 16, 12), + child: Center( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onVersionLabelTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: Text( + _appVersionLabel!, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant.withValues( + alpha: 0.75, + ), + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), ), ), ), ), ), - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 120)), - ], - ), - ), + const SliverToBoxAdapter(child: SizedBox(height: 120)), + ], + ), + ), + ); + }, ); } @@ -509,114 +686,316 @@ class _SettingsTabState extends State { ColorScheme cs, String name, String phone, + double t, ) { - return Padding( - padding: const EdgeInsets.fromLTRB(8, 12, 8, 20), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + final topPad = MediaQuery.paddingOf(context).top; + final hasPhoto = (_profile?.baseUrl ?? '').isNotEmpty; + final phoneMissing = (_profile?.phone ?? 0) == 0; + final pt = hasPhoto ? t : 0.0; + if (pt > 0) _headerEverExpanded = true; + + return ClipRect( + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + const avatarSize = 88.0; + final avatarRect = Rect.lerp( + Rect.fromLTWH( + (w - avatarSize) / 2, + topPad + 68, + avatarSize, + avatarSize, + ), + Rect.fromLTWH(0, 0, w, h), + pt, + )!; + final radius = lerpDouble(avatarSize / 2, 0, pt)!; + final iconColor = Color.lerp(cs.onSurfaceVariant, Colors.white, pt)!; + final nameColor = Color.lerp(cs.onSurface, Colors.white, pt)!; + final subColor = Color.lerp( + cs.onSurfaceVariant, + Colors.white.withValues(alpha: 0.85), + pt, + )!; + + return Stack( + clipBehavior: Clip.hardEdge, children: [ - IconButton( - icon: Icon( - Symbols.qr_code_2, - color: cs.onSurfaceVariant, - size: 26, - weight: 400, - ), - onPressed: () {}, - ), - const Expanded( - child: ConnectionStatusLine(textAlign: TextAlign.center), - ), - IconButton( - icon: Icon( - Symbols.edit, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - onPressed: () { - Navigator.push( + Positioned.fromRect( + rect: avatarRect, + child: GestureDetector( + onTap: () => AvatarHistoryScreen.open( context, - MaterialPageRoute( - builder: (context) => const EditProfileScreen(), - ), - ); - }, - ), - ], - ), - const SizedBox(height: 8), - GestureDetector( - onTap: () => AvatarHistoryScreen.open( - context, - contactId: _profile?.id ?? 0, - name: name, - currentAvatarUrl: _profile?.baseUrl, - ), - child: Container( - width: 88, - height: 88, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: cs.primary.withValues(alpha: 0.5), - width: 2.5, + contactId: _profile?.id ?? 0, + name: name, + currentAvatarUrl: _profile?.baseUrl, + ), + child: _buildMorphAvatar(cs, name, radius, pt), ), ), - child: KometAvatar( - name: name, - imageUrl: _profile?.baseUrl, - size: 88, - fontSize: 32, - ), - ), - ), - const SizedBox(height: 8), - Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w700, - fontFamily: 'Outfit', - ), - ), - _buildOnlineStatus(cs), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: _PhoneSpoiler( - text: phone, - isVisible: _isPhoneVisible, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - letterSpacing: 0.5, + if (hasPhoto) + Positioned( + left: 0, + right: 0, + top: 0, + height: topPad + 72, + child: IgnorePointer( + child: Opacity( + opacity: pt, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black38, Colors.transparent], + ), + ), + ), ), ), ), + if (hasPhoto) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 150, + child: IgnorePointer( + child: Opacity( + opacity: pt, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black54], + ), + ), + ), + ), + ), + ), + Positioned( + left: 8, + right: 8, + top: topPad + 8, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon( + Symbols.qr_code_2, + color: iconColor, + size: 26, + weight: 400, + ), + onPressed: () {}, + ), + Expanded( + child: Opacity( + opacity: 1 - pt, + child: const ConnectionStatusLine( + textAlign: TextAlign.center, + ), + ), + ), + IconButton( + icon: Icon( + Symbols.edit, + color: iconColor, + size: 22, + weight: 400, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EditProfileScreen(), + ), + ); + }, + ), + ], + ), ), - const SizedBox(width: 4), - Icon( - _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, - size: 14, - color: cs.mutedText, + Positioned( + left: 0, + right: 0, + bottom: lerpDouble(20, 14, pt)!, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _headerAligned( + pt, + Text( + name, + style: TextStyle( + color: nameColor, + fontSize: lerpDouble(20, 26, pt), + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + ), + _headerAligned( + pt, + _buildOnlineStatus(cs, textColor: subColor), + ), + const SizedBox(height: 6), + _headerAligned( + pt, + phoneMissing + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: Text( + phone, + textAlign: TextAlign.center, + style: TextStyle( + color: subColor, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.3, + ), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => setState( + () => _isPhoneVisible = !_isPhoneVisible, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _PhoneSpoiler( + text: phone, + isVisible: _isPhoneVisible, + style: TextStyle( + color: subColor, + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, + ), + ), + ), + ), + const SizedBox(width: 4), + AnimatedSlashIcon( + icon: Symbols.visibility, + slashedIcon: Symbols.visibility_off, + slashed: !_isPhoneVisible, + size: 14, + color: Color.lerp( + cs.mutedText, + Colors.white70, + pt, + ), + ), + ], + ), + ), + ], + ), ), ], - ), - ], + ); + }, ), ); } + Widget _headerAligned(double t, Widget child) { + return Align( + alignment: Alignment.lerp(Alignment.center, Alignment.centerLeft, t)!, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: lerpDouble(12, 18, t)!), + child: child, + ), + ); + } + + Widget _buildMorphAvatar( + ColorScheme cs, + String name, + double radius, + double pt, + ) { + final base = _profile?.baseUrl; + final borderOpacity = (1 - pt * 2).clamp(0.0, 1.0); + if (base == null || base.isEmpty) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: KometAvatar(name: name, size: 88, fontSize: 32), + ); + } + final letterFallback = ColoredBox( + color: cs.primaryContainer, + child: Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + return Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Stack( + fit: StackFit.expand, + children: [ + CachedNetworkImage( + imageUrl: base, + fit: BoxFit.cover, + memCacheWidth: 264, + memCacheHeight: 264, + errorWidget: (_, _, _) => letterFallback, + ), + if (_headerEverExpanded && + _profile?.baseRawUrl != null && + _profile!.baseRawUrl!.isNotEmpty) + CachedNetworkImage( + imageUrl: _profile!.baseRawUrl!, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 250), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ], + ), + ), + if (borderOpacity > 0) + IgnorePointer( + child: Opacity( + opacity: borderOpacity, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + ), + ), + ), + ], + ); + } + String _formatSelfSeen(int seconds) { final dt = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); final now = DateTime.now(); @@ -630,7 +1009,7 @@ class _SettingsTabState extends State { return '$datePart, $time'; } - Widget _buildOnlineStatus(ColorScheme cs) { + Widget _buildOnlineStatus(ColorScheme cs, {Color? textColor}) { return ValueListenableBuilder( valueListenable: KometSettings.selfOnlineCheck, builder: (context, enabled, _) { @@ -648,19 +1027,20 @@ class _SettingsTabState extends State { ? 'Был(-а) ${_formatSelfSeen(seen)}' : 'офлайн'); return Row( + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Symbols.check_circle, fill: 1, size: 15, - color: online ? kOnlineGreen : cs.mutedText, + color: online ? kSuccessGreen : cs.mutedText, ), const SizedBox(width: 5), Text( label, style: TextStyle( - color: cs.onSurfaceVariant, + color: textColor ?? cs.onSurfaceVariant, fontSize: 14, fontWeight: FontWeight.w400, ), diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index a84a334..91792a9 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -6,6 +6,7 @@ import 'dart:math'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../../core/config/device_presets.dart'; import '../../../core/storage/device_identity.dart'; @@ -17,7 +18,11 @@ import '../../../main.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/info_action_sheet.dart'; +import '../../../core/config/app_colors.dart'; +import '../../../core/config/app_shape.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/settings_card.dart'; +import '../../widgets/small_spinner.dart'; import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -73,7 +78,7 @@ class _SpoofScreenState extends State { Future _confirmFullSpoofing() { return showInfoActionSheet( context, - headerIcon: Icons.warning_amber_rounded, + headerIcon: Symbols.warning, title: 'Могут быть последствия.', subtitle: 'Меняй, только если знаешь что делаешь.', confirmLabel: 'ОК', @@ -213,13 +218,11 @@ class _SpoofScreenState extends State { _deviceNameController.text = '${androidInfo.manufacturer} ${androidInfo.model}'; _osVersionController.text = 'Android ${androidInfo.version.release}'; - _selectedArch = androidInfo.supportedAbis.isNotEmpty - ? androidInfo.supportedAbis.first - : 'arm64-v8a'; + _selectedArch = 'arm64-v8a'; } else if (Platform.isIOS) { final iosInfo = await deviceInfo.iosInfo; _selectedDeviceType = 'ANDROID'; - _selectedArch = 'arm64'; + _selectedArch = 'arm64-v8a'; _deviceNameController.text = iosInfo.utsname.machine; _osVersionController.text = iosInfo.systemVersion; } else if (Platform.isLinux) { @@ -273,7 +276,7 @@ class _SpoofScreenState extends State { _spoofingEnabled = true; _selectedDeviceType = preset.deviceType; - _selectedArch = preset.deviceType == 'IOS' ? 'arm64' : 'arm64-v8a'; + _selectedArch = 'arm64-v8a'; _buildNumberController.text = '$_hardcodedBuildNumber'; if (_selectedMethod == SpoofingMethod.full) { @@ -329,6 +332,7 @@ class _SpoofScreenState extends State { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( + shape: AppShape.dialogBorder, title: Text(l10n.spoofDialogApplyTitle), content: Column( mainAxisSize: MainAxisSize.min, @@ -437,31 +441,39 @@ class _SpoofScreenState extends State { @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar( - title: ConnectionTitleText(l10n.spoofScreenTitle), - centerTitle: true, + backgroundColor: cs.surface, + appBar: ConnectionTitleBar( + titleText: l10n.spoofScreenTitle, + backgroundColor: cs.surface, ), body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + ? const Center(child: SmallSpinner(size: 36)) + : SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ _buildEnableCard(), - const SizedBox(height: 16), + const SizedBox(height: 12), _buildInfoCard(), - const SizedBox(height: 16), + const SizedBox(height: 20), + _sectionHeader(l10n.spoofMethodTitle), _buildSpoofingMethodCard(), - const SizedBox(height: 16), + const SizedBox(height: 20), + _sectionHeader(l10n.spoofDeviceTypeTitle), _buildDeviceTypeCard(), - const SizedBox(height: 24), + const SizedBox(height: 20), + _sectionHeader(l10n.spoofMainSectionTitle), _buildMainDataCard(), - const SizedBox(height: 16), + const SizedBox(height: 20), + _sectionHeader(l10n.spoofRegionalSectionTitle), _buildRegionalDataCard(), - const SizedBox(height: 16), + const SizedBox(height: 20), + _sectionHeader(l10n.spoofIdentifiersSectionTitle), _buildIdentifiersCard(), ], ), @@ -471,62 +483,61 @@ class _SpoofScreenState extends State { ); } + Widget _sectionHeader(String title) => SectionHeader( + title, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ); + Widget _buildEnableCard() { final l10n = AppLocalizations.of(context)!; - return Card( - child: SwitchListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - title: Text( - l10n.spoofEnableTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - subtitle: Text( - _spoofingEnabled + return SettingsCard( + children: [ + SettingsToggleTile( + icon: Symbols.security, + label: l10n.spoofEnableTitle, + subtitle: _spoofingEnabled ? l10n.spoofEnableSubtitleOn : l10n.spoofEnableSubtitleOff, + value: _spoofingEnabled, + onChanged: (value) async { + if (value) { + await _applyGeneratedData(); + } else { + await _loadDeviceData(); + } + }, ), - value: _spoofingEnabled, - onChanged: (value) async { - if (value) { - await _applyGeneratedData(); - } else { - await _loadDeviceData(); - } - }, - ), + ], ); } Widget _buildInfoCard() { + final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - return Card( - color: Theme.of( - context, - ).colorScheme.secondaryContainer.withValues(alpha: 0.5), - elevation: 0, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.touch_app, - size: 18, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - l10n.spoofInfoHint, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), + return SettingsPanel( + color: cs.secondaryContainer.withValues(alpha: 0.5), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon( + Symbols.touch_app, + size: 20, + weight: 400, + color: cs.onSecondaryContainer, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + l10n.spoofInfoHint, + style: TextStyle( + fontSize: 13, + height: 1.3, + color: cs.onSecondaryContainer, ), ), - ], - ), + ), + ], ), ); } @@ -538,37 +549,35 @@ class _SpoofScreenState extends State { if (_selectedMethod == SpoofingMethod.partial) { descriptionWidget = _buildDescriptionTile( - icon: Icons.check_circle_outline, - color: Colors.green.shade700, + icon: Symbols.check_circle, + color: kSuccessGreen, text: l10n.spoofMethodPartialDescription, ); } else { descriptionWidget = _buildDescriptionTile( - icon: Icons.warning_amber_rounded, + icon: Symbols.warning, color: theme.colorScheme.error, text: l10n.spoofMethodFullDescription, ); } - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Text(l10n.spoofMethodTitle, style: theme.textTheme.titleMedium), - const SizedBox(height: 12), - SegmentedButton( - style: SegmentedButton.styleFrom(shape: const StadiumBorder()), + return SettingsPanel( + child: Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + showSelectedIcon: false, segments: [ ButtonSegment( value: SpoofingMethod.partial, label: Text(l10n.spoofMethodPartial), - icon: const Icon(Icons.security_outlined), + icon: const Icon(Symbols.security), ), ButtonSegment( value: SpoofingMethod.full, label: Text(l10n.spoofMethodFull), - icon: const Icon(Icons.public_outlined), + icon: const Icon(Symbols.public), ), ], selected: {_selectedMethod}, @@ -587,10 +596,10 @@ class _SpoofScreenState extends State { _syncDeviceLocale(); }, ), - const SizedBox(height: 12), - descriptionWidget, - ], - ), + ), + const SizedBox(height: 12), + descriptionWidget, + ], ), ); } @@ -598,54 +607,41 @@ class _SpoofScreenState extends State { Widget _buildDeviceTypeCard() { final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!; - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.spoofDeviceTypeTitle, style: theme.textTheme.titleMedium), - const SizedBox(height: 12), - _buildDescriptionTile( - icon: Icons.info_outline, - color: theme.colorScheme.primary, - text: l10n.spoofDeviceTypeDescription, + return SettingsPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDescriptionTile( + icon: Symbols.info, + color: theme.colorScheme.primary, + text: l10n.spoofDeviceTypeDescription, + ), + const SizedBox(height: 12), + if (_selectedMethod == SpoofingMethod.full) + _buildChipSelector( + options: const [ + _ChipOption('ANDROID', 'Android', Symbols.android), + _ChipOption('DESKTOP', 'Desktop', Symbols.desktop_windows), + ], + selected: _selectedDeviceType, + onSelected: _onDeviceTypeChanged, + trailing: [ + _buildDisabledChip('iOS', Symbols.phone_iphone, theme), + ], + ) + else + _buildChipSelector( + options: const [ + _ChipOption('ANDROID', 'Android', Symbols.android), + ], + selected: 'ANDROID', + onSelected: _onDeviceTypeChanged, + trailing: [ + _buildDisabledChip('iOS', Symbols.phone_iphone, theme), + _buildDisabledChip('Desktop', Symbols.desktop_windows, theme), + ], ), - const SizedBox(height: 12), - if (_selectedMethod == SpoofingMethod.full) - _buildChipSelector( - options: const [ - _ChipOption('ANDROID', 'Android', Icons.android_outlined), - _ChipOption( - 'DESKTOP', - 'Desktop', - Icons.desktop_windows_outlined, - ), - ], - selected: _selectedDeviceType, - onSelected: _onDeviceTypeChanged, - trailing: [ - _buildDisabledChip('iOS', Icons.phone_iphone_outlined, theme), - ], - ) - else - _buildChipSelector( - options: const [ - _ChipOption('ANDROID', 'Android', Icons.android_outlined), - ], - selected: 'ANDROID', - onSelected: _onDeviceTypeChanged, - trailing: [ - _buildDisabledChip('iOS', Icons.phone_iphone_outlined, theme), - _buildDisabledChip( - 'Desktop', - Icons.desktop_windows_outlined, - theme, - ), - ], - ), - ], - ), + ], ), ); } @@ -664,214 +660,189 @@ class _SpoofScreenState extends State { required Color color, required String text, }) { - return ListTile( - leading: Icon(icon, color: color), - contentPadding: EdgeInsets.zero, - title: Text( - text, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 20, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 13, + height: 1.3, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), - ), + ], ); } Widget _buildMainDataCard() { final l10n = AppLocalizations.of(context)!; - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionHeader( - l10n.spoofMainSectionTitle, - padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), - fontSize: 22, + return SettingsPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _deviceNameController, + decoration: _inputDecoration( + l10n.spoofFieldDeviceName, + Symbols.smartphone, ), - TextField( - controller: _deviceNameController, - decoration: _inputDecoration( - l10n.spoofFieldDeviceName, - Icons.smartphone_outlined, - ), + ), + const SizedBox(height: 16), + TextField( + controller: _osVersionController, + decoration: _inputDecoration( + l10n.spoofFieldOsVersion, + Symbols.layers, ), - const SizedBox(height: 16), - TextField( - controller: _osVersionController, - decoration: _inputDecoration( - l10n.spoofFieldOsVersion, - Icons.layers_outlined, - ), - ), - ], - ), + ), + ], ), ); } Widget _buildRegionalDataCard() { final l10n = AppLocalizations.of(context)!; - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionHeader( - l10n.spoofRegionalSectionTitle, - padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), - fontSize: 22, + return SettingsPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _screenController, + decoration: _inputDecoration( + l10n.spoofFieldScreen, + Symbols.fullscreen, ), - TextField( - controller: _screenController, - decoration: _inputDecoration( - l10n.spoofFieldScreen, - Icons.fullscreen_outlined, - ), + ), + const SizedBox(height: 16), + TextField( + controller: _timezoneController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldTimezone, + Symbols.public, ), - const SizedBox(height: 16), - TextField( - controller: _timezoneController, - enabled: _selectedMethod == SpoofingMethod.full, - decoration: _inputDecoration( - l10n.spoofFieldTimezone, - Icons.public_outlined, - ), + ), + const SizedBox(height: 16), + TextField( + controller: _localeController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldLocale, + Symbols.language, ), - const SizedBox(height: 16), - TextField( - controller: _localeController, - enabled: _selectedMethod == SpoofingMethod.full, - decoration: _inputDecoration( - l10n.spoofFieldLocale, - Icons.language_outlined, - ), + ), + const SizedBox(height: 16), + TextField( + controller: _deviceLocaleController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldDeviceLocale, + Symbols.translate, ), - const SizedBox(height: 16), - TextField( - controller: _deviceLocaleController, - enabled: _selectedMethod == SpoofingMethod.full, - decoration: _inputDecoration( - l10n.spoofFieldDeviceLocale, - Icons.translate_outlined, - ), - ), - ], - ), + ), + ], ), ); } Widget _buildIdentifiersCard() { final l10n = AppLocalizations.of(context)!; - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionHeader( - l10n.spoofIdentifiersSectionTitle, - padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), - fontSize: 22, + return SettingsPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDescriptionTile( + icon: Symbols.info, + color: Theme.of(context).colorScheme.tertiary, + text: l10n.spoofIdentifiersDescription, + ), + const SizedBox(height: 12), + TextField( + controller: _instanceIdController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldInstanceId, + Symbols.fingerprint, ), - _buildDescriptionTile( - icon: Icons.info_outline, - color: Theme.of(context).colorScheme.tertiary, - text: l10n.spoofIdentifiersDescription, + ), + const SizedBox(height: 16), + TextField( + controller: _clientSessionIdController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldClientSessionId, + Symbols.vpn_key, ), - const SizedBox(height: 12), - TextField( - controller: _instanceIdController, - enabled: _selectedMethod == SpoofingMethod.full, - decoration: _inputDecoration( - l10n.spoofFieldInstanceId, - Icons.fingerprint_outlined, - ), - ), - const SizedBox(height: 16), - TextField( - controller: _clientSessionIdController, - enabled: _selectedMethod == SpoofingMethod.full, - decoration: _inputDecoration( - l10n.spoofFieldClientSessionId, - Icons.vpn_key_outlined, - ), - ), - const SizedBox(height: 16), - TextField( - controller: _deviceIdController, - decoration: - _inputDecoration( - l10n.spoofFieldDeviceId, - Icons.tag_outlined, - ).copyWith( - suffixIcon: IconButton( - icon: const Icon(Icons.autorenew_outlined), - tooltip: l10n.spoofRegenerateIdTooltip, - onPressed: _generateNewDeviceId, - ), + ), + const SizedBox(height: 16), + TextField( + controller: _deviceIdController, + decoration: _inputDecoration(l10n.spoofFieldDeviceId, Symbols.tag) + .copyWith( + suffixIcon: IconButton( + icon: const Icon(Symbols.autorenew), + tooltip: l10n.spoofRegenerateIdTooltip, + onPressed: _generateNewDeviceId, ), - ), - const SizedBox(height: 16), - TextField( - controller: _appVersionController, - enabled: _selectedMethod == SpoofingMethod.full, - decoration: _inputDecoration( - l10n.spoofFieldAppVersion, - Icons.info_outline_rounded, - ), - ), - const SizedBox(height: 16), - TextField( - controller: _buildNumberController, - enabled: _selectedMethod == SpoofingMethod.full, - keyboardType: TextInputType.number, - decoration: _inputDecoration( - l10n.spoofFieldBuildNumber, - Icons.numbers_outlined, - ), - ), - const SizedBox(height: 16), - TextField( - controller: _pushDeviceTypeController, - enabled: _selectedMethod == SpoofingMethod.full, - decoration: _inputDecoration( - l10n.spoofFieldPushDeviceType, - Icons.notifications_outlined, - ), - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - l10n.spoofFieldArchitecture, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, ), + ), + const SizedBox(height: 16), + TextField( + controller: _appVersionController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldAppVersion, + Symbols.info, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _buildNumberController, + enabled: _selectedMethod == SpoofingMethod.full, + keyboardType: TextInputType.number, + decoration: _inputDecoration( + l10n.spoofFieldBuildNumber, + Symbols.numbers, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _pushDeviceTypeController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldPushDeviceType, + Symbols.notifications, + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + l10n.spoofFieldArchitecture, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - _buildChipSelector( - options: const [ - _ChipOption('arm64-v8a', 'arm64-v8a', Icons.memory_outlined), - _ChipOption( - 'armeabi-v7a', - 'armeabi-v7a', - Icons.memory_outlined, - ), - _ChipOption('x86', 'x86', Icons.memory_outlined), - _ChipOption('x86_64', 'x86_64', Icons.memory_outlined), - _ChipOption('arm64', 'arm64', Icons.memory_outlined), - ], - selected: _selectedArch, - onSelected: (value) => setState(() => _selectedArch = value), - ), - ], - ), + ), + _buildChipSelector( + options: const [ + _ChipOption('arm64-v8a', 'arm64-v8a', Symbols.memory), + _ChipOption('armeabi-v7a', 'armeabi-v7a', Symbols.memory), + _ChipOption('x86', 'x86', Symbols.memory), + _ChipOption('x86_64', 'x86_64', Symbols.memory), + _ChipOption('arm64', 'arm64', Symbols.memory), + ], + selected: _selectedArch, + onSelected: (value) => setState(() => _selectedArch = value), + ), + ], ), ); } @@ -880,7 +851,7 @@ class _SpoofScreenState extends State { return InputDecoration( labelText: label, prefixIcon: Icon(icon), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), + border: const OutlineInputBorder(borderRadius: AppShape.buttonRadius), filled: true, fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, ); @@ -902,7 +873,7 @@ class _SpoofScreenState extends State { return ChoiceChip( label: Text(opt.label), avatar: isSelected - ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer) + ? Icon(Symbols.check, size: 18, color: cs.onSecondaryContainer) : (opt.icon != null ? Icon(opt.icon, size: 18, color: cs.onSurfaceVariant) : null), @@ -919,8 +890,8 @@ class _SpoofScreenState extends State { side: BorderSide( color: isSelected ? Colors.transparent : cs.outlineVariant, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + shape: const RoundedRectangleBorder( + borderRadius: AppShape.buttonRadius, ), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -947,7 +918,7 @@ class _SpoofScreenState extends State { vertical: 16, horizontal: 16, ), - shape: const StadiumBorder(), + shape: AppShape.buttonBorder, ), child: Text(l10n.spoofButtonGenerate), ), @@ -962,12 +933,12 @@ class _SpoofScreenState extends State { vertical: 16, horizontal: 16, ), - shape: const StadiumBorder(), + shape: AppShape.buttonBorder, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.save_alt_outlined), + const Icon(Symbols.save_alt), const SizedBox(width: 8), Text(l10n.spoofButtonApply), ], diff --git a/lib/frontend/screens/profile/theme_settings_screen.dart b/lib/frontend/screens/profile/theme_settings_screen.dart index 8513039..9f436ff 100644 --- a/lib/frontend/screens/profile/theme_settings_screen.dart +++ b/lib/frontend/screens/profile/theme_settings_screen.dart @@ -11,6 +11,7 @@ import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/settings_radio_tile.dart'; +import '../../widgets/settings_card.dart'; class ThemeSettingsScreen extends StatelessWidget { const ThemeSettingsScreen({super.key}); @@ -71,11 +72,8 @@ class _ThemeModeCard extends StatelessWidget { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), + return SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), - depth: 6, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -171,11 +169,8 @@ class _AmoledCardState extends State<_AmoledCard> { return Listener( behavior: HitTestBehavior.translucent, onPointerDown: (e) => _lastPointerPosition = e.position, - child: GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), + child: SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), - depth: 6, child: Row( children: [ Icon(Symbols.contrast, color: cs.onSurface, size: 24, weight: 500), @@ -235,11 +230,7 @@ class _ScheduleCard extends StatelessWidget { return AnimatedOpacity( opacity: enabled ? 1 : 0.5, duration: const Duration(milliseconds: 200), - child: GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), - depth: 6, + child: SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/frontend/screens/profile/traffic_monitor_screen.dart b/lib/frontend/screens/profile/traffic_monitor_screen.dart index 3a4ea6b..36ff901 100644 --- a/lib/frontend/screens/profile/traffic_monitor_screen.dart +++ b/lib/frontend/screens/profile/traffic_monitor_screen.dart @@ -12,6 +12,8 @@ import '../../../core/protocol/packet.dart'; import '../../../core/transport/traffic_monitor.dart'; import '../../../core/utils/format.dart'; import '../../widgets/custom_notification.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; class TrafficMonitorScreen extends StatefulWidget { const TrafficMonitorScreen({super.key}); @@ -153,7 +155,7 @@ class _TrafficMonitorScreenState extends State { color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), @@ -202,7 +204,7 @@ class _TrafficMonitorScreenState extends State { padding: const EdgeInsets.fromLTRB(16, 10, 12, 10), decoration: BoxDecoration( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), + borderRadius: AppShape.cardRadius, ), child: AnimatedBuilder( animation: _monitor, @@ -216,7 +218,7 @@ class _TrafficMonitorScreenState extends State { height: 10, decoration: BoxDecoration( shape: BoxShape.circle, - color: on ? kOnlineGreen : cs.outline, + color: on ? kSuccessGreen : cs.outline, ), ), const SizedBox(width: 12), diff --git a/lib/frontend/screens/profile/web_push_screen.dart b/lib/frontend/screens/profile/web_push_screen.dart new file mode 100644 index 0000000..724c9b8 --- /dev/null +++ b/lib/frontend/screens/profile/web_push_screen.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/api.dart'; +import '../../../core/utils/format.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../core/utils/link_opener.dart'; +import '../../../core/webpush/max_web_socket.dart'; +import '../../../core/webpush/web_push_service.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' show accountModule, api; +import '../../widgets/confirm_dialog.dart'; +import '../../widgets/connection_status.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/section_header.dart'; +import '../../widgets/settings_card.dart'; +import '../../widgets/small_spinner.dart'; + +const String kWebPushSiteUrl = 'https://push.komet.pw'; + +enum _Stage { loading, intro, waiting, password, ready } + +class WebPushScreen extends StatefulWidget { + const WebPushScreen({super.key}); + + @override + State createState() => _WebPushScreenState(); +} + +class _WebPushScreenState extends State { + final _passwordController = TextEditingController(); + final _passwordFocus = FocusNode(); + + Animation? _routeAnimation; + void Function(AnimationStatus)? _routeAnimationListener; + + _Stage _stage = _Stage.loading; + bool _busy = false; + bool _linked = false; + WebPushLinkInfo? _link; + String? _trackId; + String? _passwordHint; + + @override + void initState() { + super.initState(); + WebPushService.instance.changes.addListener(_onServiceChanged); + _reload(); + } + + void _onServiceChanged() { + if (mounted) _reload(); + } + + @override + void dispose() { + final listener = _routeAnimationListener; + if (listener != null) _routeAnimation?.removeStatusListener(listener); + WebPushService.instance.changes.removeListener(_onServiceChanged); + _passwordController.dispose(); + _passwordFocus.dispose(); + WebPushService.instance.cancelAuth(); + super.dispose(); + } + + Future _reload() async { + final service = WebPushService.instance; + final authorized = await service.isAuthorized(); + final link = await service.linkInfo(); + if (!mounted) return; + setState(() { + _link = link; + _linked = link != null; + _stage = authorized ? _Stage.ready : _Stage.intro; + }); + } + + void _scheduleKeyboard() { + final animation = ModalRoute.of(context)?.animation; + + if (animation == null || animation.status == AnimationStatus.completed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _openKeyboard(); + }); + return; + } + + final previous = _routeAnimationListener; + if (previous != null) _routeAnimation?.removeStatusListener(previous); + + _routeAnimation = animation; + _routeAnimationListener = (status) { + if (status != AnimationStatus.completed) return; + final listener = _routeAnimationListener; + if (listener != null) animation.removeStatusListener(listener); + _routeAnimationListener = null; + if (mounted) _openKeyboard(); + }; + animation.addStatusListener(_routeAnimationListener!); + } + + void _openKeyboard() { + if (!_passwordFocus.hasFocus) _passwordFocus.requestFocus(); + SystemChannels.textInput.invokeMethod('TextInput.show'); + } + + Future _run(Future Function() action) async { + if (_busy) return; + setState(() => _busy = true); + try { + await action(); + } on MaxWebException catch (e) { + if (mounted) showCustomNotification(context, e.message); + } catch (e) { + if (mounted) showCustomNotification(context, '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _connect() => _run(() async { + final l10n = AppLocalizations.of(context)!; + if (api.state != SessionState.online) { + showCustomNotification(context, l10n.webPushNeedsOnline); + return; + } + + final service = WebPushService.instance; + final track = await service.startQrAuth(); + if (!mounted) return; + setState(() => _stage = _Stage.waiting); + + await accountModule.authorizeWebQrLogin(track.qrLink); + final step = await service.awaitApproval(track); + await _applyStep(step); + }); + + Future _submitPassword() => _run(() async { + final trackId = _trackId; + if (trackId == null) return; + final step = await WebPushService.instance.submitPassword( + trackId, + _passwordController.text, + ); + await _applyStep(step); + }); + + Future _applyStep(WebPushAuthStep step) async { + if (step.needsPassword) { + if (!mounted) return; + _trackId = step.passwordChallenge!.trackId; + _passwordHint = step.passwordChallenge!.hint; + setState(() => _stage = _Stage.password); + _scheduleKeyboard(); + return; + } + + await WebPushService.instance.finishAuth(step.loginToken!); + if (!mounted) return; + _passwordController.clear(); + setState(() => _stage = _Stage.ready); + } + + Future _signOut() async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showConfirmDialog( + context, + title: l10n.webPushSignOut, + message: l10n.webPushSignOutConfirm, + confirmLabel: l10n.webPushSignOutAction, + destructive: true, + ); + if (!confirmed || !mounted) return; + + await _run(() async { + await WebPushService.instance.signOut(); + if (!mounted) return; + setState(() { + _linked = false; + _link = null; + _trackId = null; + _stage = _Stage.intro; + }); + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + backgroundColor: cs.surface, + appBar: ConnectionTitleBar( + titleText: l10n.webPushTitle, + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: _stage == _Stage.loading + ? const Center(child: SmallSpinner(size: 36)) + : ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: _sections(context, cs, l10n), + ), + ), + ); + } + + List _sections( + BuildContext context, + ColorScheme cs, + AppLocalizations l10n, + ) => switch (_stage) { + _Stage.loading => const [], + _Stage.intro => [ + _explainer(cs, l10n.webPushIntro), + const SizedBox(height: 20), + _primary(l10n.webPushConnect, _connect), + ], + _Stage.waiting => [ + _explainer(cs, l10n.webPushWaitingBody), + const SizedBox(height: 24), + const Center(child: SmallSpinner(size: 32)), + ], + _Stage.password => [ + _explainer(cs, l10n.webPushPasswordExplainer), + if (_passwordHint != null) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + l10n.webPushPasswordHintLabel(_passwordHint!), + style: TextStyle(color: cs.tertiary, fontSize: 14), + ), + ), + ], + const SizedBox(height: 20), + TextField( + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !_busy, + autofocus: true, + obscureText: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submitPassword(), + decoration: InputDecoration( + hintText: l10n.webPushPasswordHint, + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 16), + _primary(l10n.webPushConfirm, _submitPassword), + ], + _Stage.ready => [ + SectionHeader( + _linked ? l10n.webPushLinkedTitle : l10n.webPushInstallTitle, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), + _explainer(cs, _linked ? l10n.webPushLinkedBody : l10n.webPushInstallBody), + if (_link != null) ...[ + const SizedBox(height: 12), + _linkDetails(cs, l10n, _link!), + ], + const SizedBox(height: 20), + _primary(l10n.webPushOpenSite, () { + Haptics.tap(); + openExternalUrl(context, kWebPushSiteUrl); + }), + const SizedBox(height: 24), + SettingsCard( + children: [ + SettingsNavTile( + icon: Symbols.logout, + label: l10n.webPushSignOut, + tintColor: cs.error, + onTap: _busy ? null : _signOut, + isLast: true, + ), + ], + ), + ], + }; + + Widget _detailRow(ColorScheme cs, String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 104, + child: Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + + Widget _linkDetails( + ColorScheme cs, + AppLocalizations l10n, + WebPushLinkInfo link, + ) => SettingsPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow(cs, l10n.webPushStatusService, link.host), + _detailRow(cs, l10n.webPushStatusToken, link.shortEndpoint), + if (link.linkedAt != null) + _detailRow( + cs, + l10n.webPushStatusLinkedAt, + formatDateTimeWords(link.linkedAt!), + ), + _detailRow(cs, l10n.webPushStatusDevice, link.deviceId), + ], + ), + ); + + Widget _explainer(ColorScheme cs, String text) => SettingsPanel( + child: Text( + text, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14, height: 1.5), + ), + ); + + Widget _primary(String label, VoidCallback? onPressed) => SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _busy ? null : onPressed, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: _busy + ? const SmallSpinner(size: 20) + : Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + ); +} diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart index a8d8b4b..d01a930 100644 --- a/lib/frontend/screens/profile/web_qr_scan_screen.dart +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -5,8 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import '../../../core/config/app_colors.dart'; +import '../../widgets/animated_slash_icon.dart'; import '../../widgets/connection_status.dart'; +import '../../../core/config/app_fonts.dart'; class WebQrScanScreen extends StatefulWidget { const WebQrScanScreen({super.key}); @@ -72,7 +75,7 @@ class _WebQrScanScreenState extends State { title: Text( 'QR для веба и ПК', style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontSize: 20, fontWeight: FontWeight.w600, color: Colors.white, @@ -85,8 +88,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, ); }, @@ -135,7 +140,7 @@ class _WebQrScanScreenState extends State { 'Наведите камеру на QR-код на экране компьютера', textAlign: TextAlign.center, style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontSize: 15, fontWeight: FontWeight.w500, color: Colors.white, @@ -357,7 +362,7 @@ class _TelegramStyleFinderOverlayState final finderRect = _interpolatedFinderRect(); final rrect = _finderRRect(finderRect, _frameCornerRadius); - final frameColor = _qrInView ? const Color(0xFF4ADE80) : Colors.white; + final frameColor = _qrInView ? kSuccessGreen : Colors.white; return IgnorePointer( child: Stack( fit: StackFit.expand, diff --git a/lib/frontend/screens/stories/story_composer_screen.dart b/lib/frontend/screens/stories/story_composer_screen.dart index f6d26bc..acf7531 100644 --- a/lib/frontend/screens/stories/story_composer_screen.dart +++ b/lib/frontend/screens/stories/story_composer_screen.dart @@ -1,19 +1,29 @@ import 'dart:io'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:video_player/video_player.dart'; import '../../../core/utils/haptics.dart'; import '../../../main.dart' show fileUploader, messagesModule, storiesModule; import '../../widgets/custom_notification.dart'; import '../../widgets/primary_loading_button.dart'; +import '../../../core/config/app_frost.dart'; -const int _storyExpiration = 86400; +const int _storyExpiration = 86400000; class StoryComposerScreen extends StatefulWidget { final File file; + final bool isVideo; + final int? durationMs; - const StoryComposerScreen({super.key, required this.file}); + const StoryComposerScreen({ + super.key, + required this.file, + this.isVideo = false, + this.durationMs, + }); @override State createState() => _StoryComposerScreenState(); @@ -22,10 +32,29 @@ class StoryComposerScreen extends StatefulWidget { class _StoryComposerScreenState extends State { final ValueNotifier _publishing = ValueNotifier(false); int _audience = 1; // 1 = все, 2 = контакты + VideoPlayerController? _video; + + @override + void initState() { + super.initState(); + if (widget.isVideo) _initVideo(); + } + + Future _initVideo() async { + final controller = VideoPlayerController.file(widget.file); + _video = controller; + try { + await controller.initialize(); + await controller.setLooping(true); + await controller.play(); + if (mounted) setState(() {}); + } catch (_) {} + } @override void dispose() { _publishing.dispose(); + _video?.dispose(); super.dispose(); } @@ -33,37 +62,80 @@ class _StoryComposerScreenState extends State { if (_publishing.value) return; _publishing.value = true; try { - final url = await messagesModule.requestPhotoUploadUrl(); - if (url == null || url.isEmpty) { - _fail('Не удалось получить адрес загрузки'); - return; + if (widget.isVideo) { + await _publishVideo(); + } else { + await _publishPhoto(); } - final segments = widget.file.uri.pathSegments; - final filename = segments.isNotEmpty ? segments.last : 'story.jpg'; - final token = await fileUploader.uploadPhoto( - Uri.parse(url), - widget.file, - filename: filename.isEmpty ? 'story.jpg' : filename, - ); - if (token == null || token.isEmpty) { - _fail('Не удалось загрузить фото'); - return; - } - await storiesModule.publishPhoto( - photoToken: token, - settings: _audience, - expiration: _storyExpiration, - ); - if (!mounted) return; - Haptics.success(); - Navigator.of(context).pop(); - showCustomNotification(context, 'История опубликована'); - storiesModule.loadFeed(); } catch (e) { _fail(e.toString()); } } + Future _publishPhoto() async { + final url = await messagesModule.requestPhotoUploadUrl(type: 1); + if (url == null || url.isEmpty) { + _fail('Не удалось получить адрес загрузки'); + return; + } + final segments = widget.file.uri.pathSegments; + final filename = segments.isNotEmpty ? segments.last : 'story.jpg'; + final token = await fileUploader.uploadPhoto( + Uri.parse(url), + widget.file, + filename: filename.isEmpty ? 'story.jpg' : filename, + ); + if (token == null || token.isEmpty) { + _fail('Не удалось загрузить фото'); + return; + } + await storiesModule.publishPhoto( + photoToken: token, + settings: _audience, + expiration: _storyExpiration, + ); + _onPublished(); + } + + Future _publishVideo() async { + final info = await messagesModule.requestVideoUploadUrl(type: 3); + if (info == null || info.url.isEmpty) { + _fail('Не удалось получить адрес загрузки'); + return; + } + final upload = await fileUploader.uploadVideoWithToken( + Uri.parse(info.url), + widget.file, + ); + if (!upload.ok) { + _fail('Не удалось загрузить видео'); + return; + } + final uploadedToken = upload.token; + final token = (uploadedToken != null && uploadedToken.isNotEmpty) + ? uploadedToken + : info.token; + if (token.isEmpty) { + _fail('Не удалось загрузить видео'); + return; + } + await storiesModule.publishVideo( + videoToken: token, + durationMs: widget.durationMs, + settings: _audience, + expiration: _storyExpiration, + ); + _onPublished(); + } + + void _onPublished() { + if (!mounted) return; + Haptics.success(); + Navigator.of(context).pop(); + showCustomNotification(context, 'История опубликована'); + storiesModule.loadFeed(); + } + void _fail(String message) { if (!mounted) { _publishing.value = false; @@ -74,6 +146,51 @@ class _StoryComposerScreenState extends State { showCustomNotification(context, message); } + Widget _buildPreview() { + final Widget foreground; + final Widget backdrop; + if (widget.isVideo) { + final c = _video; + if (c == null || !c.value.isInitialized) { + return const CircularProgressIndicator(color: Colors.white); + } + final size = c.value.size; + foreground = Center( + child: AspectRatio( + aspectRatio: c.value.aspectRatio, + child: VideoPlayer(c), + ), + ); + backdrop = FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: size.width, + height: size.height, + child: VideoPlayer(c), + ), + ); + } else { + foreground = Image.file(widget.file, fit: BoxFit.contain); + backdrop = Image.file(widget.file, fit: BoxFit.cover); + } + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur( + sigmaX: AppFrost.mediaBackdropSigma, + sigmaY: AppFrost.mediaBackdropSigma, + ), + child: backdrop, + ), + ), + const Positioned.fill(child: ColoredBox(color: Colors.black26)), + foreground, + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -81,9 +198,7 @@ class _StoryComposerScreenState extends State { body: Stack( fit: StackFit.expand, children: [ - Center( - child: Image.file(widget.file, fit: BoxFit.contain), - ), + Center(child: _buildPreview()), Positioned( top: 0, left: 0, diff --git a/lib/frontend/screens/stories/story_peanut.dart b/lib/frontend/screens/stories/story_peanut.dart new file mode 100644 index 0000000..4ec1591 --- /dev/null +++ b/lib/frontend/screens/stories/story_peanut.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/media/preview_image.dart'; +import '../../../models/story.dart'; + +ImageProvider? storyThumbProvider(Story story) { + final media = story.media; + if (media == null) return null; + final url = media.isVideo ? (media.thumbnailUrl ?? media.url) : media.url; + if (url != null && url.isNotEmpty) { + return CachedNetworkImageProvider(url, maxWidth: 160, maxHeight: 160); + } + return dataUriImage(story, media.previewData); +} + +class StoryPeanut extends StatefulWidget { + final List stories; + final double diameter; + final double strokeWidth; + final double gap; + final Color outlineColor; + final Duration cycle; + + const StoryPeanut({ + super.key, + required this.stories, + this.diameter = 30, + this.strokeWidth = 1.8, + this.gap = 1.6, + this.outlineColor = Colors.white, + this.cycle = const Duration(seconds: 3), + }); + + static const int maxCircles = 3; + + @override + State createState() => _StoryPeanutState(); +} + +class _StoryPeanutState extends State { + Timer? _timer; + int _cycleIndex = 0; + + bool get _cycling => widget.stories.length > StoryPeanut.maxCircles; + + int get _circleCount => + _cycling ? 1 : widget.stories.length.clamp(0, StoryPeanut.maxCircles); + + @override + void initState() { + super.initState(); + _syncTimer(); + } + + @override + void didUpdateWidget(StoryPeanut old) { + super.didUpdateWidget(old); + if (old.stories.length != widget.stories.length || + old.cycle != widget.cycle) { + _cycleIndex = 0; + _syncTimer(); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _syncTimer() { + _timer?.cancel(); + if (!_cycling) return; + _timer = Timer.periodic(widget.cycle, (_) { + if (!mounted) return; + setState(() { + _cycleIndex = (_cycleIndex + 1) % widget.stories.length; + }); + }); + } + + @override + Widget build(BuildContext context) { + final count = _circleCount; + if (count == 0) return const SizedBox.shrink(); + + final d = widget.diameter; + final step = d * 0.68; + final pad = widget.strokeWidth + widget.gap; + final width = d + step * (count - 1); + + return SizedBox( + width: width, + height: d, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < count; i++) + Positioned( + left: step * i, + top: 0, + width: d, + height: d, + child: ClipPath( + clipper: i == count - 1 + ? null + : _NotchClipper( + center: Offset(step + d / 2, d / 2), + radius: d / 2 + widget.gap, + ), + child: Padding( + padding: EdgeInsets.all(pad), + child: ClipOval(child: _thumb(i)), + ), + ), + ), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _PeanutOutlinePainter( + count: count, + diameter: d, + step: step, + strokeWidth: widget.strokeWidth, + color: widget.outlineColor, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _thumb(int index) { + final story = _cycling + ? widget.stories[_cycleIndex % widget.stories.length] + : widget.stories[index]; + final provider = storyThumbProvider(story); + final fill = ColoredBox(color: Colors.white.withValues(alpha: 0.22)); + final image = provider == null + ? fill + : Image(image: provider, fit: BoxFit.cover, gaplessPlayback: true); + if (!_cycling) return image; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 320), + child: KeyedSubtree(key: ValueKey(story.id), child: image), + ); + } +} + +class _NotchClipper extends CustomClipper { + final Offset center; + final double radius; + + const _NotchClipper({required this.center, required this.radius}); + + @override + Path getClip(Size size) { + return Path.combine( + PathOperation.difference, + Path()..addOval(Offset.zero & size), + Path()..addOval(Rect.fromCircle(center: center, radius: radius)), + ); + } + + @override + bool shouldReclip(_NotchClipper old) => + old.center != center || old.radius != radius; +} + +class _PeanutOutlinePainter extends CustomPainter { + final int count; + final double diameter; + final double step; + final double strokeWidth; + final Color color; + + const _PeanutOutlinePainter({ + required this.count, + required this.diameter, + required this.step, + required this.strokeWidth, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final radius = diameter / 2 - strokeWidth / 2; + Path union = Path(); + for (var i = 0; i < count; i++) { + final circle = Path() + ..addOval( + Rect.fromCircle( + center: Offset(diameter / 2 + step * i, diameter / 2), + radius: radius, + ), + ); + union = i == 0 ? circle : Path.combine(PathOperation.union, union, circle); + } + canvas.drawPath( + union, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..color = color, + ); + } + + @override + bool shouldRepaint(_PeanutOutlinePainter old) => + old.count != count || + old.diameter != diameter || + old.step != step || + old.strokeWidth != strokeWidth || + old.color != color; +} diff --git a/lib/frontend/screens/stories/story_ring.dart b/lib/frontend/screens/stories/story_ring.dart index 3315c1a..558fa6a 100644 --- a/lib/frontend/screens/stories/story_ring.dart +++ b/lib/frontend/screens/stories/story_ring.dart @@ -2,12 +2,71 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../../core/utils/haptics.dart'; import '../../../models/story.dart'; import '../../widgets/komet_avatar.dart'; import 'story_owner_info.dart'; +class StoryAvatarRing extends StatelessWidget { + final double diameter; + final int total; + final int read; + final double strokeWidth; + final double ringGap; + final double haloWidth; + final Widget child; + + const StoryAvatarRing({ + super.key, + required this.diameter, + required this.child, + this.total = 0, + this.read = 0, + this.strokeWidth = 2.8, + this.ringGap = 6, + this.haloWidth = 2, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final outer = diameter + ringGap * 2; + final visible = total > 0; + return SizedBox( + width: outer, + height: outer, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: Size.square(outer), + painter: visible + ? SegmentedRingPainter( + total: total, + read: read, + unreadColors: [cs.primary, cs.tertiary, cs.primary], + readColor: cs.outlineVariant, + strokeWidth: strokeWidth, + ) + : null, + ), + Container( + width: diameter + haloWidth * 2, + height: diameter + haloWidth * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: visible ? cs.surface : Colors.transparent, + ), + ), + child, + ], + ), + ); + } +} + /// Кольцо-превью истории владельца в шапке списка чатов. class StoryRing extends StatefulWidget { final StoryPreview preview; @@ -52,7 +111,8 @@ class _StoryRingState extends State { owner: widget.preview.owner, overrideInfo: widget.ownerOverride, builder: (context, info) { - final name = widget.selfLabel ?? + final name = + widget.selfLabel ?? (info?.name.isNotEmpty == true ? info!.name : '…'); return Padding( padding: const EdgeInsets.only(right: 16), @@ -71,36 +131,14 @@ class _StoryRingState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: diameter + 12, - height: diameter + 12, - child: Stack( - alignment: Alignment.center, - children: [ - CustomPaint( - size: Size.square(diameter + 12), - painter: _SegmentedRingPainter( - total: widget.preview.totalCount, - read: widget.preview.readCount, - unreadColors: [cs.primary, cs.tertiary, cs.primary], - readColor: cs.outlineVariant, - strokeWidth: 2.8, - ), - ), - Container( - width: diameter + 4, - height: diameter + 4, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.surface, - ), - ), - KometAvatar( - name: name == '…' ? '?' : name, - size: diameter, - imageUrl: info?.avatarUrl, - ), - ], + StoryAvatarRing( + diameter: diameter, + total: widget.preview.totalCount, + read: widget.preview.readCount, + child: KometAvatar( + name: name == '…' ? '?' : name, + size: diameter, + imageUrl: info?.avatarUrl, ), ), const SizedBox(height: 6), @@ -128,14 +166,14 @@ class _StoryRingState extends State { } /// Прерывистое кольцо: одна дуга на каждую историю; прочитанные приглушены. -class _SegmentedRingPainter extends CustomPainter { +class SegmentedRingPainter extends CustomPainter { final int total; final int read; final List unreadColors; final Color readColor; final double strokeWidth; - _SegmentedRingPainter({ + SegmentedRingPainter({ required this.total, required this.read, required this.unreadColors, @@ -146,9 +184,12 @@ class _SegmentedRingPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final n = total < 1 ? 1 : total; - final center = size.center(Offset.zero); - final radius = (size.width - strokeWidth) / 2; - final rect = Rect.fromCircle(center: center, radius: radius); + final rect = Rect.fromLTWH( + strokeWidth / 2, + strokeWidth / 2, + size.width - strokeWidth, + size.height - strokeWidth, + ); final segment = (2 * math.pi) / n; final gap = n == 1 ? 0.0 : math.min(0.16, segment * 0.30); @@ -173,12 +214,18 @@ class _SegmentedRingPainter extends CustomPainter { for (var i = 0; i < n; i++) { final start = -math.pi / 2 + gap / 2 + i * segment; - canvas.drawArc(rect, start, sweep, false, i < read ? readPaint : unreadPaint); + canvas.drawArc( + rect, + start, + sweep, + false, + i < read ? readPaint : unreadPaint, + ); } } @override - bool shouldRepaint(_SegmentedRingPainter old) => + bool shouldRepaint(SegmentedRingPainter old) => old.total != total || old.read != read || old.readColor != readColor || @@ -260,7 +307,7 @@ class _StorySelfTileState extends State { if (preview != null) CustomPaint( size: Size.square(diameter + 12), - painter: _SegmentedRingPainter( + painter: SegmentedRingPainter( total: preview.totalCount, read: preview.readCount, unreadColors: [cs.primary, cs.tertiary, cs.primary], @@ -316,7 +363,7 @@ class _StorySelfTileState extends State { color: cs.primary, ), child: Icon( - Icons.add, + Symbols.add, size: 14, color: cs.onPrimary, ), @@ -349,6 +396,19 @@ class _StorySelfTileState extends State { /// Свёрнутая мини-стопка колец, показывается в заголовке при закрытом доке. class FoldedStoryStack extends StatelessWidget { + static const int maxShown = 3; + static const double avatarSize = 28; + static const double _rim = 1.5; + static const double _gap = 1.5; + static const double step = 14; + + static const double outerSize = avatarSize + (_rim + _gap) * 2; + + static double widthFor(int count) { + final shown = count > maxShown ? maxShown : (count < 1 ? 1 : count); + return outerSize + step * (shown - 1); + } + final List previews; final double opacity; @@ -361,35 +421,43 @@ class FoldedStoryStack extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final shown = previews.take(3).toList(); + final shown = previews.take(maxShown).toList(); return Opacity( opacity: opacity.clamp(0.0, 1.0), - child: Stack( - children: [ - for (var i = 0; i < shown.length; i++) - Positioned( - left: i * 14.0, - child: StoryOwnerBuilder( - owner: shown[i].owner, - builder: (context, info) => Container( - padding: const EdgeInsets.all(1.5), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.surface, - border: Border.all( - color: shown[i].hasUnread ? cs.primary : cs.outlineVariant, - width: 1.5, + child: SizedBox( + height: outerSize, + width: widthFor(shown.length), + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < shown.length; i++) + Positioned( + left: i * step, + top: 0, + child: StoryOwnerBuilder( + owner: shown[i].owner, + builder: (context, info) => Container( + padding: const EdgeInsets.all(_gap), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surface, + border: Border.all( + color: shown[i].hasUnread + ? cs.primary + : cs.outlineVariant, + width: _rim, + ), + ), + child: KometAvatar( + name: info?.name.isNotEmpty == true ? info!.name : '?', + size: avatarSize, + imageUrl: info?.avatarUrl, + ), ), ), - child: KometAvatar( - name: info?.name.isNotEmpty == true ? info!.name : '?', - size: 28, - imageUrl: info?.avatarUrl, - ), ), - ), - ), - ], + ], + ), ), ); } diff --git a/lib/frontend/screens/stories/story_viewer_screen.dart b/lib/frontend/screens/stories/story_viewer_screen.dart index a011530..32331ab 100644 --- a/lib/frontend/screens/stories/story_viewer_screen.dart +++ b/lib/frontend/screens/stories/story_viewer_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'dart:ui' as ui; @@ -11,24 +12,31 @@ import 'package:video_player/video_player.dart'; import '../../../core/utils/haptics.dart'; import '../../../main.dart' show storiesModule; import '../../../models/story.dart'; -import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/small_spinner.dart'; import 'story_owner_info.dart'; +import '../../../core/config/app_frost.dart'; +import '../../../core/config/app_fonts.dart'; -const _quickReactions = ['❤️', '🔥', '😍', '👏', '😂', '😮']; const Duration _photoDuration = Duration(seconds: 5); +Offset? storyOriginOf(BuildContext context) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return null; + return box.localToGlobal(box.size.center(Offset.zero)); +} + /// Открывает вьюер историй. Если задан [origin] (глобальный центр нажатого /// кольца) — открытие анимируется расширяющимся из этой точки кругом; иначе — /// масштабным «зумом». -void openStoryViewer( +Future openStoryViewer( BuildContext context, { required List previews, int initialIndex = 0, Map ownerOverrides = const {}, Offset? origin, }) { - Navigator.of(context).push( + return Navigator.of(context).push( PageRouteBuilder( opaque: false, transitionDuration: const Duration(milliseconds: 420), @@ -43,7 +51,8 @@ void openStoryViewer( animation: animation, child: child, builder: (context, child) { - final closing = animation.status == AnimationStatus.reverse || + final closing = + animation.status == AnimationStatus.reverse || animation.status == AnimationStatus.dismissed; // Круговое раскрытие — только на открытии; закрытие всегда // мягким fade + scale (круг «схлопыванием» резал кадр). @@ -122,9 +131,6 @@ class _StoryViewerScreenState extends State bool _dragging = false; static const double _dismissThreshold = 120; - final List<_Burst> _bursts = []; - int _burstSeq = 0; - VideoPlayerController? _video; StoryPreview get _owner => widget.previews[_ownerIndex]; @@ -172,7 +178,9 @@ class _StoryViewerScreenState extends State return; } setState(() => _loading[ownerId] = true); - final stories = await storiesModule.getByOwner(widget.previews[index].owner); + final stories = await storiesModule.getByOwner( + widget.previews[index].owner, + ); if (!mounted) return; setState(() { _stories[ownerId] = stories; @@ -183,19 +191,16 @@ class _StoryViewerScreenState extends State } } - /// Индекс, с которого начать показ: сначала — сохранённая позиция просмотра, - /// иначе — первая непрочитанная. int _resumeIndex(int index, List stories) { if (stories.isEmpty) return 0; - final ownerId = widget.previews[index].owner.ownerId; - final savedId = storiesModule.lastViewedStoryId(ownerId); - if (savedId != null) { - final i = stories.indexWhere((s) => s.id == savedId); - if (i >= 0) return i; - } - final read = widget.previews[index].readCount; - if (read > 0 && read < stories.length) return read; - return 0; + final preview = widget.previews[index]; + final read = preview.readCount; + final firstUnread = (read > 0 && read < stories.length) ? read : 0; + final savedId = storiesModule.lastViewedStoryId(preview.owner.ownerId); + if (savedId == null) return firstUnread; + final saved = stories.indexWhere((s) => s.id == savedId); + if (saved <= firstUnread || saved >= stories.length - 1) return firstUnread; + return saved; } void _startStory(int index) { @@ -260,6 +265,7 @@ class _StoryViewerScreenState extends State if (_storyIndex + 1 < _ownerStories.length) { _startStory(_storyIndex + 1); } else { + storiesModule.clearLastViewed(_owner.owner.ownerId); _nextOwner(); } } @@ -315,39 +321,6 @@ class _StoryViewerScreenState extends State } } - void _spawnBurst(String emoji, Alignment from) { - final id = _burstSeq++; - setState(() => _bursts.add(_Burst(id, emoji, from))); - } - - void _removeBurst(int id) { - if (!mounted) return; - setState(() => _bursts.removeWhere((b) => b.id == id)); - } - - Future _toggleReaction(String emoji) async { - final story = _currentStory; - if (story == null || story.id == 0) return; - final isSame = story.reaction?.id == emoji; - if (!isSame) { - Haptics.medium(); - _spawnBurst(emoji, const Alignment(0, 0.55)); - } else { - Haptics.tap(); - } - final ok = await storiesModule.react( - story.owner, - story.id, - isSame ? null : StoryReaction(id: emoji), - ); - if (!mounted) return; - if (ok) { - setState(() {}); - } else { - showCustomNotification(context, 'Не удалось отправить реакцию'); - } - } - void _onDragStart(DragStartDetails _) { _dragging = true; _setPaused(true); @@ -392,8 +365,11 @@ class _StoryViewerScreenState extends State ? _buildActiveOwner() : _OwnerCover( preview: widget.previews[index], - overrideInfo: widget.ownerOverrides[ - widget.previews[index].owner.ownerId], + overrideInfo: + widget.ownerOverrides[widget + .previews[index] + .owner + .ownerId], ); return _CubePage( controller: _ownerController, @@ -429,13 +405,6 @@ class _StoryViewerScreenState extends State ); }, ), - for (final burst in _bursts) - _FloatingReaction( - key: ValueKey(burst.id), - emoji: burst.emoji, - alignment: burst.from, - onDone: () => _removeBurst(burst.id), - ), ], ), ); @@ -488,23 +457,13 @@ class _StoryViewerScreenState extends State ), const _TopScrim(), if (loading) - const Center( - child: SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: Colors.white, - ), - ), - ), + const Center(child: SmallSpinner(size: 28, color: Colors.white)), SafeArea( child: Column( children: [ _buildProgressBars(stories.length), _buildHeader(), const Spacer(), - if (story != null) _buildReactionBar(story), ], ), ), @@ -577,14 +536,12 @@ class _StoryViewerScreenState extends State info?.name.isNotEmpty == true ? info!.name : '…', maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - shadows: [ - Shadow(color: Colors.black54, blurRadius: 4), - ], + fontFamily: displayFontOf(context), + shadows: [Shadow(color: Colors.black54, blurRadius: 4)], ), ), if (story != null && story.time > 0) @@ -610,58 +567,6 @@ class _StoryViewerScreenState extends State ), ); } - - Widget _buildReactionBar(Story story) { - final current = story.reaction?.id; - return Container( - padding: const EdgeInsets.only(bottom: 6), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [Colors.black54, Colors.transparent], - ), - ), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(30), - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 14, sigmaY: 14), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(30), - border: Border.all( - color: Colors.white.withValues(alpha: 0.18), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final emoji in _quickReactions) - _ReactionButton( - emoji: emoji, - selected: current == emoji, - onTap: () => _toggleReaction(emoji), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - } } // ─── Cube (3D fold) page transform ──────────────────────────────────────── @@ -764,66 +669,6 @@ class _SegmentBar extends StatelessWidget { } } -// ─── Reaction emoji button ──────────────────────────────────────────────── -class _ReactionButton extends StatefulWidget { - final String emoji; - final bool selected; - final VoidCallback onTap; - - const _ReactionButton({ - required this.emoji, - required this.selected, - required this.onTap, - }); - - @override - State<_ReactionButton> createState() => _ReactionButtonState(); -} - -class _ReactionButtonState extends State<_ReactionButton> - with SingleTickerProviderStateMixin { - late final AnimationController _c = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 260), - lowerBound: 0.0, - upperBound: 1.0, - value: 1.0, - ); - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - void _onTap() { - _c.forward(from: 0.0); - widget.onTap(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: _onTap, - behavior: HitTestBehavior.opaque, - child: AnimatedBuilder( - animation: _c, - builder: (context, _) { - final pop = 1.0 + math.sin(_c.value * math.pi) * 0.4; - final scale = (widget.selected ? 1.15 : 1.0) * pop; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Transform.scale( - scale: scale, - child: Text(widget.emoji, style: const TextStyle(fontSize: 28)), - ), - ); - }, - ), - ); - } -} - // ─── Round icon button (close) ──────────────────────────────────────────── class _RoundIconButton extends StatelessWidget { final IconData icon; @@ -876,84 +721,6 @@ class _TopScrim extends StatelessWidget { } } -// ─── Floating reaction burst ────────────────────────────────────────────── -class _Burst { - final int id; - final String emoji; - final Alignment from; - const _Burst(this.id, this.emoji, this.from); -} - -class _FloatingReaction extends StatefulWidget { - final String emoji; - final Alignment alignment; - final VoidCallback onDone; - - const _FloatingReaction({ - super.key, - required this.emoji, - required this.alignment, - required this.onDone, - }); - - @override - State<_FloatingReaction> createState() => _FloatingReactionState(); -} - -class _FloatingReactionState extends State<_FloatingReaction> - with SingleTickerProviderStateMixin { - late final AnimationController _c = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - late final double _drift = (widget.emoji.hashCode % 40 - 20).toDouble(); - - @override - void initState() { - super.initState(); - _c.forward().whenComplete(widget.onDone); - } - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: AnimatedBuilder( - animation: _c, - builder: (context, _) { - final t = _c.value; - final rise = -160.0 * Curves.easeOut.transform(t); - final scale = t < 0.3 - ? Curves.easeOutBack.transform(t / 0.3) * 1.2 - : 1.2 - 0.2 * ((t - 0.3) / 0.7); - final opacity = t < 0.7 ? 1.0 : 1.0 - (t - 0.7) / 0.3; - return Align( - alignment: widget.alignment, - child: Transform.translate( - offset: Offset(_drift * t, rise), - child: Opacity( - opacity: opacity.clamp(0.0, 1.0), - child: Transform.scale( - scale: scale, - child: Text( - widget.emoji, - style: const TextStyle(fontSize: 64), - ), - ), - ), - ), - ); - }, - ), - ); - } -} - String _timeAgo(int epochTime) { final ms = epochTime < 1000000000000 ? epochTime * 1000 : epochTime; final diff = (DateTime.now().millisecondsSinceEpoch - ms) ~/ 1000; @@ -986,7 +753,10 @@ class _StoryMediaView extends StatelessWidget { final Widget blurBg = preview != null ? Positioned.fill( child: ImageFiltered( - imageFilter: ui.ImageFilter.blur(sigmaX: 30, sigmaY: 30), + imageFilter: ui.ImageFilter.blur( + sigmaX: AppFrost.mediaBackdropSigma, + sigmaY: AppFrost.mediaBackdropSigma, + ), child: Image(image: preview, fit: BoxFit.cover), ), ) @@ -1008,24 +778,22 @@ class _StoryMediaView extends StatelessWidget { fit: BoxFit.contain, ); } else if (preview != null) { - fg = Center(child: Image(image: preview, fit: BoxFit.contain)); + fg = Center( + child: Image(image: preview, fit: BoxFit.contain), + ); } else { fg = const SizedBox.shrink(); } - return Stack( - fit: StackFit.expand, - children: [ - blurBg, - fg, - ], - ); + return Stack(fit: StackFit.expand, children: [blurBg, fg]); } final url = media.url; Widget fg; if (url == null || url.isEmpty) { fg = preview != null - ? Center(child: Image(image: preview, fit: BoxFit.contain)) + ? Center( + child: Image(image: preview, fit: BoxFit.contain), + ) : const SizedBox.shrink(); } else { fg = CachedNetworkImage( @@ -1033,22 +801,24 @@ class _StoryMediaView extends StatelessWidget { fit: BoxFit.contain, fadeInDuration: const Duration(milliseconds: 200), placeholder: preview != null - ? (context, _) => Center(child: Image(image: preview, fit: BoxFit.contain)) + ? (context, _) => Center( + child: Image(image: preview, fit: BoxFit.contain), + ) : null, errorWidget: (context, _, _) => preview != null - ? Center(child: Image(image: preview, fit: BoxFit.contain)) + ? Center( + child: Image(image: preview, fit: BoxFit.contain), + ) : const Center( - child: Icon(Symbols.broken_image, color: Colors.white54, size: 48), + child: Icon( + Symbols.broken_image, + color: Colors.white54, + size: 48, + ), ), ); } - return Stack( - fit: StackFit.expand, - children: [ - blurBg, - fg, - ], - ); + return Stack(fit: StackFit.expand, children: [blurBg, fg]); } } @@ -1075,14 +845,7 @@ class _OwnerCover extends StatelessWidget { imageUrl: info?.avatarUrl, ), const SizedBox(height: 14), - const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white30, - ), - ), + const SmallSpinner(size: 22, color: Colors.white30), ], ), ), diff --git a/lib/frontend/screens/webapp/open_mini_app.dart b/lib/frontend/screens/webapp/open_mini_app.dart new file mode 100644 index 0000000..bba7180 --- /dev/null +++ b/lib/frontend/screens/webapp/open_mini_app.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../../../backend/modules/webapp.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../core/utils/link_opener.dart'; +import '../../../core/utils/webview_support.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' show webAppModule; +import '../../widgets/custom_notification.dart'; +import 'web_app_bridge.dart'; +import 'web_app_screen.dart'; + +Future openMiniApp( + BuildContext context, { + required int botId, + required String title, + int? chatId, + String entryPoint = WebAppEntryPoint.webApp, +}) async { + if (botId <= 0) return; + Haptics.tap(); + + if (webViewSupported) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => WebAppScreen( + title: title, + entryPoint: entryPoint, + loader: () => webAppModule.fetchLaunch(botId, chatId: chatId), + ), + ), + ); + return; + } + + try { + final launch = await webAppModule.fetchLaunch(botId, chatId: chatId); + if (!context.mounted) return; + await openExternalUrl(context, launch.url); + } on WebAppUnavailable catch (e) { + if (context.mounted) showCustomNotification(context, e.message); + } catch (_) { + if (context.mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.miniAppFailed, + ); + } + } +} diff --git a/lib/frontend/screens/webapp/web_app_bridge.dart b/lib/frontend/screens/webapp/web_app_bridge.dart new file mode 100644 index 0000000..9a03c48 --- /dev/null +++ b/lib/frontend/screens/webapp/web_app_bridge.dart @@ -0,0 +1,767 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../core/storage/token_storage.dart'; +import '../../../core/storage/webapp_storage.dart'; +import '../../../core/utils/haptics.dart'; +import '../../../core/utils/link_opener.dart'; +import '../../../core/utils/media_saver.dart'; +import '../../../core/utils/share_origin.dart'; +import '../../../main.dart' show api, messagesModule, webAppModule; +import '../../widgets/confirm_dialog.dart'; +import '../chats/chat_list_screen.dart' show openForwardScreen; +import '../profile/web_qr_scan_screen.dart'; + +abstract class WebAppEntryPoint { + static const String webApp = 'web_app'; + static const String url = 'url'; + static const String startButton = 'start_button'; + static const String inlineButton = 'inline_button'; + static const String chatProfile = 'chat_profile'; + static const String externalCallback = 'external_callback'; + static const String settings = 'settings'; + static const String fromSearch = 'from_search'; +} + +typedef WebAppMobileIdVerifier = Future?> Function( + String url, +); + +typedef WebAppEmitter = + void Function(String method, String payload, bool private); + +const Duration _gestureWindow = Duration(milliseconds: 3000); + +const Set _gestureGated = { + 'WebAppMaxShare', + 'WebAppShare', + 'WebAppDownloadFile', + 'WebAppOpenLink', + 'WebAppOpenMaxLink', +}; + +const Map _methodSlugs = { + 'WebAppReady': 'ready', + 'WebAppClose': 'close', + 'WebAppSetupBackButton': 'setup_back_button', + 'WebAppSetupClosingBehavior': 'setup_closing_behaviour', + 'WebAppBackButtonPressed': 'back_button_pressed', + 'WebAppSetupScreenCaptureBehavior': 'setup_screen_capture_behavior', + 'WebAppGetLaunchContext': 'launch_context', + 'WebAppGetViewportSize': 'get_viewport_size', + 'WebAppRequestPhone': 'request_phone', + 'WebAppOpenLink': 'open_link', + 'WebAppOpenMaxLink': 'open_max_link', + 'WebAppShare': 'web_app_share', + 'WebAppMaxShare': 'web_app_max_share', + 'WebAppDeviceStorageSaveKey': 'device_storage_save_key', + 'WebAppDeviceStorageGetKey': 'device_storage_get_key', + 'WebAppDeviceStorageClear': 'device_storage_clear', + 'WebAppSecureStorageSaveKey': 'secure_storage_save_key', + 'WebAppSecureStorageGetKey': 'secure_storage_get_key', + 'WebAppSecureStorageClear': 'secure_storage_clear', + 'WebAppBiometryGetInfo': 'biometry_get_info', + 'WebAppBiometryRequestAccess': 'biometry_request_access', + 'WebAppBiometryRequestAuth': 'biometry_request_auth', + 'WebAppBiometryUpdateToken': 'biometry_update_token', + 'WebAppBiometryOpenSettings': 'biometry_open_settings', + 'WebAppHapticFeedbackImpact': 'haptic_feedback_impact', + 'WebAppHapticFeedbackNotification': 'haptic_feedback_notification', + 'WebAppHapticFeedbackSelectionChange': 'haptic_feedback_selection_change', + 'WebAppDownloadFile': 'download_file', + 'WebAppOpenCodeReader': 'open_code_reader', + 'WebAppChangeScreenBrightness': 'change_screen_brightness', + 'WebAppNfcGetInfo': 'nfc_get_info', + 'WebAppNfcEmulateNfcTag': 'nfc_emulate_nfc_tag', + 'WebAppNfcOpenSystemSettings': 'nfc_open_system_settings', + 'WebAppVerifyMobileId': 'verify_mobile_id', +}; + +const Set _silentMethods = { + 'WebAppReady', + 'WebAppStat', + 'WebAppUrlInterceptor', + 'WebAppBackButtonPressed', +}; + +const String _shim = r''' +(function(){ + if (window.__kometWebAppBridge) { return; } + window.__kometWebAppBridge = true; + var pending = []; + function target(priv){ + var box = priv ? window.PrivateWebApp : window.WebApp; + return (box && typeof box.sendEvent === 'function') ? box : null; + } + function flush(){ + if (!pending.length) { return; } + var keep = []; + for (var i = 0; i < pending.length; i++) { + var item = pending[i]; + var box = target(item[2]); + if (!box) { keep.push(item); continue; } + try { box.sendEvent(item[0], item[1]); } catch (e) { keep.push(item); } + } + pending = keep; + } + setInterval(flush, 50); + window.__kometWebAppDeliver = function(name, data, priv){ + pending.push([name, data, !!priv]); + flush(); + }; + function post(name, data, priv){ + try { + window.flutter_inappwebview.callHandler('webAppEvent', name, data, priv); + } catch (e) {} + } + var lastGesture = 0; + function gesture(){ + var now = Date.now(); + if (now - lastGesture < 400) { return; } + lastGesture = now; + try { window.flutter_inappwebview.callHandler('webAppGesture'); } catch (e) {} + } + ['touchstart', 'pointerdown', 'mousedown', 'click'].forEach(function(name){ + try { document.addEventListener(name, gesture, true); } catch (e) {} + }); + window.WebViewHandler = { + postEvent: function(name, data){ post(name, data, false); }, + resolveShare: function(requestId, bytes, mimeType, fileName){ + try { + window.flutter_inappwebview.callHandler( + 'webAppResolveShare', requestId, mimeType, fileName); + } catch (e) {} + } + }; + window.PrivateWebViewHandler = { + postEvent: function(name, data){ post(name, data, true); }, + resolveShare: function(){} + }; + if (!window.AndroidPerf) { + window.AndroidPerf = { trackFcp: function(){} }; + } +})(); +'''; + +class WebAppBridge { + WebAppBridge({ + required this.botId, + required this.entryPoint, + required this.contextResolver, + required this.viewportResolver, + required this.onClose, + this.privateChannel = false, + this.mobileIdVerifier, + this.emitter, + }); + + final int botId; + final String entryPoint; + final BuildContext? Function() contextResolver; + final Size Function() viewportResolver; + final VoidCallback onClose; + final bool privateChannel; + final WebAppMobileIdVerifier? mobileIdVerifier; + final WebAppEmitter? emitter; + + InAppWebViewController? _controller; + DateTime? _lastGesture; + bool _customBackButton = false; + bool _closeConfirmation = false; + + bool get handlesBackButton => _customBackButton; + + bool get needsCloseConfirmation => _closeConfirmation; + + UserScript get userScript => UserScript( + source: _shim, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ); + + void attach(InAppWebViewController controller) { + _controller = controller; + controller.addJavaScriptHandler( + handlerName: 'webAppEvent', + callback: (args) { + final name = args.isNotEmpty ? args[0]?.toString() : null; + if (name == null || name.isEmpty) return null; + final raw = args.length > 1 ? args[1]?.toString() : null; + final private = args.length > 2 && args[2] == true; + handleEvent(name, raw, private); + return null; + }, + ); + controller.addJavaScriptHandler( + handlerName: 'webAppGesture', + callback: (args) { + registerGesture(); + return null; + }, + ); + controller.addJavaScriptHandler( + handlerName: 'webAppResolveShare', + callback: (args) => null, + ); + } + + void registerGesture() => _lastGesture = DateTime.now(); + + void notifyBackPressed() => _send('WebAppBackButtonPressed', const {}); + + void dispose() => _controller = null; + + void _send(String method, Map data, {bool private = false}) { + final payload = jsonEncode(data); + final custom = emitter; + if (custom != null) { + custom(method, payload, private); + return; + } + final controller = _controller; + if (controller == null) return; + controller.evaluateJavascript( + source: + 'window.__kometWebAppDeliver(' + '${jsonEncode(method)}, ${jsonEncode(payload)}, $private);', + ); + } + + void _ok( + String method, + String? requestId, + String status, { + bool private = false, + }) { + _send(method, { + 'status': status, + 'requestId': ?requestId, + }, private: private); + } + + void _fail( + String method, + String? requestId, + String reason, { + bool private = false, + }) { + if (requestId == null) return; + final slug = _methodSlugs[method] ?? 'unsupported_method'; + _send(method, { + 'requestId': requestId, + 'error': {'code': 'client.$slug.$reason'}, + }, private: private); + } + + Future handleEvent(String method, String? raw, bool private) async { + if (private && !privateChannel) return; + if (_gestureGated.contains(method) && !_hasRecentGesture) return; + + Map data = const {}; + if (raw != null && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) data = Map.from(decoded); + } catch (_) { + _fail(method, null, 'json_decode_error', private: private); + return; + } + } + final requestId = data['requestId']?.toString(); + + switch (method) { + case 'WebAppClose': + onClose(); + return; + case 'WebAppSetupBackButton': + _customBackButton = data['isVisible'] == true; + return; + case 'WebAppSetupClosingBehavior': + _closeConfirmation = data['needConfirmation'] == true; + return; + case 'WebAppSetupScreenCaptureBehavior': + _send(method, { + 'requestId': ?requestId, + 'isScreenCaptureEnabled': data['isScreenCaptureEnabled'] == true, + }); + return; + case 'WebAppGetLaunchContext': + _send(method, { + 'requestId': ?requestId, + 'entryPoint': entryPoint, + }); + return; + case 'WebAppGetViewportSize': + final size = viewportResolver(); + _send(method, { + 'requestId': ?requestId, + 'height': size.height.round(), + 'width': size.width.round(), + 'isStateStable': true, + }); + return; + case 'WebAppRequestPhone': + await _requestPhone(method, requestId); + return; + case 'WebAppOpenLink': + case 'WebAppOpenMaxLink': + await _openLink(data['url']?.toString()); + return; + case 'WebAppShare': + await _share(method, requestId, data); + return; + case 'WebAppMaxShare': + await _maxShare(method, requestId, data); + return; + case 'WebAppDeviceStorageSaveKey': + case 'WebAppSecureStorageSaveKey': + await _storageSave(method, requestId, data); + return; + case 'WebAppDeviceStorageGetKey': + case 'WebAppSecureStorageGetKey': + await _storageGet(method, requestId, data); + return; + case 'WebAppDeviceStorageClear': + case 'WebAppSecureStorageClear': + await _storageClear(method, requestId); + return; + case 'WebAppBiometryGetInfo': + await _biometryInfo(method, requestId); + return; + case 'WebAppBiometryRequestAccess': + case 'WebAppBiometryRequestAuth': + await _biometryAuth(method, requestId); + return; + case 'WebAppBiometryUpdateToken': + await _biometryUpdateToken(method, requestId, data); + return; + case 'WebAppBiometryOpenSettings': + _ok(method, requestId, 'opened'); + return; + case 'WebAppHapticFeedbackImpact': + await _impact(data['impactStyle']?.toString()); + _ok(method, requestId, 'impactOccured'); + return; + case 'WebAppHapticFeedbackNotification': + await _notification(data['notificationType']?.toString()); + _ok(method, requestId, 'notificationOccured'); + return; + case 'WebAppHapticFeedbackSelectionChange': + await Haptics.selection(); + _ok(method, requestId, 'selectionChanged'); + return; + case 'WebAppDownloadFile': + await _downloadFile(method, requestId, data); + return; + case 'WebAppOpenCodeReader': + await _openCodeReader(method, requestId); + return; + case 'WebAppNfcGetInfo': + _send(method, { + 'requestId': ?requestId, + 'available': false, + 'enabled': false, + }); + return; + case 'WebAppVerifyMobileId': + await _verifyMobileId(method, requestId, data, private); + return; + case 'WebAppChangeScreenBrightness': + case 'WebAppNfcEmulateNfcTag': + case 'WebAppNfcOpenSystemSettings': + _fail(method, requestId, 'not_supported', private: private); + return; + default: + if (_silentMethods.contains(method)) return; + _fail(method, requestId, 'unsupported_method', private: private); + } + } + + bool get _hasRecentGesture { + final last = _lastGesture; + if (last == null) return false; + return DateTime.now().difference(last) < _gestureWindow; + } + + Future _requestPhone(String method, String? requestId) async { + final context = contextResolver(); + if (context == null) { + _fail(method, requestId, 'request_error'); + return; + } + final confirmed = await showConfirmDialog( + context, + title: 'Передать номер телефона?', + message: 'Мини-приложение получит ваш номер телефона.', + confirmLabel: 'Поделиться', + cancelLabel: 'Отклонить', + ); + if (!confirmed) { + _fail(method, requestId, 'user_refused_provide_phone_number'); + return; + } + try { + final phone = await webAppModule.requestPhone(botId); + _send(method, { + 'requestId': ?requestId, + 'phone': phone.phone, + 'hash': phone.hash, + 'authDate': phone.authDate, + }); + } catch (_) { + _fail(method, requestId, 'request_error'); + } + } + + Future _openLink(String? url) async { + if (url == null || url.isEmpty) return; + final context = contextResolver(); + if (context == null) return; + await openExternalUrl(context, url); + } + + Future _share( + String method, + String? requestId, + Map data, + ) async { + final text = _shareText(data); + if (text == null) { + _fail(method, requestId, 'invalid_request'); + return; + } + try { + final result = await Share.share( + text, + sharePositionOrigin: shareOriginOf(contextResolver()), + ); + _send(method, { + 'requestId': ?requestId, + 'status': result.status == ShareResultStatus.dismissed + ? 'cancelled' + : 'shared', + }); + } catch (_) { + _fail(method, requestId, 'invalid_request'); + } + } + + Future _maxShare( + String method, + String? requestId, + Map data, + ) async { + final text = _shareText(data); + if (text == null) { + _fail(method, requestId, 'invalid_request'); + return; + } + final context = contextResolver(); + if (context == null) { + _fail(method, requestId, 'invalid_request'); + return; + } + final target = await openForwardScreen(context: context); + if (target == null) { + _send(method, { + 'requestId': ?requestId, + 'status': 'cancelled', + }); + return; + } + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + _fail(method, requestId, 'invalid_request'); + return; + } + try { + await messagesModule.sendMessage(accountId, target.chatId, text); + _send(method, { + 'requestId': ?requestId, + 'status': 'shared', + }); + } catch (_) { + _fail(method, requestId, 'invalid_request'); + } + } + + String? _shareText(Map data) { + final text = data['text']?.toString(); + final link = data['link']?.toString(); + final parts = [ + if (text != null && text.isNotEmpty) text, + if (link != null && link.isNotEmpty) link, + ]; + if (parts.isEmpty) return null; + return parts.join('\n'); + } + + WebAppStorageBackend _backendOf(String method) => + method.startsWith('WebAppSecure') + ? WebAppStorageBackend.secure + : WebAppStorageBackend.device; + + Future _storageSave( + String method, + String? requestId, + Map data, + ) async { + final key = data['key']?.toString(); + if (key == null || key.isEmpty) { + _fail(method, requestId, 'invalid_request'); + return; + } + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + _fail(method, requestId, 'invalid_request'); + return; + } + final backend = _backendOf(method); + final value = data['value']; + if (value == null) { + await WebAppStorage.remove(accountId, botId, backend, key); + _ok(method, requestId, 'removed'); + return; + } + final saved = await WebAppStorage.save( + accountId, + botId, + backend, + key, + value.toString(), + ); + if (!saved) { + _fail(method, requestId, 'too_many_keys'); + return; + } + _ok(method, requestId, 'updated'); + } + + Future _storageGet( + String method, + String? requestId, + Map data, + ) async { + final key = data['key']?.toString(); + if (key == null || key.isEmpty) { + _fail(method, requestId, 'invalid_request'); + return; + } + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + _fail(method, requestId, 'invalid_request'); + return; + } + final value = await WebAppStorage.read( + accountId, + botId, + _backendOf(method), + key, + ); + if (value == null) { + _fail(method, requestId, 'not_found'); + return; + } + _send(method, { + 'requestId': ?requestId, + 'key': key, + 'value': value, + }); + } + + Future _storageClear(String method, String? requestId) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + _fail(method, requestId, 'invalid_request'); + return; + } + await WebAppStorage.clear(accountId, botId, _backendOf(method)); + _ok(method, requestId, 'cleared'); + } + + Future _biometryInfo(String method, String? requestId) async { + final accountId = await TokenStorage.getActiveAccountId(); + final deviceId = api.deviceId ?? ''; + if (accountId == null) { + _send(method, { + 'requestId': ?requestId, + 'available': false, + 'deviceId': deviceId, + }); + return; + } + final (requested, granted) = await WebAppStorage.biometryAccess( + accountId, + botId, + ); + final token = await WebAppStorage.biometryToken(accountId, botId); + _send(method, { + 'requestId': ?requestId, + 'available': true, + 'type': const ['unknown'], + 'accessRequested': requested, + 'accessGranted': granted, + 'tokenSaved': token != null, + 'deviceId': deviceId, + }); + } + + Future _biometryAuth(String method, String? requestId) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + _fail(method, requestId, 'access_denied'); + return; + } + var token = await WebAppStorage.biometryToken(accountId, botId); + if (token == null) { + token = _randomToken(); + await WebAppStorage.saveBiometryToken(accountId, botId, token); + } + await WebAppStorage.setBiometryAccess( + accountId, + botId, + requested: true, + granted: true, + ); + _send(method, { + 'requestId': ?requestId, + 'token': token, + 'status': 'authorized', + 'granted': true, + 'accessGranted': true, + }); + } + + Future _biometryUpdateToken( + String method, + String? requestId, + Map data, + ) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + _fail(method, requestId, 'access_denied'); + return; + } + final token = data['token']?.toString(); + if (token == null || token.isEmpty) { + await WebAppStorage.removeBiometryToken(accountId, botId); + _ok(method, requestId, 'removed'); + return; + } + if (token.length > 1024) { + _fail(method, requestId, 'too_large'); + return; + } + await WebAppStorage.saveBiometryToken(accountId, botId, token); + _ok(method, requestId, 'updated'); + } + + Future _impact(String? style) async { + switch (style) { + case 'heavy': + case 'rigid': + await Haptics.heavy(); + return; + case 'medium': + await Haptics.medium(); + return; + default: + await Haptics.tap(); + } + } + + Future _notification(String? type) async { + if (type == 'error') { + await Haptics.error(); + return; + } + await Haptics.success(); + } + + Future _downloadFile( + String method, + String? requestId, + Map data, + ) async { + final url = data['url']?.toString(); + if (url == null || url.isEmpty) { + _fail(method, requestId, 'invalid_request'); + return; + } + final rawName = data['file_name']?.toString(); + final name = (rawName == null || rawName.isEmpty) + ? 'webapp_${DateTime.now().millisecondsSinceEpoch}' + : rawName; + final result = await saveMediaFile( + cacheName: 'webapp_${botId}_${url.hashCode & 0x7fffffff}_$name', + resolveUrl: () async => url, + saveName: name, + kind: SaveMediaKind.file, + ); + _send(method, { + 'requestId': ?requestId, + 'status': result.ok ? 'success' : 'cancelled', + }); + } + + Future _openCodeReader(String method, String? requestId) async { + final context = contextResolver(); + if (context == null) { + _fail(method, requestId, 'not_supported'); + return; + } + final value = await Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (_, _, _) => const WebQrScanScreen(), + transitionsBuilder: (_, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + if (value == null || value.isEmpty) { + _fail(method, requestId, 'cancelled'); + return; + } + _send(method, { + 'requestId': ?requestId, + 'value': value, + }); + } + + Future _verifyMobileId( + String method, + String? requestId, + Map data, + bool private, + ) async { + final verifier = mobileIdVerifier; + final url = data['url']?.toString(); + if (verifier == null || url == null || url.isEmpty) { + _fail(method, requestId, 'not_supported', private: private); + return; + } + try { + final result = await verifier(url); + if (result == null) { + _fail(method, requestId, 'request_error', private: private); + return; + } + _send(method, { + 'requestId': ?requestId, + 'statusCode': result['statusCode'], + 'headers': result['headers'] ?? const {}, + 'data': result['data'] ?? '', + }, private: private); + } catch (_) { + _fail(method, requestId, 'request_error', private: private); + } + } + + static String _randomToken() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } +} diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index 114db51..ad410e5 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -6,13 +6,21 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/webapp.dart'; import '../../../core/storage/spoofing_service.dart'; +import '../../../core/utils/link_opener.dart'; +import '../../../main.dart' show api; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/error_view.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/webview_permission_prompt.dart'; +import 'web_app_bridge.dart'; class WebAppScreen extends StatefulWidget { final String title; final Future Function() loader; + final String entryPoint; + final bool privateChannel; + final WebAppMobileIdVerifier? mobileIdVerifier; final List? extraUserScripts; final void Function(InAppWebViewController controller)? onWebViewCreated; final void Function( @@ -22,6 +30,9 @@ class WebAppScreen extends StatefulWidget { onConsoleMessage; final void Function(InAppWebViewController controller, WebUri? url)? onLoadStart; + final Future Function(String url)? onExternalCallback; + final bool closeAfterExternalCallback; + final bool preferSystemUserAgent; final Future Function( InAppWebViewController controller, NavigationAction navigationAction, @@ -33,10 +44,16 @@ class WebAppScreen extends StatefulWidget { super.key, required this.title, required this.loader, + this.entryPoint = WebAppEntryPoint.webApp, + this.privateChannel = false, + this.mobileIdVerifier, this.extraUserScripts, this.onWebViewCreated, this.onConsoleMessage, this.onLoadStart, + this.onExternalCallback, + this.closeAfterExternalCallback = false, + this.preferSystemUserAgent = false, this.shouldOverrideUrlLoading, }); @@ -46,10 +63,12 @@ class WebAppScreen extends StatefulWidget { class _WebAppScreenState extends State { InAppWebViewController? _controller; + WebAppBridge? _bridge; WebAppLaunch? _launch; String? _loadError; String _userAgent = ''; double _progress = 0; + Size _viewport = Size.zero; @override void initState() { @@ -57,31 +76,121 @@ class _WebAppScreenState extends State { _load(); } + @override + void dispose() { + _bridge?.dispose(); + super.dispose(); + } + Future _load() async { setState(() { _loadError = null; _launch = null; + _bridge?.dispose(); + _bridge = null; }); try { - _userAgent = await SpoofingService.getWebViewUserAgent() ?? ''; + // Тот же UA, что уходит в sessionInit (из handshake-устройства ядра), + // чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа. + // Для веб-аппов с внешней авторизацией (Госуслуги/ЕСИА) клиентский UA + // ядра отбраковывается антифродом — там нужен UA настоящего WebView. + _userAgent = ''; + if (widget.preferSystemUserAgent) { + try { + _userAgent = await InAppWebViewController.getDefaultUserAgent(); + } catch (_) {} + } + if (_userAgent.isEmpty) { + _userAgent = + api.session?.userAgent() ?? + await SpoofingService.getWebViewUserAgent() ?? + ''; + } final launch = await widget.loader(); if (!mounted) return; - setState(() => _launch = launch); + setState(() { + _launch = launch; + _bridge = _createBridge(launch.botId); + }); } catch (e) { if (!mounted) return; setState(() => _loadError = e.toString()); } } + WebAppBridge _createBridge(int botId) => WebAppBridge( + botId: botId, + entryPoint: widget.entryPoint, + privateChannel: widget.privateChannel, + mobileIdVerifier: widget.mobileIdVerifier, + contextResolver: () => mounted ? context : null, + viewportResolver: () => _viewport, + onClose: _closeFromWebApp, + ); + + void _closeFromWebApp() { + if (!mounted) return; + Navigator.of(context).maybePop(); + } + Future _handleBack() async { + final bridge = _bridge; + if (bridge != null && bridge.handlesBackButton) { + bridge.notifyBackPressed(); + return false; + } final controller = _controller; if (controller != null && await controller.canGoBack()) { await controller.goBack(); return false; } + if (bridge != null && bridge.needsCloseConfirmation && mounted) { + return showConfirmDialog( + context, + title: widget.title, + message: 'Закрыть мини-приложение?', + confirmLabel: 'Закрыть', + ); + } return true; } + Future _handleNavigation( + InAppWebViewController controller, + NavigationAction action, + ) async { + final uri = action.request.url; + final callback = widget.onExternalCallback; + if (callback != null && uri?.queryParameters['externalCallback'] == '1') { + try { + final launch = await callback(uri.toString()); + if (!mounted) return NavigationActionPolicy.CANCEL; + if (widget.closeAfterExternalCallback) { + Navigator.of(context).pop(launch); + return NavigationActionPolicy.CANCEL; + } + setState(() { + _launch = launch; + _loadError = null; + }); + await controller.loadUrl( + urlRequest: URLRequest(url: WebUri(launch.url)), + ); + } catch (e) { + if (mounted) setState(() => _loadError = e.toString()); + } + return NavigationActionPolicy.CANCEL; + } + final handler = widget.shouldOverrideUrlLoading; + if (handler != null) return handler(controller, action, _launch?.url); + + if (uri != null && leavesWebView(uri.scheme)) { + if (mounted) await openExternalUrl(context, uri.toString()); + return NavigationActionPolicy.CANCEL; + } + return NavigationActionPolicy.ALLOW; + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -131,14 +240,28 @@ class _WebAppScreenState extends State { return ErrorView(message: _loadError!, onRetry: _load); } final launch = _launch; - if (launch == null) { - return const Center(child: CircularProgressIndicator()); + final bridge = _bridge; + if (launch == null || bridge == null) { + return const Center(child: SmallSpinner(size: 36)); } + return LayoutBuilder( + builder: (context, constraints) { + _viewport = Size(constraints.maxWidth, constraints.maxHeight); + return Listener( + onPointerDown: (_) => bridge.registerGesture(), + child: _buildWebView(launch, bridge), + ); + }, + ); + } + + Widget _buildWebView(WebAppLaunch launch, WebAppBridge bridge) { return InAppWebView( initialUrlRequest: URLRequest(url: WebUri(launch.url)), - initialUserScripts: widget.extraUserScripts == null - ? null - : UnmodifiableListView(widget.extraUserScripts!), + initialUserScripts: UnmodifiableListView([ + bridge.userScript, + ...?widget.extraUserScripts, + ]), initialSettings: InAppWebViewSettings( javaScriptEnabled: true, domStorageEnabled: true, @@ -146,25 +269,32 @@ class _WebAppScreenState extends State { supportZoom: false, transparentBackground: true, mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + sharedCookiesEnabled: true, + allowsBackForwardNavigationGestures: true, useHybridComposition: true, - useShouldOverrideUrlLoading: widget.shouldOverrideUrlLoading != null, + supportMultipleWindows: true, + allowFileAccess: false, + useShouldOverrideUrlLoading: true, userAgent: _userAgent, ), onWebViewCreated: (controller) { _controller = controller; + bridge.attach(controller); widget.onWebViewCreated?.call(controller); }, onPermissionRequest: (controller, request) => askWebViewPermission(context, request), + onCreateWindow: (controller, action) async { + final url = action.request.url?.toString(); + if (url != null && url.isNotEmpty && mounted) { + await openExternalUrl(context, url); + } + return false; + }, onConsoleMessage: widget.onConsoleMessage, onLoadStart: widget.onLoadStart, - shouldOverrideUrlLoading: widget.shouldOverrideUrlLoading == null - ? null - : (controller, action) => widget.shouldOverrideUrlLoading!( - controller, - action, - launch.url, - ), + shouldOverrideUrlLoading: _handleNavigation, onProgressChanged: (controller, progress) { if (!mounted) return; setState(() => _progress = progress / 100); diff --git a/lib/frontend/widgets/account_switcher_overlay.dart b/lib/frontend/widgets/account_switcher_overlay.dart index 983f672..dd7ca00 100644 --- a/lib/frontend/widgets/account_switcher_overlay.dart +++ b/lib/frontend/widgets/account_switcher_overlay.dart @@ -9,6 +9,7 @@ import '../../core/storage/token_storage.dart'; import '../../core/utils/haptics.dart'; import 'animated_overlay_popup.dart'; import 'komet_avatar.dart'; +import '../../core/config/app_frost.dart'; class AccountSwitcherController extends ChangeNotifier { Offset? pointer; @@ -239,7 +240,7 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> animation: overlayAnimation, builder: (ctx, _) { final t = overlayAnimation.value.clamp(0.0, 1.0); - final blurSigma = 14.0 * t; + final blurSigma = AppFrost.overlaySigma * t; return GestureDetector( onTap: closeOverlay, behavior: HitTestBehavior.opaque, diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart index cc39617..dddedf2 100644 --- a/lib/frontend/widgets/adaptive_shell.dart +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -23,12 +23,16 @@ class DesktopChatSelection { final String name; final String imageUrl; final String chatType; + final String? initialMessageId; + final int? initialMessageTime; const DesktopChatSelection({ required this.chatId, required this.name, required this.imageUrl, required this.chatType, + this.initialMessageId, + this.initialMessageTime, }); } @@ -139,11 +143,15 @@ class _AdaptiveShellState extends State { child: _selected == null ? _EmptyChatPane(colorScheme: cs) : ChatScreen( - key: ValueKey(_selected!.chatId), + key: ValueKey( + '${_selected!.chatId}:${_selected!.initialMessageId ?? ''}', + ), chatId: _selected!.chatId, name: _selected!.name, imageUrl: _selected!.imageUrl, chatType: _selected!.chatType, + initialMessageId: _selected!.initialMessageId, + initialMessageTime: _selected!.initialMessageTime, embedded: true, onClose: _closeChat, ), 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/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 9142cfe..9445161 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -1,18 +1,34 @@ +import 'dart:async'; import 'dart:io'; +import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/core/config/app_frost.dart'; +import 'package:komet/core/config/app_nav_pill_style.dart'; +import 'package:komet/core/config/app_visual_style.dart'; import 'package:komet/core/media/gallery_source.dart'; +import 'package:komet/core/media/video_transcoder.dart'; import 'package:komet/core/utils/format.dart'; +import 'package:komet/frontend/widgets/attachment/contact_picker_page.dart'; import 'package:komet/frontend/widgets/attachment/media_preview_screen.dart'; import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; +import 'package:komet/frontend/widgets/attachment/photo_hero.dart'; +import 'package:komet/frontend/widgets/attachment/video_edit.dart'; +import 'package:komet/frontend/widgets/attachment/video_preview_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/sheet_helpers.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; import 'package:komet/l10n/app_localizations.dart'; +import '../small_spinner.dart'; + const int _navItemCount = 5; List _buildNavItems(AppLocalizations l10n) => [ @@ -20,7 +36,7 @@ List _buildNavItems(AppLocalizations l10n) => [ PillNavItem(icon: Symbols.description, label: l10n.scheduledAttachFile), PillNavItem(icon: Symbols.location_on, label: l10n.scheduledAttachLocation), PillNavItem(icon: Symbols.bar_chart, label: l10n.attachSheetPoll), - PillNavItem(icon: Symbols.person, label: l10n.nfcPeerFirstNameFallback), + PillNavItem(icon: Symbols.person, label: l10n.attachSheetContact), ]; Future showAttachmentSheet( @@ -30,19 +46,21 @@ Future showAttachmentSheet( VoidCallback? onPickFile, VoidCallback? onShareLocation, VoidCallback? onCreatePoll, + ValueChanged? onSendContact, }) { return showModalBottomSheet( context: context, isScrollControlled: true, requestFocus: false, backgroundColor: Colors.transparent, - barrierColor: Colors.black.withValues(alpha: 0.45), + barrierColor: AppFrost.scrim(), builder: (_) => AttachmentSheet( title: title, onSend: onSend, onPickFile: onPickFile, onShareLocation: onShareLocation, onCreatePoll: onCreatePoll, + onSendContact: onSendContact, ), ); } @@ -53,6 +71,7 @@ class AttachmentSheet extends StatefulWidget { final VoidCallback? onPickFile; final VoidCallback? onShareLocation; final VoidCallback? onCreatePoll; + final ValueChanged? onSendContact; const AttachmentSheet({ super.key, @@ -61,6 +80,7 @@ class AttachmentSheet extends StatefulWidget { this.onPickFile, this.onShareLocation, this.onCreatePoll, + this.onSendContact, }); @override @@ -68,12 +88,19 @@ class AttachmentSheet extends StatefulWidget { } class _AttachmentSheetState extends State { + static const int _loadAhead = 24; + static List? _cachedItems; static GalleryPermission _cachedPermission = GalleryPermission.granted; + static bool _cachedHasMore = false; final GallerySource _source = GallerySource.create(); final ValueNotifier> _selected = ValueNotifier({}); + final Map> _thumbKeys = {}; final Map _edits = {}; + final Map _videoEdits = {}; + bool _videoEditorReady = false; + bool _exporting = false; final Set _tempFiles = {}; final Set _sentFiles = {}; final TextEditingController _captionCtrl = TextEditingController(); @@ -84,6 +111,9 @@ class _AttachmentSheetState extends State { double _navDragAccumDx = 0; bool _loading = true; + bool _loadingMore = false; + bool _hasMore = false; + int _loadToken = 0; GalleryPermission _permission = GalleryPermission.granted; List _items = const []; @@ -94,11 +124,15 @@ class _AttachmentSheetState extends State { if (cached != null) { _items = cached; _permission = _cachedPermission; + _hasMore = _cachedHasMore; _loading = false; _loadGallery(silent: true); } else { _loadGallery(); } + VideoTranscoder.ensureAvailable().then((ready) { + if (mounted && ready) setState(() => _videoEditorReady = true); + }); } @override @@ -114,26 +148,60 @@ class _AttachmentSheetState extends State { } Future _loadGallery({bool silent = false}) async { + final token = ++_loadToken; if (!silent) setState(() => _loading = true); final permission = await _source.ensurePermission(); - if (!mounted) return; + if (!mounted || token != _loadToken) return; if (permission == GalleryPermission.denied) { _cachedItems = null; + _cachedHasMore = false; setState(() { _permission = permission; _items = const []; + _hasMore = false; _loading = false; }); return; } - final items = await _source.load(limit: 120); - if (!mounted) return; - _cachedItems = items; - _cachedPermission = permission; + final loaded = _items.length; + final page = await _source.load( + offset: 0, + limit: loaded > GallerySource.pageSize ? loaded : GallerySource.pageSize, + ); + if (!mounted || token != _loadToken) return; + _permission = permission; + _loading = false; + _publishItems(page.items, page.hasMore); + } + + Future _loadMore() async { + if (_loading || _loadingMore || !_hasMore) return; + final token = _loadToken; + final offset = _items.length; + _loadingMore = true; + final page = await _source.load(offset: offset); + _loadingMore = false; + if (!mounted || token != _loadToken || offset != _items.length) return; + if (page.items.isEmpty) { + _cachedHasMore = false; + setState(() => _hasMore = false); + return; + } + _publishItems([..._items, ...page.items], page.hasMore); + } + + void _publishItems(List items, bool hasMore) { + final seen = {}; + final unique = [ + for (final item in items) + if (seen.add(item.id)) item, + ]; + _cachedItems = unique; + _cachedPermission = _permission; + _cachedHasMore = hasMore; setState(() { - _permission = permission; - _items = items; - _loading = false; + _items = unique; + _hasMore = hasMore; }); } @@ -143,11 +211,45 @@ class _AttachmentSheetState extends State { _selected.value = next; } + GlobalKey<_ThumbnailState> _thumbKey(String id) => + _thumbKeys.putIfAbsent(id, () => GlobalKey<_ThumbnailState>()); + void _openPreview(GalleryItem item) { + final thumbKey = _thumbKey(item.id); + final hero = PhotoHeroController( + origin: () => photoHeroRect(thumbKey), + image: thumbKey.currentState?.provider, + ); + if (item.isVideo) { + final edit = _videoEdits.putIfAbsent(item.id, VideoEditState.new); + Navigator.of(context).push( + PhotoHeroRoute( + hero: hero, + builder: (_) => VideoPreviewScreen( + item: item, + hero: hero, + title: widget.title, + selectedIds: _selected, + editable: _videoEditorReady, + edit: edit, + onToggleSelection: () => _toggleSelection(item), + onSend: () => _sendSelection(fallback: item), + onEditChanged: () { + if (mounted) setState(() {}); + }, + initialCaption: _captionCtrl.text, + onCaptionChanged: (text) => _captionCtrl.text = text, + ), + ), + ); + return; + } Navigator.of(context).push( - MaterialPageRoute( + PhotoHeroRoute( + hero: hero, builder: (_) => MediaPreviewScreen( item: item, + hero: hero, title: widget.title, selectedIds: _selected, onToggleSelection: () => _toggleSelection(item), @@ -172,20 +274,127 @@ class _AttachmentSheetState extends State { ); } - void _onCameraTap() { - showCustomNotification( - context, - AppLocalizations.of(context)!.attachSheetCameraComingSoon, - ); + Future _onCameraTap() async { + final l10n = AppLocalizations.of(context)!; + XFile? shot; + try { + shot = await ImagePicker().pickImage(source: ImageSource.camera); + } catch (_) { + if (mounted) showCustomNotification(context, l10n.attachSheetCameraError); + return; + } + if (shot == null || !mounted) return; + _openPreview(GalleryItem.fromFile(File(shot.path))); } - void _sendSelection({GalleryItem? fallback}) { + Future _exportVideos(List chosen) async { + final jobs = <(GalleryItem, VideoEditState)>[]; + for (final item in chosen) { + if (!item.isVideo) continue; + final edit = _videoEdits[item.id]; + if (edit != null && edit.hasEdits) jobs.add((item, edit)); + } + if (jobs.isEmpty) return true; + + final progress = ValueNotifier(0); + final navigator = Navigator.of(context, rootNavigator: true); + var cancelled = false; + setState(() => _exporting = true); + unawaited( + showGeneralDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black54, + pageBuilder: (_, _, _) => _ExportProgress( + progress: progress, + onCancel: () { + cancelled = true; + VideoTranscoder.cancel(); + }, + ), + ), + ); + + var ok = true; + for (final (item, edit) in jobs) { + final file = item.localFile ?? await item.originFile(); + if (file == null) { + ok = false; + break; + } + final info = await VideoTranscoder.probe(file.path); + var source = info != null && info.width > 0 && info.height > 0 + ? Size(info.width.toDouble(), info.height.toDouble()) + : Size.zero; + if (source.isEmpty) { + final dims = await item.dimensions(); + if (dims == null) { + ok = false; + break; + } + source = Size(dims.$1.toDouble(), dims.$2.toDouble()); + } + final signature = edit.signature(source); + if (edit.exported != null && edit.exportedSignature == signature) { + continue; + } + progress.value = 0; + final spec = await buildVideoExportSpec( + edit, + file.path, + source, + info?.fps ?? 30, + ); + if (spec == null) { + ok = false; + break; + } + final done = await VideoTranscoder.export( + spec, + onProgress: (value) => progress.value = value, + ); + final overlay = spec.overlayPath; + if (overlay != null) { + File(overlay).delete().then((_) {}, onError: (_) {}); + } + if (!done) { + ok = false; + break; + } + edit.exported = File(spec.output); + edit.exportedSignature = signature; + _tempFiles.add(spec.output); + } + + progress.dispose(); + navigator.pop(); + if (!mounted) return false; + setState(() => _exporting = false); + if (!ok && !cancelled) { + showCustomNotification( + context, + AppLocalizations.of(context)!.videoEditorExportFailed, + ); + } + return ok; + } + + Future _sendSelection({GalleryItem? fallback}) async { + if (_exporting) return; final ids = _selected.value; var chosen = _items.where((it) => ids.contains(it.id)).toList(); if (chosen.isEmpty && fallback != null) chosen = [fallback]; if (chosen.isEmpty) return; + if (!await _exportVideos(chosen) || !mounted) return; final picked = chosen - .map((it) => PickedPhoto(item: it, editedFile: _edits[it.id]?.working)) + .map( + (it) => PickedPhoto( + item: it, + editedFile: it.isVideo + ? _videoEdits[it.id]?.exported + : _edits[it.id]?.working, + ), + ) .toList(); final callback = widget.onSend; if (callback != null) { @@ -322,11 +531,20 @@ class _AttachmentSheetState extends State { buttonLabel: l10n.attachSheetCreatePoll, onTap: widget.onCreatePoll, ), - _buildPlaceholderPage(cs, bottomReserve), + _buildContactPage(cs, bottomReserve), ], ); } + Widget _buildContactPage(ColorScheme cs, double bottomReserve) { + final onSendContact = widget.onSendContact; + if (onSendContact == null) return _buildPlaceholderPage(cs, bottomReserve); + return ContactPickerPage( + bottomReserve: bottomReserve, + onPick: onSendContact, + ); + } + Widget _buildActionPage( ColorScheme cs, double bottomReserve, { @@ -392,7 +610,7 @@ class _AttachmentSheetState extends State { double bottomReserve, ) { if (_loading) { - return Center(child: CircularProgressIndicator(color: cs.primary)); + return Center(child: SmallSpinner(size: 36, color: cs.primary)); } if (_permission == GalleryPermission.denied) { return _buildDenied(scrollController, cs, bottomReserve); @@ -442,12 +660,7 @@ class _AttachmentSheetState extends State { ), ), SliverPadding( - padding: EdgeInsets.fromLTRB( - hpad, - spacing, - hpad, - bottomReserve + 6, - ), + padding: const EdgeInsets.fromLTRB(hpad, spacing, hpad, 0), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, @@ -455,9 +668,13 @@ class _AttachmentSheetState extends State { crossAxisSpacing: spacing, ), delegate: SliverChildBuilderDelegate((context, index) { + if (index >= gridPhotos.length - _loadAhead) { + unawaited(_loadMore()); + } final item = gridPhotos[index]; return _GalleryTile( key: ValueKey(item.id), + thumbKey: _thumbKey(item.id), item: item, selectedIds: _selected, onOpen: () => _openPreview(item), @@ -468,6 +685,18 @@ class _AttachmentSheetState extends State { }, childCount: gridPhotos.length), ), ), + SliverToBoxAdapter( + child: Column( + children: [ + if (_hasMore) + Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: SmallSpinner(size: 22, color: cs.primary), + ), + SizedBox(height: bottomReserve + 6), + ], + ), + ), ], ); }, @@ -481,6 +710,7 @@ class _AttachmentSheetState extends State { final item = photos[i]; return _GalleryTile( key: ValueKey(item.id), + thumbKey: _thumbKey(item.id), item: item, selectedIds: _selected, onOpen: () => _openPreview(item), @@ -739,13 +969,32 @@ class _AttachmentSheetState extends State { animation: _pageController, builder: (context, _) { final cs = Theme.of(context).colorScheme; - return SlidingPillNav( - items: navItems, - position: _currentPageT(), - geometry: geometry, - onTap: _onSectionTap, - backgroundColor: _composerColor(cs), - borderColor: _composerBorderColor(cs), + return ValueListenableBuilder( + valueListenable: AppVisualStyle.current, + builder: (context, style, _) => + ValueListenableBuilder( + valueListenable: AppNavPillStyle.current, + builder: (context, navStyle, _) { + final frost = + style.glossyChrome && + NavPillMaterial.isFrost(navStyle); + final liquid = + style.glossyChrome && + NavPillMaterial.isLiquid(navStyle); + return SlidingPillNav( + items: navItems, + position: _currentPageT(), + geometry: geometry, + onTap: _onSectionTap, + backgroundColor: liquid + ? null + : (frost + ? AppFrost.glassTint(cs) + : _composerColor(cs)), + borderColor: _composerBorderColor(cs), + ); + }, + ), ); }, ), @@ -775,6 +1024,7 @@ class _AttachmentSheetState extends State { controller: _captionCtrl, style: TextStyle(color: cs.onSurface, fontSize: 15), cursorColor: cs.primary, + textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, @@ -815,34 +1065,193 @@ class _KeepAlivePageState extends State<_KeepAlivePage> } } -class _CameraTile extends StatelessWidget { +enum _CameraAccess { unknown, granted, denied } + +class _CameraTile extends StatefulWidget { final VoidCallback onTap; final ColorScheme cs; const _CameraTile({required this.onTap, required this.cs}); + @override + State<_CameraTile> createState() => _CameraTileState(); +} + +class _CameraTileState extends State<_CameraTile> with WidgetsBindingObserver { + static const String _askedKey = 'komet_camera_permission_asked'; + + CameraController? _controller; + bool _starting = false; + _CameraAccess _access = _CameraAccess.unknown; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _resolveAccess(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _resolveAccess(); + } else if (state == AppLifecycleState.inactive || + state == AppLifecycleState.paused) { + _stopPreview(); + } + } + + Future _resolveAccess() async { + if (!(Platform.isAndroid || Platform.isIOS)) return; + + var status = await Permission.camera.status; + if (status.isDenied) { + final prefs = await SharedPreferences.getInstance(); + if (!(prefs.getBool(_askedKey) ?? false)) { + await prefs.setBool(_askedKey, true); + status = await Permission.camera.request(); + } + } + if (!mounted) return; + + final granted = status.isGranted; + setState( + () => _access = granted ? _CameraAccess.granted : _CameraAccess.denied, + ); + if (granted) _startPreview(); + } + + void _onTap() { + if (_access == _CameraAccess.denied) { + openAppSettings(); + return; + } + widget.onTap(); + } + + Future _startPreview() async { + if (_starting || _controller != null) return; + _starting = true; + try { + final cameras = await availableCameras(); + if (cameras.isEmpty || !mounted) return; + final back = cameras.firstWhere( + (c) => c.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first, + ); + final controller = CameraController( + back, + ResolutionPreset.low, + enableAudio: false, + ); + await controller.initialize(); + if (!mounted) { + await controller.dispose(); + return; + } + setState(() => _controller = controller); + } catch (_) { + } finally { + _starting = false; + } + } + + void _stopPreview() { + final controller = _controller; + if (controller == null) return; + _controller = null; + controller.dispose(); + if (mounted) setState(() {}); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final cs = widget.cs; + final controller = _controller; + final hasPreview = controller != null && controller.value.isInitialized; + final denied = _access == _CameraAccess.denied; + final l10n = AppLocalizations.of(context)!; + return GestureDetector( - onTap: onTap, - child: Container( - color: cs.surfaceContainerHighest, - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.photo_camera, - size: 34, - color: cs.onSurface, - weight: 400, + onTap: _onTap, + child: Stack( + fit: StackFit.expand, + children: [ + if (hasPreview) + _buildPreview(controller) + else + Container(color: cs.surfaceContainerHighest), + if (hasPreview) + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0x66000000)], + ), + ), ), - const SizedBox(height: 6), - Text( - AppLocalizations.of(context)!.attachSheetCamera, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + Align( + alignment: hasPreview ? Alignment.bottomLeft : Alignment.center, + child: Padding( + padding: const EdgeInsets.all(8), + child: hasPreview + ? const Icon( + Symbols.photo_camera, + size: 22, + color: Colors.white, + weight: 500, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + denied + ? Symbols.no_photography + : Symbols.photo_camera, + size: 34, + color: cs.onSurface, + weight: 400, + ), + const SizedBox(height: 6), + Text( + denied + ? l10n.attachSheetCameraAllow + : l10n.attachSheetCamera, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), ), - ], + ), + ], + ), + ); + } + + Widget _buildPreview(CameraController controller) { + final size = controller.value.previewSize; + if (size == null) { + return Container(color: widget.cs.surfaceContainerHighest); + } + return ClipRect( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: size.height, + height: size.width, + child: CameraPreview(controller), ), ), ); @@ -850,6 +1259,7 @@ class _CameraTile extends StatelessWidget { } class _GalleryTile extends StatefulWidget { + final GlobalKey<_ThumbnailState> thumbKey; final GalleryItem item; final ValueListenable> selectedIds; final VoidCallback onOpen; @@ -859,6 +1269,7 @@ class _GalleryTile extends StatefulWidget { const _GalleryTile({ super.key, + required this.thumbKey, required this.item, required this.selectedIds, required this.onOpen, @@ -905,6 +1316,7 @@ class _GalleryTileState extends State<_GalleryTile> { duration: const Duration(milliseconds: 150), curve: Curves.easeOut, child: _Thumbnail( + key: widget.thumbKey, item: item, editedFile: widget.editedFile, cs: widget.cs, @@ -1000,7 +1412,12 @@ class _Thumbnail extends StatefulWidget { final File? editedFile; final ColorScheme cs; - const _Thumbnail({required this.item, this.editedFile, required this.cs}); + const _Thumbnail({ + super.key, + required this.item, + this.editedFile, + required this.cs, + }); @override State<_Thumbnail> createState() => _ThumbnailState(); @@ -1008,52 +1425,116 @@ class _Thumbnail extends StatefulWidget { class _ThumbnailState extends State<_Thumbnail> { static const int _pixelSize = 320; - Future? _future; + ImageProvider? _provider; + + ImageProvider? get provider => _provider; @override void initState() { super.initState(); - if (widget.item.localFile == null) { - _future = widget.item.thumbnail(_pixelSize); + _resolveProvider(); + } + + @override + void didUpdateWidget(covariant _Thumbnail oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.editedFile?.path != oldWidget.editedFile?.path || + widget.item.id != oldWidget.item.id) { + setState(_resolveProvider); } } + void _resolveProvider() { + final file = + widget.editedFile ?? + (widget.item.isVideo ? null : widget.item.localFile); + if (file != null) { + _provider = ResizeImage( + FileImage(file), + width: _pixelSize, + allowUpscaling: false, + ); + return; + } + _provider = null; + final id = widget.item.id; + widget.item.thumbnail(_pixelSize).then((data) { + if (!mounted || data == null || widget.item.id != id) return; + if (widget.editedFile != null) return; + setState(() => _provider = MemoryImage(data)); + }); + } + @override Widget build(BuildContext context) { - final edited = widget.editedFile; - if (edited != null) { - return Image.file( - edited, - fit: BoxFit.cover, - cacheWidth: _pixelSize, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _placeholder(), - ); - } - final file = widget.item.localFile; - if (file != null) { - return Image.file( - file, - fit: BoxFit.cover, - cacheWidth: _pixelSize, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _placeholder(), - ); - } - return FutureBuilder( - future: _future, - builder: (context, snapshot) { - final data = snapshot.data; - if (data == null) return _placeholder(); - return Image.memory( - data, - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => _placeholder(), - ); - }, + final provider = _provider; + if (provider == null) return _placeholder(); + return Image( + image: provider, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => _placeholder(), ); } - Widget _placeholder() => ColoredBox(color: widget.cs.surfaceContainerHighest); + Widget _placeholder() => ColoredBox( + color: widget.cs.surfaceContainerHighest, + child: widget.item.isVideo + ? Center( + child: Icon( + Symbols.movie, + size: 28, + color: widget.cs.onSurfaceVariant, + ), + ) + : null, + ); +} + +class _ExportProgress extends StatelessWidget { + final ValueListenable progress; + final VoidCallback onCancel; + + const _ExportProgress({required this.progress, required this.onCancel}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + return Center( + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 22, 24, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, _) => SizedBox( + width: 46, + height: 46, + child: CircularProgressIndicator( + value: value <= 0 ? null : value, + strokeWidth: 3, + ), + ), + ), + const SizedBox(height: 16), + Text( + l10n.videoEditorProcessing, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + const SizedBox(height: 6), + TextButton( + onPressed: onCancel, + child: Text(l10n.photoEditorCancel), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart index d4a6e0a..8ca1cc7 100644 --- a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -6,15 +6,35 @@ import '../../../../backend/modules/messages.dart'; import '../../../../core/config/app_colors.dart'; import '../../../../core/config/komet_settings.dart'; import '../../../../core/utils/format.dart'; +import '../../../../core/utils/text_format.dart'; import '../../../../models/attachment.dart'; import '../../formatted_message_text.dart'; +import '../../sending_clock_icon.dart'; +import '../../photo_viewer.dart'; enum MessageType { text, attachment, voice, control } enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } +typedef ForwardedSourceTap = + void Function(ForwardedMessageAttachment forwarded); + final Expando<({bool full, String text})> _clockTextCache = Expando(); +class BubblePresentation { + final String? text; + final List formatRanges; + final String? sourceMessageId; + final int? sourceChatId; + + const BubblePresentation({ + this.text, + this.formatRanges = const [], + this.sourceMessageId, + this.sourceChatId, + }); +} + ({IconData icon, Color color}) messageStatusVisual( String? status, { required Color dimColor, @@ -44,8 +64,9 @@ class BubbleContext { static const double photoMinSize = 100.0; static const double photoBorderRadius = 12.0; static const double bubbleBorderRadius = 20.0; - static const double captionPaddingHorizontal = 6.0; - static const double captionPaddingRight = 4.0; + static const double captionPaddingHorizontal = 10.0; + static const double captionPaddingRight = 6.0; + static const double captionPaddingTop = 6.0; static const double compactTimePadding = 8.0; final BuildContext context; @@ -62,10 +83,17 @@ class BubbleContext { final bool isMe; final int myId; final String chatType; + final int? chatId; + final String? chatName; + final PhotoViewerActions? photoActions; final String? overrideStatus; final ValueListenable? otherReadTime; final ValueListenable>? uploadProgress; final void Function(StickerAttachment sticker)? onStickerTap; + final ForwardedSourceTap? onForwardedSourceTap; + final BubblePresentation? presentation; + final bool metaInFooter; + final Widget Function(Widget)? selectable; BubbleContext({ required this.context, @@ -79,13 +107,56 @@ class BubbleContext { required this.isMe, required this.myId, required this.chatType, + this.chatId, + this.chatName, + this.photoActions, this.overrideStatus, this.otherReadTime, this.uploadProgress, this.onStickerTap, + this.onForwardedSourceTap, this.reactionInfo, + this.presentation, + this.metaInFooter = false, + this.selectable, }) : dim = text.withValues(alpha: 0.7); + String? get contentText => + presentation == null ? message.text : presentation!.text; + + List get contentFormatRanges => + presentation == null ? message.formatRanges : presentation!.formatRanges; + + String get sourceMessageId => presentation?.sourceMessageId ?? message.id; + + int get sourceChatId => presentation?.sourceChatId ?? message.chatId; + + BubbleContext withPresentation(BubblePresentation value) => BubbleContext( + context: context, + cs: cs, + text: text, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoWithCaption, + hasMultiplePhotosNoCaption: hasMultiplePhotosNoCaption, + message: message, + isMe: isMe, + myId: myId, + chatType: chatType, + chatId: chatId, + chatName: chatName, + photoActions: photoActions, + overrideStatus: overrideStatus, + otherReadTime: otherReadTime, + uploadProgress: uploadProgress, + onStickerTap: onStickerTap, + onForwardedSourceTap: onForwardedSourceTap, + reactionInfo: reactionInfo, + presentation: value, + metaInFooter: metaInFooter, + selectable: selectable, + ); + String get clockText { final full = KometSettings.fullTimestamp.value; final cached = _clockTextCache[message]; @@ -102,18 +173,27 @@ class BubbleContext { Widget caption() { final style = TextStyle(color: text, fontSize: 16, height: 1.3); - final ranges = message.formatRanges; - if (FormattedMessageText.isFormatted(message.text, ranges)) { - return FormattedMessageText( - text: message.text!, + final captionText = contentText; + final ranges = contentFormatRanges; + final Widget body; + if (FormattedMessageText.isFormatted(captionText, ranges)) { + body = FormattedMessageText( + text: captionText!, ranges: ranges, style: style, ); + } else { + body = Text(captionText ?? '', style: style); } - return Text(message.text ?? '', style: style); + final wrap = selectable; + return wrap == null ? body : wrap(body); } - Widget meta() { + Widget meta() => metaInFooter ? const SizedBox.shrink() : _metaRow(); + + Widget footerMeta() => _metaRow(); + + Widget _metaRow() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( @@ -129,6 +209,8 @@ class BubbleContext { } Widget compactTime() { + if (metaInFooter) return const SizedBox.shrink(); + final bgColor = isMe ? Colors.black.withValues(alpha: 0.4) : Colors.black.withValues(alpha: 0.5); @@ -154,6 +236,10 @@ class BubbleContext { const SizedBox(width: 3), const Icon(Symbols.delete, size: 11, color: Colors.white), ], + if (isMe) ...[ + const SizedBox(width: 3), + statusIcon(color: Colors.white, size: 12), + ], ], ), ); @@ -161,14 +247,17 @@ class BubbleContext { Widget deletedIcon() => Icon(Symbols.delete, size: 13, color: dim); - Widget statusIcon() { + Widget statusIcon({Color? color, double size = 14}) { final base = overrideStatus ?? message.status; final rt = otherReadTime; - if (rt == null) return _statusIconFor(base); + if (rt == null) return _statusIconFor(base, color: color, size: size); return ValueListenableBuilder( valueListenable: rt, - builder: (context, readTime, _) => - _statusIconFor(_readUpgradedStatus(base, readTime)), + builder: (context, readTime, _) => _statusIconFor( + _readUpgradedStatus(base, readTime), + color: color, + size: size, + ), ); } @@ -181,8 +270,11 @@ class BubbleContext { return base; } - Widget _statusIconFor(String? status) { - final v = messageStatusVisual(status, dimColor: dim); - return Icon(v.icon, size: 14, color: v.color); + Widget _statusIconFor(String? status, {Color? color, double size = 14}) { + final v = messageStatusVisual(status, dimColor: color ?? dim); + if (isSendingStatus(status)) { + return SendingClockIcon(color: v.color, size: size); + } + return Icon(v.icon, size: size, color: v.color); } } diff --git a/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart b/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart index 5392da9..0c10496 100644 --- a/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart @@ -1,9 +1,17 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../../core/config/app_colors.dart'; +import '../../../../backend/modules/contacts.dart'; +import '../../../../core/utils/haptics.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../main.dart' show api; import '../../../../models/attachment.dart'; +import '../../../screens/contacts/open_contact_profile.dart'; +import '../../custom_notification.dart'; +import '../../komet_avatar.dart'; +import '../../small_spinner.dart'; import 'bubble_context.dart'; Widget buildContactCard( @@ -13,85 +21,313 @@ Widget buildContactCard( String? name, String? photoUrl, String? phoneNumber, + int? contactId, + String? userId, }) { - final isMe = ctx.isMe; + return _ContactCard( + ctx: ctx, + firstName: firstName, + lastName: lastName, + name: name, + photoUrl: photoUrl, + phoneNumber: phoneNumber, + contactId: contactId, + userId: userId, + ); +} - final first = firstName ?? ''; - final last = lastName ?? ''; - final hasFirstName = first.isNotEmpty; - final hasLastName = last.isNotEmpty; +class _ContactCard extends StatefulWidget { + final BubbleContext ctx; + final String? firstName; + final String? lastName; + final String? name; + final String? photoUrl; + final String? phoneNumber; + final int? contactId; + final String? userId; - final resolvedName = (hasFirstName || hasLastName) - ? '${hasFirstName ? first : ''}${hasLastName ? ' $last' : ''}'.trim() - : (name ?? 'Contact'); + const _ContactCard({ + required this.ctx, + this.firstName, + this.lastName, + this.name, + this.photoUrl, + this.phoneNumber, + this.contactId, + this.userId, + }); - final bgColor = isMe ? ctx.systemTint : ctx.cs.surfaceContainerHighest; + int? get resolvedContactId => contactId ?? int.tryParse(userId?.trim() ?? ''); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(24), - ), - child: photoUrl != null && photoUrl.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(24), - child: CachedNetworkImage( - imageUrl: photoUrl, - fit: BoxFit.cover, - memCacheWidth: kAvatarThumbSize, - memCacheHeight: kAvatarThumbSize, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => Icon( - Symbols.person, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 24, - ), + String get resolvedName { + final first = firstName?.trim() ?? ''; + final last = lastName?.trim() ?? ''; + final fullName = '$first $last'.trim(); + if (fullName.isNotEmpty) return fullName; + final fallback = name?.trim() ?? ''; + return fallback.isEmpty ? 'Contact' : fallback; + } + + String get nameForAdd { + final first = firstName?.trim() ?? ''; + return first.isEmpty ? resolvedName : first; + } + + int get resolvedPhone { + final digits = phoneNumber?.replaceAll(RegExp(r'\D'), '') ?? ''; + return int.tryParse(digits) ?? 0; + } + + @override + State<_ContactCard> createState() => _ContactCardState(); +} + +class _ContactCardState extends State<_ContactCard> { + bool? _isContact; + bool _adding = false; + int _statusGeneration = 0; + + @override + void initState() { + super.initState(); + ContactsModule.revision.addListener(_onContactsChanged); + unawaited(_refreshContactStatus()); + } + + @override + void didUpdateWidget(_ContactCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.resolvedContactId != widget.resolvedContactId || + oldWidget.ctx.message.accountId != widget.ctx.message.accountId || + oldWidget.ctx.myId != widget.ctx.myId) { + _isContact = null; + unawaited(_refreshContactStatus()); + } + } + + @override + void dispose() { + ContactsModule.revision.removeListener(_onContactsChanged); + super.dispose(); + } + + void _onContactsChanged() { + unawaited(_refreshContactStatus()); + } + + Future _refreshContactStatus() async { + final generation = ++_statusGeneration; + final contactId = widget.resolvedContactId; + if (contactId == null) { + if (mounted && generation == _statusGeneration) { + setState(() => _isContact = false); + } + return; + } + if (contactId == widget.ctx.myId) { + if (mounted && generation == _statusGeneration) { + setState(() => _isContact = true); + } + return; + } + + CachedContact? contact; + try { + contact = await ContactsModule.getContact( + widget.ctx.message.accountId, + contactId, + ); + } catch (_) {} + if (!mounted || generation != _statusGeneration) return; + setState(() => _isContact = contact != null); + } + + Future _addContact() async { + final contactId = widget.resolvedContactId; + if (contactId == null || _adding || _isContact != false) return; + Haptics.tap(); + setState(() => _adding = true); + try { + final contact = await ContactsModule.addContact( + api, + contactId, + widget.nameForAdd, + phone: widget.resolvedPhone, + ); + if (!mounted) return; + if (contact == null) { + showCustomNotification( + context, + AppLocalizations.of(context)!.addContactError, + ); + return; + } + setState(() => _isContact = true); + showCustomNotification( + context, + AppLocalizations.of(context)!.nfcContactAdded, + ); + } catch (_) { + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.addContactError, + ); + } + } finally { + if (mounted) setState(() => _adding = false); + } + } + + void _openProfile() { + final contactId = widget.resolvedContactId; + if (contactId == null) return; + Haptics.tap(); + unawaited( + openContactDialogProfile( + context, + contactId: contactId, + name: widget.resolvedName, + avatarUrl: widget.photoUrl, + ), + ); + } + + @override + Widget build(BuildContext context) { + final ctx = widget.ctx; + final l10n = AppLocalizations.of(context)!; + final canAdd = + widget.resolvedContactId != null && + widget.resolvedContactId != ctx.myId && + _isContact != true; + final buttonColor = ctx.isMe + ? Colors.black.withValues(alpha: 0.16) + : ctx.cs.onSurface.withValues(alpha: 0.08); + + return SizedBox( + key: const ValueKey('contact-card'), + width: 320, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 8, 3), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + KometAvatar( + name: widget.resolvedName, + imageUrl: widget.photoUrl, + size: 48, + fadeIn: false, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.resolvedName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ctx.text, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1.15, + ), + ), + const SizedBox(height: 4), + Text( + _isContact == true + ? l10n.contactBubbleAlreadyAdded + : l10n.contactBubbleNew, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.1, + ), + ), + ], ), - ) - : Icon( - Symbols.person, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 24, ), - ), - const SizedBox(width: 12), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - resolvedName.isNotEmpty ? resolvedName : 'Contact', - style: TextStyle( - color: ctx.text, - fontSize: 15, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (phoneNumber != null) ...[ - const SizedBox(height: 2), - Text( - phoneNumber, - style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2), + if (canAdd) ...[ + const SizedBox(width: 6), + _ContactActionButton( + key: const ValueKey('contact-add-button'), + tooltip: l10n.nfcAddContact, + icon: Symbols.person_add, + color: buttonColor, + foreground: ctx.text, + loading: _adding || _isContact == null, + onPressed: _isContact == false && !_adding + ? _addContact + : null, + ), + ], + const SizedBox(width: 6), + _ContactActionButton( + key: const ValueKey('contact-profile-button'), + tooltip: l10n.contactBubbleOpenProfile, + icon: Symbols.chat_bubble, + color: buttonColor, + foreground: ctx.text, + onPressed: widget.resolvedContactId == null + ? null + : _openProfile, ), ], - ], - ), + ), + Align(alignment: Alignment.centerRight, child: ctx.meta()), + ], ), - ], - ), - ); + ), + ); + } +} + +class _ContactActionButton extends StatelessWidget { + final String tooltip; + final IconData icon; + final Color color; + final Color foreground; + final bool loading; + final VoidCallback? onPressed; + + const _ContactActionButton({ + super.key, + required this.tooltip, + required this.icon, + required this.color, + required this.foreground, + this.loading = false, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: color, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: 38, + height: 38, + child: IconButton( + tooltip: tooltip, + padding: EdgeInsets.zero, + onPressed: onPressed, + icon: loading + ? SmallSpinner(size: 17, color: foreground) + : Icon(icon, color: foreground, size: 20, fill: 1), + ), + ), + ); + } } class ContactBubble extends StatelessWidget { @@ -109,6 +345,8 @@ class ContactBubble extends StatelessWidget { name: contact.name, photoUrl: contact.photoUrl ?? contact.baseUrl, phoneNumber: contact.phoneNumber, + contactId: contact.contactId, + userId: contact.userId, ); } } diff --git a/lib/frontend/widgets/attachment/bubbles/control_bubble.dart b/lib/frontend/widgets/attachment/bubbles/control_bubble.dart new file mode 100644 index 0000000..d69db12 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/control_bubble.dart @@ -0,0 +1,169 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../../../backend/modules/messages.dart'; +import '../../../../models/attachment.dart'; + +class _ControlSegment { + final String text; + final int? userId; + + const _ControlSegment(this.text, [this.userId]); +} + +class _ControlText { + final List<_ControlSegment> segments; + final int? tapUserId; + + const _ControlText(this.segments, this.tapUserId); +} + +class ControlBubble extends StatefulWidget { + final CachedMessage message; + final ColorScheme cs; + final void Function(int userId)? onUserTap; + + const ControlBubble({ + super.key, + required this.message, + required this.cs, + this.onUserTap, + }); + + @override + State createState() => _ControlBubbleState(); +} + +class _ControlBubbleState extends State { + final Map _recognizers = {}; + + @override + void dispose() { + for (final recognizer in _recognizers.values) { + recognizer.dispose(); + } + super.dispose(); + } + + TapGestureRecognizer _recognizerFor(int userId) => _recognizers.putIfAbsent( + userId, + () => TapGestureRecognizer()..onTap = () => widget.onUserTap?.call(userId), + ); + + String _nameOf(int userId) => ContactCache.get(userId) ?? 'Пользователь'; + + int? _mentionedUser(ControlAttachment control) { + final direct = control.userId; + if (direct != null && direct != 0) return direct; + final ids = control.userIds; + if (ids != null && ids.length == 1) return ids.first; + return null; + } + + _ControlText _resolveText(ControlAttachment control) { + final senderId = widget.message.senderId; + final sender = _ControlSegment(_nameOf(senderId), senderId); + + switch (control.event) { + case 'new': + return _ControlText([ + sender, + const _ControlSegment(' создал(а) чат'), + ], senderId); + case 'add': + final ids = control.userIds ?? const []; + final segments = <_ControlSegment>[ + sender, + const _ControlSegment(' добавил(а) '), + ]; + for (var i = 0; i < ids.length; i++) { + if (i > 0) segments.add(const _ControlSegment(', ')); + segments.add(_ControlSegment(_nameOf(ids[i]), ids[i])); + } + return _ControlText(segments, ids.length == 1 ? ids.first : null); + case 'leave': + return _ControlText([ + sender, + const _ControlSegment(' покинул(а) чат'), + ], senderId); + case 'joinByLink': + return _ControlText([ + sender, + const _ControlSegment(' присоединился(-ась) к чату'), + ], senderId); + case 'pin': + return _ControlText([ + sender, + const _ControlSegment(' закрепил(а) сообщение'), + ], senderId); + case ControlAttachment.botStartedEvent: + final payload = widget.message.botStartPayload; + return _ControlText([ + const _ControlSegment('Бот запущен'), + if (payload != null) _ControlSegment(': $payload'), + ], null); + default: + return _ControlText([ + _ControlSegment(control.title ?? ''), + ], _mentionedUser(control) ?? senderId); + } + } + + @override + Widget build(BuildContext context) { + final attachments = widget.message.attachments; + if (attachments == null || attachments.isEmpty) { + return const SizedBox.shrink(); + } + + final control = attachments.first; + if (control is! ControlAttachment) return const SizedBox.shrink(); + + final resolved = _resolveText(control); + if (resolved.segments.every((s) => s.text.isEmpty)) { + return const SizedBox.shrink(); + } + + final cs = widget.cs; + final interactive = widget.onUserTap != null; + + final bubble = Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Text.rich( + TextSpan( + children: [ + for (final segment in resolved.segments) + TextSpan( + text: segment.text, + style: interactive && segment.userId != null + ? const TextStyle(fontWeight: FontWeight.w600) + : null, + recognizer: interactive && segment.userId != null + ? _recognizerFor(segment.userId!) + : null, + ), + ], + ), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ); + + final tapUserId = resolved.tapUserId; + if (!interactive || tapUserId == null) return bubble; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onUserTap!(tapUserId), + child: bubble, + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart index 0ee86af..8065ce6 100644 --- a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart @@ -1,17 +1,29 @@ +import 'dart:io'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/main.dart'; import '../../../../core/utils/download_progress.dart'; +import '../../../../core/utils/download_history.dart'; import '../../../../core/utils/file_download.dart'; +import '../../../../core/utils/media_cache.dart'; import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; +import '../../../../core/crypto/chat_crypto_service.dart'; +import '../../../../core/crypto/encrypted_photo_cache.dart'; import '../../../../models/attachment.dart'; import '../../custom_notification.dart'; +import '../../decrypted_photo.dart'; +import '../../photo_viewer.dart'; +import '../../upload_progress_ring.dart'; import 'bubble_context.dart'; class FileBubble extends StatelessWidget { + static const double _previewWidth = 240; + static const double _previewHeight = 160; + final BubbleContext ctx; final FileAttachment file; final bool fill; @@ -34,6 +46,11 @@ class FileBubble extends StatelessWidget { final preview = file.preview; final previewUrl = preview?.baseUrl ?? preview?.previewData ?? ''; + final previewWidget = _preview( + cacheName: cacheName, + previewUrl: previewUrl, + encrypted: fileId != null && _isEncryptedImage(name), + ); final inner = Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), @@ -41,21 +58,7 @@ class FileBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (previewUrl.isNotEmpty) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: previewUrl, - width: 240, - height: 160, - fit: BoxFit.cover, - memCacheWidth: 480, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), - ), - const SizedBox(height: 8), - ], + ?previewWidget, Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -67,14 +70,27 @@ class FileBubble extends StatelessWidget { color: isMe ? ctx.systemTint : ctx.cs.primaryContainer, borderRadius: BorderRadius.circular(10), ), - child: Icon( - Symbols.description, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 20, - ), + child: ctx.uploadProgress == null + ? Icon( + Symbols.description, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 20, + ) + : UploadProgressRing( + progress: ctx.uploadProgress!, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 38, + strokeWidth: 2.4, + iconSize: 14, + padding: const EdgeInsets.all(4), + ), ), const SizedBox(width: 10), - Flexible( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -113,38 +129,49 @@ class FileBubble extends StatelessWidget { ValueListenableBuilder( valueListenable: MediaDownloadProgress.notifier(cacheName), builder: (context, progress, _) { - final downloading = progress != null; - return GestureDetector( - onTap: downloading - ? null - : () => _downloadFile(ctx.context, file, name), - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.systemTint - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, + final iconColor = isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary; + Widget circle(Widget child, VoidCallback? onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.systemTint + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: child, ), - child: downloading - ? Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, - value: progress > 0 ? progress : null, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - ), - ) - : Icon( - Symbols.download, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 18, - ), + ); + } + + if (progress != null) { + return circle( + Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: iconColor, + ), + ), + null, + ); + } + + return ValueListenableBuilder( + valueListenable: MediaCache.presence(cacheName), + builder: (context, cached, _) => circle( + Icon( + cached ? Symbols.check : Symbols.download, + color: iconColor, + size: 18, + ), + () => _downloadFile(ctx.context, file, name), ), ); }, @@ -155,7 +182,225 @@ class FileBubble extends StatelessWidget { ], ), ); - return fill ? inner : IntrinsicWidth(child: inner); + final body = fill ? inner : IntrinsicWidth(child: inner); + if (!_isViewableImage(name)) return body; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openInViewer(ctx.context, name, cacheName), + child: body, + ); + } + + static bool _isViewableImage(String name) => + name.toLowerCase().endsWith('.png'); + + bool _isEncryptedImage(String name) => + _isViewableImage(name) && + ChatCryptoService.instance.isEnabled( + ctx.message.accountId, + ctx.message.chatId, + ); + + Widget? _preview({ + required String cacheName, + required String previewUrl, + required bool encrypted, + }) { + if (encrypted) { + return DecryptedPhoto( + accountId: ctx.message.accountId, + chatId: ctx.message.chatId, + cacheName: cacheName, + size: file.size ?? 0, + urlLoader: _fileUrl, + builder: (view) => _encryptedPreview(view, previewUrl), + ); + } + if (previewUrl.isEmpty) return null; + return _networkPreview(previewUrl); + } + + Widget _encryptedPreview(EncryptedPhotoView? view, String previewUrl) { + switch (view?.status) { + case EncryptedPhotoStatus.decrypted: + return _framed( + Image.file( + view!.file!, + width: _previewWidth, + height: _previewHeight, + fit: BoxFit.cover, + cacheWidth: 480, + errorBuilder: (_, _, _) => _placeholder( + icon: Symbols.broken_image, + label: 'Файл повреждён', + ), + ), + ); + case EncryptedPhotoStatus.plain: + return previewUrl.isEmpty + ? const SizedBox.shrink() + : _networkPreview(previewUrl); + case EncryptedPhotoStatus.wrongKey: + return _placeholder(icon: Symbols.lock, label: 'Неверный ключ'); + case EncryptedPhotoStatus.locked: + return _placeholder( + icon: Symbols.lock, + label: 'Нажмите, чтобы открыть', + ); + case null: + return _placeholder(); + } + } + + Widget _networkPreview(String url) => _framed( + CachedNetworkImage( + imageUrl: url, + width: _previewWidth, + height: _previewHeight, + fit: BoxFit.cover, + memCacheWidth: 480, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ); + + Widget _placeholder({IconData? icon, String? label}) => _framed( + Container( + width: _previewWidth, + height: _previewHeight, + alignment: Alignment.center, + color: ctx.isMe ? ctx.systemTint : ctx.cs.surfaceContainerHighest, + child: icon == null + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: ctx.dim), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 26, color: ctx.dim), + if (label != null) ...[ + const SizedBox(height: 6), + Text(label, style: TextStyle(color: ctx.dim, fontSize: 12)), + ], + ], + ), + ), + ); + + Widget _framed(Widget child) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClipRRect(borderRadius: BorderRadius.circular(10), child: child), + ); + + Future _fileUrl() { + final fileId = file.fileId; + if (fileId == null) return Future.value(null); + return messagesModule.getFileUrl( + messageId: ctx.message.id, + chatId: ctx.message.chatId, + fileId: fileId, + ); + } + + Future _openInViewer( + BuildContext context, + String name, + String cacheName, + ) async { + final fileId = file.fileId; + if (fileId == null) return; + Haptics.tap(); + + final wasCached = (await MediaCache.existing(cacheName)) != null; + if (!wasCached) MediaDownloadProgress.set(cacheName, 0); + File? local; + try { + final url = await _fileUrl(); + if (url != null && url.isNotEmpty) { + local = await MediaCache.getOrDownload( + cacheName, + url, + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + ); + } + } finally { + if (!wasCached) MediaDownloadProgress.set(cacheName, null); + } + + if (!context.mounted) return; + if (local == null) { + showCustomNotification(context, 'Не удалось загрузить файл'); + return; + } + + final kind = downloadKindForName(name); + try { + await DownloadHistory.record( + DownloadMetadata( + cacheName: cacheName, + name: kind == DownloadKind.file ? name : '', + kind: kind, + sourceName: ctx.chatName ?? '', + thumbnailUrl: + file.preview?.baseUrl ?? + file.preview?.previewData ?? + file.previewData, + expectedSize: file.size ?? 0, + chatId: ctx.message.chatId, + messageId: ctx.message.id, + messageTime: ctx.message.time, + ), + local, + ); + } catch (_) {} + + if (!context.mounted) return; + final shown = await _decryptIfNeeded(context, local, cacheName); + if (!context.mounted || shown == null) return; + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PhotoViewerScreen( + photos: [PhotoAttachment(localPath: shown.path)], + chatId: ctx.message.chatId, + message: ctx.message, + isFile: true, + ), + ), + ); + } + + Future _decryptIfNeeded( + BuildContext context, + File local, + String cacheName, + ) async { + final accountId = ctx.message.accountId; + final chatId = ctx.message.chatId; + if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return local; + + final view = await EncryptedPhotoCache.instance.resolve( + accountId: accountId, + chatId: chatId, + cacheName: cacheName, + urlLoader: _fileUrl, + ); + if (!context.mounted) return null; + + switch (view.status) { + case EncryptedPhotoStatus.decrypted: + return view.file; + case EncryptedPhotoStatus.plain: + return local; + case EncryptedPhotoStatus.wrongKey: + showCustomNotification(context, 'Неверный ключ'); + return null; + case EncryptedPhotoStatus.locked: + showCustomNotification(context, 'Не удалось расшифровать фото'); + return null; + } } Future _downloadFile( @@ -171,8 +416,10 @@ class FileBubble extends StatelessWidget { Haptics.tap(); final cacheName = '${fileId}_$name'; + final cached = (await MediaCache.existing(cacheName)) != null; + final kind = downloadKindForName(name); - MediaDownloadProgress.set(cacheName, 0); + if (!cached) MediaDownloadProgress.set(cacheName, 0); final result = await openCachedFile( cacheName, () => messagesModule.getFileUrl( @@ -181,8 +428,24 @@ class FileBubble extends StatelessWidget { fileId: fileId, ), onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + onReady: () { + if (!cached) MediaDownloadProgress.set(cacheName, null); + }, + download: DownloadMetadata( + cacheName: cacheName, + name: kind == DownloadKind.file ? name : '', + kind: kind, + sourceName: ctx.chatName ?? '', + thumbnailUrl: + file.preview?.baseUrl ?? + file.preview?.previewData ?? + file.previewData, + expectedSize: file.size ?? 0, + chatId: ctx.message.chatId, + messageId: ctx.message.id, + messageTime: ctx.message.time, + ), ); - MediaDownloadProgress.set(cacheName, null); if (!context.mounted) return; if (!result.ok) { showCustomNotification( diff --git a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart index 10b73d5..8dba8a3 100644 --- a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart @@ -5,197 +5,116 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../../backend/modules/messages.dart'; import '../../../../models/attachment.dart'; import 'bubble_context.dart'; -import 'contact_bubble.dart'; -import 'file_bubble.dart'; -import 'photo_bubble.dart'; -import 'sticker_bubble.dart'; -Widget _forwardedHeader( - BubbleContext ctx, - ForwardedMessageAttachment forwarded, -) { - final headerColor = ctx.dim; - final displaySender = +String _forwardedSourceName(ForwardedMessageAttachment forwarded) { + final resolved = forwarded.originalSenderName ?? - ContactCache.get(forwarded.originalSenderId) ?? - forwarded.originalSenderId.toString(); - final senderAvatar = - forwarded.originalSenderAvatar ?? - ContactCache.getAvatar(forwarded.originalSenderId); - return Padding( - padding: const EdgeInsets.only(left: 8, top: 8, right: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - if (senderAvatar != null && senderAvatar.isNotEmpty) - CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - senderAvatar, - maxWidth: 96, - maxHeight: 96, - ), - backgroundColor: ctx.cs.primaryContainer, - ) - else - CircleAvatar( - radius: 10, - backgroundColor: ctx.cs.primaryContainer, - child: Text( - displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', - style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer), - ), - ), - const SizedBox(width: 6), - Flexible( - child: Text( - displaySender, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ); + ContactCache.get(forwarded.originalSenderId); + if (resolved != null && resolved.isNotEmpty) return resolved; + if (forwarded.isChannel) return 'Канал'; + if (forwarded.originalSenderId != 0) { + return forwarded.originalSenderId.toString(); + } + return 'Сообщение'; } -class ForwardedPhotoBubble extends StatelessWidget { +String? _forwardedSourceAvatar(ForwardedMessageAttachment forwarded) => + forwarded.originalSenderAvatar ?? + ContactCache.getAvatar(forwarded.originalSenderId); + +class ForwardedHeader extends StatelessWidget { final BubbleContext ctx; final ForwardedMessageAttachment forwarded; - final List photos; + final EdgeInsetsGeometry padding; - const ForwardedPhotoBubble({ + const ForwardedHeader({ super.key, required this.ctx, required this.forwarded, - required this.photos, + this.padding = const EdgeInsets.only(left: 8, top: 8, right: 8), }); @override Widget build(BuildContext context) { - final message = ctx.message; - final hasCaption = message.text != null && message.text!.isNotEmpty; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _forwardedHeader(ctx, forwarded), - const SizedBox(height: 4), - if (hasCaption) ...[ - Padding( - padding: const EdgeInsets.only(left: 8), + final headerColor = ctx.dim; + final displaySender = _forwardedSourceName(forwarded); + final senderAvatar = _forwardedSourceAvatar(forwarded); + final content = Padding( + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), + backgroundColor: ctx.cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: ctx.cs.primaryContainer, + child: Text( + displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', + style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Flexible( child: Text( - message.text ?? '', - style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), + displaySender, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), ), - const SizedBox(height: 6), ], - PhotoBubble(ctx: ctx, photos: photos), - ], + ), + ); + final onTap = ctx.onForwardedSourceTap; + if (onTap == null) return content; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(forwarded), + child: content, ); } } -class ForwardedGenericBubble extends StatelessWidget { +class ForwardedHeaderFloating extends StatelessWidget { final BubbleContext ctx; final ForwardedMessageAttachment forwarded; - final List attachments; - const ForwardedGenericBubble({ + const ForwardedHeaderFloating({ super.key, required this.ctx, required this.forwarded, - required this.attachments, }); @override Widget build(BuildContext context) { - return IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - _forwardedHeader(ctx, forwarded), - const SizedBox(height: 4), - ...attachments.map((a) { - if (a is FileAttachment) { - return FileBubble(ctx: ctx, file: a, fill: true); - } - if (a is StickerAttachment) { - return StickerBubble(ctx: ctx, sticker: a); - } - return const SizedBox.shrink(); - }), - ], + return Material( + color: ctx.isMe + ? ctx.cs.primaryContainer + : ctx.cs.surfaceContainerHighest, + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(18), + child: ForwardedHeader( + ctx: ctx, + forwarded: forwarded, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), ), ); } } - -class ForwardedStickerBubble extends StatelessWidget { - final BubbleContext ctx; - final ForwardedMessageAttachment forwarded; - final MessageAttachment sticker; - - const ForwardedStickerBubble({ - super.key, - required this.ctx, - required this.forwarded, - required this.sticker, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _forwardedHeader(ctx, forwarded), - const SizedBox(height: 4), - StickerBubble(ctx: ctx, sticker: sticker), - ], - ); - } -} - -class ForwardedContactBubble extends StatelessWidget { - final BubbleContext ctx; - final ForwardedMessageAttachment forwarded; - - const ForwardedContactBubble({ - super.key, - required this.ctx, - required this.forwarded, - }); - - @override - Widget build(BuildContext context) { - final contact = forwarded.originalContact!; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _forwardedHeader(ctx, forwarded), - const SizedBox(height: 4), - buildContactCard( - ctx, - firstName: contact.firstName, - lastName: contact.lastName, - name: contact.name, - photoUrl: contact.photoUrl ?? contact.baseUrl, - phoneNumber: contact.phoneNumber, - ), - ], - ); - } -} diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart index 0c0826a..3a6ac6a 100644 --- a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math' as math; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; @@ -7,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../../models/attachment.dart'; import '../../photo_viewer.dart'; +import '../photo_hero.dart'; import 'bubble_context.dart'; class PhotoBubble extends StatelessWidget { @@ -20,20 +22,75 @@ class PhotoBubble extends StatelessWidget { final BubbleContext ctx; final List photos; + final bool hasContentAbove; - const PhotoBubble({super.key, required this.ctx, required this.photos}); + const PhotoBubble({ + super.key, + required this.ctx, + required this.photos, + this.hasContentAbove = false, + }); + + static double layoutWidth(List photos) { + if (photos.length != 1) return BubbleContext.photoMaxSize; + return _displaySize(photos.single).width; + } + + static Size _displaySize(PhotoAttachment photo) { + final width = photo.width?.toDouble() ?? 200; + final height = photo.height?.toDouble() ?? 200; + + final downScale = math.min( + 1.0, + math.min( + BubbleContext.photoMaxSize / width, + BubbleContext.photoMaxSize / height, + ), + ); + var displayWidth = width * downScale; + var displayHeight = height * downScale; + + final upScale = math.max( + 1.0, + math.max( + BubbleContext.photoMinSize / displayWidth, + BubbleContext.photoMinSize / displayHeight, + ), + ); + displayWidth *= upScale; + displayHeight *= upScale; + + return Size( + displayWidth.clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ), + displayHeight.clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ), + ); + } @override Widget build(BuildContext context) { - final message = ctx.message; - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasMessageCaption = ctx.contentText?.isNotEmpty ?? false; + final resolvedCaption = hasMessageCaption ? ctx.caption() : null; + final hasCaption = resolvedCaption != null; final count = photos.length; Widget photosWidget; if (count == 1) { - photosWidget = _buildSinglePhoto(ctx, photos[0]); + photosWidget = _buildSinglePhoto( + ctx, + photos[0], + hasCaption: hasCaption, + hasContentAbove: hasContentAbove, + ); } else if (count == 2) { photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); + } else if (count == 3) { + photosWidget = _buildThreePhotos(ctx, photos); } else { photosWidget = _buildPhotoGrid(ctx, photos); } @@ -52,15 +109,8 @@ class PhotoBubble extends StatelessWidget { } if (count == 1) { - final photo = photos[0]; - final pw = photo.width?.toDouble() ?? 200; - final photoWidth = pw.clamp( - BubbleContext.photoMinSize, - BubbleContext.photoMaxSize, - ); - return SizedBox( - width: photoWidth, + width: layoutWidth(photos), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -70,12 +120,13 @@ class PhotoBubble extends StatelessWidget { padding: const EdgeInsets.only( left: BubbleContext.captionPaddingHorizontal, right: BubbleContext.captionPaddingRight, + top: BubbleContext.captionPaddingTop, bottom: 6, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(child: ctx.caption()), + Expanded(child: resolvedCaption), ctx.meta(), ], ), @@ -94,12 +145,13 @@ class PhotoBubble extends StatelessWidget { padding: const EdgeInsets.only( left: BubbleContext.captionPaddingHorizontal, right: BubbleContext.captionPaddingRight, + top: BubbleContext.captionPaddingTop, bottom: 6, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(child: ctx.caption()), + Expanded(child: resolvedCaption), ctx.meta(), ], ), @@ -108,22 +160,19 @@ class PhotoBubble extends StatelessWidget { ); } - Widget _buildSinglePhoto(BubbleContext ctx, PhotoAttachment photo) { - final width = photo.width?.toDouble() ?? 200; - final height = photo.height?.toDouble() ?? 200; - - final constrainedWidth = width.clamp( - BubbleContext.photoMinSize, - BubbleContext.photoMaxSize, - ); - final constrainedHeight = height.clamp( - BubbleContext.photoMinSize, - BubbleContext.photoMaxSize, - ); + Widget _buildSinglePhoto( + BubbleContext ctx, + PhotoAttachment photo, { + required bool hasCaption, + required bool hasContentAbove, + }) { + final size = _displaySize(photo); + final constrainedWidth = size.width; + final constrainedHeight = size.height; final dpr = MediaQuery.of(ctx.context).devicePixelRatio; - final matchTop = ctx.hasPhotoWithCaption; - final matchBottom = !ctx.hasPhotoWithCaption; + final matchTop = hasCaption && !hasContentAbove; + final matchBottom = !hasCaption; final topR = matchTop ? _bigRadius : _photoRadius; final bottomL = matchBottom @@ -133,13 +182,17 @@ class PhotoBubble extends StatelessWidget { ? (ctx.isMe ? _smallRadius : _bigRadius) : _smallRadius; + final radius = BorderRadius.only( + topLeft: topR, + topRight: topR, + bottomLeft: bottomL, + bottomRight: bottomR, + ); + final memWidth = (constrainedWidth * dpr).round(); + final memHeight = (constrainedHeight * dpr).round(); + return ClipRRect( - borderRadius: BorderRadius.only( - topLeft: topR, - topRight: topR, - bottomLeft: bottomL, - bottomRight: bottomR, - ), + borderRadius: radius, child: Stack( children: [ _buildPhotoImage( @@ -147,16 +200,25 @@ class PhotoBubble extends StatelessWidget { photo, constrainedWidth, constrainedHeight, - memWidth: (constrainedWidth * dpr).round(), - memHeight: (constrainedHeight * dpr).round(), + memWidth: memWidth, + memHeight: memHeight, ), if (ctx.uploadProgress != null) _buildUploadOverlay(ctx.uploadProgress!, 0), if (ctx.uploadProgress == null) Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _openPhotoViewer(ctx.context, photo), + child: Builder( + builder: (tileContext) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openPhotoViewer( + ctx.context, + 0, + tileContext: tileContext, + radius: radius, + memWidth: memWidth, + memHeight: memHeight, + ), + ), ), ), ], @@ -275,10 +337,7 @@ class PhotoBubble extends StatelessWidget { ); } - Widget _buildPhotoGrid(BubbleContext ctx, List photos) { - final displayCount = photos.length > 4 ? 4 : photos.length; - final remaining = photos.length - 4; - + Widget _buildThreePhotos(BubbleContext ctx, List photos) { final matchTop = ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; final matchBottom = @@ -290,50 +349,122 @@ class PhotoBubble extends StatelessWidget { matchBottom: matchBottom, isMe: ctx.isMe, ), - child: GridView.count( - crossAxisCount: 2, - mainAxisSpacing: 2, - crossAxisSpacing: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(displayCount, (i) { - if (i == 3 && remaining > 0) { - return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining', i); - } - return _buildPhotoTile(ctx, photos[i], i); - }), + child: AspectRatio( + aspectRatio: 3 / 2, + child: Row( + children: [ + Expanded(flex: 2, child: _buildFillTile(ctx, photos[0], 0)), + const SizedBox(width: 2), + Expanded( + child: Column( + children: [ + Expanded(child: _buildFillTile(ctx, photos[1], 1)), + const SizedBox(height: 2), + Expanded(child: _buildFillTile(ctx, photos[2], 2)), + ], + ), + ), + ], + ), ), ); } - Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) { + Widget _buildPhotoGrid(BubbleContext ctx, List photos) { + final displayCount = photos.length > 4 ? 4 : photos.length; + final remaining = photos.length - 4; + + final matchTop = + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; + final matchBottom = + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; + + final rows = []; + for (var i = 0; i < displayCount; i += 2) { + if (rows.isNotEmpty) rows.add(const SizedBox(height: 2)); + rows.add( + Row( + children: [ + Expanded(child: _buildGridTile(ctx, photos, i, remaining)), + const SizedBox(width: 2), + Expanded( + child: i + 1 < displayCount + ? _buildGridTile(ctx, photos, i + 1, remaining) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + return ClipRRect( + borderRadius: _multiPhotoCornerRadius( + matchTop: matchTop, + matchBottom: matchBottom, + isMe: ctx.isMe, + ), + child: Column(mainAxisSize: MainAxisSize.min, children: rows), + ); + } + + Widget _buildGridTile( + BubbleContext ctx, + List photos, + int index, + int remaining, + ) { + if (index == 3 && remaining > 0) { + return _buildPhotoTileWithOverlay( + ctx, + photos[index], + '+$remaining', + index, + ); + } + return _buildPhotoTile(ctx, photos[index], index); + } + + Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) => + AspectRatio(aspectRatio: 1, child: _buildFillTile(ctx, photo, index)); + + Widget _buildFillTile(BubbleContext ctx, PhotoAttachment photo, int index) { final cachePx = (BubbleContext.photoMaxSize / 2 * MediaQuery.of(ctx.context).devicePixelRatio) .round(); - return AspectRatio( - aspectRatio: 1, - child: Stack( - children: [ - _buildPhotoImage( - ctx, - photo, - double.infinity, - double.infinity, + return Stack( + children: [ + _buildPhotoImage( + ctx, + photo, + double.infinity, + double.infinity, + memWidth: cachePx, + memHeight: cachePx, + ), + if (ctx.uploadProgress != null) + _buildUploadOverlay(ctx.uploadProgress!, index), + if (ctx.uploadProgress == null) + _buildTileTapTarget(ctx, index, cachePx), + ], + ); + } + + Widget _buildTileTapTarget(BubbleContext ctx, int index, int cachePx) { + return Positioned.fill( + child: Builder( + builder: (tileContext) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openPhotoViewer( + ctx.context, + index, + tileContext: tileContext, + radius: BorderRadius.zero, memWidth: cachePx, memHeight: cachePx, ), - if (ctx.uploadProgress != null) - _buildUploadOverlay(ctx.uploadProgress!, index), - if (ctx.uploadProgress == null) - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _openPhotoViewer(ctx.context, photo), - ), - ), - ], + ), ), ); } @@ -378,6 +509,8 @@ class PhotoBubble extends StatelessWidget { ), if (ctx.uploadProgress != null) _buildUploadOverlay(ctx.uploadProgress!, index), + if (ctx.uploadProgress == null) + _buildTileTapTarget(ctx, index, cachePx), ], ), ); @@ -407,13 +540,62 @@ class PhotoBubble extends StatelessWidget { ); } - void _openPhotoViewer(BuildContext context, PhotoAttachment photo) { + static ImageProvider? _photoProvider( + PhotoAttachment photo, { + required int memWidth, + required int memHeight, + }) { + final localPath = photo.localPath; + if (localPath != null) { + return ResizeImage.resizeIfNeeded( + memWidth, + null, + FileImage(File(localPath)), + ); + } final url = photo.baseUrl ?? ''; - if (url.isEmpty) return; + if (url.isEmpty) return null; + return ResizeImage.resizeIfNeeded( + memWidth, + memHeight, + CachedNetworkImageProvider(url), + ); + } + + static Size? _photoSize(PhotoAttachment photo) { + final width = photo.width ?? 0; + final height = photo.height ?? 0; + if (width <= 0 || height <= 0) return null; + return Size(width.toDouble(), height.toDouble()); + } + + void _openPhotoViewer( + BuildContext context, + int index, { + required BuildContext tileContext, + required BorderRadius radius, + required int memWidth, + required int memHeight, + }) { + final photo = photos[index]; + final hero = PhotoHeroController( + origin: () => photoHeroRectOf(tileContext), + image: _photoProvider(photo, memWidth: memWidth, memHeight: memHeight), + size: _photoSize(photo), + radius: radius, + ); Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => PhotoViewerScreen(baseUrl: url), + PhotoHeroRoute( + hero: hero, + builder: (_) => PhotoViewerScreen( + photos: photos, + initialIndex: index, + chatId: ctx.chatId, + message: ctx.message, + actions: ctx.photoActions, + hero: hero, + sourceName: ctx.chatName, + ), ), ); } diff --git a/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart b/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart index ca67681..fb529f0 100644 --- a/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart @@ -20,11 +20,11 @@ class PollBubble extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ PollView( - chatId: ctx.message.chatId, - messageId: ctx.message.id, + chatId: ctx.sourceChatId, + messageId: ctx.sourceMessageId, pollId: poll.pollId, myId: ctx.myId, - fallbackTitle: poll.title ?? ctx.message.text, + fallbackTitle: poll.title ?? ctx.contentText, textColor: ctx.text, dimColor: ctx.dim, accentColor: ctx.isMe diff --git a/lib/frontend/widgets/attachment/bubbles/share_bubble.dart b/lib/frontend/widgets/attachment/bubbles/share_bubble.dart index f8b510b..a767f06 100644 --- a/lib/frontend/widgets/attachment/bubbles/share_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/share_bubble.dart @@ -16,8 +16,8 @@ class ShareBubble extends StatelessWidget { @override Widget build(BuildContext context) { final isMe = ctx.isMe; - final message = ctx.message; - final hasText = message.text != null && message.text!.isNotEmpty; + final text = ctx.contentText; + final hasText = text?.isNotEmpty ?? false; final image = share.image; final imageUrl = image?.baseUrl ?? image?.previewData ?? ''; final cardColor = isMe @@ -123,8 +123,8 @@ class ShareBubble extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: FormattedMessageText( - text: message.text!, - ranges: message.formatRanges, + text: text!, + ranges: ctx.contentFormatRanges, style: TextStyle( color: ctx.text, fontSize: 16, diff --git a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart index 2c15657..6eb68d8 100644 --- a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../../models/attachment.dart'; import '../../lottie_image.dart'; +import '../../sending_clock_icon.dart'; import 'bubble_context.dart'; class StickerBubble extends StatelessWidget { @@ -86,6 +87,9 @@ class StickerBubble extends StatelessWidget { Widget _buildStickerStatusIcon() { final status = ctx.overrideStatus ?? ctx.message.status; final v = messageStatusVisual(status, dimColor: Colors.white); + if (isSendingStatus(status)) { + return SendingClockIcon(color: v.color, size: 13); + } return Icon(v.icon, size: 13, color: v.color); } } diff --git a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart index 0d8e9f7..086d378 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart @@ -3,11 +3,13 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/main.dart'; +import '../../../../core/media/preview_image.dart'; import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../models/attachment.dart'; import '../../custom_notification.dart'; -import '../../video_player_screen.dart'; +import '../../upload_progress_ring.dart'; +import '../../photo_viewer.dart'; import 'bubble_context.dart'; import 'video_note_bubble.dart'; @@ -17,25 +19,35 @@ class VideoBubble extends StatelessWidget { const VideoBubble({super.key, required this.ctx, required this.video}); + static double layoutWidth(VideoAttachment video) { + return (video.width?.toDouble() ?? 200.0).clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ); + } + @override Widget build(BuildContext context) { final message = ctx.message; if (video.isNote) { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - VideoNoteBubble( - attachment: video, - messageId: message.id, - chatId: message.chatId, - cs: ctx.cs, - ), - const SizedBox(height: 6), - ctx.meta(), - ], + return VideoNoteBubble( + attachment: video, + messageId: message.id, + chatId: message.chatId, + sourceMessageId: ctx.sourceMessageId, + sourceChatId: ctx.sourceChatId, + senderId: message.senderId, + isMe: ctx.isMe, + time: message.time, + cs: ctx.cs, + textColor: ctx.text, + meta: ctx.meta(), + uploadProgress: ctx.uploadProgress, ); } - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasMessageCaption = ctx.contentText?.isNotEmpty ?? false; + final resolvedCaption = hasMessageCaption ? ctx.caption() : null; + final hasCaption = resolvedCaption != null; final thumb = video.thumbnail; final durationMs = video.duration; final previewUrl = (thumb != null && thumb.isNotEmpty) @@ -44,12 +56,8 @@ class VideoBubble extends StatelessWidget { ? video.baseUrl! : (video.previewData ?? ''); - final w = video.width; final h = video.height; - final width = (w?.toDouble() ?? 200.0).clamp( - BubbleContext.photoMinSize, - BubbleContext.photoMaxSize, - ); + final width = layoutWidth(video); final height = (h?.toDouble() ?? 150.0).clamp( BubbleContext.photoMinSize, BubbleContext.photoMaxSize, @@ -63,39 +71,78 @@ class VideoBubble extends StatelessWidget { child: Icon(Symbols.videocam, size: 48, color: ctx.cs.onSurfaceVariant), ); + final localThumb = dataUriImage(video, video.previewData); + final uploading = ctx.uploadProgress; + + Widget previewImage() { + if (previewUrl.isNotEmpty && !previewUrl.startsWith('data:')) { + return CachedNetworkImage( + imageUrl: previewUrl, + width: width, + height: height, + fit: BoxFit.cover, + memCacheWidth: (width * dpr).round(), + fadeInDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, + errorWidget: (_, _, _) => localThumb == null + ? placeholder() + : Image( + image: localThumb, + width: width, + height: height, + fit: BoxFit.cover, + ), + ); + } + if (localThumb != null) { + return Image( + image: localThumb, + width: width, + height: height, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => placeholder(), + ); + } + return placeholder(); + } + final preview = ClipRRect( borderRadius: BorderRadius.circular(BubbleContext.photoBorderRadius), child: Stack( children: [ - previewUrl.isEmpty - ? placeholder() - : CachedNetworkImage( - imageUrl: previewUrl, - width: width, - height: height, - fit: BoxFit.cover, - memCacheWidth: (width * dpr).round(), - fadeInDuration: Duration.zero, - placeholderFadeInDuration: Duration.zero, - errorWidget: (_, _, _) => placeholder(), + previewImage(), + if (uploading != null) + Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.35), + child: Center( + child: UploadProgressRing( + progress: uploading, + color: Colors.white, + trackColor: Colors.white24, + ), ), - Positioned.fill( - child: Center( - child: Container( - width: 48, - height: 48, - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Symbols.play_arrow, - color: Colors.white, - size: 30, + ), + ) + else + Positioned.fill( + child: Center( + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon( + Symbols.play_arrow, + color: Colors.white, + size: 30, + ), ), ), ), - ), if (durationMs != null && durationMs > 0) Positioned( left: 6, @@ -112,12 +159,13 @@ class VideoBubble extends StatelessWidget { ), ), ), - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _playVideo(ctx.context, video), + if (uploading == null) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _playVideo(ctx.context, video), + ), ), - ), ], ), ); @@ -146,12 +194,13 @@ class VideoBubble extends StatelessWidget { padding: const EdgeInsets.only( left: BubbleContext.captionPaddingHorizontal, right: BubbleContext.captionPaddingRight, + top: BubbleContext.captionPaddingTop, bottom: 6, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(child: ctx.caption()), + Expanded(child: resolvedCaption), ctx.meta(), ], ), @@ -171,8 +220,8 @@ class VideoBubble extends StatelessWidget { Haptics.tap(); final sources = await messagesModule.getVideoSources( - messageId: ctx.message.id, - chatId: ctx.message.chatId, + messageId: ctx.sourceMessageId, + chatId: ctx.sourceChatId, token: token, videoId: videoId, ); @@ -185,7 +234,14 @@ class VideoBubble extends StatelessWidget { Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => VideoPlayerScreen(sources: sources), + builder: (_) => PhotoViewerScreen.video( + attachment: video, + initialVideoSources: sources, + chatId: ctx.message.chatId, + message: ctx.message, + actions: ctx.photoActions, + sourceName: ctx.chatName, + ), ), ); } diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart index 30b59e4..2de3c24 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -1,49 +1,208 @@ +import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; +import 'dart:io'; +import 'dart:math' as math; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; import 'package:komet/main.dart'; +import '../../../../core/media/media_playback.dart'; +import '../../../../core/media/video_note_frame.dart'; +import '../../../../core/media/video_note_preloader.dart'; +import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/logger.dart'; -import '../../../../core/utils/media_cache.dart'; import '../../../../models/attachment.dart'; +import '../../small_spinner.dart'; +import '../../upload_progress_ring.dart'; class VideoNoteBubble extends StatefulWidget { final VideoAttachment attachment; final String messageId; final int chatId; + final String? sourceMessageId; + final int? sourceChatId; + final int senderId; + final bool isMe; + final int time; final ColorScheme cs; + final Color textColor; + final Widget meta; + final ValueListenable>? uploadProgress; const VideoNoteBubble({ super.key, required this.attachment, required this.messageId, required this.chatId, + this.sourceMessageId, + this.sourceChatId, + required this.senderId, + required this.isMe, + required this.time, required this.cs, + required this.textColor, + required this.meta, + this.uploadProgress, }); @override State createState() => _VideoNoteBubbleState(); } -class _VideoNoteBubbleState extends State { - static const double _size = 210; +class _VideoNoteBubbleState extends State + with SingleTickerProviderStateMixin { + static const double _baseSize = 210; + static const double _expandedScale = 1.7; + static const Duration _expandDuration = Duration(milliseconds: 280); + static const Duration _swapDuration = Duration(milliseconds: 220); + + static _VideoNoteBubbleState? _playingNote; + + late final AnimationController _expand; + final ValueNotifier _ringProgress = ValueNotifier(0); + Uint8List? _preview; + Size _frameSize = Size.zero; VideoPlayerController? _controller; + VideoPlayerController? _local; + Future? _initializing; + Duration? _pendingSeek; + double? _lastAngle; + bool _playing = false; bool _loading = false; bool _error = false; + bool _scrubbing = false; + bool _seekInFlight = false; + bool _resumeAfterScrub = false; + + int? get _videoId => widget.attachment.videoId; + String get _cacheName => 'videonote_$_videoId.mp4'; + int get _attachmentDurationMs => widget.attachment.duration ?? 0; + String? get _localPath => widget.attachment.localPath; + + String? get _posterUrl { + for (final candidate in [ + widget.attachment.thumbnail, + widget.attachment.baseUrl, + ]) { + if (candidate == null || candidate.isEmpty) continue; + if (candidate.startsWith('data:')) continue; + return candidate; + } + return null; + } + + bool get _ready { + final controller = _controller; + return controller != null && controller.value.isInitialized; + } + + @override + void initState() { + super.initState(); + _expand = AnimationController(vsync: this, duration: _expandDuration); + _preview = _previewBytes(widget.attachment.previewData); + final local = _localPath; + if (local != null) { + unawaited(_openLocalPreview(File(local))); + return; + } + if (VideoNotePreloader.autoLoads(widget.attachment.duration)) { + unawaited(_warmCache()); + } + } + + @override + void didUpdateWidget(VideoNoteBubble old) { + super.didUpdateWidget(old); + if (old.attachment.previewData != widget.attachment.previewData) { + _preview = _previewBytes(widget.attachment.previewData); + } + if (old.attachment.localPath != _localPath) _dropLocalPreview(); + } @override void dispose() { - _controller?.removeListener(_onTick); - _controller?.dispose(); + if (_playingNote == this) _playingNote = null; + _PreviewPool.unregister(this); + _expand.dispose(); + _ringProgress.dispose(); + _dropLocalPreview(); + final controller = _controller; + _controller = null; + if (controller != null) { + controller.removeListener(_onTick); + MediaPlayback.instance.releaseVideoNote(controller); + } super.dispose(); } + Future _openLocalPreview(File file) async { + final controller = VideoPlayerController.file(file); + try { + await controller.initialize(); + } catch (e) { + logger.w('VideoNoteBubble: локальное превью не открылось: $e'); + await controller.dispose(); + return; + } + if (!mounted || file.path != _localPath) { + await controller.dispose(); + return; + } + await controller.setVolume(0); + if (!mounted) { + await controller.dispose(); + return; + } + setState(() => _local = controller); + } + + void _dropLocalPreview() { + final local = _local; + if (local == null) return; + _local = null; + unawaited(local.dispose()); + } + + void _claimPlayback() { + final controller = _controller; + if (controller == null) return; + MediaPlayback.instance.activateVideoNote( + VideoNoteTrack( + cacheName: _cacheName, + chatId: widget.chatId, + messageId: widget.messageId, + senderId: widget.senderId, + isMe: widget.isMe, + time: widget.time, + controller: controller, + preview: _preview, + ), + ); + } + void _onTick() { - if (mounted) setState(() {}); + final controller = _controller; + if (controller == null || !mounted) return; + final value = controller.value; + + if (!_scrubbing) { + final total = value.duration.inMilliseconds; + _ringProgress.value = total > 0 + ? (value.position.inMilliseconds / total).clamp(0.0, 1.0) + : 0.0; + } + if (value.size != _frameSize || value.isPlaying != _playing) { + setState(() { + _frameSize = value.size; + _playing = value.isPlaying; + }); + } } static Uint8List? _previewBytes(String? data) { @@ -58,144 +217,564 @@ class _VideoNoteBubbleState extends State { } } + Future _warmCache() => _fetch(priority: false); + + Future _fetch({required bool priority}) { + final videoId = _videoId; + final token = widget.attachment.videoToken; + if (videoId == null || token == null) return Future.value(null); + return VideoNotePreloader.load( + _cacheName, + () => messagesModule.getVideoUrl( + messageId: widget.sourceMessageId ?? widget.messageId, + chatId: widget.sourceChatId ?? widget.chatId, + token: token, + videoId: videoId, + ), + priority: priority, + cancelled: priority ? null : () => !mounted, + ); + } + + Future _ensureController(File file) async { + if (!mounted) return null; + if (_controller != null) return _controller; + final live = MediaPlayback.instance.liveVideoNote(_cacheName); + if (live != null) { + _controller = live; + live.addListener(_onTick); + _PreviewPool.pin(this); + if (mounted) setState(() => _frameSize = live.value.size); + return live; + } + final running = _initializing; + if (running != null) { + await running; + return _controller; + } + + final controller = VideoPlayerController.file(file); + final future = controller.initialize(); + _initializing = future; + try { + await future; + } catch (e) { + logger.w('VideoNoteBubble: инициализация не удалась: $e'); + await controller.dispose(); + _initializing = null; + return null; + } + _initializing = null; + + if (!mounted) { + await controller.dispose(); + return null; + } + + _controller = controller; + MediaPlayback.instance.holdVideoNote(controller); + await controller.setLooping(true); + await controller.seekTo(Duration.zero); + controller.addListener(_onTick); + _PreviewPool.register(this); + if (mounted) setState(() => _frameSize = controller.value.size); + return controller; + } + + void _releasePreview() { + final controller = _controller; + if (controller == null) return; + if (MediaPlayback.instance.isActiveVideoNote(controller)) return; + _controller = null; + controller.removeListener(_onTick); + MediaPlayback.instance.releaseVideoNote(controller); + if (mounted) setState(() => _frameSize = Size.zero); + } + Future _toggle() async { - final existing = _controller; - if (existing != null) { - setState( - () => existing.value.isPlaying ? existing.pause() : existing.play(), - ); + if (_videoId == null) return; + if (_ready) { + if (_controller!.value.isPlaying) { + await _pause(); + } else { + await _play(); + } return; } if (_loading) return; - final a = widget.attachment; - final videoId = a.videoId; - final token = a.videoToken; - if (videoId == null || token == null) { - setState(() => _error = true); - return; - } - - setState(() => _loading = true); + setState(() { + _loading = true; + _error = false; + }); Haptics.tap(); + + final file = await _fetch(priority: true); + if (!mounted) return; + final controller = file == null ? null : await _ensureController(file); + if (!mounted) return; + + setState(() { + _loading = false; + _error = controller == null; + }); + if (controller != null) await _play(); + } + + Future _play() async { + final controller = _controller; + if (controller == null) return; + final other = _playingNote; + if (other != null && other != this) await other._pause(); + _playingNote = this; + _PreviewPool.pin(this); + _claimPlayback(); + await controller.play(); + _expand.forward(); + if (mounted) setState(() {}); + } + + Future _pause() async { + final controller = _controller; + if (controller == null) return; + await controller.pause(); + if (_playingNote == this) _playingNote = null; + _PreviewPool.register(this); + _expand.reverse(); + if (mounted) setState(() {}); + } + + void _seekToProgress(double progress) { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + final total = controller.value.duration; + if (total.inMilliseconds <= 0) return; + _pendingSeek = total * progress.clamp(0.0, 1.0); + if (!_seekInFlight) _drainSeeks(); + } + + NoteRingGeometry _geometry(double extent) => + NoteRingGeometry(extent: extent, knobRadius: _scrubbing ? 9 : 7); + + void _ringTap(Offset local, double extent) { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + Haptics.tap(); + _seekToProgress(_geometry(extent).progressAt(local)); + } + + Future _ringDragStart(Offset local, double extent) async { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + _resumeAfterScrub = controller.value.isPlaying; + if (_resumeAfterScrub) await controller.pause(); + if (!mounted) return; + + final geometry = _geometry(extent); + final target = geometry.progressAt(local); + Haptics.tap(); + _lastAngle = geometry.angleAt(local); + _ringProgress.value = target; + setState(() => _scrubbing = true); + _seekToProgress(target); + } + + void _ringDragUpdate(Offset local, double extent) { + final previous = _lastAngle; + if (!_scrubbing || previous == null) return; + final geometry = _geometry(extent); + final angle = geometry.angleAt(local); + _lastAngle = angle; + _ringProgress.value = geometry.advance( + _ringProgress.value, + NoteRingGeometry.angleDelta(previous, angle), + ); + _seekToProgress(_ringProgress.value); + } + + Future _ringDragEnd() async { + if (!_scrubbing) return; + setState(() { + _scrubbing = false; + _lastAngle = null; + }); + if (!_resumeAfterScrub) return; + _resumeAfterScrub = false; + await _controller?.play(); + if (mounted) setState(() {}); + } + + Future _drainSeeks() async { + _seekInFlight = true; try { - final cacheName = 'videonote_$videoId.mp4'; - var file = await MediaCache.existing(cacheName); - if (file == null) { - final url = await messagesModule.getVideoUrl( - messageId: widget.messageId, - chatId: widget.chatId, - token: token, - videoId: videoId, - ); - if (url == null) throw Exception('no_url'); - file = await MediaCache.getOrDownload(cacheName, url); - if (file == null) throw Exception('download'); + var target = _pendingSeek; + while (target != null) { + _pendingSeek = null; + await _controller?.seekTo(target); + target = _pendingSeek; } - if (!mounted) return; - final c = VideoPlayerController.file(file); - _controller = c; - await c.initialize(); - if (!mounted) { - c.dispose(); - return; - } - await c.setLooping(true); - c.addListener(_onTick); - c.play(); - setState(() => _loading = false); } catch (e) { - logger.w('VideoNoteBubble._toggle: $e'); - if (mounted) { - setState(() { - _loading = false; - _error = true; - }); - } + logger.w('VideoNoteBubble._drainSeeks: $e'); + } finally { + _seekInFlight = false; } } @override Widget build(BuildContext context) { - final a = widget.attachment; - final c = _controller; - final ready = c != null && c.value.isInitialized; - final playing = ready && c.value.isPlaying; - final preview = _previewBytes(a.previewData); + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : _baseSize * _expandedScale; + return AnimatedBuilder( + animation: _expand, + builder: (context, _) { + final t = Curves.easeOutCubic.transform(_expand.value); + final size = math.min( + _baseSize * (1 + (_expandedScale - 1) * t), + maxWidth, + ); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildCircle(size), + const SizedBox(height: 6), + SizedBox(width: size, child: _buildMetaRow()), + ], + ); + }, + ); + }, + ); + } - double progress = 0; - if (ready && c.value.duration.inMilliseconds > 0) { - progress = - c.value.position.inMilliseconds / c.value.duration.inMilliseconds; - } + Widget _buildCircle(double size) { + final ready = _ready; + final playing = ready && _playing && !_scrubbing; + final preview = _preview; + final local = _local; + final uploading = widget.uploadProgress; return GestureDetector( - onTap: _toggle, + onTap: uploading == null ? _toggle : null, child: SizedBox( - width: _size, - height: _size, + width: size, + height: size, child: Stack( alignment: Alignment.center, children: [ - ClipOval( - child: SizedBox( - width: _size, - height: _size, - child: ready - ? FittedBox( - fit: BoxFit.cover, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: c.value.size.width, - height: c.value.size.height, - child: VideoPlayer(c), - ), - ) - : preview != null - ? Image.memory( - preview, - fit: BoxFit.cover, - gaplessPlayback: true, - ) - : Container(color: widget.cs.surfaceContainerHighest), + RepaintBoundary( + child: ClipOval( + child: SizedBox( + width: size, + height: size, + child: AnimatedSwitcher( + duration: _swapDuration, + child: ready + ? _videoSurface( + _controller!, + const ValueKey('note-video'), + size, + ) + : local != null && local.value.isInitialized + ? _videoSurface( + local, + const ValueKey('note-local'), + size, + ) + : _buildPoster(preview), + ), + ), ), ), - if (ready) - SizedBox( - width: _size - 2, - height: _size - 2, - child: CircularProgressIndicator( - value: progress.clamp(0.0, 1.0), - strokeWidth: 3, - color: widget.cs.primary, - backgroundColor: Colors.white24, + if (uploading != null) ...[ + Positioned.fill( + child: ClipOval( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.35), + ), ), ), - if (!playing) - Container( - width: 52, - height: 52, - decoration: const BoxDecoration( - color: Colors.black45, - shape: BoxShape.circle, - ), - child: _loading - ? const Padding( - padding: EdgeInsets.all(14), - child: CircularProgressIndicator( - strokeWidth: 2, + UploadProgressRing( + progress: uploading, + color: Colors.white, + trackColor: Colors.white24, + ), + ] else ...[ + if (ready) _buildRing(size), + if (!playing) + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: _loading + ? const Padding( + padding: EdgeInsets.all(14), + child: SmallSpinner(size: 36, color: Colors.white), + ) + : Icon( + _error ? Symbols.error : Symbols.play_arrow, color: Colors.white, + size: 30, ), - ) - : Icon( - _error ? Symbols.error : Symbols.play_arrow, - color: Colors.white, - size: 30, - ), - ), + ), + ], ], ), ), ); } + + Widget _buildPoster(Uint8List? preview) { + final url = _posterUrl; + if (url == null) { + return _inlinePreview(preview, const ValueKey('note-preview')); + } + + final dpr = MediaQuery.devicePixelRatioOf(context); + return SizedBox.expand( + key: const ValueKey('note-poster'), + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + memCacheWidth: (_baseSize * dpr).round(), + fadeInDuration: _swapDuration, + placeholderFadeInDuration: Duration.zero, + placeholder: (_, _) => _inlinePreview(preview, null), + errorWidget: (_, _, _) => _inlinePreview(preview, null), + ), + ); + } + + Widget _inlinePreview(Uint8List? preview, Key? key) { + if (preview == null) { + return SizedBox.expand( + key: key, + child: ColoredBox(color: widget.cs.surfaceContainerHighest), + ); + } + return SizedBox.expand( + key: key, + child: Image.memory( + preview, + fit: BoxFit.cover, + gaplessPlayback: true, + filterQuality: FilterQuality.medium, + ), + ); + } + + Widget _videoSurface( + VideoPlayerController controller, + Key key, + double fallback, + ) { + final frame = videoNoteFrameSize(controller.value.size, fallback); + return SizedBox.expand( + key: key, + child: FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: frame.width, + height: frame.height, + child: VideoPlayer(controller), + ), + ), + ); + } + + Widget _buildRing(double size) { + return GestureDetector( + onTapUp: (details) => _ringTap(details.localPosition, size), + onPanStart: (details) => _ringDragStart(details.localPosition, size), + onPanUpdate: (details) => _ringDragUpdate(details.localPosition, size), + onPanEnd: (_) => _ringDragEnd(), + onPanCancel: _ringDragEnd, + child: CustomPaint( + size: Size(size, size), + painter: _NoteRingPainter( + geometry: _geometry(size), + progress: _ringProgress, + color: widget.cs.primary, + trackColor: Colors.white30, + ), + ), + ); + } + + Widget _buildMetaRow() { + final controller = _controller; + final ready = _ready; + final totalMs = ready + ? controller!.value.duration.inMilliseconds + : _attachmentDurationMs; + final showPosition = ready && (_playing || _scrubbing); + final style = TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ); + + return Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: showPosition + ? ValueListenableBuilder( + valueListenable: _ringProgress, + builder: (context, progress, _) => Text( + formatSecondsMmSs((progress * totalMs) ~/ 1000), + style: style, + ), + ) + : Text(formatSecondsMmSs((totalMs / 1000).round()), style: style), + ), + const Spacer(), + widget.meta, + ], + ); + } +} + +class NoteRingGeometry { + const NoteRingGeometry({required this.extent, required this.knobRadius}); + + static const double startAngle = -math.pi / 2; + static const double bandTolerance = 12; + static const double knobTolerance = 26; + static const double stroke = 3; + + final double extent; + final double knobRadius; + + double get radius => extent / 2 - knobRadius - 1; + + Offset get center => Offset(extent / 2, extent / 2); + + Offset knobCenter(double progress) { + final angle = startAngle + 2 * math.pi * progress.clamp(0.0, 1.0); + return center + Offset(math.cos(angle) * radius, math.sin(angle) * radius); + } + + double angleAt(Offset local) { + final vector = local - center; + return math.atan2(vector.dy, vector.dx); + } + + double progressAt(Offset local) { + var turns = (angleAt(local) - startAngle) / (2 * math.pi) % 1.0; + if (turns < 0) turns += 1.0; + return turns; + } + + static double angleDelta(double from, double to) { + var delta = to - from; + while (delta > math.pi) { + delta -= 2 * math.pi; + } + while (delta < -math.pi) { + delta += 2 * math.pi; + } + return delta; + } + + double advance(double progress, double delta) => + (progress + delta / (2 * math.pi)).clamp(0.0, 1.0); + + bool grabs(Offset position, double progress) { + if ((position - knobCenter(progress)).distance <= knobTolerance) { + return true; + } + return ((position - center).distance - radius).abs() <= bandTolerance; + } +} + +class _NoteRingPainter extends CustomPainter { + _NoteRingPainter({ + required this.geometry, + required this.progress, + required this.color, + required this.trackColor, + }) : super(repaint: progress); + + final NoteRingGeometry geometry; + final ValueListenable progress; + final Color color; + final Color trackColor; + + @override + void paint(Canvas canvas, Size size) { + final value = progress.value; + final center = geometry.center; + final radius = geometry.radius; + + canvas.drawCircle( + center, + radius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = NoteRingGeometry.stroke + ..color = trackColor, + ); + + final sweep = 2 * math.pi * value.clamp(0.0, 1.0); + if (sweep > 0) { + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + NoteRingGeometry.startAngle, + sweep, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = NoteRingGeometry.stroke + ..strokeCap = StrokeCap.round + ..color = color, + ); + } + + final knob = geometry.knobCenter(value); + final knobRadius = geometry.knobRadius; + canvas.drawCircle( + knob, + knobRadius, + Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2), + ); + canvas.drawCircle(knob, knobRadius, Paint()..color = Colors.white); + canvas.drawCircle(knob, knobRadius - 2.5, Paint()..color = color); + } + + @override + bool hitTest(Offset position) => geometry.grabs(position, progress.value); + + @override + bool shouldRepaint(_NoteRingPainter old) => + old.geometry.extent != geometry.extent || + old.geometry.knobRadius != geometry.knobRadius || + old.color != color || + old.trackColor != trackColor; +} + +class _PreviewPool { + static const int _maxIdle = 4; + static final List<_VideoNoteBubbleState> _idle = []; + + static void register(_VideoNoteBubbleState state) { + _idle + ..remove(state) + ..add(state); + while (_idle.length > _maxIdle) { + _idle.removeAt(0)._releasePreview(); + } + } + + static void pin(_VideoNoteBubbleState state) => _idle.remove(state); + + static void unregister(_VideoNoteBubbleState state) => _idle.remove(state); } diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart index db11091..d6d3125 100644 --- a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -1,18 +1,17 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:ogg_opus_player/ogg_opus_player.dart'; import 'package:komet/main.dart'; import '../../../../backend/modules/messages.dart'; import '../../../../core/config/app_colors.dart'; import '../../../../core/config/komet_settings.dart'; +import '../../../../core/media/media_playback.dart'; +import '../../../../core/media/voice_audio_controller.dart'; import '../../../../core/utils/format.dart'; import '../../../../core/utils/logger.dart'; -import '../../../../core/utils/media_cache.dart'; import '../../custom_notification.dart'; +import '../../small_spinner.dart'; class VoiceMessageBubble extends StatefulWidget { final int duration; @@ -27,8 +26,12 @@ class VoiceMessageBubble extends StatefulWidget { final String? waveData; final int chatId; final String messageId; + final int? sourceChatId; + final String? sourceMessageId; + final int senderId; final int? audioId; final String? preloadedText; + final ValueListenable>? uploadProgress; const VoiceMessageBubble({ super.key, @@ -44,24 +47,26 @@ class VoiceMessageBubble extends StatefulWidget { this.waveData, required this.chatId, required this.messageId, + this.sourceChatId, + this.sourceMessageId, + required this.senderId, this.audioId, this.preloadedText, + this.uploadProgress, }); @override State createState() => _VoiceMessageBubbleState(); } +const double _transcriptionMaxHeight = 132; + class _VoiceMessageBubbleState extends State { - bool _isPlaying = false; - final ValueNotifier _progress = ValueNotifier(0.0); bool _transcriptionVisible = false; String? _transcriptionText; bool _transcriptionLoading = false; - OggOpusPlayer? _player; - bool _loadingAudio = false; - Timer? _ticker; + late final VoiceAudioController _audio; late final List _amps = _parseWave(widget.waveData); static List _parseWave(String? data) { @@ -73,75 +78,82 @@ class _VoiceMessageBubbleState extends State { void initState() { super.initState(); _transcriptionText = widget.preloadedText; + _audio = MediaPlayback.instance.acquireVoice( + cacheName: _cacheName, + resolveUrl: () async => widget.url, + fallbackDuration: Duration(seconds: widget.duration), + ); + _audio.failure.addListener(_onFailure); + TranscriptionCache.listen(_sourceMessageId, _onTranscriptionPush); + _adoptCachedTranscription(); } @override void dispose() { - _ticker?.cancel(); - _player?.state.removeListener(_onPlayerState); - _player?.dispose(); - _progress.dispose(); + TranscriptionCache.unlisten(_sourceMessageId, _onTranscriptionPush); + _audio.failure.removeListener(_onFailure); + MediaPlayback.instance.releaseVoice(_audio); super.dispose(); } - Future _togglePlay() async { - if (_loadingAudio) return; - - if (_player != null) { - if (_isPlaying) { - _player!.pause(); - } else { - if (widget.duration > 0 && - _player!.currentPosition >= widget.duration - 0.05) { - _progress.value = 0; - } - _player!.play(); - } - return; - } - - final url = widget.url; - if (url.isEmpty) return; - - setState(() => _loadingAudio = true); - try { - final name = '${widget.audioId ?? widget.messageId}.ogg'; - final file = await MediaCache.getOrDownload(name, url); - if (!mounted) return; - if (file == null) { - showCustomNotification(context, 'Не удалось загрузить аудио'); - return; - } - final player = OggOpusPlayer(file.path); - _player = player; - player.state.addListener(_onPlayerState); - _ticker = Timer.periodic( - const Duration(milliseconds: 60), - (_) => _onTick(), - ); - player.play(); - } catch (e) { - logger.w('VoiceBubble._togglePlay: $e'); - if (mounted) showCustomNotification(context, 'Ошибка воспроизведения'); - } finally { - if (mounted) setState(() => _loadingAudio = false); - } + void _adoptCachedTranscription() { + final cached = TranscriptionCache.get(_sourceMessageId); + if (cached == null || cached.status != 1) return; + _transcriptionText = cached.text ?? 'не удалось распознать текст'; + _transcriptionVisible = TranscriptionCache.isExpanded(_sourceMessageId); } - void _onTick() { - final player = _player; - if (player == null || widget.duration <= 0) return; - final pos = player.currentPosition; - _progress.value = (pos / widget.duration).clamp(0.0, 1.0); - } - - void _onPlayerState() { - final state = _player?.state.value; + void _onTranscriptionPush() { if (!mounted) return; - final playing = state == PlayerState.playing; - if (playing != _isPlaying) setState(() => _isPlaying = playing); - if (state == PlayerState.ended) { - _progress.value = 1.0; + setState(() { + _transcriptionLoading = false; + _adoptCachedTranscription(); + }); + } + + void _showTranscription(String text) { + _transcriptionText = text; + _transcriptionVisible = true; + TranscriptionCache.setExpanded(_sourceMessageId, true); + } + + String get _cacheName => '${widget.audioId ?? _sourceMessageId}.ogg'; + + int get _sourceChatId => widget.sourceChatId ?? widget.chatId; + + String get _sourceMessageId => widget.sourceMessageId ?? widget.messageId; + + void _claimPlayback() { + MediaPlayback.instance.activateVoice( + VoiceTrack( + cacheName: _cacheName, + chatId: widget.chatId, + messageId: widget.messageId, + senderId: widget.senderId, + isMe: widget.isMe, + time: widget.time, + audio: _audio, + ), + ); + } + + bool get _uploading => widget.uploadProgress != null; + + void _toggle() { + if (_uploading) return; + _claimPlayback(); + _audio.toggle(); + } + + void _onFailure() { + if (!mounted) return; + switch (_audio.failure.value) { + case VoiceAudioFailure.none: + return; + case VoiceAudioFailure.download: + showCustomNotification(context, 'Не удалось загрузить аудио'); + case VoiceAudioFailure.playback: + showCustomNotification(context, 'Ошибка воспроизведения'); } } @@ -199,13 +211,120 @@ class _VoiceMessageBubbleState extends State { return Icon(icon, size: 14, color: color); } + Color get _accent => + widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary; + + Widget _buildPlayButton() { + final uploading = widget.uploadProgress; + return GestureDetector( + onTap: uploading == null ? _toggle : null, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: widget.isMe + ? widget.cs.onPrimaryContainer.withValues(alpha: 0.12) + : widget.cs.primaryContainer, + shape: BoxShape.circle, + ), + child: uploading != null + ? _buildUploadIndicator(uploading) + : AnimatedBuilder( + animation: Listenable.merge([ + _audio.downloaded, + _audio.downloadProgress, + _audio.playing, + ]), + builder: (context, _) { + final progress = _audio.downloadProgress.value; + if (progress != null) { + return Padding( + padding: const EdgeInsets.all(4), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: _accent, + backgroundColor: _accent.withValues(alpha: 0.2), + ), + ); + } + final IconData icon; + if (_audio.playing.value) { + icon = Symbols.pause; + } else if (_audio.downloaded.value) { + icon = Symbols.play_arrow; + } else { + icon = Symbols.arrow_downward; + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + transitionBuilder: (child, animation) => + ScaleTransition(scale: animation, child: child), + child: Icon( + icon, + key: ValueKey(icon), + color: _accent, + size: 18, + ), + ); + }, + ), + ), + ); + } + + Widget _buildUploadIndicator(ValueListenable> progress) { + return Padding( + padding: const EdgeInsets.all(4), + child: ValueListenableBuilder>( + valueListenable: progress, + builder: (context, values, _) { + final value = values.isEmpty + ? 0.0 + : values.reduce((a, b) => a + b) / values.length; + return TweenAnimationBuilder( + tween: Tween(end: value.clamp(0.0, 1.0)), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + builder: (context, shown, _) => CircularProgressIndicator( + strokeWidth: 2, + value: shown >= 1.0 ? null : shown, + color: _accent, + backgroundColor: _accent.withValues(alpha: 0.2), + ), + ); + }, + ), + ); + } + + Widget _buildTimeLabel() { + return AnimatedBuilder( + animation: Listenable.merge([ + _audio.position, + _audio.duration, + _audio.playing, + ]), + builder: (context, _) { + final elapsed = _audio.position.value; + final total = _audio.duration.value; + final seconds = elapsed > 0 ? elapsed.round() : total.round(); + return Text( + formatSecondsMmSs(seconds), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { - final waveInactiveColor = widget.isMe - ? widget.cs.onPrimaryContainer.withValues(alpha: 0.35) - : widget.cs.surfaceContainerHighest; + final waveInactiveColor = widget.textColor.withValues(alpha: 0.35); final waveActiveColor = widget.isMe - ? widget.cs.onPrimaryContainer.withValues(alpha: 0.7) + ? widget.cs.onPrimaryContainer : widget.cs.primary; return SizedBox( @@ -217,52 +336,16 @@ class _VoiceMessageBubbleState extends State { Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - GestureDetector( - onTap: _togglePlay, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: widget.isMe - ? widget.cs.onPrimaryContainer.withValues(alpha: 0.12) - : widget.cs.primaryContainer, - shape: BoxShape.circle, - ), - child: _loadingAudio - ? Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, - color: widget.isMe - ? widget.cs.onPrimaryContainer - : widget.cs.primary, - ), - ) - : Icon( - _isPlaying ? Symbols.pause : Symbols.play_arrow, - color: widget.isMe - ? widget.cs.onPrimaryContainer - : widget.cs.primary, - size: 18, - ), - ), - ), + _buildPlayButton(), const SizedBox(width: 10), Expanded( - child: SizedBox( - height: 26, - child: ValueListenableBuilder( - valueListenable: _progress, - builder: (context, progress, _) => CustomPaint( - size: Size.infinite, - painter: _WaveformPainter( - amps: _amps, - progress: progress, - active: waveActiveColor, - inactive: waveInactiveColor, - ), - ), - ), + child: _SeekableWaveform( + onClaim: _claimPlayback, + onToggle: _toggle, + audio: _audio, + amps: _amps, + active: waveActiveColor, + inactive: waveInactiveColor, ), ), const SizedBox(width: 8), @@ -273,13 +356,9 @@ class _VoiceMessageBubbleState extends State { height: 32, child: Center( child: _transcriptionLoading - ? SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: widget.textColor.withValues(alpha: 0.6), - ), + ? SmallSpinner( + size: 12, + color: widget.textColor.withValues(alpha: 0.6), ) : Text( 'Т', @@ -298,18 +377,7 @@ class _VoiceMessageBubbleState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 32, - child: Center( - child: Text( - formatSecondsMmSs(widget.duration), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.7), - fontSize: 11, - ), - ), - ), - ), + SizedBox(width: 32, child: Center(child: _buildTimeLabel())), const SizedBox(width: 10), Expanded( child: AnimatedSize( @@ -317,15 +385,21 @@ class _VoiceMessageBubbleState extends State { curve: Curves.easeOut, alignment: Alignment.topLeft, child: _transcriptionVisible - ? Text( - _transcriptionText ?? '', - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.8), - fontSize: 12, - height: 1.3, + ? ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: _transcriptionMaxHeight, + ), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Text( + _transcriptionText ?? '', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + height: 1.3, + ), + ), ), - maxLines: 10, - overflow: TextOverflow.ellipsis, ) : const SizedBox.shrink(), ), @@ -396,16 +470,16 @@ class _VoiceMessageBubbleState extends State { if (_transcriptionVisible && _transcriptionText != null) { setState(() { _transcriptionVisible = false; + TranscriptionCache.setExpanded(_sourceMessageId, false); }); return; } - if (TranscriptionCache.has(widget.messageId)) { - final cached = TranscriptionCache.get(widget.messageId)!; - setState(() { - _transcriptionText = cached.text ?? 'не удалось распознать текст'; - _transcriptionVisible = true; - }); + if (TranscriptionCache.has(_sourceMessageId)) { + final cached = TranscriptionCache.get(_sourceMessageId)!; + setState( + () => _showTranscription(cached.text ?? 'не удалось распознать текст'), + ); return; } @@ -415,24 +489,24 @@ class _VoiceMessageBubbleState extends State { try { final result = await messagesModule.requestTranscription( - widget.chatId, - int.tryParse(widget.messageId) ?? 0, + _sourceChatId, + int.tryParse(_sourceMessageId) ?? 0, widget.audioId!, ); - TranscriptionCache.put(widget.messageId, result); + TranscriptionCache.put(_sourceMessageId, result); if (!mounted) return; setState(() { _transcriptionLoading = false; if (result.status == 1) { - _transcriptionText = (result.text == null || result.text!.isEmpty) - ? 'не удалось распознать текст' - : result.text; - _transcriptionVisible = true; + _showTranscription( + (result.text == null || result.text!.isEmpty) + ? 'не удалось распознать текст' + : result.text!, + ); } else if (result.status == 0) { - _transcriptionText = 'транскрибация...'; - _transcriptionVisible = true; + _showTranscription('транскрибация...'); } }); } catch (e) { @@ -440,24 +514,137 @@ class _VoiceMessageBubbleState extends State { if (!mounted) return; setState(() { _transcriptionLoading = false; - _transcriptionText = 'ошибка транскрибации'; - _transcriptionVisible = true; + _showTranscription('ошибка транскрибации'); }); } } } +class _SeekableWaveform extends StatefulWidget { + final VoiceAudioController audio; + final List amps; + final Color active; + final Color inactive; + final VoidCallback onClaim; + final VoidCallback onToggle; + + const _SeekableWaveform({ + required this.audio, + required this.amps, + required this.active, + required this.inactive, + required this.onClaim, + required this.onToggle, + }); + + @override + State<_SeekableWaveform> createState() => _SeekableWaveformState(); +} + +class _SeekableWaveformState extends State<_SeekableWaveform> { + static const double _hitHeight = 32; + static const double _waveHeight = 26; + + double _width = 0; + + VoiceAudioController get _audio => widget.audio; + + void _claimPlayback() => widget.onClaim(); + + void _toggle() => widget.onToggle(); + + double _secondsAt(double dx) { + final total = _audio.duration.value; + if (_width <= 0 || total <= 0) return 0; + return (dx / _width).clamp(0.0, 1.0) * total; + } + + void _onTapUp(TapUpDetails details) { + if (!_audio.downloaded.value) { + _toggle(); + return; + } + _claimPlayback(); + _audio.seekTo(_secondsAt(details.localPosition.dx)); + } + + void _onDragStart(DragStartDetails details) { + if (!_audio.downloaded.value) return; + _claimPlayback(); + _audio.scrubStart(); + _audio.scrubTo(_secondsAt(details.localPosition.dx)); + } + + void _onDragUpdate(DragUpdateDetails details) { + if (!_audio.scrubbing) return; + _audio.scrubTo(_secondsAt(details.localPosition.dx)); + } + + void _onDragEnd(DragEndDetails details) => _audio.scrubEnd(); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + _width = constraints.maxWidth; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: _onTapUp, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + onHorizontalDragCancel: _audio.scrubEnd, + child: SizedBox( + height: _hitHeight, + child: Center( + child: SizedBox( + height: _waveHeight, + child: AnimatedBuilder( + animation: Listenable.merge([ + _audio.position, + _audio.duration, + _audio.playing, + _audio.downloaded, + ]), + builder: (context, _) { + final total = _audio.duration.value; + final progress = total > 0 + ? (_audio.position.value / total).clamp(0.0, 1.0) + : 0.0; + return CustomPaint( + size: Size.infinite, + painter: _WaveformPainter( + amps: widget.amps, + progress: progress, + active: widget.active, + inactive: widget.inactive, + knob: _audio.downloaded.value && progress > 0, + ), + ); + }, + ), + ), + ), + ), + ); + }, + ); + } +} + class _WaveformPainter extends CustomPainter { final List amps; final double progress; final Color active; final Color inactive; + final bool knob; const _WaveformPainter({ required this.amps, required this.progress, required this.active, required this.inactive, + this.knob = false, }); @override @@ -480,6 +667,7 @@ class _WaveformPainter extends CustomPainter { track..color = active, ); } + _paintKnob(canvas, size, center); return; } @@ -504,6 +692,23 @@ class _WaveformPainter extends CustomPainter { paint, ); } + + _paintKnob(canvas, size, center); + } + + void _paintKnob(Canvas canvas, Size size, double center) { + if (!knob) return; + final x = (size.width * progress.clamp(0.0, 1.0)).clamp( + 1.5, + size.width - 1.5, + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x - 1.5, 0, 3, size.height), + const Radius.circular(1.5), + ), + Paint()..color = active, + ); } @override @@ -511,5 +716,6 @@ class _WaveformPainter extends CustomPainter { old.progress != progress || old.active != active || old.inactive != inactive || + old.knob != knob || !identical(old.amps, amps); } diff --git a/lib/frontend/widgets/attachment/contact_picker_page.dart b/lib/frontend/widgets/attachment/contact_picker_page.dart new file mode 100644 index 0000000..94d55a0 --- /dev/null +++ b/lib/frontend/widgets/attachment/contact_picker_page.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/core/config/debug_test.dart'; +import 'package:komet/core/contacts/device_contacts_service.dart'; +import 'package:komet/core/storage/app_database.dart'; +import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/frontend/widgets/small_spinner.dart'; +import 'package:komet/frontend/widgets/springy_tap.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +class ContactPickerPage extends StatefulWidget { + final double bottomReserve; + final ValueChanged onPick; + + const ContactPickerPage({ + super.key, + required this.bottomReserve, + required this.onPick, + }); + + @override + State createState() => _ContactPickerPageState(); +} + +class _ContactPickerPageState extends State { + final TextEditingController _queryCtrl = TextEditingController(); + + List _contacts = const []; + String _query = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _queryCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + final loaded = await _fetch(); + if (!mounted) return; + setState(() { + _contacts = loaded; + _loading = false; + }); + } + + Future> _fetch() async { + if (DebugTest.enabled) { + return ContactsModule.debugContacts() + ..sort((a, b) => a.firstName.compareTo(b.firstName)); + } + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null) return const []; + final contacts = await ContactsModule.getContacts(profile.id); + contacts.sort((a, b) => _displayName(a).compareTo(_displayName(b))); + return contacts; + } + + String _displayName(CachedContact contact) { + final book = DeviceContactsService.nameForPhone(contact.phone); + if (book != null && book.isNotEmpty) return book; + final last = contact.lastName; + final full = (last != null && last.isNotEmpty) + ? '${contact.firstName} $last' + : contact.firstName; + final trimmed = full.trim(); + return trimmed.isEmpty ? '+${contact.phone}' : trimmed; + } + + List get _visible { + final query = _query.trim().toLowerCase(); + if (query.isEmpty) return _contacts; + return _contacts + .where( + (c) => + _displayName(c).toLowerCase().contains(query) || + c.phone.toString().contains(query), + ) + .toList(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + + return Padding( + padding: EdgeInsets.only(bottom: widget.bottomReserve), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: TextField( + controller: _queryCtrl, + onChanged: (value) => setState(() => _query = value), + style: TextStyle(color: cs.onSurface, fontSize: 15), + decoration: InputDecoration( + hintText: l10n.attachSheetContactSearchHint, + hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + prefixIcon: Icon( + Symbols.search, + color: cs.onSurfaceVariant, + size: 20, + ), + isDense: true, + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + ), + ), + Expanded(child: _buildBody(cs, l10n)), + ], + ), + ); + } + + Widget _buildBody(ColorScheme cs, AppLocalizations l10n) { + if (_loading) { + return Center(child: SmallSpinner(size: 36, color: cs.primary)); + } + final visible = _visible; + if (visible.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.person_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text( + _contacts.isEmpty + ? l10n.attachSheetNoContacts + : l10n.attachSheetNoContactsFound, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), + ], + ), + ), + ); + } + + return ListView.builder( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.only(bottom: 8), + itemCount: visible.length, + itemBuilder: (context, index) { + final contact = visible[index]; + return _buildTile(cs, contact); + }, + ); + } + + Widget _buildTile(ColorScheme cs, CachedContact contact) { + final name = _displayName(contact); + return SpringyTap( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.of(context).pop(); + widget.onPick(contact); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + KometAvatar(name: name, imageUrl: contact.baseUrl, size: 44), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '+${contact.phone}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/editor_common.dart b/lib/frontend/widgets/attachment/editor_common.dart new file mode 100644 index 0000000..6081cb7 --- /dev/null +++ b/lib/frontend/widgets/attachment/editor_common.dart @@ -0,0 +1,2247 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_colors.dart'; +import '../../../core/config/app_shape.dart'; +import '../../../l10n/app_localizations.dart'; +import '../custom_notification.dart'; +import '../small_spinner.dart'; +import 'photo_hero.dart'; + +const Color kEditorPanel = Color(0xFF0A0A0A); +const Color kEditorDrawPanel = Color(0xFF101010); +const Color kEditorBar = Color(0xFF1E1E1E); + +const List kPenWheel = [ + Color(0xFFFF3B30), + Color(0xFFFFCC00), + Color(0xFF34C759), + Color(0xFF00C7BE), + Color(0xFF2F8FFF), + Color(0xFFAF52DE), + Color(0xFFFF3B30), +]; + +class CropState { + final int quarterTurns; + final bool flipH; + final double straightenDeg; + final Rect cropNorm; + + const CropState({ + required this.quarterTurns, + required this.flipH, + required this.straightenDeg, + required this.cropNorm, + }); + + bool sameAs(CropState o) => + quarterTurns == o.quarterTurns && + flipH == o.flipH && + (straightenDeg - o.straightenDeg).abs() < 0.05 && + cropNorm == o.cropNorm; +} + +class CropGeometry { + final Size source; + final int quarterTurns; + final bool flipH; + final double straightenDeg; + + const CropGeometry({ + required this.source, + this.quarterTurns = 0, + this.flipH = false, + this.straightenDeg = 0, + }); + + double get phi => straightenDeg * math.pi / 180 - quarterTurns * math.pi / 2; + + Size get orientedSize => + quarterTurns.isOdd ? Size(source.height, source.width) : source; + + Size get rotatedSize { + final c = math.cos(phi).abs(); + final s = math.sin(phi).abs(); + return Size( + source.width * c + source.height * s, + source.width * s + source.height * c, + ); + } + + double baseScale(Size vp) { + final o = orientedSize; + if (o.isEmpty || vp.isEmpty) return 1; + const margin = 0.9; + return math.min(vp.width / o.width, vp.height / o.height) * margin; + } + + Rect fittedRect(Size vp) { + final o = orientedSize; + final base = baseScale(vp); + return Rect.fromCenter( + center: Offset(vp.width / 2, vp.height / 2), + width: o.width * base, + height: o.height * base, + ); + } + + double scaleFor(Size vp, Rect crop) { + final base = baseScale(vp); + final center = Offset(vp.width / 2, vp.height / 2); + final c = math.cos(-phi); + final s = math.sin(-phi); + var maxS = 0.0; + for (final corner in [ + crop.topLeft, + crop.topRight, + crop.bottomLeft, + crop.bottomRight, + ]) { + final rx = corner.dx - center.dx; + final ry = corner.dy - center.dy; + final lx = rx * c - ry * s; + final ly = rx * s + ry * c; + maxS = math.max( + maxS, + math.max(lx.abs() / (source.width / 2), ly.abs() / (source.height / 2)), + ); + } + return math.max(base, maxS); + } + + Matrix4 viewportMatrix(Size vp, Rect crop) { + final scale = scaleFor(vp, crop); + return Matrix4.identity() + ..translateByDouble(vp.width / 2, vp.height / 2, 0, 1) + ..multiply(flipH ? Matrix4.diagonal3Values(-1, 1, 1) : Matrix4.identity()) + ..rotateZ(phi) + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(-source.width / 2, -source.height / 2, 0, 1); + } + + Rect cropInRotated(Size vp, Rect crop) { + final scale = scaleFor(vp, crop); + final rotated = rotatedSize; + if (scale <= 0 || rotated.isEmpty) return const Rect.fromLTRB(0, 0, 1, 1); + final center = Offset(vp.width / 2, vp.height / 2); + final left = rotated.width / 2 + (crop.left - center.dx) / scale; + final top = rotated.height / 2 + (crop.top - center.dy) / scale; + final right = rotated.width / 2 + (crop.right - center.dx) / scale; + final bottom = rotated.height / 2 + (crop.bottom - center.dy) / scale; + return Rect.fromLTRB( + (left / rotated.width).clamp(0.0, 1.0), + (top / rotated.height).clamp(0.0, 1.0), + (right / rotated.width).clamp(0.0, 1.0), + (bottom / rotated.height).clamp(0.0, 1.0), + ); + } +} + +class CropView { + final double scale; + final Offset focus; + + const CropView({required this.scale, required this.focus}); + + static const double margin = 0.9; + + static CropView fit(Rect crop, Size vp) { + if (crop.isEmpty || vp.isEmpty) { + return CropView(scale: 1, focus: crop.center); + } + return CropView( + scale: math.min( + vp.width * margin / crop.width, + vp.height * margin / crop.height, + ), + focus: crop.center, + ); + } + + static CropView lerp(CropView a, CropView b, double t) => CropView( + scale: a.scale + (b.scale - a.scale) * t, + focus: Offset.lerp(a.focus, b.focus, t)!, + ); + + Matrix4 matrix(Size vp) => Matrix4.identity() + ..translateByDouble(vp.width / 2, vp.height / 2, 0, 1) + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(-focus.dx, -focus.dy, 0, 1); + + Offset toDisplay(Offset logical, Size vp) => + Offset(vp.width / 2, vp.height / 2) + (logical - focus) * scale; + + Offset toLogical(Offset display, Size vp) => + focus + (display - Offset(vp.width / 2, vp.height / 2)) / scale; + + Rect rect(Rect logical, Size vp) => Rect.fromPoints( + toDisplay(logical.topLeft, vp), + toDisplay(logical.bottomRight, vp), + ); +} + +class CropWorkspace extends StatefulWidget { + final Size imageSize; + final Widget Function(BuildContext context, Matrix4 matrix) imageBuilder; + final CropState? initialState; + final Future Function( + CropState state, + Size viewport, + bool changed, + bool identity, + ) + onApply; + + const CropWorkspace({ + super.key, + required this.imageSize, + required this.imageBuilder, + required this.onApply, + this.initialState, + }); + + @override + State createState() => _CropWorkspaceState(); +} + +class _CropWorkspaceState extends State + with SingleTickerProviderStateMixin { + int _quarterTurns = 0; + bool _flipH = false; + double _straightenDeg = 0; + Rect? _crop; + Size _viewport = Size.zero; + bool _stateApplied = false; + bool _busy = false; + int _handle = -1; + + CropView _view = const CropView(scale: 1, focus: Offset.zero); + CropView? _viewFrom; + CropView? _viewTo; + + late final AnimationController _zoom = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + ); + late final Animation _curve = CurvedAnimation( + parent: _zoom, + curve: Curves.easeOutCubic, + ); + final ValueNotifier _rev = ValueNotifier(0); + + @override + void initState() { + super.initState(); + final initial = widget.initialState; + if (initial != null) { + _quarterTurns = initial.quarterTurns; + _flipH = initial.flipH; + _straightenDeg = initial.straightenDeg; + } + _zoom.addStatusListener((status) { + if (status != AnimationStatus.completed) return; + final target = _viewTo; + if (target != null) _view = target; + _viewFrom = null; + _viewTo = null; + }); + } + + @override + void dispose() { + _zoom.dispose(); + _rev.dispose(); + super.dispose(); + } + + CropGeometry get _geometry => CropGeometry( + source: widget.imageSize, + quarterTurns: _quarterTurns, + flipH: _flipH, + straightenDeg: _straightenDeg, + ); + + CropView get _liveView { + final from = _viewFrom; + final to = _viewTo; + if (from == null || to == null) return _view; + return CropView.lerp(from, to, _curve.value); + } + + void _setCrop(Rect r) { + _crop = r; + _rev.value++; + } + + void _animateTo(CropView target) { + _viewFrom = _liveView; + _viewTo = target; + _zoom.forward(from: 0); + } + + void _ensureCrop(Size vp) { + if (_crop != null && _viewport == vp) return; + _viewport = vp; + final initial = widget.initialState; + if (initial != null && !_stateApplied) { + _stateApplied = true; + _crop = Rect.fromLTRB( + initial.cropNorm.left * vp.width, + initial.cropNorm.top * vp.height, + initial.cropNorm.right * vp.width, + initial.cropNorm.bottom * vp.height, + ); + } else { + _crop = _geometry.fittedRect(vp); + } + _view = CropView.fit(_crop!, vp); + _viewFrom = null; + _viewTo = null; + } + + void _refit() { + final crop = _crop; + if (crop == null || _viewport == Size.zero) return; + _animateTo(CropView.fit(crop, _viewport)); + } + + void _reset() { + setState(() { + _quarterTurns = 0; + _flipH = false; + _straightenDeg = 0; + _crop = _geometry.fittedRect(_viewport); + }); + _refit(); + } + + void _rotate90() { + setState(() { + _quarterTurns = (_quarterTurns + 1) % 4; + _straightenDeg = 0; + _crop = _geometry.fittedRect(_viewport); + }); + _refit(); + } + + void _flip() => setState(() => _flipH = !_flipH); + + bool get _isFullCrop { + final c = _crop; + if (c == null) return true; + final f = _geometry.fittedRect(_viewport); + return (c.left - f.left).abs() < 1 && + (c.top - f.top).abs() < 1 && + (c.right - f.right).abs() < 1 && + (c.bottom - f.bottom).abs() < 1; + } + + CropState _currentState(Size vp, Rect crop) => CropState( + quarterTurns: _quarterTurns, + flipH: _flipH, + straightenDeg: _straightenDeg, + cropNorm: Rect.fromLTRB( + crop.left / vp.width, + crop.top / vp.height, + crop.right / vp.width, + crop.bottom / vp.height, + ), + ); + + Future _done() async { + if (_busy) return; + final crop = _crop; + final vp = _viewport; + if (crop == null || vp == Size.zero) { + Navigator.of(context).pop(); + return; + } + final state = _currentState(vp, crop); + final initial = widget.initialState; + final identity = + _quarterTurns == 0 && !_flipH && _straightenDeg == 0 && _isFullCrop; + final changed = initial != null ? !state.sameAs(initial) : !identity; + setState(() => _busy = true); + final result = await widget.onApply(state, vp, changed, identity); + if (!mounted) return; + if (result == null && changed) { + setState(() => _busy = false); + showCustomNotification( + context, + AppLocalizations.of(context)!.photoEditorApplyFailed, + ); + return; + } + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + Expanded(child: _buildViewport()), + _buildTools(), + _buildActions(), + ], + ), + if (_busy) const BusyOverlay(), + ], + ), + ), + ); + } + + Widget _buildViewport() { + return LayoutBuilder( + builder: (context, constraints) { + final vp = constraints.biggest; + _ensureCrop(vp); + return Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (d) => _onPanStart(d.localPosition, vp), + onPanUpdate: (d) => _onPanUpdate(d.delta, vp), + onPanEnd: (_) => _onPanEnd(), + onPanCancel: _onPanEnd, + child: PhotoHeroFade( + child: AnimatedBuilder( + animation: Listenable.merge([_rev, _curve]), + builder: (context, _) { + final view = _liveView; + final crop = _crop!; + final matrix = view.matrix(vp) + ..multiply(_geometry.viewportMatrix(vp, crop)); + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + widget.imageBuilder(context, matrix), + CustomPaint( + painter: CropChromePainter(view.rect(crop, vp)), + ), + ], + ), + ); + }, + ), + ), + ), + IgnorePointer( + child: Center( + child: FractionallySizedBox( + widthFactor: CropView.margin, + heightFactor: CropView.margin, + child: const PhotoHeroAnchor(child: SizedBox.expand()), + ), + ), + ), + ], + ); + }, + ); + } + + void _onPanStart(Offset pos, Size vp) { + final crop = _crop; + if (crop == null) return; + _handle = hitCropHandle(pos, _liveView.rect(crop, vp)); + } + + void _onPanUpdate(Offset delta, Size vp) { + final crop = _crop; + if (crop == null || _handle < 0) return; + final view = _liveView; + _setCrop( + moveCropHandle( + crop, + _handle, + delta / view.scale, + _geometry.fittedRect(vp), + ), + ); + } + + void _onPanEnd() { + if (_handle < 0) return; + _handle = -1; + _refit(); + } + + Widget _buildTools() { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + IconButton( + onPressed: _flip, + icon: Icon( + Symbols.flip, + color: _flipH ? MediaAccent.of(context) : Colors.white, + ), + tooltip: l10n.photoEditorFlipTooltip, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _rev, + builder: (context, _, _) => StraightenRuler( + value: _straightenDeg, + onChanged: (v) { + _straightenDeg = v; + _rev.value++; + }, + ), + ), + ), + IconButton( + onPressed: _rotate90, + icon: const Icon( + Symbols.rotate_90_degrees_ccw, + color: Colors.white, + ), + tooltip: l10n.photoEditorRotateTooltip, + ), + ], + ), + ); + } + + Widget _buildActions() { + final l10n = AppLocalizations.of(context)!; + return Container( + color: kEditorPanel, + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + l10n.photoEditorCancel, + style: const TextStyle(color: Colors.white, fontSize: 15), + ), + ), + TextButton( + onPressed: _reset, + child: Text( + l10n.photoEditorReset, + style: const TextStyle(color: Colors.white, fontSize: 15), + ), + ), + TextButton( + onPressed: _busy ? null : _done, + child: Text( + l10n.photoEditorDone, + style: TextStyle( + color: _busy ? Colors.white38 : MediaAccent.of(context), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class CropChromePainter extends CustomPainter { + final Rect crop; + + CropChromePainter(this.crop); + + @override + void paint(Canvas canvas, Size size) => paintCropChrome(canvas, size, crop); + + @override + bool shouldRepaint(covariant CropChromePainter old) => old.crop != crop; +} + +class MatrixImagePainter extends CustomPainter { + final ui.Image image; + final Matrix4 matrix; + + MatrixImagePainter(this.image, this.matrix); + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.transform(matrix.storage); + canvas.drawImage( + image, + Offset.zero, + Paint()..filterQuality = FilterQuality.medium, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant MatrixImagePainter old) => + old.matrix != matrix || old.image != image; +} + +class CropPainter extends CustomPainter { + final ui.Image image; + final Matrix4 matrix; + final Rect crop; + + CropPainter({required this.image, required this.matrix, required this.crop}); + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.transform(matrix.storage); + canvas.drawImage( + image, + Offset.zero, + Paint()..filterQuality = FilterQuality.medium, + ); + canvas.restore(); + paintCropChrome(canvas, size, crop); + } + + @override + bool shouldRepaint(covariant CropPainter old) => + old.matrix != matrix || old.crop != crop || old.image != image; +} + +void paintCropChrome(Canvas canvas, Size size, Rect crop) { + canvas.drawPath( + Path.combine( + PathOperation.difference, + Path()..addRect(Offset.zero & size), + Path()..addRect(crop), + ), + Paint()..color = Colors.black.withValues(alpha: 0.55), + ); + + final grid = Paint() + ..color = Colors.white.withValues(alpha: 0.4) + ..strokeWidth = 0.7; + for (var i = 1; i < 3; i++) { + final x = crop.left + crop.width * i / 3; + final y = crop.top + crop.height * i / 3; + canvas.drawLine(Offset(x, crop.top), Offset(x, crop.bottom), grid); + canvas.drawLine(Offset(crop.left, y), Offset(crop.right, y), grid); + } + + final border = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + canvas.drawRect(crop, border); + + final bracket = Paint() + ..color = Colors.white + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + const len = 20.0; + void corner(Offset o, double dx, double dy) { + canvas.drawLine(o, o.translate(dx, 0), bracket); + canvas.drawLine(o, o.translate(0, dy), bracket); + } + + corner(crop.topLeft, len, len); + corner(crop.topRight, -len, len); + corner(crop.bottomLeft, len, -len); + corner(crop.bottomRight, -len, -len); +} + +int hitCropHandle(Offset pt, Rect c) { + const r = 34.0; + final corners = [c.topLeft, c.topRight, c.bottomRight, c.bottomLeft]; + for (var i = 0; i < 4; i++) { + if ((pt - corners[i]).distance < r) return i; + } + final insideV = pt.dy > c.top - r && pt.dy < c.bottom + r; + final insideH = pt.dx > c.left - r && pt.dx < c.right + r; + if ((pt.dx - c.left).abs() < r && insideV) return 4; + if ((pt.dx - c.right).abs() < r && insideV) return 5; + if ((pt.dy - c.top).abs() < r && insideH) return 6; + if ((pt.dy - c.bottom).abs() < r && insideH) return 7; + if (c.contains(pt)) return 8; + return -1; +} + +Rect moveCropHandle(Rect c, int handle, Offset delta, Rect bounds) { + const minSize = 64.0; + if (handle == 8) { + var nl = c.left + delta.dx; + var nt = c.top + delta.dy; + var nr = c.right + delta.dx; + var nb = c.bottom + delta.dy; + if (nl < bounds.left) { + nr += bounds.left - nl; + nl = bounds.left; + } + if (nt < bounds.top) { + nb += bounds.top - nt; + nt = bounds.top; + } + if (nr > bounds.right) { + nl -= nr - bounds.right; + nr = bounds.right; + } + if (nb > bounds.bottom) { + nt -= nb - bounds.bottom; + nb = bounds.bottom; + } + return Rect.fromLTRB(nl, nt, nr, nb); + } + + var l = c.left; + var t = c.top; + var r = c.right; + var bo = c.bottom; + switch (handle) { + case 0: + l += delta.dx; + t += delta.dy; + case 1: + r += delta.dx; + t += delta.dy; + case 2: + r += delta.dx; + bo += delta.dy; + case 3: + l += delta.dx; + bo += delta.dy; + case 4: + l += delta.dx; + case 5: + r += delta.dx; + case 6: + t += delta.dy; + case 7: + bo += delta.dy; + } + l = l.clamp(bounds.left, math.max(bounds.left, r - minSize)); + t = t.clamp(bounds.top, math.max(bounds.top, bo - minSize)); + r = r.clamp(math.min(bounds.right, l + minSize), bounds.right); + bo = bo.clamp(math.min(bounds.bottom, t + minSize), bounds.bottom); + return Rect.fromLTRB(l, t, r, bo); +} + +class StraightenRuler extends StatelessWidget { + final double value; + final ValueChanged onChanged; + + const StraightenRuler({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (d) { + onChanged((value - d.delta.dx * 0.22).clamp(-45.0, 45.0)); + }, + onDoubleTap: () => onChanged(0), + child: SizedBox( + height: 56, + child: CustomPaint( + painter: _RulerPainter(value, MediaAccent.of(context)), + ), + ), + ); + } +} + +class _RulerPainter extends CustomPainter { + final double value; + final Color accent; + + _RulerPainter(this.value, this.accent); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + const pxPerDeg = 6.0; + final baseY = size.height - 6; + + final tick = Paint()..strokeWidth = 1; + for (var deg = -60; deg <= 60; deg++) { + final x = cx + (deg - value) * pxPerDeg; + if (x < 0 || x > size.width) continue; + final major = deg % 5 == 0; + tick.color = Colors.white.withValues(alpha: major ? 0.85 : 0.4); + final h = major ? 14.0 : 8.0; + canvas.drawLine(Offset(x, baseY - h), Offset(x, baseY), tick); + } + + final tp = TextPainter( + text: TextSpan( + text: '${value.toStringAsFixed(1).replaceAll('.', ',')}°', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontStyle: FontStyle.italic, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(cx - tp.width / 2, 0)); + + canvas.drawLine( + Offset(cx, baseY - 18), + Offset(cx, baseY + 2), + Paint() + ..color = accent + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(covariant _RulerPainter old) => + old.value != value || old.accent != accent; +} + +enum DrawTool { pen, marker, neon, eraser } + +enum ShapeKind { circle, rectangle, star, cloud, arrow } + +enum EditTab { draw, stickers, text } + +sealed class EditMark {} + +class StrokeMark extends EditMark { + final List points; + final Color color; + final double width; + final DrawTool tool; + + StrokeMark({ + required this.points, + required this.color, + required this.width, + required this.tool, + }); +} + +class ShapeMark extends EditMark { + final ShapeKind kind; + final Offset start; + final Offset end; + final Color color; + final double width; + + ShapeMark({ + required this.kind, + required this.start, + required this.end, + required this.color, + required this.width, + }); +} + +class TextMark extends EditMark { + String text; + Offset position; + Color color; + double fontSize; + double rotation; + + TextMark({ + required this.text, + required this.position, + required this.color, + required this.fontSize, + this.rotation = 0, + }); +} + +class MarkupEditor extends StatefulWidget { + final Widget background; + final double aspectRatio; + final List initialMarks; + final Future Function(List marks, Size canvas) onApply; + + const MarkupEditor({ + super.key, + required this.background, + required this.aspectRatio, + required this.onApply, + this.initialMarks = const [], + }); + + @override + State createState() => _MarkupEditorState(); +} + +class _MarkupEditorState extends State { + final GlobalKey _boundaryKey = GlobalKey(); + final ValueNotifier _canvasRev = ValueNotifier(0); + late final List _marks = [...widget.initialMarks]; + StrokeMark? _liveStroke; + ShapeMark? _liveShape; + TextMark? _draggingText; + + DrawTool _tool = DrawTool.pen; + Color _color = Colors.white; + double _width = 8; + TextMark? _selectedText; + bool _resizingText = false; + double _resizeBaseSize = 0; + double _resizeBaseDist = 1; + double _resizeBaseRotation = 0; + double _resizeBaseAngle = 0; + ShapeKind? _shapeMode; + EditTab _tab = EditTab.draw; + bool _paletteOpen = false; + bool _shapesOpen = false; + bool _baking = false; + + @override + void dispose() { + _canvasRev.dispose(); + super.dispose(); + } + + void _bumpCanvas() => _canvasRev.value++; + + void _undo() { + if (_marks.isEmpty) return; + if (identical(_marks.last, _selectedText)) _selectedText = null; + setState(() => _marks.removeLast()); + } + + void _clearAll() { + if (_marks.isEmpty) return; + _selectedText = null; + setState(_marks.clear); + } + + void _onPanStart(Offset pos) { + if (_tab == EditTab.text) { + final sel = _selectedText; + if (sel != null && _nearHandle(sel, pos)) { + final v = pos - sel.position; + _resizingText = true; + _resizeBaseSize = sel.fontSize; + _resizeBaseDist = math.max(8, v.distance); + _resizeBaseRotation = sel.rotation; + _resizeBaseAngle = math.atan2(v.dy, v.dx); + return; + } + final hit = _hitText(pos); + _draggingText = hit; + if (hit != null && !identical(hit, _selectedText)) { + _selectedText = hit; + _bumpCanvas(); + } + return; + } + final shape = _shapeMode; + if (shape != null) { + _liveShape = ShapeMark( + kind: shape, + start: pos, + end: pos, + color: _color, + width: _width, + ); + } else { + _liveStroke = StrokeMark( + points: [pos], + color: _color, + width: _width, + tool: _tool, + ); + } + _bumpCanvas(); + } + + void _onPanUpdate(Offset pos) { + if (_tab == EditTab.text) { + if (_resizingText) { + final sel = _selectedText; + if (sel != null) { + final v = pos - sel.position; + final angle = math.atan2(v.dy, v.dx); + sel.fontSize = (_resizeBaseSize * v.distance / _resizeBaseDist).clamp( + 10.0, + 200.0, + ); + sel.rotation = _resizeBaseRotation + (angle - _resizeBaseAngle); + _bumpCanvas(); + } + return; + } + final t = _draggingText; + if (t != null) { + t.position = pos; + _bumpCanvas(); + } + return; + } + final shape = _liveShape; + if (shape != null) { + _liveShape = ShapeMark( + kind: shape.kind, + start: shape.start, + end: pos, + color: shape.color, + width: shape.width, + ); + _bumpCanvas(); + } else if (_liveStroke != null) { + final pts = _liveStroke!.points; + if (pts.isEmpty || (pos - pts.last).distance >= 2.0) { + pts.add(pos); + _bumpCanvas(); + } + } + } + + void _onPanEnd() { + if (_tab == EditTab.text) { + _resizingText = false; + _draggingText = null; + return; + } + final shape = _liveShape; + if (shape != null) { + if ((shape.end - shape.start).distance > 4) _marks.add(shape); + setState(() { + _liveShape = null; + _shapeMode = null; + }); + } else if (_liveStroke != null) { + if (_liveStroke!.points.isNotEmpty) _marks.add(_liveStroke!); + setState(() => _liveStroke = null); + } + } + + TextMark? _hitText(Offset pos) { + for (final m in _marks.reversed) { + if (m is! TextMark) continue; + final local = _toLocal(pos, m); + final box = textMarkSize(m); + if (local.dx.abs() <= box.width / 2 && local.dy.abs() <= box.height / 2) { + return m; + } + } + return null; + } + + Offset _toLocal(Offset pos, TextMark t) { + final v = pos - t.position; + final c = math.cos(-t.rotation); + final s = math.sin(-t.rotation); + return Offset(v.dx * c - v.dy * s, v.dx * s + v.dy * c); + } + + bool _nearHandle(TextMark t, Offset pos) { + final (left, right) = handlePositions(t); + return (pos - left).distance < 26 || (pos - right).distance < 26; + } + + Future _addText() async { + final l10n = AppLocalizations.of(context)!; + final controller = TextEditingController(); + final String? text; + try { + text = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + shape: AppShape.dialogBorder, + title: Text( + l10n.photoEditorTextDialogTitle, + style: const TextStyle(color: Colors.white), + ), + content: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(color: Colors.white), + cursorColor: Colors.white, + decoration: InputDecoration( + hintText: l10n.photoEditorTextDialogHint, + hintStyle: const TextStyle(color: Colors.white38), + ), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(l10n.spoofDialogCancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: Text(l10n.photoEditorOk), + ), + ], + ), + ); + } finally { + controller.dispose(); + } + if (text == null || text.trim().isEmpty || !mounted) return; + final ro = _boundaryKey.currentContext?.findRenderObject(); + final size = ro is RenderBox ? ro.size : const Size(300, 300); + final mark = TextMark( + text: text.trim(), + position: Offset(size.width / 2, size.height / 2), + color: _color, + fontSize: 34, + ); + setState(() { + _marks.add(mark); + _selectedText = mark; + }); + } + + Future _apply() async { + if (_baking) return; + final ro = _boundaryKey.currentContext?.findRenderObject(); + final canvas = ro is RenderBox && !ro.size.isEmpty + ? ro.size + : const Size(300, 300); + setState(() => _baking = true); + final result = await widget.onApply(_marks, canvas); + if (!mounted) return; + if (result == null && _marks.isNotEmpty) { + setState(() => _baking = false); + showCustomNotification( + context, + AppLocalizations.of(context)!.photoEditorApplyChangesFailed, + ); + return; + } + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Column( + children: [ + _buildTopBar(), + Expanded(child: _buildCanvas()), + _buildBottomPanel(), + ], + ), + if (_tab == EditTab.draw) _buildSideSlider(), + if (_baking) const BusyOverlay(), + ], + ), + ); + } + + Widget _buildTopBar() { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + children: [ + IconButton( + onPressed: _marks.isEmpty ? null : _undo, + icon: const Icon(Symbols.undo), + color: Colors.white, + disabledColor: Colors.white24, + ), + const Spacer(), + TextButton( + onPressed: _marks.isEmpty ? null : _clearAll, + child: Text( + AppLocalizations.of(context)!.photoEditorClearAll, + style: TextStyle( + color: _marks.isEmpty ? Colors.white24 : Colors.white, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCanvas() { + final aspect = widget.aspectRatio; + return Center( + child: AspectRatio( + aspectRatio: aspect <= 0 ? 1.0 : aspect, + child: ValueListenableBuilder( + valueListenable: _canvasRev, + child: widget.background, + builder: (context, _, image) { + final selected = _tab == EditTab.text ? _selectedText : null; + return Stack( + fit: StackFit.expand, + children: [ + PhotoHeroTarget( + child: RepaintBoundary( + key: _boundaryKey, + child: Stack( + fit: StackFit.expand, + children: [ + image!, + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (d) => _onPanStart(d.localPosition), + onPanUpdate: (d) => _onPanUpdate(d.localPosition), + onPanEnd: (_) => _onPanEnd(), + child: CustomPaint( + painter: DrawingPainter( + marks: _marks, + live: _liveStroke ?? _liveShape, + ), + ), + ), + ), + ], + ), + ), + ), + if (selected != null) + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: SelectionPainter( + selected, + MediaAccent.of(context), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildSideSlider() { + return Positioned( + left: 2, + top: 0, + bottom: 0, + child: Center( + child: SizedBox( + height: 220, + child: RotatedBox( + quarterTurns: 3, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + thumbColor: Colors.white, + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9), + ), + child: Slider( + min: 2, + max: 40, + value: _width, + onChanged: (v) => setState(() => _width = v), + ), + ), + ), + ), + ), + ); + } + + Widget _buildBottomPanel() { + return Container( + color: kEditorDrawPanel, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_paletteOpen) _buildColorPicker(), + if (_shapesOpen && _tab == EditTab.draw) _buildShapesRow(), + _buildToolbar(), + const SizedBox(height: 2), + _buildTabs(), + ], + ), + ), + ); + } + + Widget _buildToolbar() { + switch (_tab) { + case EditTab.draw: + return _buildDrawToolbar(); + case EditTab.text: + return _buildTextToolbar(); + case EditTab.stickers: + return const SizedBox(height: 56); + } + } + + Widget _buildDrawToolbar() { + return SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: 10), + _buildColorButton(), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildToolButton(DrawTool.pen, Symbols.edit), + _buildToolButton(DrawTool.marker, Symbols.ink_highlighter), + _buildToolButton(DrawTool.neon, Symbols.auto_awesome), + _buildToolButton(DrawTool.eraser, Symbols.ink_eraser), + ], + ), + ), + IconButton( + onPressed: () => setState(() { + _shapesOpen = !_shapesOpen; + _paletteOpen = false; + }), + icon: Icon( + Symbols.add, + color: _shapeMode != null ? _color : Colors.white, + ), + ), + const SizedBox(width: 8), + ], + ), + ); + } + + Widget _buildTextToolbar() { + return SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: 10), + _buildColorButton(), + const SizedBox(width: 14), + TextButton.icon( + onPressed: _addText, + icon: const Icon(Symbols.add, color: Colors.white), + label: Text( + AppLocalizations.of(context)!.photoEditorAddText, + style: const TextStyle(color: Colors.white, fontSize: 15), + ), + ), + const Spacer(), + ], + ), + ); + } + + Widget _buildColorButton() { + return GestureDetector( + onTap: () => setState(() { + _paletteOpen = !_paletteOpen; + _shapesOpen = false; + }), + child: Container( + width: 32, + height: 32, + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: SweepGradient(colors: kPenWheel), + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _color, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ); + } + + Widget _buildToolButton(DrawTool tool, IconData icon) { + final selected = _shapeMode == null && _tool == tool; + return GestureDetector( + onTap: () => setState(() { + _tool = tool; + _shapeMode = null; + _shapesOpen = false; + }), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected + ? Colors.white.withValues(alpha: 0.18) + : Colors.transparent, + ), + child: Icon( + icon, + color: selected ? Colors.white : Colors.white60, + size: 24, + ), + ), + ); + } + + Widget _buildColorPicker() { + return ColorPicker( + color: _color, + onChanged: (c) => setState(() { + _color = c; + if (_tab == EditTab.text) _selectedText?.color = c; + }), + ); + } + + Widget _buildShapesRow() { + const shapes = <(ShapeKind, IconData)>[ + (ShapeKind.circle, Symbols.circle), + (ShapeKind.rectangle, Symbols.rectangle), + (ShapeKind.star, Symbols.star), + (ShapeKind.cloud, Symbols.cloud), + (ShapeKind.arrow, Symbols.north_east), + ]; + return SizedBox( + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final (kind, icon) in shapes) + IconButton( + onPressed: () => setState(() { + _shapeMode = kind; + _shapesOpen = false; + }), + icon: Icon( + icon, + color: _shapeMode == kind ? _color : Colors.white, + ), + ), + ], + ), + ); + } + + Widget _buildTabs() { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + height: 48, + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Symbols.close, color: Colors.white), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildTab(l10n.photoEditorTabDraw, EditTab.draw), + _buildTab( + l10n.photoEditorTabStickers, + EditTab.stickers, + disabled: true, + ), + _buildTab(l10n.photoEditorTabText, EditTab.text), + ], + ), + ), + IconButton( + onPressed: _baking ? null : _apply, + icon: const Icon(Symbols.check, color: Colors.white), + ), + ], + ), + ); + } + + Widget _buildTab(String label, EditTab tab, {bool disabled = false}) { + final selected = _tab == tab; + return GestureDetector( + onTap: disabled + ? null + : () => setState(() { + _tab = tab; + _paletteOpen = false; + _shapesOpen = false; + if (tab != EditTab.draw) _shapeMode = null; + }), + child: Text( + label, + style: TextStyle( + color: disabled + ? Colors.white24 + : (selected ? Colors.white : Colors.white60), + fontSize: 14, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class DrawingPainter extends CustomPainter { + final List marks; + final EditMark? live; + + DrawingPainter({required this.marks, this.live}); + + @override + void paint(Canvas canvas, Size size) => paintMarks(canvas, size); + + void paintMarks(Canvas canvas, Size size) { + final needsLayer = _hasEraser(); + if (needsLayer) canvas.saveLayer(Offset.zero & size, Paint()); + for (final m in marks) { + _paintMark(canvas, m); + } + final l = live; + if (l != null) _paintMark(canvas, l); + if (needsLayer) canvas.restore(); + } + + bool _hasEraser() { + for (final m in marks) { + if (m is StrokeMark && m.tool == DrawTool.eraser) return true; + } + final l = live; + return l is StrokeMark && l.tool == DrawTool.eraser; + } + + void _paintMark(Canvas canvas, EditMark m) { + switch (m) { + case StrokeMark s: + _paintStroke(canvas, s); + case ShapeMark sh: + _paintShape(canvas, sh); + case TextMark t: + _paintText(canvas, t); + } + } + + void _paintStroke(Canvas canvas, StrokeMark s) { + final paint = Paint() + ..color = s.color + ..strokeWidth = s.width + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + + switch (s.tool) { + case DrawTool.pen: + break; + case DrawTool.marker: + paint.color = s.color.withValues(alpha: 0.4); + paint.strokeWidth = s.width * 1.6; + paint.strokeCap = StrokeCap.square; + case DrawTool.neon: + final glow = Paint() + ..color = s.color.withValues(alpha: 0.7) + ..strokeWidth = s.width * 2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + _drawStrokeGeometry(canvas, s, glow); + paint.color = Colors.white; + case DrawTool.eraser: + paint.blendMode = BlendMode.clear; + } + + _drawStrokeGeometry(canvas, s, paint); + } + + void _drawStrokeGeometry(Canvas canvas, StrokeMark s, Paint paint) { + if (s.points.length < 2) { + final dot = Paint() + ..color = paint.color + ..blendMode = paint.blendMode + ..maskFilter = paint.maskFilter + ..style = PaintingStyle.fill; + canvas.drawCircle(s.points.first, paint.strokeWidth / 2, dot); + return; + } + final path = Path()..moveTo(s.points.first.dx, s.points.first.dy); + for (var i = 1; i < s.points.length; i++) { + path.lineTo(s.points[i].dx, s.points[i].dy); + } + canvas.drawPath(path, paint); + } + + void _paintShape(Canvas canvas, ShapeMark sh) { + final paint = Paint() + ..color = sh.color + ..strokeWidth = sh.width + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final rect = Rect.fromPoints(sh.start, sh.end); + switch (sh.kind) { + case ShapeKind.circle: + canvas.drawOval(rect, paint); + case ShapeKind.rectangle: + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(10)), + paint, + ); + case ShapeKind.star: + canvas.drawPath(_starPath(rect), paint); + case ShapeKind.cloud: + canvas.drawPath(_cloudPath(rect), paint); + case ShapeKind.arrow: + _paintArrow(canvas, sh.start, sh.end, paint); + } + } + + Path _starPath(Rect rect) { + final cx = rect.center.dx; + final cy = rect.center.dy; + final outer = math.min(rect.width.abs(), rect.height.abs()) / 2; + final inner = outer * 0.45; + final path = Path(); + for (var i = 0; i < 10; i++) { + final r = i.isEven ? outer : inner; + final angle = -math.pi / 2 + i * math.pi / 5; + final x = cx + r * math.cos(angle); + final y = cy + r * math.sin(angle); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + path.close(); + return path; + } + + Path _cloudPath(Rect rect) { + final w = rect.width; + final h = rect.height; + Offset pt(double nx, double ny) => + Offset(rect.left + nx * w, rect.top + ny * h); + final path = Path()..moveTo(pt(0.25, 0.78).dx, pt(0.25, 0.78).dy); + path + ..cubicTo( + pt(0.0, 0.78).dx, + pt(0.0, 0.78).dy, + pt(0.0, 0.45).dx, + pt(0.0, 0.45).dy, + pt(0.22, 0.42).dx, + pt(0.22, 0.42).dy, + ) + ..cubicTo( + pt(0.2, 0.12).dx, + pt(0.2, 0.12).dy, + pt(0.56, 0.08).dx, + pt(0.56, 0.08).dy, + pt(0.62, 0.36).dx, + pt(0.62, 0.36).dy, + ) + ..cubicTo( + pt(0.86, 0.24).dx, + pt(0.86, 0.24).dy, + pt(1.02, 0.5).dx, + pt(1.02, 0.5).dy, + pt(0.8, 0.6).dx, + pt(0.8, 0.6).dy, + ) + ..cubicTo( + pt(1.02, 0.66).dx, + pt(1.02, 0.66).dy, + pt(0.96, 0.9).dx, + pt(0.96, 0.9).dy, + pt(0.74, 0.8).dx, + pt(0.74, 0.8).dy, + ) + ..cubicTo( + pt(0.7, 0.98).dx, + pt(0.7, 0.98).dy, + pt(0.34, 0.98).dx, + pt(0.34, 0.98).dy, + pt(0.25, 0.78).dx, + pt(0.25, 0.78).dy, + ) + ..close(); + return path; + } + + void _paintArrow(Canvas canvas, Offset start, Offset end, Paint paint) { + canvas.drawLine(start, end, paint); + final angle = math.atan2(end.dy - start.dy, end.dx - start.dx); + final headLen = math.max(paint.strokeWidth * 4, 18.0); + const headAngle = math.pi / 7; + final p1 = + end - + Offset(math.cos(angle - headAngle), math.sin(angle - headAngle)) * + headLen; + final p2 = + end - + Offset(math.cos(angle + headAngle), math.sin(angle + headAngle)) * + headLen; + canvas.drawLine(end, p1, paint); + canvas.drawLine(end, p2, paint); + } + + void _paintText(Canvas canvas, TextMark t) { + final tp = layoutText(t); + canvas.save(); + canvas.translate(t.position.dx, t.position.dy); + canvas.rotate(t.rotation); + tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2)); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant DrawingPainter oldDelegate) => true; +} + +final Expando<_TextLayout> _textLayoutCache = Expando<_TextLayout>(); + +class _TextLayout { + final String text; + final double fontSize; + final Color color; + final TextPainter painter; + + _TextLayout(this.text, this.fontSize, this.color, this.painter); +} + +TextPainter layoutText(TextMark t) { + final cached = _textLayoutCache[t]; + if (cached != null && + cached.text == t.text && + cached.fontSize == t.fontSize && + cached.color == t.color) { + return cached.painter; + } + final tp = TextPainter( + text: TextSpan( + text: t.text, + style: TextStyle( + color: t.color, + fontSize: t.fontSize, + fontWeight: FontWeight.w600, + shadows: const [Shadow(blurRadius: 4, color: Colors.black54)], + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + )..layout(maxWidth: 2000); + _textLayoutCache[t] = _TextLayout(t.text, t.fontSize, t.color, tp); + return tp; +} + +Size textMarkSize(TextMark t) { + final tp = layoutText(t); + return Size(tp.width + 32, tp.height + 24); +} + +(Offset, Offset) handlePositions(TextMark t) { + final hw = textMarkSize(t).width / 2; + final c = math.cos(t.rotation); + final s = math.sin(t.rotation); + return ( + t.position + Offset(-hw * c, -hw * s), + t.position + Offset(hw * c, hw * s), + ); +} + +class SelectionPainter extends CustomPainter { + final TextMark text; + final Color accent; + + SelectionPainter(this.text, this.accent); + + @override + void paint(Canvas canvas, Size size) { + final box = textMarkSize(text); + final hw = box.width / 2; + final hh = box.height / 2; + canvas.save(); + canvas.translate(text.position.dx, text.position.dy); + canvas.rotate(text.rotation); + + final border = Paint() + ..color = Colors.white + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + final tl = Offset(-hw, -hh); + final tr = Offset(hw, -hh); + final br = Offset(hw, hh); + final bl = Offset(-hw, hh); + _dashedLine(canvas, tl, tr, border); + _dashedLine(canvas, tr, br, border); + _dashedLine(canvas, br, bl, border); + _dashedLine(canvas, bl, tl, border); + + final fill = Paint() + ..color = accent + ..style = PaintingStyle.fill; + final ring = Paint() + ..color = Colors.white + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + for (final c in [Offset(-hw, 0), Offset(hw, 0)]) { + canvas.drawCircle(c, 7, fill); + canvas.drawCircle(c, 7, ring); + } + canvas.restore(); + } + + void _dashedLine(Canvas canvas, Offset a, Offset b, Paint paint) { + const dash = 7.0; + const gap = 5.0; + final total = (b - a).distance; + if (total <= 0) return; + final dir = (b - a) / total; + var d = 0.0; + while (d < total) { + final start = a + dir * d; + final end = a + dir * math.min(d + dash, total); + canvas.drawLine(start, end, paint); + d += dash + gap; + } + } + + @override + bool shouldRepaint(covariant SelectionPainter oldDelegate) => true; +} + +class ColorPicker extends StatefulWidget { + final Color color; + final ValueChanged onChanged; + + const ColorPicker({super.key, required this.color, required this.onChanged}); + + @override + State createState() => _ColorPickerState(); +} + +class _ColorPickerState extends State { + late HSVColor _hsv; + + @override + void initState() { + super.initState(); + final hsv = HSVColor.fromColor(widget.color); + _hsv = hsv.saturation == 0 ? hsv.withHue(0) : hsv; + } + + void _setSV(Offset pos, Size size) { + if (size.width <= 0 || size.height <= 0) return; + final s = (pos.dx / size.width).clamp(0.0, 1.0); + final v = (1 - pos.dy / size.height).clamp(0.0, 1.0); + setState(() => _hsv = _hsv.withSaturation(s).withValue(v)); + widget.onChanged(_hsv.toColor()); + } + + void _setHue(double dx, double width) { + if (width <= 0) return; + setState(() => _hsv = _hsv.withHue((dx / width).clamp(0.0, 1.0) * 360)); + widget.onChanged(_hsv.toColor()); + } + + @override + Widget build(BuildContext context) { + final hueColor = HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor(); + return Container( + color: kEditorDrawPanel, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 132, + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (d) => _setSV(d.localPosition, size), + onPanUpdate: (d) => _setSV(d.localPosition, size), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Colors.white, hueColor], + ), + ), + ), + ), + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black], + ), + ), + ), + ), + Positioned( + left: _hsv.saturation * size.width - 9, + top: (1 - _hsv.value) * size.height - 9, + child: _thumb(_hsv.toColor()), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 14), + SizedBox( + height: 22, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (d) => _setHue(d.localPosition.dx, width), + onPanUpdate: (d) => _setHue(d.localPosition.dx, width), + child: ClipRRect( + borderRadius: BorderRadius.circular(11), + child: Stack( + children: [ + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF00FFFF), + Color(0xFF0000FF), + Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + ), + ), + ), + ), + Positioned( + left: (_hsv.hue / 360) * width - 9, + top: 1, + bottom: 1, + child: _thumb(hueColor), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _thumb(Color color) { + return Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)], + ), + ); + } +} + +class ColorAdjust { + double enhance; + double exposure; + double contrast; + double saturation; + double warmth; + double vignette; + + ColorAdjust({ + this.enhance = 0, + this.exposure = 0, + this.contrast = 0, + this.saturation = 0, + this.warmth = 0, + this.vignette = 0, + }); + + ColorAdjust copy() => ColorAdjust( + enhance: enhance, + exposure: exposure, + contrast: contrast, + saturation: saturation, + warmth: warmth, + vignette: vignette, + ); + + bool get pristine => + enhance == 0 && + exposure == 0 && + contrast == 0 && + saturation == 0 && + warmth == 0 && + vignette == 0; + + bool get colorPristine => + enhance == 0 && + exposure == 0 && + contrast == 0 && + saturation == 0 && + warmth == 0; + + List matrix() { + var m = identityMatrix(); + m = mulMatrix(brightnessMatrix(1 + exposure), m); + m = mulMatrix(contrastMatrix(1 + contrast), m); + m = mulMatrix(saturationMatrix(1 + saturation), m); + m = mulMatrix(warmthMatrix(warmth), m); + if (enhance > 0) { + m = mulMatrix(contrastMatrix(1 + enhance * 0.35), m); + m = mulMatrix(saturationMatrix(1 + enhance * 0.4), m); + m = mulMatrix(brightnessMatrix(1 + enhance * 0.05), m); + } + return m; + } + + Gradient vignetteGradient() => RadialGradient( + radius: 0.9, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: (vignette * 0.6).clamp(0.0, 1.0)), + ], + stops: const [0.5, 1.0], + ); +} + +class AdjustSliders extends StatelessWidget { + final ColorAdjust adjust; + final VoidCallback onChanged; + + const AdjustSliders({ + super.key, + required this.adjust, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _slider(context, l10n.photoEditorEnhance, adjust.enhance, 0, 1, (v) { + adjust.enhance = v; + }), + _slider(context, l10n.photoEditorExposure, adjust.exposure, -1, 1, ( + v, + ) { + adjust.exposure = v; + }), + _slider(context, l10n.photoEditorContrast, adjust.contrast, -1, 1, ( + v, + ) { + adjust.contrast = v; + }), + _slider( + context, + l10n.photoEditorSaturation, + adjust.saturation, + -1, + 1, + (v) { + adjust.saturation = v; + }, + ), + _slider(context, l10n.photoEditorWarmth, adjust.warmth, -1, 1, (v) { + adjust.warmth = v; + }), + _slider(context, l10n.photoEditorVignette, adjust.vignette, 0, 1, ( + v, + ) { + adjust.vignette = v; + }), + ], + ), + ); + } + + Widget _slider( + BuildContext context, + String label, + double value, + double min, + double max, + ValueChanged apply, + ) { + return Row( + children: [ + SizedBox( + width: 104, + child: Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbColor: Colors.white, + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), + ), + child: Slider( + min: min, + max: max, + value: value.clamp(min, max), + onChanged: (v) { + apply(v); + onChanged(); + }, + ), + ), + ), + ], + ); + } +} + +List identityMatrix() => [ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]; + +List brightnessMatrix(double f) => [ + f, + 0, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + 0, + 1, + 0, +]; + +List contrastMatrix(double c) { + final t = 127.5 * (1 - c); + return [c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0]; +} + +List saturationMatrix(double s) { + const lr = 0.2126; + const lg = 0.7152; + const lb = 0.0722; + final i = 1 - s; + return [ + lr * i + s, + lg * i, + lb * i, + 0, + 0, + lr * i, + lg * i + s, + lb * i, + 0, + 0, + lr * i, + lg * i, + lb * i + s, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]; +} + +List warmthMatrix(double w) { + final o = w * 25.0; + return [1, 0, 0, 0, o, 0, 1, 0, 0, 0, 0, 0, 1, 0, -o, 0, 0, 0, 1, 0]; +} + +List mulMatrix(List a, List b) { + double at(List m, int r, int c) => + r < 4 ? m[r * 5 + c] : (c == 4 ? 1.0 : 0.0); + final out = List.filled(20, 0); + for (var r = 0; r < 4; r++) { + for (var c = 0; c < 5; c++) { + var sum = 0.0; + for (var k = 0; k < 5; k++) { + sum += at(a, r, k) * at(b, k, c); + } + out[r * 5 + c] = sum; + } + } + return out; +} diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index bc8dfe9..f67467b 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -7,14 +6,16 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; +import 'package:komet/frontend/widgets/attachment/photo_hero.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; -import '../../../core/config/app_colors.dart'; - -const Color _kBar = Color(0xFF1E1E1E); +import 'editor_common.dart'; +import 'preview_chrome.dart'; +import '../small_spinner.dart'; class MediaPreviewScreen extends StatefulWidget { final GalleryItem item; + final PhotoHeroController hero; final String? title; final ValueListenable> selectedIds; final VoidCallback onToggleSelection; @@ -28,6 +29,7 @@ class MediaPreviewScreen extends StatefulWidget { const MediaPreviewScreen({ super.key, required this.item, + required this.hero, required this.selectedIds, required this.onToggleSelection, required this.onSend, @@ -47,13 +49,18 @@ class _MediaPreviewScreenState extends State { late final TextEditingController _caption = TextEditingController( text: widget.initialCaption, ); + final TransformationController _zoom = TransformationController(); + final GlobalKey _stageKey = GlobalKey(); File? _workingFile; File? _cropSource; CropState? _cropState; + Size? _workingSize; + PhotoHeroController? _activeHero; @override void initState() { super.initState(); + _zoom.addListener(_syncHero); _caption.addListener(() => widget.onCaptionChanged?.call(_caption.text)); _cropState = widget.editState?.cropState; _cropSource = widget.editState?.cropSource; @@ -63,34 +70,73 @@ class _MediaPreviewScreenState extends State { Future _resolveWorkingFile() async { final initial = widget.editState?.working ?? widget.item.localFile; if (initial != null) { - _workingFile = initial; + _setWorkingFile(initial); return; } final file = await widget.item.originFile(); + if (!mounted || file == null) return; + setState(() => _setWorkingFile(file)); + } + + void _setWorkingFile(File file) { + _workingFile = file; + widget.hero.image.value = FileImage(file); + _resolveWorkingSize(file); + } + + Future _resolveWorkingSize(File file) async { + final dims = await imageFileDimensions(file); + if (!mounted || dims == null || _workingFile?.path != file.path) return; + _workingSize = Size(dims.$1.toDouble(), dims.$2.toDouble()); + } + + Rect? _stageOrigin() { + if (_zoom.value.getMaxScaleOnAxis() > 1.01) return null; + final box = photoHeroRect(_stageKey); + final size = _workingSize; + if (box == null || size == null) return null; + return inscribeRect(size, box); + } + + Future _flight(File file) async { + await _resolveWorkingSize(file); if (!mounted) return; - setState(() => _workingFile = file); + final provider = FileImage(file); + await precacheImage(provider, context); + if (!mounted) return; + _activeHero?.image.value = provider; } @override void dispose() { _caption.dispose(); + _zoom.dispose(); super.dispose(); } + void _syncHero() => + widget.hero.enabled = _zoom.value.getMaxScaleOnAxis() <= 1.01; + void _send() { Navigator.of(context).pop(); widget.onSend(); } - Future _pushEditor(Widget editor) { - return Navigator.of(context).push( - PageRouteBuilder( - opaque: true, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (_, _, _) => editor, - ), + Future _pushEditor(Widget Function() builder) async { + final file = _workingFile; + if (file == null) return null; + final hero = PhotoHeroController( + origin: _stageOrigin, + image: FileImage(file), ); + _activeHero = hero; + try { + return await Navigator.of( + context, + ).push(PhotoHeroRoute(hero: hero, builder: (_) => builder())); + } finally { + _activeHero = null; + } } void _reportEdit() { @@ -114,17 +160,24 @@ class _MediaPreviewScreenState extends State { final source = _cropSource ??= widget.item.localFile ?? await widget.item.originFile(); if (source == null || !mounted) return; - final result = await _pushEditor( - PhotoCropEditor(source: source, initialState: _cropState), + await _pushEditor( + () => PhotoCropEditor( + source: source, + initialState: _cropState, + onPreview: _applyCrop, + ), ); - if (result != null && mounted) { - final old = _workingFile; - _cropState = result.state; - widget.tempFiles.add(result.file.path); - setState(() => _workingFile = result.file); - _reportEdit(); - _disposeTemp(old, {result.file.path, _cropSource?.path ?? ''}); - } + } + + Future _applyCrop(CropResult result) async { + if (!mounted) return; + final old = _workingFile; + _cropState = result.state; + widget.tempFiles.add(result.file.path); + setState(() => _setWorkingFile(result.file)); + _reportEdit(); + _disposeTemp(old, {result.file.path, _cropSource?.path ?? ''}); + await _flight(result.file); } Future _openDraw() async { @@ -136,37 +189,36 @@ class _MediaPreviewScreenState extends State { showCustomNotification(context, 'Не удалось открыть редактор'); return; } - final result = await _pushEditor( - PhotoDrawEditor(source: file, imageWidth: dims.$1, imageHeight: dims.$2), + await _pushEditor( + () => PhotoDrawEditor( + source: file, + imageWidth: dims.$1, + imageHeight: dims.$2, + onPreview: _applyBaked, + ), ); - if (result != null && mounted) { - final oldWorking = _workingFile; - final oldCropSource = _cropSource; - _cropSource = result; - _cropState = null; - widget.tempFiles.add(result.path); - setState(() => _workingFile = result); - _reportEdit(); - _disposeTemp(oldWorking, {result.path}); - _disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''}); - } } Future _openAdjust() async { final file = _workingFile; if (file == null) return; - final result = await _pushEditor(PhotoAdjustEditor(source: file)); - if (result != null && mounted) { - final oldWorking = _workingFile; - final oldCropSource = _cropSource; - _cropSource = result; - _cropState = null; - widget.tempFiles.add(result.path); - setState(() => _workingFile = result); - _reportEdit(); - _disposeTemp(oldWorking, {result.path}); - _disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''}); - } + await _pushEditor( + () => PhotoAdjustEditor(source: file, onPreview: _applyBaked), + ); + } + + Future _applyBaked(File result) async { + if (!mounted) return; + final oldWorking = _workingFile; + final oldCropSource = _cropSource; + _cropSource = result; + _cropState = null; + widget.tempFiles.add(result.path); + setState(() => _setWorkingFile(result)); + _reportEdit(); + _disposeTemp(oldWorking, {result.path}); + _disposeTemp(oldCropSource, {result.path, oldWorking?.path ?? ''}); + await _flight(result); } @override @@ -190,7 +242,7 @@ class _MediaPreviewScreenState extends State { actions: [ Padding( padding: const EdgeInsets.only(right: 14), - child: _SelectionToggle( + child: PreviewSelectionToggle( selectedIds: widget.selectedIds, id: widget.item.id, onTap: widget.onToggleSelection, @@ -201,11 +253,14 @@ class _MediaPreviewScreenState extends State { body: Column( children: [ Expanded( - child: Center( - child: InteractiveViewer( - minScale: 1, - maxScale: 4, - child: _buildImage(), + child: PhotoHeroTarget( + child: Center( + child: InteractiveViewer( + minScale: 1, + maxScale: 4, + transformationController: _zoom, + child: KeyedSubtree(key: _stageKey, child: _buildImage()), + ), ), ), ), @@ -216,15 +271,19 @@ class _MediaPreviewScreenState extends State { } Widget _buildImage() { - final file = _workingFile; - if (file == null) { - return const SizedBox( - width: 36, - height: 36, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white24), - ); - } - return Image.file(file, fit: BoxFit.contain, gaplessPlayback: true); + return ValueListenableBuilder( + valueListenable: widget.hero.image, + builder: (context, provider, _) { + if (provider == null) { + return const SmallSpinner(size: 36, color: Colors.white24); + } + return Image( + image: provider, + fit: BoxFit.contain, + gaplessPlayback: true, + ); + }, + ); } Widget _buildBottomBar() { @@ -247,7 +306,7 @@ class _MediaPreviewScreenState extends State { Widget _buildCaptionField() { return Container( decoration: BoxDecoration( - color: _kBar, + color: kEditorBar, borderRadius: BorderRadius.circular(28), ), padding: const EdgeInsets.fromLTRB(20, 6, 8, 6), @@ -271,7 +330,7 @@ class _MediaPreviewScreenState extends State { valueListenable: widget.selectedIds, builder: (context, selected, _) { final count = selected.isEmpty ? 1 : selected.length; - return _CountBadge(count: count); + return PreviewCountBadge(count: count); }, ), ], @@ -286,193 +345,23 @@ class _MediaPreviewScreenState extends State { child: Container( height: 52, decoration: BoxDecoration( - color: _kBar, + color: kEditorBar, borderRadius: BorderRadius.circular(28), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _ToolIcon(icon: Symbols.crop_rotate, onTap: _openCrop), - _ToolIcon(icon: Symbols.brush, onTap: _openDraw), - const _FileToggle(), - _ToolIcon(icon: Symbols.tune, onTap: _openAdjust), + PreviewToolIcon(icon: Symbols.crop_rotate, onTap: _openCrop), + PreviewToolIcon(icon: Symbols.brush, onTap: _openDraw), + const PreviewFileToggle(), + PreviewToolIcon(icon: Symbols.tune, onTap: _openAdjust), ], ), ), ), const SizedBox(width: 10), - _SendButton(onTap: _send), + PreviewSendButton(onTap: _send), ], ); } } - -class _SelectionToggle extends StatelessWidget { - final ValueListenable> selectedIds; - final String id; - final VoidCallback onTap; - - const _SelectionToggle({ - required this.selectedIds, - required this.id, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder>( - valueListenable: selectedIds, - builder: (context, selected, _) { - final index = selected.toList().indexOf(id); - final isSelected = index >= 0; - return GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Container( - width: 30, - height: 30, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isSelected ? kEditorAccent : Colors.transparent, - border: Border.all(color: Colors.white, width: 2), - ), - child: isSelected - ? Text( - '${index + 1}', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w700, - height: 1.0, - ), - ) - : null, - ), - ); - }, - ); - } -} - -class _CountBadge extends StatelessWidget { - final int count; - - const _CountBadge({required this.count}); - - @override - Widget build(BuildContext context) { - return CustomPaint( - painter: const _DashedCirclePainter(color: Colors.white), - child: SizedBox( - width: 34, - height: 34, - child: Center( - child: Text( - '$count', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ); - } -} - -class _DashedCirclePainter extends CustomPainter { - final Color color; - - const _DashedCirclePainter({required this.color}); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round; - final rect = Rect.fromLTWH(1.5, 1.5, size.width - 3, size.height - 3); - const dashes = 22; - const sweep = (2 * math.pi) / dashes; - const dashRatio = 0.55; - for (var i = 0; i < dashes; i++) { - canvas.drawArc(rect, i * sweep, sweep * dashRatio, false, paint); - } - } - - @override - bool shouldRepaint(covariant _DashedCirclePainter oldDelegate) => - oldDelegate.color != color; -} - -class _ToolIcon extends StatelessWidget { - final IconData icon; - final VoidCallback onTap; - - const _ToolIcon({required this.icon, required this.onTap}); - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: onTap, - icon: Icon(icon, color: Colors.white, size: 24), - ); - } -} - -class _FileToggle extends StatefulWidget { - const _FileToggle(); - - @override - State<_FileToggle> createState() => _FileToggleState(); -} - -class _FileToggleState extends State<_FileToggle> { - bool _active = false; - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: () => setState(() => _active = !_active), - icon: TweenAnimationBuilder( - tween: Tween(end: _active ? 1 : 0), - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - builder: (context, t, _) { - final color = Color.lerp( - Colors.white54, - Color.lerp(Colors.white, kEditorAccent, 0.4), - t, - ); - return Icon(Symbols.description, color: color, size: 24); - }, - ), - ); - } -} - -class _SendButton extends StatelessWidget { - final VoidCallback onTap; - - const _SendButton({required this.onTap}); - - @override - Widget build(BuildContext context) { - return Material( - color: kEditorAccent, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: onTap, - child: const SizedBox( - width: 52, - height: 52, - child: Icon(Symbols.send, color: Colors.white, size: 24, fill: 1), - ), - ), - ); - } -} diff --git a/lib/frontend/widgets/attachment/photo_editor.dart b/lib/frontend/widgets/attachment/photo_editor.dart index d5a4fb8..b77a9ab 100644 --- a/lib/frontend/widgets/attachment/photo_editor.dart +++ b/lib/frontend/widgets/attachment/photo_editor.dart @@ -13,28 +13,10 @@ import 'package:komet/frontend/widgets/custom_notification.dart'; import '../../../core/config/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import '../small_spinner.dart'; +import 'editor_common.dart'; +import 'photo_hero.dart'; -const Color _kPanel = Color(0xFF0A0A0A); - -class CropState { - final int quarterTurns; - final bool flipH; - final double straightenDeg; - final Rect cropNorm; - - const CropState({ - required this.quarterTurns, - required this.flipH, - required this.straightenDeg, - required this.cropNorm, - }); - - bool sameAs(CropState o) => - quarterTurns == o.quarterTurns && - flipH == o.flipH && - (straightenDeg - o.straightenDeg).abs() < 0.05 && - cropNorm == o.cropNorm; -} +export 'editor_common.dart' show CropState; class CropResult { final File file; @@ -54,8 +36,14 @@ class PhotoEditState { class PhotoCropEditor extends StatefulWidget { final File source; final CropState? initialState; + final Future Function(CropResult result)? onPreview; - const PhotoCropEditor({super.key, required this.source, this.initialState}); + const PhotoCropEditor({ + super.key, + required this.source, + this.initialState, + this.onPreview, + }); @override State createState() => _PhotoCropEditorState(); @@ -63,15 +51,6 @@ class PhotoCropEditor extends StatefulWidget { class _PhotoCropEditorState extends State { ui.Image? _image; - int _quarterTurns = 0; - bool _flipH = false; - double _straightenDeg = 0; - Rect? _crop; - Size _viewport = Size.zero; - bool _baking = false; - bool _stateApplied = false; - int _handle = -1; - final ValueNotifier _rev = ValueNotifier(0); @override void initState() { @@ -79,11 +58,6 @@ class _PhotoCropEditorState extends State { _load(); } - void _setCrop(Rect r) { - _crop = r; - _rev.value++; - } - Future _load() async { try { final bytes = await widget.source.readAsBytes(); @@ -103,1762 +77,149 @@ class _PhotoCropEditorState extends State { @override void dispose() { _image?.dispose(); - _rev.dispose(); super.dispose(); } - double get _imgW => _image!.width.toDouble(); - double get _imgH => _image!.height.toDouble(); - double get _phi => - _straightenDeg * math.pi / 180 - _quarterTurns * math.pi / 2; - - Size _orientedSize() { - final swap = _quarterTurns.isOdd; - return swap ? Size(_imgH, _imgW) : Size(_imgW, _imgH); - } - - double _baseScale(Size vp) { - final o = _orientedSize(); - const margin = 0.9; - return math.min(vp.width / o.width, vp.height / o.height) * margin; - } - - Rect _fittedRect(Size vp) { - final o = _orientedSize(); - final base = _baseScale(vp); - return Rect.fromCenter( - center: Offset(vp.width / 2, vp.height / 2), - width: o.width * base, - height: o.height * base, - ); - } - - double _scaleFor(Size vp, Rect crop) { - final base = _baseScale(vp); - final center = Offset(vp.width / 2, vp.height / 2); - final c = math.cos(-_phi); - final s = math.sin(-_phi); - var maxS = 0.0; - for (final corner in [ - crop.topLeft, - crop.topRight, - crop.bottomLeft, - crop.bottomRight, - ]) { - final rx = corner.dx - center.dx; - final ry = corner.dy - center.dy; - final lx = rx * c - ry * s; - final ly = rx * s + ry * c; - maxS = math.max( - maxS, - math.max(lx.abs() / (_imgW / 2), ly.abs() / (_imgH / 2)), - ); - } - return math.max(base, maxS); - } - - Matrix4 _matrix(Size vp, Rect crop) { - final scale = _scaleFor(vp, crop); - return Matrix4.identity() - ..translateByDouble(vp.width / 2, vp.height / 2, 0, 1) - ..multiply( - _flipH ? Matrix4.diagonal3Values(-1, 1, 1) : Matrix4.identity(), - ) - ..rotateZ(_phi) - ..scaleByDouble(scale, scale, 1, 1) - ..translateByDouble(-_imgW / 2, -_imgH / 2, 0, 1); - } - - void _ensureCrop(Size vp) { - if (_crop != null && _viewport == vp) return; - _viewport = vp; - final init = widget.initialState; - if (init != null && !_stateApplied) { - _stateApplied = true; - _quarterTurns = init.quarterTurns; - _flipH = init.flipH; - _straightenDeg = init.straightenDeg; - _crop = Rect.fromLTRB( - init.cropNorm.left * vp.width, - init.cropNorm.top * vp.height, - init.cropNorm.right * vp.width, - init.cropNorm.bottom * vp.height, - ); - } else { - _crop = _fittedRect(vp); - } - } - - CropState _currentState(Size vp, Rect crop) => CropState( - quarterTurns: _quarterTurns, - flipH: _flipH, - straightenDeg: _straightenDeg, - cropNorm: Rect.fromLTRB( - crop.left / vp.width, - crop.top / vp.height, - crop.right / vp.width, - crop.bottom / vp.height, - ), - ); - - void _reset() { - setState(() { - _quarterTurns = 0; - _flipH = false; - _straightenDeg = 0; - _crop = _fittedRect(_viewport); - }); - } - - void _rotate90() { - setState(() { - _quarterTurns = (_quarterTurns + 1) % 4; - _straightenDeg = 0; - _crop = _fittedRect(_viewport); - }); - } - - void _flip() => setState(() => _flipH = !_flipH); - - int _hitHandle(Offset pt, Rect c) { - const r = 34.0; - final corners = [c.topLeft, c.topRight, c.bottomRight, c.bottomLeft]; - for (var i = 0; i < 4; i++) { - if ((pt - corners[i]).distance < r) return i; - } - final insideV = pt.dy > c.top - r && pt.dy < c.bottom + r; - final insideH = pt.dx > c.left - r && pt.dx < c.right + r; - if ((pt.dx - c.left).abs() < r && insideV) return 4; - if ((pt.dx - c.right).abs() < r && insideV) return 5; - if ((pt.dy - c.top).abs() < r && insideH) return 6; - if ((pt.dy - c.bottom).abs() < r && insideH) return 7; - if (c.contains(pt)) return 8; - return -1; - } - - void _onPanStart(Offset pt) { - final c = _crop; - if (c == null) return; - _handle = _hitHandle(pt, c); - } - - void _onPanUpdate(Offset delta) { - final c = _crop; - if (c == null || _handle < 0) return; - final b = _fittedRect(_viewport); - const minSize = 64.0; - - if (_handle == 8) { - var nl = c.left + delta.dx; - var nt = c.top + delta.dy; - var nr = c.right + delta.dx; - var nb = c.bottom + delta.dy; - if (nl < b.left) { - nr += b.left - nl; - nl = b.left; - } - if (nt < b.top) { - nb += b.top - nt; - nt = b.top; - } - if (nr > b.right) { - nl -= nr - b.right; - nr = b.right; - } - if (nb > b.bottom) { - nt -= nb - b.bottom; - nb = b.bottom; - } - _setCrop(Rect.fromLTRB(nl, nt, nr, nb)); - return; - } - - var l = c.left; - var t = c.top; - var r = c.right; - var bo = c.bottom; - switch (_handle) { - case 0: - l += delta.dx; - t += delta.dy; - case 1: - r += delta.dx; - t += delta.dy; - case 2: - r += delta.dx; - bo += delta.dy; - case 3: - l += delta.dx; - bo += delta.dy; - case 4: - l += delta.dx; - case 5: - r += delta.dx; - case 6: - t += delta.dy; - case 7: - bo += delta.dy; - } - l = l.clamp(b.left, math.max(b.left, r - minSize)); - t = t.clamp(b.top, math.max(b.top, bo - minSize)); - r = r.clamp(math.min(b.right, l + minSize), b.right); - bo = bo.clamp(math.min(b.bottom, t + minSize), b.bottom); - _setCrop(Rect.fromLTRB(l, t, r, bo)); - } - - Future _done() async { - if (_baking) return; - final crop = _crop; - final vp = _viewport; - if (crop == null || vp == Size.zero) { - Navigator.of(context).pop(); - return; - } - final state = _currentState(vp, crop); - final init = widget.initialState; - final noChange = init != null - ? state.sameAs(init) - : (_quarterTurns == 0 && - !_flipH && - _straightenDeg == 0 && - _isFullCrop()); - if (noChange) { - Navigator.of(context).pop(); - return; - } - setState(() => _baking = true); - final file = await _bake(); - if (!mounted) return; - if (file == null) { - setState(() => _baking = false); - showCustomNotification( - context, - AppLocalizations.of(context)!.photoEditorApplyFailed, - ); - return; - } - Navigator.of(context).pop(CropResult(file, state)); - } - - bool _isFullCrop() { - final c = _crop; - if (c == null) return true; - final f = _fittedRect(_viewport); - return (c.left - f.left).abs() < 1 && - (c.top - f.top).abs() < 1 && - (c.right - f.right).abs() < 1 && - (c.bottom - f.bottom).abs() < 1; - } - - Future _bake() async { - final img = _image; - final crop = _crop; - final vp = _viewport; - if (img == null || crop == null || vp == Size.zero) return null; - try { - final m = _matrix(vp, crop); - final upscale = 1 / _baseScale(vp); - var outW = crop.width * upscale; - var outH = crop.height * upscale; - const maxDim = 4096; - final mx = math.max(outW, outH); - final cap = mx > maxDim ? maxDim / mx : 1.0; - final eff = upscale * cap; - final pxW = (crop.width * eff).round(); - final pxH = (crop.height * eff).round(); - if (pxW <= 0 || pxH <= 0) return null; - - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - canvas.scale(eff); - canvas.translate(-crop.left, -crop.top); - canvas.transform(m.storage); - canvas.drawImage( - img, - Offset.zero, - Paint()..filterQuality = FilterQuality.high, - ); - final picture = recorder.endRecording(); - return await rasterPictureToJpegFile(picture, pxW, pxH, prefix: 'crop'); - } catch (_) { - return null; - } + Future _apply( + CropState state, + Size vp, + bool changed, + bool identity, + ) async { + if (!changed) return null; + final file = await _bakeCrop(_image!, state, vp); + if (file == null) return null; + final result = CropResult(file, state); + await widget.onPreview?.call(result); + return result; } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Column( - children: [ - Expanded(child: _buildViewport()), - _buildTools(), - _buildActions(), - ], - ), - ), - ); - } - - Widget _buildViewport() { - final img = _image; - if (img == null) { - return const Center( - child: CircularProgressIndicator(color: Colors.white), + final image = _image; + if (image == null) { + return const Scaffold( + backgroundColor: Colors.black, + body: Center(child: SmallSpinner(size: 36, color: Colors.white)), ); } - return LayoutBuilder( - builder: (context, constraints) { - final vp = constraints.biggest; - _ensureCrop(vp); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (d) => _onPanStart(d.localPosition), - onPanUpdate: (d) => _onPanUpdate(d.delta), - child: ValueListenableBuilder( - valueListenable: _rev, - builder: (context, _, _) { - final crop = _crop!; - return CustomPaint( - size: vp, - painter: _CropPainter( - image: img, - matrix: _matrix(vp, crop), - crop: crop, - ), - ); - }, - ), - ); - }, - ); - } - - Widget _buildTools() { - final l10n = AppLocalizations.of(context)!; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - children: [ - IconButton( - onPressed: _flip, - icon: Icon( - Symbols.flip, - color: _flipH ? kEditorAccent : Colors.white, - ), - tooltip: l10n.photoEditorFlipTooltip, - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: _rev, - builder: (context, _, _) => _StraightenRuler( - value: _straightenDeg, - onChanged: (v) { - _straightenDeg = v; - _rev.value++; - }, - ), - ), - ), - IconButton( - onPressed: _rotate90, - icon: const Icon( - Symbols.rotate_90_degrees_ccw, - color: Colors.white, - ), - tooltip: l10n.photoEditorRotateTooltip, - ), - ], - ), - ); - } - - Widget _buildActions() { - final l10n = AppLocalizations.of(context)!; - return Container( - color: _kPanel, - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - l10n.photoEditorCancel, - style: const TextStyle(color: Colors.white, fontSize: 15), - ), - ), - TextButton( - onPressed: _reset, - child: Text( - l10n.photoEditorReset, - style: const TextStyle(color: Colors.white, fontSize: 15), - ), - ), - TextButton( - onPressed: _baking ? null : _done, - child: Text( - l10n.photoEditorDone, - style: TextStyle( - color: _baking ? Colors.white38 : kEditorAccent, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), + return CropWorkspace( + imageSize: Size(image.width.toDouble(), image.height.toDouble()), + initialState: widget.initialState, + onApply: _apply, + imageBuilder: (context, matrix) => + CustomPaint(painter: MatrixImagePainter(image, matrix)), ); } } -class _CropPainter extends CustomPainter { - final ui.Image image; - final Matrix4 matrix; - final Rect crop; +Future _bakeCrop(ui.Image img, CropState state, Size vp) async { + if (vp == Size.zero) return null; + try { + final geometry = CropGeometry( + source: Size(img.width.toDouble(), img.height.toDouble()), + quarterTurns: state.quarterTurns, + flipH: state.flipH, + straightenDeg: state.straightenDeg, + ); + final crop = Rect.fromLTRB( + state.cropNorm.left * vp.width, + state.cropNorm.top * vp.height, + state.cropNorm.right * vp.width, + state.cropNorm.bottom * vp.height, + ); + final m = geometry.viewportMatrix(vp, crop); + final upscale = 1 / geometry.baseScale(vp); + const maxDim = 4096; + final mx = math.max(crop.width * upscale, crop.height * upscale); + final cap = mx > maxDim ? maxDim / mx : 1.0; + final eff = upscale * cap; + final pxW = (crop.width * eff).round(); + final pxH = (crop.height * eff).round(); + if (pxW <= 0 || pxH <= 0) return null; - _CropPainter({required this.image, required this.matrix, required this.crop}); - - @override - void paint(Canvas canvas, Size size) { - canvas.save(); - canvas.transform(matrix.storage); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(eff); + canvas.translate(-crop.left, -crop.top); + canvas.transform(m.storage); canvas.drawImage( - image, + img, Offset.zero, - Paint()..filterQuality = FilterQuality.medium, - ); - canvas.restore(); - - canvas.drawPath( - Path.combine( - PathOperation.difference, - Path()..addRect(Offset.zero & size), - Path()..addRect(crop), - ), - Paint()..color = Colors.black.withValues(alpha: 0.55), - ); - - final grid = Paint() - ..color = Colors.white.withValues(alpha: 0.4) - ..strokeWidth = 0.7; - for (var i = 1; i < 3; i++) { - final x = crop.left + crop.width * i / 3; - final y = crop.top + crop.height * i / 3; - canvas.drawLine(Offset(x, crop.top), Offset(x, crop.bottom), grid); - canvas.drawLine(Offset(crop.left, y), Offset(crop.right, y), grid); - } - - final border = Paint() - ..color = Colors.white.withValues(alpha: 0.7) - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; - canvas.drawRect(crop, border); - - final bracket = Paint() - ..color = Colors.white - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - const len = 20.0; - void corner(Offset o, double dx, double dy) { - canvas.drawLine(o, o.translate(dx, 0), bracket); - canvas.drawLine(o, o.translate(0, dy), bracket); - } - - corner(crop.topLeft, len, len); - corner(crop.topRight, -len, len); - corner(crop.bottomLeft, len, -len); - corner(crop.bottomRight, -len, -len); - } - - @override - bool shouldRepaint(covariant _CropPainter old) => - old.matrix != matrix || old.crop != crop || old.image != image; -} - -class _StraightenRuler extends StatelessWidget { - final double value; - final ValueChanged onChanged; - - const _StraightenRuler({required this.value, required this.onChanged}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragUpdate: (d) { - onChanged((value - d.delta.dx * 0.22).clamp(-45.0, 45.0)); - }, - onDoubleTap: () => onChanged(0), - child: SizedBox( - height: 56, - child: CustomPaint(painter: _RulerPainter(value)), - ), + Paint()..filterQuality = FilterQuality.high, ); + final picture = recorder.endRecording(); + return await rasterPictureToJpegFile(picture, pxW, pxH, prefix: 'crop'); + } catch (_) { + return null; } } -class _RulerPainter extends CustomPainter { - final double value; - - _RulerPainter(this.value); - - @override - void paint(Canvas canvas, Size size) { - final cx = size.width / 2; - const pxPerDeg = 6.0; - final baseY = size.height - 6; - - final tick = Paint()..strokeWidth = 1; - for (var deg = -60; deg <= 60; deg++) { - final x = cx + (deg - value) * pxPerDeg; - if (x < 0 || x > size.width) continue; - final major = deg % 5 == 0; - tick.color = Colors.white.withValues(alpha: major ? 0.85 : 0.4); - final h = major ? 14.0 : 8.0; - canvas.drawLine(Offset(x, baseY - h), Offset(x, baseY), tick); - } - - final tp = TextPainter( - text: TextSpan( - text: '${value.toStringAsFixed(1).replaceAll('.', ',')}°', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontStyle: FontStyle.italic, - ), - ), - textDirection: TextDirection.ltr, - )..layout(); - tp.paint(canvas, Offset(cx - tp.width / 2, 0)); - - canvas.drawLine( - Offset(cx, baseY - 18), - Offset(cx, baseY + 2), - Paint() - ..color = kEditorAccent - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round, - ); - } - - @override - bool shouldRepaint(covariant _RulerPainter old) => old.value != value; -} - -const Color _kDrawPanel = Color(0xFF101010); - -enum DrawTool { pen, marker, neon, eraser } - -enum ShapeKind { circle, rectangle, star, cloud, arrow } - -enum _EditTab { draw, stickers, text } - -sealed class EditMark {} - -class StrokeMark extends EditMark { - final List points; - final Color color; - final double width; - final DrawTool tool; - - StrokeMark({ - required this.points, - required this.color, - required this.width, - required this.tool, - }); -} - -class ShapeMark extends EditMark { - final ShapeKind kind; - final Offset start; - final Offset end; - final Color color; - final double width; - - ShapeMark({ - required this.kind, - required this.start, - required this.end, - required this.color, - required this.width, - }); -} - -class TextMark extends EditMark { - String text; - Offset position; - Color color; - double fontSize; - double rotation; - - TextMark({ - required this.text, - required this.position, - required this.color, - required this.fontSize, - this.rotation = 0, - }); -} - -class PhotoDrawEditor extends StatefulWidget { +class PhotoDrawEditor extends StatelessWidget { final File source; final int imageWidth; final int imageHeight; + final Future Function(File result)? onPreview; const PhotoDrawEditor({ super.key, required this.source, required this.imageWidth, required this.imageHeight, + this.onPreview, }); + Future _apply(List marks, Size canvas) async { + if (marks.isEmpty) return null; + final file = await _bakeMarks(source, marks, canvas); + if (file == null) return null; + await onPreview?.call(file); + return file; + } + @override - State createState() => _PhotoDrawEditorState(); + Widget build(BuildContext context) { + return MarkupEditor( + aspectRatio: imageHeight > 0 ? imageWidth / imageHeight : 1.0, + background: Image.file(source, fit: BoxFit.cover, gaplessPlayback: true), + onApply: _apply, + ); + } } -class _PhotoDrawEditorState extends State { - final GlobalKey _boundaryKey = GlobalKey(); - final ValueNotifier _canvasRev = ValueNotifier(0); - final List _marks = []; - StrokeMark? _liveStroke; - ShapeMark? _liveShape; - TextMark? _draggingText; +Future _bakeMarks(File source, List marks, Size box) async { + if (box.isEmpty) return null; + try { + final bytes = await source.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final image = frame.image; - DrawTool _tool = DrawTool.pen; - Color _color = Colors.white; - double _width = 8; - TextMark? _selectedText; - bool _resizingText = false; - double _resizeBaseSize = 0; - double _resizeBaseDist = 1; - double _resizeBaseRotation = 0; - double _resizeBaseAngle = 0; - ShapeKind? _shapeMode; - _EditTab _tab = _EditTab.draw; - bool _paletteOpen = false; - bool _shapesOpen = false; - bool _baking = false; + const maxDim = 4096; + final srcMax = math.max(image.width, image.height); + final cap = srcMax > maxDim ? maxDim / srcMax : 1.0; + final outW = (image.width * cap).round(); + final outH = (image.height * cap).round(); + final scale = outW / box.width; - @override - void dispose() { - _canvasRev.dispose(); - super.dispose(); - } + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(scale); + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + Rect.fromLTWH(0, 0, box.width, box.height), + Paint(), + ); + DrawingPainter(marks: marks).paintMarks(canvas, box); + final picture = recorder.endRecording(); + image.dispose(); + codec.dispose(); - void _bumpCanvas() => _canvasRev.value++; - - void _undo() { - if (_marks.isEmpty) return; - if (identical(_marks.last, _selectedText)) _selectedText = null; - setState(() => _marks.removeLast()); - } - - void _clearAll() { - if (_marks.isEmpty) return; - _selectedText = null; - setState(_marks.clear); - } - - void _onPanStart(Offset pos) { - if (_tab == _EditTab.text) { - final sel = _selectedText; - if (sel != null && _nearHandle(sel, pos)) { - final v = pos - sel.position; - _resizingText = true; - _resizeBaseSize = sel.fontSize; - _resizeBaseDist = math.max(8, v.distance); - _resizeBaseRotation = sel.rotation; - _resizeBaseAngle = math.atan2(v.dy, v.dx); - return; - } - final hit = _hitText(pos); - _draggingText = hit; - if (hit != null && !identical(hit, _selectedText)) { - _selectedText = hit; - _bumpCanvas(); - } - return; - } - final shape = _shapeMode; - if (shape != null) { - _liveShape = ShapeMark( - kind: shape, - start: pos, - end: pos, - color: _color, - width: _width, - ); - } else { - _liveStroke = StrokeMark( - points: [pos], - color: _color, - width: _width, - tool: _tool, - ); - } - _bumpCanvas(); - } - - void _onPanUpdate(Offset pos) { - if (_tab == _EditTab.text) { - if (_resizingText) { - final sel = _selectedText; - if (sel != null) { - final v = pos - sel.position; - final angle = math.atan2(v.dy, v.dx); - sel.fontSize = (_resizeBaseSize * v.distance / _resizeBaseDist).clamp( - 10.0, - 200.0, - ); - sel.rotation = _resizeBaseRotation + (angle - _resizeBaseAngle); - _bumpCanvas(); - } - return; - } - final t = _draggingText; - if (t != null) { - t.position = pos; - _bumpCanvas(); - } - return; - } - final shape = _liveShape; - if (shape != null) { - _liveShape = ShapeMark( - kind: shape.kind, - start: shape.start, - end: pos, - color: shape.color, - width: shape.width, - ); - _bumpCanvas(); - } else if (_liveStroke != null) { - final pts = _liveStroke!.points; - if (pts.isEmpty || (pos - pts.last).distance >= 2.0) { - pts.add(pos); - _bumpCanvas(); - } - } - } - - void _onPanEnd() { - if (_tab == _EditTab.text) { - _resizingText = false; - _draggingText = null; - return; - } - final shape = _liveShape; - if (shape != null) { - if ((shape.end - shape.start).distance > 4) _marks.add(shape); - setState(() { - _liveShape = null; - _shapeMode = null; - }); - } else if (_liveStroke != null) { - if (_liveStroke!.points.isNotEmpty) _marks.add(_liveStroke!); - setState(() => _liveStroke = null); - } - } - - TextMark? _hitText(Offset pos) { - for (final m in _marks.reversed) { - if (m is! TextMark) continue; - final local = _toLocal(pos, m); - final box = textMarkSize(m); - if (local.dx.abs() <= box.width / 2 && local.dy.abs() <= box.height / 2) { - return m; - } - } + return await rasterPictureToJpegFile(picture, outW, outH, prefix: 'edit'); + } catch (_) { return null; } - - Offset _toLocal(Offset pos, TextMark t) { - final v = pos - t.position; - final c = math.cos(-t.rotation); - final s = math.sin(-t.rotation); - return Offset(v.dx * c - v.dy * s, v.dx * s + v.dy * c); - } - - bool _nearHandle(TextMark t, Offset pos) { - final (left, right) = handlePositions(t); - return (pos - left).distance < 26 || (pos - right).distance < 26; - } - - Future _addText() async { - final l10n = AppLocalizations.of(context)!; - final controller = TextEditingController(); - final String? text; - try { - text = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), - title: Text( - l10n.photoEditorTextDialogTitle, - style: const TextStyle(color: Colors.white), - ), - content: TextField( - controller: controller, - autofocus: true, - style: const TextStyle(color: Colors.white), - cursorColor: Colors.white, - decoration: InputDecoration( - hintText: l10n.photoEditorTextDialogHint, - hintStyle: const TextStyle(color: Colors.white38), - ), - onSubmitted: (v) => Navigator.pop(ctx, v), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text(l10n.spoofDialogCancel), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: Text(l10n.photoEditorOk), - ), - ], - ), - ); - } finally { - controller.dispose(); - } - if (text == null || text.trim().isEmpty || !mounted) return; - final ro = _boundaryKey.currentContext?.findRenderObject(); - final size = ro is RenderBox ? ro.size : const Size(300, 300); - final mark = TextMark( - text: text.trim(), - position: Offset(size.width / 2, size.height / 2), - color: _color, - fontSize: 34, - ); - setState(() { - _marks.add(mark); - _selectedText = mark; - }); - } - - Future _apply() async { - if (_baking) return; - if (_marks.isEmpty) { - Navigator.of(context).pop(); - return; - } - setState(() => _baking = true); - final file = await _bake(); - if (!mounted) return; - if (file == null) { - setState(() => _baking = false); - showCustomNotification( - context, - AppLocalizations.of(context)!.photoEditorApplyChangesFailed, - ); - return; - } - Navigator.of(context).pop(file); - } - - Future _bake() async { - final ro = _boundaryKey.currentContext?.findRenderObject(); - if (ro is! RenderBox || ro.size.isEmpty) return null; - final box = ro.size; - try { - final bytes = await widget.source.readAsBytes(); - final codec = await ui.instantiateImageCodec(bytes); - final frame = await codec.getNextFrame(); - final image = frame.image; - - const maxDim = 4096; - final srcMax = math.max(image.width, image.height); - final cap = srcMax > maxDim ? maxDim / srcMax : 1.0; - final outW = (image.width * cap).round(); - final outH = (image.height * cap).round(); - final scale = outW / box.width; - - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - canvas.scale(scale); - canvas.drawImageRect( - image, - Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), - Rect.fromLTWH(0, 0, box.width, box.height), - Paint(), - ); - _DrawingPainter(marks: _marks).paintMarks(canvas, box); - final picture = recorder.endRecording(); - image.dispose(); - codec.dispose(); - - return await rasterPictureToJpegFile(picture, outW, outH, prefix: 'edit'); - } catch (_) { - return null; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - Column( - children: [ - _buildTopBar(), - Expanded(child: _buildCanvas()), - _buildBottomPanel(), - ], - ), - if (_tab == _EditTab.draw) _buildSideSlider(), - if (_baking) const BusyOverlay(), - ], - ), - ); - } - - Widget _buildTopBar() { - return SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Row( - children: [ - IconButton( - onPressed: _marks.isEmpty ? null : _undo, - icon: const Icon(Symbols.undo), - color: Colors.white, - disabledColor: Colors.white24, - ), - const Spacer(), - TextButton( - onPressed: _marks.isEmpty ? null : _clearAll, - child: Text( - AppLocalizations.of(context)!.photoEditorClearAll, - style: TextStyle( - color: _marks.isEmpty ? Colors.white24 : Colors.white, - fontSize: 15, - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildCanvas() { - final aspect = widget.imageHeight > 0 - ? widget.imageWidth / widget.imageHeight - : 1.0; - return Center( - child: AspectRatio( - aspectRatio: aspect <= 0 ? 1.0 : aspect, - child: ValueListenableBuilder( - valueListenable: _canvasRev, - child: Image.file( - widget.source, - fit: BoxFit.cover, - gaplessPlayback: true, - ), - builder: (context, _, image) { - final selected = _tab == _EditTab.text ? _selectedText : null; - return Stack( - fit: StackFit.expand, - children: [ - RepaintBoundary( - key: _boundaryKey, - child: Stack( - fit: StackFit.expand, - children: [ - image!, - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (d) => _onPanStart(d.localPosition), - onPanUpdate: (d) => _onPanUpdate(d.localPosition), - onPanEnd: (_) => _onPanEnd(), - child: CustomPaint( - painter: _DrawingPainter( - marks: _marks, - live: _liveStroke ?? _liveShape, - ), - ), - ), - ), - ], - ), - ), - if (selected != null) - Positioned.fill( - child: IgnorePointer( - child: CustomPaint(painter: _SelectionPainter(selected)), - ), - ), - ], - ); - }, - ), - ), - ); - } - - Widget _buildSideSlider() { - return Positioned( - left: 2, - top: 0, - bottom: 0, - child: Center( - child: SizedBox( - height: 220, - child: RotatedBox( - quarterTurns: 3, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 3, - thumbColor: Colors.white, - activeTrackColor: Colors.white, - inactiveTrackColor: Colors.white24, - overlayShape: SliderComponentShape.noOverlay, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9), - ), - child: Slider( - min: 2, - max: 40, - value: _width, - onChanged: (v) => setState(() => _width = v), - ), - ), - ), - ), - ), - ); - } - - Widget _buildBottomPanel() { - return Container( - color: _kDrawPanel, - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_paletteOpen) _buildColorPicker(), - if (_shapesOpen && _tab == _EditTab.draw) _buildShapesRow(), - _buildToolbar(), - const SizedBox(height: 2), - _buildTabs(), - ], - ), - ), - ); - } - - Widget _buildToolbar() { - switch (_tab) { - case _EditTab.draw: - return _buildDrawToolbar(); - case _EditTab.text: - return _buildTextToolbar(); - case _EditTab.stickers: - return const SizedBox(height: 56); - } - } - - Widget _buildDrawToolbar() { - return SizedBox( - height: 56, - child: Row( - children: [ - const SizedBox(width: 10), - _buildColorButton(), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildToolButton(DrawTool.pen, Symbols.edit), - _buildToolButton(DrawTool.marker, Symbols.ink_highlighter), - _buildToolButton(DrawTool.neon, Symbols.auto_awesome), - _buildToolButton(DrawTool.eraser, Symbols.ink_eraser), - ], - ), - ), - IconButton( - onPressed: () => setState(() { - _shapesOpen = !_shapesOpen; - _paletteOpen = false; - }), - icon: Icon( - Symbols.add, - color: _shapeMode != null ? _color : Colors.white, - ), - ), - const SizedBox(width: 8), - ], - ), - ); - } - - Widget _buildTextToolbar() { - return SizedBox( - height: 56, - child: Row( - children: [ - const SizedBox(width: 10), - _buildColorButton(), - const SizedBox(width: 14), - TextButton.icon( - onPressed: _addText, - icon: const Icon(Symbols.add, color: Colors.white), - label: Text( - AppLocalizations.of(context)!.photoEditorAddText, - style: const TextStyle(color: Colors.white, fontSize: 15), - ), - ), - const Spacer(), - ], - ), - ); - } - - Widget _buildColorButton() { - return GestureDetector( - onTap: () => setState(() { - _paletteOpen = !_paletteOpen; - _shapesOpen = false; - }), - child: Container( - width: 32, - height: 32, - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - shape: BoxShape.circle, - gradient: SweepGradient( - colors: [ - Color(0xFFFF3B30), - Color(0xFFFFCC00), - kOnlineGreen, - Color(0xFF00C7BE), - kEditorAccent, - Color(0xFFAF52DE), - Color(0xFFFF3B30), - ], - ), - ), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _color, - border: Border.all(color: Colors.white, width: 1.5), - ), - ), - ), - ); - } - - Widget _buildToolButton(DrawTool tool, IconData icon) { - final selected = _shapeMode == null && _tool == tool; - return GestureDetector( - onTap: () => setState(() { - _tool = tool; - _shapeMode = null; - _shapesOpen = false; - }), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 3), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: selected - ? Colors.white.withValues(alpha: 0.18) - : Colors.transparent, - ), - child: Icon( - icon, - color: selected ? Colors.white : Colors.white60, - size: 24, - ), - ), - ); - } - - Widget _buildColorPicker() { - return _ColorPicker( - color: _color, - onChanged: (c) => setState(() { - _color = c; - if (_tab == _EditTab.text) _selectedText?.color = c; - }), - ); - } - - Widget _buildShapesRow() { - const shapes = <(ShapeKind, IconData)>[ - (ShapeKind.circle, Symbols.circle), - (ShapeKind.rectangle, Symbols.rectangle), - (ShapeKind.star, Symbols.star), - (ShapeKind.cloud, Symbols.cloud), - (ShapeKind.arrow, Symbols.north_east), - ]; - return SizedBox( - height: 48, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final (kind, icon) in shapes) - IconButton( - onPressed: () => setState(() { - _shapeMode = kind; - _shapesOpen = false; - }), - icon: Icon( - icon, - color: _shapeMode == kind ? _color : Colors.white, - ), - ), - ], - ), - ); - } - - Widget _buildTabs() { - final l10n = AppLocalizations.of(context)!; - return SizedBox( - height: 48, - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Symbols.close, color: Colors.white), - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildTab(l10n.photoEditorTabDraw, _EditTab.draw), - _buildTab( - l10n.photoEditorTabStickers, - _EditTab.stickers, - disabled: true, - ), - _buildTab(l10n.photoEditorTabText, _EditTab.text), - ], - ), - ), - IconButton( - onPressed: _baking ? null : _apply, - icon: const Icon(Symbols.check, color: Colors.white), - ), - ], - ), - ); - } - - Widget _buildTab(String label, _EditTab tab, {bool disabled = false}) { - final selected = _tab == tab; - return GestureDetector( - onTap: disabled - ? null - : () => setState(() { - _tab = tab; - _paletteOpen = false; - _shapesOpen = false; - if (tab != _EditTab.draw) _shapeMode = null; - }), - child: Text( - label, - style: TextStyle( - color: disabled - ? Colors.white24 - : (selected ? Colors.white : Colors.white60), - fontSize: 14, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - letterSpacing: 0.5, - ), - ), - ); - } -} - -class _DrawingPainter extends CustomPainter { - final List marks; - final EditMark? live; - - _DrawingPainter({required this.marks, this.live}); - - @override - void paint(Canvas canvas, Size size) => paintMarks(canvas, size); - - void paintMarks(Canvas canvas, Size size) { - final needsLayer = _hasEraser(); - if (needsLayer) canvas.saveLayer(Offset.zero & size, Paint()); - for (final m in marks) { - _paintMark(canvas, m); - } - final l = live; - if (l != null) _paintMark(canvas, l); - if (needsLayer) canvas.restore(); - } - - bool _hasEraser() { - for (final m in marks) { - if (m is StrokeMark && m.tool == DrawTool.eraser) return true; - } - final l = live; - return l is StrokeMark && l.tool == DrawTool.eraser; - } - - void _paintMark(Canvas canvas, EditMark m) { - switch (m) { - case StrokeMark s: - _paintStroke(canvas, s); - case ShapeMark sh: - _paintShape(canvas, sh); - case TextMark t: - _paintText(canvas, t); - } - } - - void _paintStroke(Canvas canvas, StrokeMark s) { - final paint = Paint() - ..color = s.color - ..strokeWidth = s.width - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..style = PaintingStyle.stroke; - - switch (s.tool) { - case DrawTool.pen: - break; - case DrawTool.marker: - paint.color = s.color.withValues(alpha: 0.4); - paint.strokeWidth = s.width * 1.6; - paint.strokeCap = StrokeCap.square; - case DrawTool.neon: - final glow = Paint() - ..color = s.color.withValues(alpha: 0.7) - ..strokeWidth = s.width * 2 - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..style = PaintingStyle.stroke - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); - _drawStrokeGeometry(canvas, s, glow); - paint.color = Colors.white; - case DrawTool.eraser: - paint.blendMode = BlendMode.clear; - } - - _drawStrokeGeometry(canvas, s, paint); - } - - void _drawStrokeGeometry(Canvas canvas, StrokeMark s, Paint paint) { - if (s.points.length < 2) { - final dot = Paint() - ..color = paint.color - ..blendMode = paint.blendMode - ..maskFilter = paint.maskFilter - ..style = PaintingStyle.fill; - canvas.drawCircle(s.points.first, paint.strokeWidth / 2, dot); - return; - } - final path = Path()..moveTo(s.points.first.dx, s.points.first.dy); - for (var i = 1; i < s.points.length; i++) { - path.lineTo(s.points[i].dx, s.points[i].dy); - } - canvas.drawPath(path, paint); - } - - void _paintShape(Canvas canvas, ShapeMark sh) { - final paint = Paint() - ..color = sh.color - ..strokeWidth = sh.width - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round; - final rect = Rect.fromPoints(sh.start, sh.end); - switch (sh.kind) { - case ShapeKind.circle: - canvas.drawOval(rect, paint); - case ShapeKind.rectangle: - canvas.drawRRect( - RRect.fromRectAndRadius(rect, const Radius.circular(10)), - paint, - ); - case ShapeKind.star: - canvas.drawPath(_starPath(rect), paint); - case ShapeKind.cloud: - canvas.drawPath(_cloudPath(rect), paint); - case ShapeKind.arrow: - _paintArrow(canvas, sh.start, sh.end, paint); - } - } - - Path _starPath(Rect rect) { - final cx = rect.center.dx; - final cy = rect.center.dy; - final outer = math.min(rect.width.abs(), rect.height.abs()) / 2; - final inner = outer * 0.45; - final path = Path(); - for (var i = 0; i < 10; i++) { - final r = i.isEven ? outer : inner; - final angle = -math.pi / 2 + i * math.pi / 5; - final x = cx + r * math.cos(angle); - final y = cy + r * math.sin(angle); - if (i == 0) { - path.moveTo(x, y); - } else { - path.lineTo(x, y); - } - } - path.close(); - return path; - } - - Path _cloudPath(Rect rect) { - final w = rect.width; - final h = rect.height; - Offset pt(double nx, double ny) => - Offset(rect.left + nx * w, rect.top + ny * h); - final path = Path()..moveTo(pt(0.25, 0.78).dx, pt(0.25, 0.78).dy); - path - ..cubicTo( - pt(0.0, 0.78).dx, - pt(0.0, 0.78).dy, - pt(0.0, 0.45).dx, - pt(0.0, 0.45).dy, - pt(0.22, 0.42).dx, - pt(0.22, 0.42).dy, - ) - ..cubicTo( - pt(0.2, 0.12).dx, - pt(0.2, 0.12).dy, - pt(0.56, 0.08).dx, - pt(0.56, 0.08).dy, - pt(0.62, 0.36).dx, - pt(0.62, 0.36).dy, - ) - ..cubicTo( - pt(0.86, 0.24).dx, - pt(0.86, 0.24).dy, - pt(1.02, 0.5).dx, - pt(1.02, 0.5).dy, - pt(0.8, 0.6).dx, - pt(0.8, 0.6).dy, - ) - ..cubicTo( - pt(1.02, 0.66).dx, - pt(1.02, 0.66).dy, - pt(0.96, 0.9).dx, - pt(0.96, 0.9).dy, - pt(0.74, 0.8).dx, - pt(0.74, 0.8).dy, - ) - ..cubicTo( - pt(0.7, 0.98).dx, - pt(0.7, 0.98).dy, - pt(0.34, 0.98).dx, - pt(0.34, 0.98).dy, - pt(0.25, 0.78).dx, - pt(0.25, 0.78).dy, - ) - ..close(); - return path; - } - - void _paintArrow(Canvas canvas, Offset start, Offset end, Paint paint) { - canvas.drawLine(start, end, paint); - final angle = math.atan2(end.dy - start.dy, end.dx - start.dx); - final headLen = math.max(paint.strokeWidth * 4, 18.0); - const headAngle = math.pi / 7; - final p1 = - end - - Offset(math.cos(angle - headAngle), math.sin(angle - headAngle)) * - headLen; - final p2 = - end - - Offset(math.cos(angle + headAngle), math.sin(angle + headAngle)) * - headLen; - canvas.drawLine(end, p1, paint); - canvas.drawLine(end, p2, paint); - } - - void _paintText(Canvas canvas, TextMark t) { - final tp = layoutText(t); - canvas.save(); - canvas.translate(t.position.dx, t.position.dy); - canvas.rotate(t.rotation); - tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2)); - canvas.restore(); - } - - @override - bool shouldRepaint(covariant _DrawingPainter oldDelegate) => true; -} - -final Expando<_TextLayout> _textLayoutCache = Expando<_TextLayout>(); - -class _TextLayout { - final String text; - final double fontSize; - final Color color; - final TextPainter painter; - - _TextLayout(this.text, this.fontSize, this.color, this.painter); -} - -TextPainter layoutText(TextMark t) { - final cached = _textLayoutCache[t]; - if (cached != null && - cached.text == t.text && - cached.fontSize == t.fontSize && - cached.color == t.color) { - return cached.painter; - } - final tp = TextPainter( - text: TextSpan( - text: t.text, - style: TextStyle( - color: t.color, - fontSize: t.fontSize, - fontWeight: FontWeight.w600, - shadows: const [Shadow(blurRadius: 4, color: Colors.black54)], - ), - ), - textAlign: TextAlign.center, - textDirection: TextDirection.ltr, - )..layout(maxWidth: 2000); - _textLayoutCache[t] = _TextLayout(t.text, t.fontSize, t.color, tp); - return tp; -} - -Size textMarkSize(TextMark t) { - final tp = layoutText(t); - return Size(tp.width + 32, tp.height + 24); -} - -(Offset, Offset) handlePositions(TextMark t) { - final hw = textMarkSize(t).width / 2; - final c = math.cos(t.rotation); - final s = math.sin(t.rotation); - return ( - t.position + Offset(-hw * c, -hw * s), - t.position + Offset(hw * c, hw * s), - ); -} - -class _SelectionPainter extends CustomPainter { - final TextMark text; - - _SelectionPainter(this.text); - - @override - void paint(Canvas canvas, Size size) { - final box = textMarkSize(text); - final hw = box.width / 2; - final hh = box.height / 2; - canvas.save(); - canvas.translate(text.position.dx, text.position.dy); - canvas.rotate(text.rotation); - - final border = Paint() - ..color = Colors.white - ..strokeWidth = 1.5 - ..style = PaintingStyle.stroke; - final tl = Offset(-hw, -hh); - final tr = Offset(hw, -hh); - final br = Offset(hw, hh); - final bl = Offset(-hw, hh); - _dashedLine(canvas, tl, tr, border); - _dashedLine(canvas, tr, br, border); - _dashedLine(canvas, br, bl, border); - _dashedLine(canvas, bl, tl, border); - - final fill = Paint() - ..color = kEditorAccent - ..style = PaintingStyle.fill; - final ring = Paint() - ..color = Colors.white - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - for (final c in [Offset(-hw, 0), Offset(hw, 0)]) { - canvas.drawCircle(c, 7, fill); - canvas.drawCircle(c, 7, ring); - } - canvas.restore(); - } - - void _dashedLine(Canvas canvas, Offset a, Offset b, Paint paint) { - const dash = 7.0; - const gap = 5.0; - final total = (b - a).distance; - if (total <= 0) return; - final dir = (b - a) / total; - var d = 0.0; - while (d < total) { - final start = a + dir * d; - final end = a + dir * math.min(d + dash, total); - canvas.drawLine(start, end, paint); - d += dash + gap; - } - } - - @override - bool shouldRepaint(covariant _SelectionPainter oldDelegate) => true; -} - -class _ColorPicker extends StatefulWidget { - final Color color; - final ValueChanged onChanged; - - const _ColorPicker({required this.color, required this.onChanged}); - - @override - State<_ColorPicker> createState() => _ColorPickerState(); -} - -class _ColorPickerState extends State<_ColorPicker> { - late HSVColor _hsv; - - @override - void initState() { - super.initState(); - final hsv = HSVColor.fromColor(widget.color); - _hsv = hsv.saturation == 0 ? hsv.withHue(0) : hsv; - } - - void _setSV(Offset pos, Size size) { - if (size.width <= 0 || size.height <= 0) return; - final s = (pos.dx / size.width).clamp(0.0, 1.0); - final v = (1 - pos.dy / size.height).clamp(0.0, 1.0); - setState(() => _hsv = _hsv.withSaturation(s).withValue(v)); - widget.onChanged(_hsv.toColor()); - } - - void _setHue(double dx, double width) { - if (width <= 0) return; - setState(() => _hsv = _hsv.withHue((dx / width).clamp(0.0, 1.0) * 360)); - widget.onChanged(_hsv.toColor()); - } - - @override - Widget build(BuildContext context) { - final hueColor = HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor(); - return Container( - color: _kDrawPanel, - padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 132, - child: LayoutBuilder( - builder: (context, constraints) { - final size = constraints.biggest; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (d) => _setSV(d.localPosition, size), - onPanUpdate: (d) => _setSV(d.localPosition, size), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [Colors.white, hueColor], - ), - ), - ), - ), - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black], - ), - ), - ), - ), - Positioned( - left: _hsv.saturation * size.width - 9, - top: (1 - _hsv.value) * size.height - 9, - child: _thumb(_hsv.toColor()), - ), - ], - ), - ), - ); - }, - ), - ), - const SizedBox(height: 14), - SizedBox( - height: 22, - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (d) => _setHue(d.localPosition.dx, width), - onPanUpdate: (d) => _setHue(d.localPosition.dx, width), - child: ClipRRect( - borderRadius: BorderRadius.circular(11), - child: Stack( - children: [ - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xFFFF0000), - Color(0xFFFFFF00), - Color(0xFF00FF00), - Color(0xFF00FFFF), - Color(0xFF0000FF), - Color(0xFFFF00FF), - Color(0xFFFF0000), - ], - ), - ), - ), - ), - Positioned( - left: (_hsv.hue / 360) * width - 9, - top: 1, - bottom: 1, - child: _thumb(hueColor), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ); - } - - Widget _thumb(Color color) { - return Container( - width: 18, - height: 18, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)], - ), - ); - } } enum BlurMode { off, radial, linear } @@ -1867,8 +228,9 @@ enum _Tab { adjust, blur, curves } class PhotoAdjustEditor extends StatefulWidget { final File source; + final Future Function(File result)? onPreview; - const PhotoAdjustEditor({super.key, required this.source}); + const PhotoAdjustEditor({super.key, required this.source, this.onPreview}); @override State createState() => _PhotoAdjustEditorState(); @@ -1878,12 +240,7 @@ class _PhotoAdjustEditorState extends State { ui.Image? _image; final ValueNotifier _rev = ValueNotifier(0); - double _enhance = 0; - double _exposure = 0; - double _contrast = 0; - double _saturation = 0; - double _warmth = 0; - double _vignette = 0; + final ColorAdjust _adjust = ColorAdjust(); BlurMode _blur = BlurMode.off; Offset _blurCenter = const Offset(0.5, 0.5); double _blurInner = 0.18; @@ -1962,28 +319,7 @@ class _PhotoAdjustEditorState extends State { bool get _curvesIdentity => _curves.every(_curveIdentity); bool get _pristine => - _enhance == 0 && - _exposure == 0 && - _contrast == 0 && - _saturation == 0 && - _warmth == 0 && - _vignette == 0 && - _blur == BlurMode.off && - _curvesIdentity; - - List _colorMatrix() { - var m = _identity(); - m = _mulMatrix(_brightness(1 + _exposure), m); - m = _mulMatrix(_contrastMatrix(1 + _contrast), m); - m = _mulMatrix(_saturationMatrix(1 + _saturation), m); - m = _mulMatrix(_warmthMatrix(_warmth), m); - if (_enhance > 0) { - m = _mulMatrix(_contrastMatrix(1 + _enhance * 0.35), m); - m = _mulMatrix(_saturationMatrix(1 + _enhance * 0.4), m); - m = _mulMatrix(_brightness(1 + _enhance * 0.05), m); - } - return m; - } + _adjust.pristine && _blur == BlurMode.off && _curvesIdentity; Gradient _maskGradient() { if (_blur == BlurMode.linear) { @@ -2225,15 +561,6 @@ class _PhotoAdjustEditorState extends State { } } - Gradient _vignetteGradient() => RadialGradient( - radius: 0.9, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: (_vignette * 0.6).clamp(0.0, 1.0)), - ], - stops: const [0.5, 1.0], - ); - Future _bake() async { final img = _image; if (img == null) return null; @@ -2258,7 +585,7 @@ class _PhotoAdjustEditorState extends State { canvas.saveLayer( rect, - Paint()..colorFilter = ColorFilter.matrix(_colorMatrix()), + Paint()..colorFilter = ColorFilter.matrix(_adjust.matrix()), ); if (_blur == BlurMode.off) { canvas.drawImageRect(curved, src, rect, Paint()); @@ -2283,10 +610,10 @@ class _PhotoAdjustEditorState extends State { } canvas.restore(); - if (_vignette > 0) { + if (_adjust.vignette > 0) { canvas.drawRect( rect, - Paint()..shader = _vignetteGradient().createShader(rect), + Paint()..shader = _adjust.vignetteGradient().createShader(rect), ); } @@ -2322,6 +649,8 @@ class _PhotoAdjustEditorState extends State { ); return; } + await widget.onPreview?.call(file); + if (!mounted) return; Navigator.of(context).pop(file); } @@ -2334,7 +663,11 @@ class _PhotoAdjustEditorState extends State { children: [ Column( children: [ - Expanded(child: ClipRect(child: _buildPreview())), + Expanded( + child: PhotoHeroTarget( + child: ClipRect(child: _buildPreview()), + ), + ), _buildTabContent(), _buildBottomBar(), ], @@ -2349,9 +682,7 @@ class _PhotoAdjustEditorState extends State { Widget _buildPreview() { final img = _image; if (img == null) { - return const Center( - child: CircularProgressIndicator(color: Colors.white), - ); + return const Center(child: SmallSpinner(size: 36, color: Colors.white)); } return LayoutBuilder( builder: (context, constraints) { @@ -2366,13 +697,15 @@ class _PhotoAdjustEditorState extends State { fit: StackFit.expand, children: [ ColorFiltered( - colorFilter: ColorFilter.matrix(_colorMatrix()), + colorFilter: ColorFilter.matrix(_adjust.matrix()), child: _buildBlurLayer(shown), ), - if (_vignette > 0) + if (_adjust.vignette > 0) IgnorePointer( child: DecoratedBox( - decoration: BoxDecoration(gradient: _vignetteGradient()), + decoration: BoxDecoration( + gradient: _adjust.vignetteGradient(), + ), ), ), if (blurTab) @@ -2535,101 +868,8 @@ class _PhotoAdjustEditorState extends State { Widget _buildSliders() { return ValueListenableBuilder( valueListenable: _rev, - builder: (context, _, _) { - final l10n = AppLocalizations.of(context)!; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _slider( - l10n.photoEditorEnhance, - _enhance, - 0, - 1, - (v) => _enhance = v, - ), - _slider( - l10n.photoEditorExposure, - _exposure, - -1, - 1, - (v) => _exposure = v, - ), - _slider( - l10n.photoEditorContrast, - _contrast, - -1, - 1, - (v) => _contrast = v, - ), - _slider( - l10n.photoEditorSaturation, - _saturation, - -1, - 1, - (v) => _saturation = v, - ), - _slider( - l10n.photoEditorWarmth, - _warmth, - -1, - 1, - (v) => _warmth = v, - ), - _slider( - l10n.photoEditorVignette, - _vignette, - 0, - 1, - (v) => _vignette = v, - ), - ], - ), - ); - }, - ); - } - - Widget _slider( - String label, - double value, - double min, - double max, - ValueChanged onChanged, - ) { - return Row( - children: [ - SizedBox( - width: 104, - child: Text( - label, - style: const TextStyle(color: Colors.white70, fontSize: 13), - overflow: TextOverflow.ellipsis, - ), - ), - Expanded( - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 2, - thumbColor: Colors.white, - activeTrackColor: Colors.white, - inactiveTrackColor: Colors.white24, - overlayShape: SliderComponentShape.noOverlay, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), - ), - child: Slider( - min: min, - max: max, - value: value.clamp(min, max), - onChanged: (v) { - onChanged(v); - _rev.value++; - }, - ), - ), - ), - ], + builder: (context, _, _) => + AdjustSliders(adjust: _adjust, onChanged: () => _rev.value++), ); } @@ -2664,12 +904,16 @@ class _PhotoAdjustEditorState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: selected ? kEditorAccent : Colors.white, size: 30), + Icon( + icon, + color: selected ? MediaAccent.of(context) : Colors.white, + size: 30, + ), const SizedBox(height: 6), Text( label, style: TextStyle( - color: selected ? kEditorAccent : Colors.white70, + color: selected ? MediaAccent.of(context) : Colors.white70, fontSize: 12, ), ), @@ -2681,7 +925,7 @@ class _PhotoAdjustEditorState extends State { Widget _buildBottomBar() { final l10n = AppLocalizations.of(context)!; return Container( - color: _kPanel, + color: kEditorPanel, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ @@ -2704,7 +948,7 @@ class _PhotoAdjustEditorState extends State { child: Text( l10n.photoEditorDone, style: TextStyle( - color: _baking ? Colors.white38 : kEditorAccent, + color: _baking ? Colors.white38 : MediaAccent.of(context), fontSize: 15, fontWeight: FontWeight.w600, ), @@ -2720,97 +964,12 @@ class _PhotoAdjustEditorState extends State { return IconButton( onPressed: disabled ? null : () => setState(() => _tab = tab), icon: Icon(icon), - color: selected ? kEditorAccent : Colors.white, + color: selected ? MediaAccent.of(context) : Colors.white, disabledColor: Colors.white24, ); } } -List _identity() => [ - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, -]; - -List _brightness(double f) => [ - f, - 0, - 0, - 0, - 0, - 0, - f, - 0, - 0, - 0, - 0, - 0, - f, - 0, - 0, - 0, - 0, - 0, - 1, - 0, -]; - -List _contrastMatrix(double c) { - final t = 127.5 * (1 - c); - return [c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0]; -} - -List _saturationMatrix(double s) { - const lr = 0.2126; - const lg = 0.7152; - const lb = 0.0722; - final i = 1 - s; - return [ - lr * i + s, - lg * i, - lb * i, - 0, - 0, - lr * i, - lg * i + s, - lb * i, - 0, - 0, - lr * i, - lg * i, - lb * i + s, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - ]; -} - -List _warmthMatrix(double w) { - final o = w * 25.0; - return [1, 0, 0, 0, o, 0, 1, 0, 0, 0, 0, 0, 1, 0, -o, 0, 0, 0, 1, 0]; -} - Uint8List _applyLutsToBytes((Uint8List, List, List, List) args) { final (rgba, rl, gl, bl) = args; for (var i = 0; i < rgba.length; i += 4) { @@ -2821,22 +980,6 @@ Uint8List _applyLutsToBytes((Uint8List, List, List, List) args) { return rgba; } -List _mulMatrix(List a, List b) { - double at(List m, int r, int c) => - r < 4 ? m[r * 5 + c] : (c == 4 ? 1.0 : 0.0); - final out = List.filled(20, 0); - for (var r = 0; r < 4; r++) { - for (var c = 0; c < 5; c++) { - var sum = 0.0; - for (var k = 0; k < 5; k++) { - sum += at(a, r, k) * at(b, k, c); - } - out[r * 5 + c] = sum; - } - } - return out; -} - class _RotateAround extends GradientTransform { final double radians; final Offset center; diff --git a/lib/frontend/widgets/attachment/photo_hero.dart b/lib/frontend/widgets/attachment/photo_hero.dart new file mode 100644 index 0000000..0cd2b0a --- /dev/null +++ b/lib/frontend/widgets/attachment/photo_hero.dart @@ -0,0 +1,363 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +typedef PhotoHeroOrigin = Rect? Function(); + +Rect? photoHeroRect(GlobalKey? key) => + _globalRect(key?.currentContext?.findRenderObject()); + +Rect? photoHeroRectOf(BuildContext context) => + context.mounted ? _globalRect(context.findRenderObject()) : null; + +Rect? _globalRect(RenderObject? object) { + if (object is! RenderBox || !object.attached || !object.hasSize) return null; + final size = object.size; + if (size.isEmpty) return null; + return MatrixUtils.transformRect( + object.getTransformTo(null), + Offset.zero & size, + ); +} + +Rect inscribeRect(Size source, Rect box, {bool cover = false}) => + _inscribe(source, box, cover: cover); + +Rect _inscribe(Size source, Rect box, {required bool cover}) { + if (source.isEmpty || box.isEmpty) return box; + final scaleX = box.width / source.width; + final scaleY = box.height / source.height; + final scale = cover ? math.max(scaleX, scaleY) : math.min(scaleX, scaleY); + return Alignment.center.inscribe( + Size(source.width * scale, source.height * scale), + box, + ); +} + +class PhotoHeroController { + PhotoHeroController({ + required this.origin, + ImageProvider? image, + this.size, + this.radius = BorderRadius.zero, + }) : image = ValueNotifier(image); + + final PhotoHeroOrigin origin; + final ValueNotifier image; + final ValueNotifier flying = ValueNotifier(false); + final Size? size; + final BorderRadius radius; + final GlobalKey areaKey = GlobalKey(); + + bool enabled = true; + + Rect? get areaRect => photoHeroRect(areaKey); + + Rect? get originRect => enabled ? origin() : null; + + bool get canFly => image.value != null && originRect != null; + + void dispose() { + image.dispose(); + flying.dispose(); + } +} + +class PhotoHeroRoute extends PageRouteBuilder { + PhotoHeroRoute({required this.hero, required WidgetBuilder builder}) + : super( + transitionDuration: const Duration(milliseconds: 320), + reverseTransitionDuration: const Duration(milliseconds: 280), + pageBuilder: (context, animation, secondaryAnimation) => + PhotoHeroScope(controller: hero, child: builder(context)), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + _PhotoHeroTransition( + controller: hero, + animation: animation, + child: child, + ), + ); + + final PhotoHeroController hero; + + @override + void dispose() { + hero.dispose(); + super.dispose(); + } +} + +class PhotoHeroScope extends InheritedWidget { + const PhotoHeroScope({ + super.key, + required this.controller, + required super.child, + }); + + final PhotoHeroController controller; + + static PhotoHeroController? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.controller; + + @override + bool updateShouldNotify(PhotoHeroScope oldWidget) => + controller != oldWidget.controller; +} + +class PhotoHeroTarget extends StatelessWidget { + const PhotoHeroTarget({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => + PhotoHeroAnchor(child: PhotoHeroFade(child: child)); +} + +class PhotoHeroAnchor extends StatelessWidget { + const PhotoHeroAnchor({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final controller = PhotoHeroScope.maybeOf(context); + if (controller == null) return child; + return KeyedSubtree(key: controller.areaKey, child: child); + } +} + +class PhotoHeroFade extends StatelessWidget { + const PhotoHeroFade({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final controller = PhotoHeroScope.maybeOf(context); + if (controller == null) return child; + return ValueListenableBuilder( + valueListenable: controller.flying, + child: child, + builder: (context, flying, child) => + flying ? Opacity(opacity: 0, child: child) : child!, + ); + } +} + +class RawImageProvider extends ImageProvider { + const RawImageProvider(this.image); + + final ui.Image image; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + RawImageProvider key, + ImageDecoderCallback decode, + ) => OneFrameImageStreamCompleter( + SynchronousFuture(ImageInfo(image: image.clone())), + ); + + @override + bool operator ==(Object other) => + other is RawImageProvider && identical(other.image, image); + + @override + int get hashCode => identityHashCode(image); +} + +class _PhotoHeroTransition extends StatefulWidget { + const _PhotoHeroTransition({ + required this.controller, + required this.animation, + required this.child, + }); + + final PhotoHeroController controller; + final Animation animation; + final Widget child; + + @override + State<_PhotoHeroTransition> createState() => _PhotoHeroTransitionState(); +} + +class _PhotoHeroTransitionState extends State<_PhotoHeroTransition> { + ImageStream? _stream; + ImageStreamListener? _listener; + Size? _resolvedSize; + Rect? _from; + Rect? _area; + ImageProvider? _flightProvider; + Widget? _flightImage; + + Size? get _imageSize => widget.controller.size ?? _resolvedSize; + + bool get _flying => widget.controller.flying.value; + + @override + void initState() { + super.initState(); + widget.controller.image.addListener(_resolveImage); + widget.animation.addStatusListener(_onStatusChanged); + if (widget.animation.status != AnimationStatus.completed) { + widget.controller.flying.value = widget.controller.canFly; + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _resolveImage(); + } + + @override + void dispose() { + widget.animation.removeStatusListener(_onStatusChanged); + widget.controller.image.removeListener(_resolveImage); + _detachStream(); + super.dispose(); + } + + void _onStatusChanged(AnimationStatus status) { + switch (status) { + case AnimationStatus.forward: + case AnimationStatus.reverse: + _startFlight(); + case AnimationStatus.completed: + case AnimationStatus.dismissed: + _stopFlight(); + } + } + + void _startFlight() { + _from = null; + _area = null; + _setFlying(widget.controller.canFly); + } + + void _stopFlight() => _setFlying(false); + + void _setFlying(bool value) { + if (_flying == value) return; + widget.controller.flying.value = value; + if (mounted) setState(() {}); + } + + void _detachStream() { + final listener = _listener; + if (listener != null) _stream?.removeListener(listener); + _stream = null; + _listener = null; + } + + void _resolveImage() { + if (!mounted) return; + final provider = widget.controller.image.value; + if (provider == null) return; + final stream = provider.resolve(createLocalImageConfiguration(context)); + if (stream.key == _stream?.key) return; + _detachStream(); + final listener = ImageStreamListener((info, synchronous) { + final size = Size( + info.image.width.toDouble(), + info.image.height.toDouble(), + ); + info.dispose(); + if (_resolvedSize == size) return; + if (_resolvedSize != null && _flying) return; + if (synchronous) { + _resolvedSize = size; + } else if (mounted) { + setState(() => _resolvedSize = size); + } + }); + _listener = listener; + _stream = stream..addListener(listener); + } + + Widget _imageWidget(ImageProvider provider) { + if (!identical(_flightProvider, provider)) { + _flightProvider = provider; + _flightImage = Image( + image: provider, + fit: BoxFit.fill, + gaplessPlayback: true, + ); + } + return _flightImage!; + } + + @override + Widget build(BuildContext context) { + final provider = widget.controller.image.value; + if (!_flying || provider == null) { + return FadeTransition(opacity: widget.animation, child: widget.child); + } + return Stack( + children: [ + widget.child, + Positioned.fill( + child: IgnorePointer( + child: AnimatedBuilder( + animation: widget.animation, + child: _imageWidget(provider), + builder: (context, child) => _layoutFlight(child!), + ), + ), + ), + ], + ); + } + + Widget _layoutFlight(Widget image) { + final from = _from ??= widget.controller.originRect; + if (from == null) return const SizedBox.shrink(); + final area = _area ??= widget.controller.areaRect; + final imageSize = _imageSize; + if (area == null || imageSize == null) { + return _position(image, from, from, 1); + } + final t = Curves.fastOutSlowIn.transform( + widget.animation.value.clamp(0.0, 1.0), + ); + final target = _inscribe(imageSize, area, cover: false); + return _position( + image, + Rect.lerp(from, target, t)!, + Rect.lerp(_inscribe(imageSize, from, cover: true), target, t)!, + 1 - t, + ); + } + + Widget _position(Widget image, Rect clip, Rect rect, double radiusT) { + final radius = widget.controller.radius * radiusT; + final content = Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: rect.left - clip.left, + top: rect.top - clip.top, + width: rect.width, + height: rect.height, + child: image, + ), + ], + ); + return Stack( + children: [ + Positioned.fromRect( + rect: clip, + child: radius == BorderRadius.zero + ? ClipRect(child: content) + : ClipRRect(borderRadius: radius, child: content), + ), + ], + ); + } +} diff --git a/lib/frontend/widgets/attachment/preview_chrome.dart b/lib/frontend/widgets/attachment/preview_chrome.dart new file mode 100644 index 0000000..497768c --- /dev/null +++ b/lib/frontend/widgets/attachment/preview_chrome.dart @@ -0,0 +1,178 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_colors.dart'; + +class PreviewSelectionToggle extends StatelessWidget { + final ValueListenable> selectedIds; + final String id; + final VoidCallback onTap; + + const PreviewSelectionToggle({ + super.key, + required this.selectedIds, + required this.id, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: selectedIds, + builder: (context, selected, _) { + final index = selected.toList().indexOf(id); + final isSelected = index >= 0; + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? MediaAccent.of(context) : Colors.transparent, + border: Border.all(color: Colors.white, width: 2), + ), + child: isSelected + ? Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ) + : null, + ), + ); + }, + ); + } +} + +class PreviewCountBadge extends StatelessWidget { + final int count; + + const PreviewCountBadge({super.key, required this.count}); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: const _DashedCirclePainter(color: Colors.white), + child: SizedBox( + width: 34, + height: 34, + child: Center( + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} + +class _DashedCirclePainter extends CustomPainter { + final Color color; + + const _DashedCirclePainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + final rect = Rect.fromLTWH(1.5, 1.5, size.width - 3, size.height - 3); + const dashes = 22; + const sweep = (2 * math.pi) / dashes; + const dashRatio = 0.55; + for (var i = 0; i < dashes; i++) { + canvas.drawArc(rect, i * sweep, sweep * dashRatio, false, paint); + } + } + + @override + bool shouldRepaint(covariant _DashedCirclePainter oldDelegate) => + oldDelegate.color != color; +} + +class PreviewToolIcon extends StatelessWidget { + final IconData icon; + final VoidCallback onTap; + + const PreviewToolIcon({super.key, required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onTap, + icon: Icon(icon, color: Colors.white, size: 24), + ); + } +} + +class PreviewFileToggle extends StatefulWidget { + const PreviewFileToggle({super.key}); + + @override + State createState() => _FileToggleState(); +} + +class _FileToggleState extends State { + bool _active = false; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => setState(() => _active = !_active), + icon: TweenAnimationBuilder( + tween: Tween(end: _active ? 1 : 0), + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + builder: (context, t, _) { + final color = Color.lerp( + Colors.white54, + Color.lerp(Colors.white, MediaAccent.of(context), 0.4), + t, + ); + return Icon(Symbols.description, color: color, size: 24); + }, + ), + ); + } +} + +class PreviewSendButton extends StatelessWidget { + final VoidCallback onTap; + + const PreviewSendButton({super.key, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Material( + color: MediaAccent.of(context), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: const SizedBox( + width: 52, + height: 52, + child: Icon(Symbols.send, color: Colors.white, size: 24, fill: 1), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/video_edit.dart b/lib/frontend/widgets/attachment/video_edit.dart new file mode 100644 index 0000000..0d67add --- /dev/null +++ b/lib/frontend/widgets/attachment/video_edit.dart @@ -0,0 +1,300 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../../core/media/video_transcoder.dart'; +import 'editor_common.dart'; + +const List kVideoQualitySteps = [144, 240, 360, 480, 720, 1080]; + +const Duration kMinTrimDuration = Duration(milliseconds: 800); + +class VideoCropEdit { + final CropState state; + final Size viewport; + + const VideoCropEdit({required this.state, required this.viewport}); +} + +class VideoEditState { + Duration start = Duration.zero; + Duration end = Duration.zero; + bool muted = false; + VideoCropEdit? crop; + List marks = []; + Size marksCanvas = Size.zero; + ColorAdjust adjust = ColorAdjust(); + int? maxShortSide; + + File? exported; + String? exportedSignature; + + bool get trimmed => + start > Duration.zero || + (sourceDuration > Duration.zero && end < sourceDuration); + + Duration sourceDuration = Duration.zero; + + Duration get duration { + final span = end - start; + return span > Duration.zero ? span : Duration.zero; + } + + bool get hasEdits => + trimmed || + muted || + crop != null || + marks.isNotEmpty || + !adjust.pristine || + maxShortSide != null; + + String signature(Size source) { + final geometry = VideoGeometry.resolve(crop, source); + final out = geometry.outputSize(maxShortSide); + final m = adjust.matrix().map((v) => v.toStringAsFixed(3)).join(','); + return [ + start.inMilliseconds, + end.inMilliseconds, + muted, + geometry.rotationDegrees.toStringAsFixed(3), + geometry.flipH, + geometry.cropNorm, + out, + m, + marks.length, + marksCanvas, + _marksFingerprint(), + ].join('|'); + } + + String _marksFingerprint() { + final buffer = StringBuffer(); + for (final mark in marks) { + switch (mark) { + case StrokeMark s: + buffer.write('s${s.points.length}${s.tool.index}${s.width}'); + case ShapeMark sh: + buffer.write('h${sh.kind.index}${sh.start}${sh.end}'); + case TextMark t: + buffer.write('t${t.text}${t.position}${t.fontSize}${t.rotation}'); + } + } + return buffer.toString(); + } +} + +class VideoGeometry { + final Size source; + + final double phi; + final bool flipH; + final Rect cropNorm; + + const VideoGeometry({ + required this.source, + required this.phi, + required this.flipH, + required this.cropNorm, + }); + + static VideoGeometry resolve(VideoCropEdit? edit, Size source) { + if (edit == null || source.isEmpty) { + return VideoGeometry( + source: source, + phi: 0, + flipH: false, + cropNorm: const Rect.fromLTRB(0, 0, 1, 1), + ); + } + final state = edit.state; + final geometry = CropGeometry( + source: source, + quarterTurns: state.quarterTurns, + flipH: state.flipH, + straightenDeg: state.straightenDeg, + ); + final vp = edit.viewport; + final rect = Rect.fromLTRB( + state.cropNorm.left * vp.width, + state.cropNorm.top * vp.height, + state.cropNorm.right * vp.width, + state.cropNorm.bottom * vp.height, + ); + return VideoGeometry( + source: source, + phi: geometry.phi, + flipH: state.flipH, + cropNorm: geometry.cropInRotated(vp, rect), + ); + } + + double get rotationDegrees { + final degrees = phi * 180 / math.pi; + return flipH ? degrees : -degrees; + } + + VideoGeometry withSource(Size other) => + VideoGeometry(source: other, phi: phi, flipH: flipH, cropNorm: cropNorm); + + Size get rotatedSize { + final c = math.cos(phi).abs(); + final s = math.sin(phi).abs(); + return Size( + source.width * c + source.height * s, + source.width * s + source.height * c, + ); + } + + Size get naturalOutput { + final r = rotatedSize; + return Size(cropNorm.width * r.width, cropNorm.height * r.height); + } + + Size outputSize(int? maxShortSide) { + var w = naturalOutput.width; + var h = naturalOutput.height; + if (w <= 0 || h <= 0) return const Size(2, 2); + final short = math.min(w, h); + if (maxShortSide != null && short > maxShortSide) { + final k = maxShortSide / short; + w *= k; + h *= k; + } + return Size(_even(w), _even(h)); + } + + static double _even(double value) => + math.max(2, (value / 2).round() * 2).toDouble(); + + Matrix4 sourceToOutput() { + final r = rotatedSize; + return Matrix4.identity() + ..translateByDouble( + r.width / 2 - cropNorm.left * r.width, + r.height / 2 - cropNorm.top * r.height, + 0, + 1, + ) + ..multiply(flipH ? Matrix4.diagonal3Values(-1, 1, 1) : Matrix4.identity()) + ..rotateZ(phi) + ..translateByDouble(-source.width / 2, -source.height / 2, 0, 1); + } +} + +List? glColorMatrix(ColorAdjust adjust) { + if (adjust.colorPristine) return null; + final m = adjust.matrix(); + return [ + m[0], + m[5], + m[10], + 0, + m[1], + m[6], + m[11], + 0, + m[2], + m[7], + m[12], + 0, + m[4] / 255, + m[9] / 255, + m[14] / 255, + 1, + ]; +} + +List videoQualityOptions(int naturalShortSide) { + final options = kVideoQualitySteps + .where((step) => step < naturalShortSide) + .toList(); + options.add(naturalShortSide); + return options; +} + +int estimateVideoBitrate(Size output, double fps) { + final rate = output.width * output.height * (fps <= 0 ? 30 : fps) * 0.09; + return rate.round().clamp(300000, 12000000); +} + +int estimateVideoSizeBytes(Size output, double fps, Duration duration) { + final bitrate = estimateVideoBitrate(output, fps); + return (bitrate * duration.inMilliseconds / 8000).round(); +} + +Future bakeVideoOverlay(VideoEditState edit, Size output) async { + if (edit.marks.isEmpty && edit.adjust.vignette <= 0) return null; + final width = output.width.round(); + final height = output.height.round(); + if (width <= 0 || height <= 0) return null; + try { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final rect = Rect.fromLTWH(0, 0, output.width, output.height); + if (edit.adjust.vignette > 0) { + canvas.drawRect( + rect, + Paint()..shader = edit.adjust.vignetteGradient().createShader(rect), + ); + } + if (edit.marks.isNotEmpty && !edit.marksCanvas.isEmpty) { + canvas.save(); + canvas.scale( + output.width / edit.marksCanvas.width, + output.height / edit.marksCanvas.height, + ); + DrawingPainter(marks: edit.marks).paintMarks(canvas, edit.marksCanvas); + canvas.restore(); + } + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (data == null) return null; + final dir = await getTemporaryDirectory(); + final file = File( + p.join( + dir.path, + 'komet_vov_${DateTime.now().microsecondsSinceEpoch}.png', + ), + ); + await file.writeAsBytes(data.buffer.asUint8List()); + return file; + } catch (_) { + return null; + } +} + +Future buildVideoExportSpec( + VideoEditState edit, + String input, + Size source, + double fps, +) async { + final output = await VideoTranscoder.outputFile('video'); + if (output == null) return null; + final geometry = VideoGeometry.resolve(edit.crop, source); + final size = geometry.outputSize(edit.maxShortSide); + final overlay = await bakeVideoOverlay(edit, size); + final full = geometry.cropNorm == const Rect.fromLTRB(0, 0, 1, 1); + return VideoExportSpec( + input: input, + output: output.path, + startMs: edit.start.inMilliseconds, + endMs: edit.end > Duration.zero ? edit.end.inMilliseconds : null, + removeAudio: edit.muted, + rotationDegrees: geometry.rotationDegrees, + flipH: geometry.flipH, + crop: full ? null : geometry.cropNorm, + outWidth: size.width.round(), + outHeight: size.height.round(), + rgbMatrix: glColorMatrix(edit.adjust), + overlayPath: overlay?.path, + bitrate: estimateVideoBitrate(size, fps), + ); +} diff --git a/lib/frontend/widgets/attachment/video_editor.dart b/lib/frontend/widgets/attachment/video_editor.dart new file mode 100644 index 0000000..6be0e0a --- /dev/null +++ b/lib/frontend/widgets/attachment/video_editor.dart @@ -0,0 +1,659 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import '../small_spinner.dart'; +import 'editor_common.dart'; +import 'photo_hero.dart'; +import 'video_edit.dart'; + +class VideoStill extends StatelessWidget { + final ui.Image frame; + final VideoGeometry geometry; + final ColorAdjust adjust; + final List marks; + final Size marksCanvas; + + const VideoStill({ + super.key, + required this.frame, + required this.geometry, + required this.adjust, + this.marks = const [], + this.marksCanvas = Size.zero, + }); + + @override + Widget build(BuildContext context) { + final local = geometry.withSource( + Size(frame.width.toDouble(), frame.height.toDouble()), + ); + return ColorFiltered( + colorFilter: ColorFilter.matrix(adjust.matrix()), + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint(painter: _StillPainter(frame, local)), + if (adjust.vignette > 0) + DecoratedBox( + decoration: BoxDecoration(gradient: adjust.vignetteGradient()), + ), + if (marks.isNotEmpty && !marksCanvas.isEmpty) + FittedBox( + fit: BoxFit.fill, + child: SizedBox( + width: marksCanvas.width, + height: marksCanvas.height, + child: CustomPaint(painter: DrawingPainter(marks: marks)), + ), + ), + ], + ), + ); + } +} + +class _StillPainter extends CustomPainter { + final ui.Image frame; + final VideoGeometry geometry; + + _StillPainter(this.frame, this.geometry); + + @override + void paint(Canvas canvas, Size size) { + final out = geometry.naturalOutput; + if (out.isEmpty || size.isEmpty) return; + canvas.clipRect(Offset.zero & size); + canvas.save(); + canvas.scale(size.width / out.width, size.height / out.height); + canvas.transform(geometry.sourceToOutput().storage); + canvas.drawImage( + frame, + Offset.zero, + Paint()..filterQuality = FilterQuality.medium, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _StillPainter old) => + old.frame != frame || old.geometry != geometry; +} + +Future composeVideoStill( + ui.Image frame, + VideoGeometry geometry, + ColorAdjust adjust, { + List marks = const [], + Size marksCanvas = Size.zero, +}) async { + final local = geometry.withSource( + Size(frame.width.toDouble(), frame.height.toDouble()), + ); + final out = local.naturalOutput; + final width = out.width.round(); + final height = out.height.round(); + if (width <= 0 || height <= 0) return null; + try { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final rect = Rect.fromLTWH(0, 0, out.width, out.height); + canvas.saveLayer( + rect, + Paint()..colorFilter = ColorFilter.matrix(adjust.matrix()), + ); + canvas.save(); + canvas.clipRect(rect); + canvas.transform(local.sourceToOutput().storage); + canvas.drawImage( + frame, + Offset.zero, + Paint()..filterQuality = FilterQuality.medium, + ); + canvas.restore(); + canvas.restore(); + if (adjust.vignette > 0) { + canvas.drawRect( + rect, + Paint()..shader = adjust.vignetteGradient().createShader(rect), + ); + } + if (marks.isNotEmpty && !marksCanvas.isEmpty) { + canvas.save(); + canvas.scale( + out.width / marksCanvas.width, + out.height / marksCanvas.height, + ); + DrawingPainter(marks: marks).paintMarks(canvas, marksCanvas); + canvas.restore(); + } + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + return image; + } catch (_) { + return null; + } +} + +class VideoCropEditor extends StatelessWidget { + final ui.Image frame; + final ColorAdjust adjust; + final VideoCropEdit? initial; + final Future Function(VideoCropResult result)? onPreview; + + const VideoCropEditor({ + super.key, + required this.frame, + required this.adjust, + this.initial, + this.onPreview, + }); + + Future _apply( + CropState state, + Size viewport, + bool changed, + bool identity, + ) async { + if (!changed && !identity) return null; + final result = identity + ? const VideoCropResult(null) + : VideoCropResult(VideoCropEdit(state: state, viewport: viewport)); + await onPreview?.call(result); + return result; + } + + @override + Widget build(BuildContext context) { + return CropWorkspace( + imageSize: Size(frame.width.toDouble(), frame.height.toDouble()), + initialState: initial?.state, + onApply: _apply, + imageBuilder: (context, matrix) => ColorFiltered( + colorFilter: ColorFilter.matrix(adjust.matrix()), + child: CustomPaint(painter: MatrixImagePainter(frame, matrix)), + ), + ); + } +} + +class VideoCropResult { + final VideoCropEdit? crop; + + const VideoCropResult(this.crop); +} + +class VideoMarksResult { + final List marks; + final Size canvas; + + const VideoMarksResult(this.marks, this.canvas); +} + +class VideoDrawEditor extends StatelessWidget { + final ui.Image frame; + final VideoGeometry geometry; + final ColorAdjust adjust; + final List initialMarks; + final Future Function(VideoMarksResult result)? onPreview; + + const VideoDrawEditor({ + super.key, + required this.frame, + required this.geometry, + required this.adjust, + this.initialMarks = const [], + this.onPreview, + }); + + @override + Widget build(BuildContext context) { + final out = geometry.naturalOutput; + return MarkupEditor( + aspectRatio: out.height > 0 ? out.width / out.height : 1.0, + initialMarks: initialMarks, + background: VideoStill(frame: frame, geometry: geometry, adjust: adjust), + onApply: (marks, canvas) async { + final result = VideoMarksResult([...marks], canvas); + await onPreview?.call(result); + return result; + }, + ); + } +} + +class VideoAdjustEditor extends StatefulWidget { + final ui.Image frame; + final VideoGeometry geometry; + final ColorAdjust initial; + final List marks; + final Size marksCanvas; + final Future Function(ColorAdjust result)? onPreview; + + const VideoAdjustEditor({ + super.key, + required this.frame, + required this.geometry, + required this.initial, + this.marks = const [], + this.marksCanvas = Size.zero, + this.onPreview, + }); + + @override + State createState() => _VideoAdjustEditorState(); +} + +class _VideoAdjustEditorState extends State { + late final ColorAdjust _adjust = widget.initial.copy(); + final ValueNotifier _rev = ValueNotifier(0); + bool _busy = false; + + Future _done() async { + if (_busy) return; + setState(() => _busy = true); + await widget.onPreview?.call(_adjust); + if (!mounted) return; + Navigator.of(context).pop(_adjust); + } + + @override + void dispose() { + _rev.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final out = widget.geometry.naturalOutput; + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: out.height > 0 + ? out.width / out.height + : 1.0, + child: PhotoHeroTarget( + child: ValueListenableBuilder( + valueListenable: _rev, + builder: (context, _, _) => VideoStill( + frame: widget.frame, + geometry: widget.geometry, + adjust: _adjust, + marks: widget.marks, + marksCanvas: widget.marksCanvas, + ), + ), + ), + ), + ), + ), + ValueListenableBuilder( + valueListenable: _rev, + builder: (context, _, _) => AdjustSliders( + adjust: _adjust, + onChanged: () => _rev.value++, + ), + ), + Container( + color: kEditorPanel, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + l10n.photoEditorCancel, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + ), + ), + ), + const Spacer(), + Icon(Symbols.tune, color: MediaAccent.of(context)), + const Spacer(), + TextButton( + onPressed: _busy ? null : _done, + child: Text( + l10n.photoEditorDone, + style: TextStyle( + color: _busy + ? Colors.white38 + : MediaAccent.of(context), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + if (_busy) const BusyOverlay(), + ], + ), + ), + ); + } +} + +class VideoQualityEditor extends StatefulWidget { + final ui.Image frame; + final VideoGeometry geometry; + final ColorAdjust adjust; + final List marks; + final Size marksCanvas; + final List options; + final int selected; + final double fps; + final Duration duration; + final String? title; + final Future Function(int result)? onPreview; + + const VideoQualityEditor({ + super.key, + required this.frame, + required this.geometry, + required this.adjust, + required this.options, + required this.selected, + this.marks = const [], + this.marksCanvas = Size.zero, + required this.fps, + required this.duration, + this.title, + this.onPreview, + }); + + @override + State createState() => _VideoQualityEditorState(); +} + +class _VideoQualityEditorState extends State { + late int _index = math.max(0, widget.options.indexOf(widget.selected)); + bool _busy = false; + + Future _done() async { + if (_busy) return; + final value = widget.options[_index]; + setState(() => _busy = true); + await widget.onPreview?.call(value); + if (!mounted) return; + Navigator.of(context).pop(value); + } + + Size get _outputSize => widget.geometry.outputSize(widget.options[_index]); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final out = widget.geometry.naturalOutput; + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + surfaceTintColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Symbols.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: VideoHeaderTitle( + title: widget.title, + size: _outputSize, + duration: widget.duration, + bytes: estimateVideoSizeBytes( + _outputSize, + widget.fps, + widget.duration, + ), + ), + ), + body: Stack( + children: [ + Column( + children: [ + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: out.height > 0 ? out.width / out.height : 1.0, + child: PhotoHeroTarget( + child: VideoStill( + frame: widget.frame, + geometry: widget.geometry, + adjust: widget.adjust, + marks: widget.marks, + marksCanvas: widget.marksCanvas, + ), + ), + ), + ), + ), + SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.videoEditorQualityLow, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + Text( + l10n.videoEditorQualityHigh, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ], + ), + ), + _QualitySlider( + count: widget.options.length, + index: _index, + onChanged: (value) => setState(() => _index = value), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + l10n.photoEditorCancel, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + ), + ), + ), + TextButton( + onPressed: _busy ? null : _done, + child: Text( + l10n.photoEditorDone, + style: TextStyle( + color: _busy + ? Colors.white38 + : MediaAccent.of(context), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + if (_busy) const BusyOverlay(), + ], + ), + ); + } +} + +class _QualitySlider extends StatelessWidget { + final int count; + final int index; + final ValueChanged onChanged; + + const _QualitySlider({ + required this.count, + required this.index, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final accent = MediaAccent.of(context); + return SizedBox( + height: 40, + child: LayoutBuilder( + builder: (context, constraints) { + const inset = 16.0; + final span = math.max(1.0, constraints.maxWidth - inset * 2); + void pick(double dx) { + if (count <= 1) return; + final t = ((dx - inset) / span).clamp(0.0, 1.0); + final next = (t * (count - 1)).round(); + if (next != index) onChanged(next); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => pick(d.localPosition.dx), + onHorizontalDragUpdate: (d) => pick(d.localPosition.dx), + child: CustomPaint( + painter: _QualitySliderPainter( + count: count, + index: index, + accent: accent, + inset: inset, + ), + ), + ); + }, + ), + ); + } +} + +class _QualitySliderPainter extends CustomPainter { + final int count; + final int index; + final Color accent; + final double inset; + + _QualitySliderPainter({ + required this.count, + required this.index, + required this.accent, + required this.inset, + }); + + @override + void paint(Canvas canvas, Size size) { + final y = size.height / 2; + final left = inset; + final right = size.width - inset; + final track = Paint() + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + canvas.drawLine( + Offset(left, y), + Offset(right, y), + track..color = Colors.white24, + ); + final step = count <= 1 ? 0.0 : (right - left) / (count - 1); + final active = left + step * index; + canvas.drawLine(Offset(left, y), Offset(active, y), track..color = accent); + for (var i = 0; i < count; i++) { + final x = left + step * i; + canvas.drawCircle( + Offset(x, y), + 4, + Paint()..color = i <= index ? accent : Colors.white38, + ); + } + canvas.drawCircle(Offset(active, y), 9, Paint()..color = accent); + } + + @override + bool shouldRepaint(covariant _QualitySliderPainter old) => + old.index != index || old.count != count || old.accent != accent; +} + +class VideoHeaderTitle extends StatelessWidget { + final String? title; + final Size size; + final Duration duration; + final int bytes; + + const VideoHeaderTitle({ + super.key, + required this.size, + required this.duration, + required this.bytes, + this.title, + }); + + @override + Widget build(BuildContext context) { + final name = title; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (name != null && name.isNotEmpty) + Text( + name, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + Text( + '${size.width.round()}x${size.height.round()}, ' + '${_duration(duration)}, ~${_bytes(bytes)}', + style: const TextStyle(fontSize: 13, color: Colors.white70), + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + static String _duration(Duration value) { + final minutes = value.inMinutes; + final seconds = value.inSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } + + static String _bytes(int value) { + if (value >= 1024 * 1024) { + return '${(value / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(value / 1024).toStringAsFixed(1)} KB'; + } +} diff --git a/lib/frontend/widgets/attachment/video_preview_screen.dart b/lib/frontend/widgets/attachment/video_preview_screen.dart new file mode 100644 index 0000000..38fd4c5 --- /dev/null +++ b/lib/frontend/widgets/attachment/video_preview_screen.dart @@ -0,0 +1,994 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:video_player/video_player.dart'; + +import 'package:komet/core/media/gallery_source.dart'; +import 'package:komet/core/media/video_transcoder.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/lottie_slash_icon.dart'; + +import '../../../core/config/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import '../small_spinner.dart'; +import 'editor_common.dart'; +import 'photo_hero.dart'; +import 'preview_chrome.dart'; +import 'video_edit.dart'; +import 'video_editor.dart'; + +const int _kStripFrames = 12; + +class VideoPreviewScreen extends StatefulWidget { + final GalleryItem item; + final PhotoHeroController hero; + final String? title; + final ValueListenable> selectedIds; + final VoidCallback onToggleSelection; + final VoidCallback onSend; + final VideoEditState edit; + final bool editable; + final VoidCallback? onEditChanged; + final String initialCaption; + final ValueChanged? onCaptionChanged; + + const VideoPreviewScreen({ + super.key, + required this.item, + required this.hero, + required this.selectedIds, + required this.onToggleSelection, + required this.onSend, + required this.edit, + this.editable = true, + this.title, + this.onEditChanged, + this.initialCaption = '', + this.onCaptionChanged, + }); + + @override + State createState() => _VideoPreviewScreenState(); +} + +class _VideoPreviewScreenState extends State { + late final TextEditingController _caption = TextEditingController( + text: widget.initialCaption, + ); + + final GlobalKey _stageKey = GlobalKey(); + final List _flightImages = []; + + PhotoHeroController? _activeHero; + ui.Image? _editorFrame; + + File? _file; + VideoInfo? _info; + VideoPlayerController? _controller; + List _strip = const []; + int _sourceBytes = 0; + bool _busy = false; + bool _scrubbing = false; + + VideoEditState get _edit => widget.edit; + + @override + void initState() { + super.initState(); + _caption.addListener(() => widget.onCaptionChanged?.call(_caption.text)); + _load(); + } + + @override + void dispose() { + _releaseFlights(); + _caption.dispose(); + final controller = _controller; + _controller = null; + controller?.removeListener(_onTick); + controller?.dispose(); + super.dispose(); + } + + Future _load() async { + final file = widget.item.localFile ?? await widget.item.originFile(); + if (file == null || !mounted) return; + _file = file; + _sourceBytes = await file.length().catchError((_) => 0); + _info = await VideoTranscoder.probe(file.path); + if (!mounted) return; + final controller = VideoPlayerController.file(file); + try { + await controller.initialize(); + } catch (_) { + controller.dispose(); + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.videoEditorFrameFailed, + ); + } + return; + } + if (!mounted) { + controller.dispose(); + return; + } + final duration = _duration(controller); + if (_edit.end <= Duration.zero) { + _edit.sourceDuration = duration; + _edit.end = duration; + } + controller.addListener(_onTick); + await controller.setVolume(_edit.muted ? 0 : 1); + setState(() => _controller = controller); + unawaited(controller.play()); + unawaited(_loadStrip(file, duration)); + } + + Duration _duration(VideoPlayerController controller) { + final info = _info; + if (info != null && info.durationMs > 0) { + return Duration(milliseconds: info.durationMs); + } + return controller.value.duration; + } + + Future _loadStrip(File file, Duration duration) async { + if (duration <= Duration.zero) return; + final step = duration.inMilliseconds / _kStripFrames; + final times = List.generate( + _kStripFrames, + (i) => (step * (i + 0.5)).round(), + ); + final frames = await VideoTranscoder.frames(file.path, times, size: 160); + if (!mounted) return; + setState(() => _strip = frames); + } + + void _onTick() { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + if (_scrubbing) return; + final position = controller.value.position; + if (position >= _edit.end && _edit.end > Duration.zero) { + controller.seekTo(_edit.start); + if (!controller.value.isPlaying) unawaited(controller.play()); + } else if (position < _edit.start - const Duration(milliseconds: 120)) { + controller.seekTo(_edit.start); + } + } + + Size get _sourceSize { + final info = _info; + if (info != null && info.width > 0 && info.height > 0) { + return Size(info.width.toDouble(), info.height.toDouble()); + } + final size = _controller?.value.size ?? Size.zero; + return size.isEmpty ? const Size(16, 9) : size; + } + + double get _fps => _info?.fps ?? 30; + + VideoGeometry get _geometry => VideoGeometry.resolve(_edit.crop, _sourceSize); + + Size get _outputSize => _geometry.outputSize(_edit.maxShortSide); + + int get _naturalShortSide { + final natural = _geometry.naturalOutput; + return math.max(2, math.min(natural.width, natural.height).round()); + } + + void _changed() { + setState(() {}); + widget.onEditChanged?.call(); + } + + void _togglePlay() { + final controller = _controller; + if (controller == null) return; + if (controller.value.isPlaying) { + controller.pause(); + } else { + if (controller.value.position >= _edit.end) { + controller.seekTo(_edit.start); + } + controller.play(); + } + setState(() {}); + } + + void _toggleMute() { + _edit.muted = !_edit.muted; + _controller?.setVolume(_edit.muted ? 0 : 1); + _changed(); + } + + void _send() { + Navigator.of(context).pop(); + widget.onSend(); + } + + Future _pushEditor( + (ui.Image, ui.Image) prepared, + Widget Function() builder, + ) async { + final (frame, flight) = prepared; + final hero = PhotoHeroController( + origin: () => photoHeroRect(_stageKey), + image: RawImageProvider(flight), + ); + _activeHero = hero; + _editorFrame = frame; + _flightImages.add(flight); + try { + return await Navigator.of( + context, + ).push(PhotoHeroRoute(hero: hero, builder: (_) => builder())); + } finally { + _activeHero = null; + _editorFrame = null; + frame.dispose(); + _releaseFlights(); + } + } + + void _releaseFlights() { + if (_flightImages.isEmpty) return; + final images = List.of(_flightImages); + _flightImages.clear(); + for (final image in images) { + unawaited(RawImageProvider(image).evict()); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + for (final image in images) { + image.dispose(); + } + }); + } + + Future _preview( + void Function() apply, { + required bool withMarks, + }) async { + apply(); + if (!mounted) return; + setState(() {}); + widget.onEditChanged?.call(); + final frame = _editorFrame; + final hero = _activeHero; + if (frame == null || hero == null) return; + final image = await composeVideoStill( + frame, + _geometry, + _edit.adjust, + marks: withMarks ? _edit.marks : const [], + marksCanvas: withMarks ? _edit.marksCanvas : Size.zero, + ); + if (image == null) return; + if (!mounted || !identical(_activeHero, hero)) { + image.dispose(); + return; + } + _flightImages.add(image); + hero.image.value = RawImageProvider(image); + } + + Future _grabFrame() async { + final file = _file; + if (file == null) return null; + final position = _controller?.value.position ?? _edit.start; + final frames = await VideoTranscoder.frames( + file.path, + [ + position.inMilliseconds.clamp( + _edit.start.inMilliseconds, + math.max(_edit.start.inMilliseconds, _edit.end.inMilliseconds), + ), + ], + size: 1280, + precise: true, + ); + final data = frames.isEmpty ? null : frames.first; + if (data == null) return null; + try { + final codec = await ui.instantiateImageCodec(data); + final frame = await codec.getNextFrame(); + codec.dispose(); + return frame.image; + } catch (_) { + return null; + } + } + + Future<(ui.Image, ui.Image)?> _prepare({required bool withMarks}) async { + _controller?.pause(); + setState(() => _busy = true); + final frame = await _grabFrame(); + ui.Image? flight; + if (frame != null) { + flight = await composeVideoStill( + frame, + _geometry, + _edit.adjust, + marks: withMarks ? _edit.marks : const [], + marksCanvas: withMarks ? _edit.marksCanvas : Size.zero, + ); + } + if (!mounted) { + frame?.dispose(); + flight?.dispose(); + return null; + } + setState(() => _busy = false); + if (frame == null || flight == null) { + frame?.dispose(); + flight?.dispose(); + showCustomNotification( + context, + AppLocalizations.of(context)!.videoEditorFrameFailed, + ); + return null; + } + return (frame, flight); + } + + Future _openCrop() async { + final prepared = await _prepare(withMarks: false); + if (prepared == null) return; + await _pushEditor( + prepared, + () => VideoCropEditor( + frame: prepared.$1, + adjust: _edit.adjust, + initial: _edit.crop, + onPreview: (result) => + _preview(() => _edit.crop = result.crop, withMarks: true), + ), + ); + } + + Future _openDraw() async { + final prepared = await _prepare(withMarks: true); + if (prepared == null) return; + await _pushEditor( + prepared, + () => VideoDrawEditor( + frame: prepared.$1, + geometry: _geometry, + adjust: _edit.adjust, + initialMarks: _edit.marks, + onPreview: (result) => _preview(() { + _edit.marks = result.marks; + _edit.marksCanvas = result.canvas; + }, withMarks: true), + ), + ); + } + + Future _openAdjust() async { + final prepared = await _prepare(withMarks: true); + if (prepared == null) return; + await _pushEditor( + prepared, + () => VideoAdjustEditor( + frame: prepared.$1, + geometry: _geometry, + initial: _edit.adjust, + marks: _edit.marks, + marksCanvas: _edit.marksCanvas, + onPreview: (result) => + _preview(() => _edit.adjust = result, withMarks: true), + ), + ); + } + + Future _openQuality() async { + final prepared = await _prepare(withMarks: true); + if (prepared == null) return; + final natural = _naturalShortSide; + final options = videoQualityOptions(natural); + await _pushEditor( + prepared, + () => VideoQualityEditor( + frame: prepared.$1, + geometry: _geometry, + adjust: _edit.adjust, + marks: _edit.marks, + marksCanvas: _edit.marksCanvas, + options: options, + selected: _edit.maxShortSide ?? natural, + fps: _fps, + duration: _edit.duration, + title: widget.title, + onPreview: (result) => _preview( + () => _edit.maxShortSide = result >= natural ? null : result, + withMarks: true, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + surfaceTintColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Symbols.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: VideoHeaderTitle( + title: widget.title, + size: _outputSize, + duration: _edit.duration, + bytes: _edit.hasEdits + ? estimateVideoSizeBytes(_outputSize, _fps, _edit.duration) + : _sourceBytes, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 14), + child: PreviewSelectionToggle( + selectedIds: widget.selectedIds, + id: widget.item.id, + onTap: widget.onToggleSelection, + ), + ), + ], + ), + body: Stack( + children: [ + Column( + children: [ + Expanded( + child: PhotoHeroTarget(child: Center(child: _stage())), + ), + _bottomBar(), + ], + ), + if (_busy) const BusyOverlay(), + ], + ), + ); + } + + Widget _stage() { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) { + return const SmallSpinner(size: 36, color: Colors.white24); + } + final output = _geometry.naturalOutput; + if (output.isEmpty) return const SizedBox.shrink(); + return AspectRatio( + key: _stageKey, + aspectRatio: output.width / output.height, + child: GestureDetector( + onTap: _togglePlay, + behavior: HitTestBehavior.opaque, + child: LayoutBuilder( + builder: (context, constraints) { + final scale = constraints.maxWidth / output.width; + final matrix = Matrix4.diagonal3Values(scale, scale, 1) + ..multiply(_geometry.sourceToOutput()); + final source = _sourceSize; + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + ColorFiltered( + colorFilter: ColorFilter.matrix(_edit.adjust.matrix()), + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: 0, + minHeight: 0, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Transform( + alignment: Alignment.topLeft, + transform: matrix, + child: SizedBox( + width: source.width, + height: source.height, + child: VideoPlayer(controller), + ), + ), + ), + ), + if (_edit.adjust.vignette > 0) + IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: _edit.adjust.vignetteGradient(), + ), + ), + ), + if (_edit.marks.isNotEmpty && !_edit.marksCanvas.isEmpty) + IgnorePointer( + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox( + width: _edit.marksCanvas.width, + height: _edit.marksCanvas.height, + child: CustomPaint( + painter: DrawingPainter(marks: _edit.marks), + ), + ), + ), + ), + if (!controller.value.isPlaying) + const IgnorePointer(child: Center(child: _PlayBadge())), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _bottomBar() { + final controller = _controller; + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.editable) ...[ + Row( + children: [ + _MuteButton(muted: _edit.muted, onTap: _toggleMute), + const Spacer(), + ], + ), + const SizedBox(height: 4), + ], + if (widget.editable && + controller != null && + controller.value.isInitialized) + TrimBar( + frames: _strip, + controller: controller, + duration: _edit.sourceDuration, + start: _edit.start, + end: _edit.end, + onScrub: _onScrub, + onTrim: _onTrim, + ), + const SizedBox(height: 10), + _captionField(), + const SizedBox(height: 10), + _toolbar(), + ], + ), + ), + ); + } + + void _onScrub(Duration position, bool active) { + _scrubbing = active; + final controller = _controller; + if (controller == null) return; + if (active && controller.value.isPlaying) controller.pause(); + controller.seekTo(position); + } + + void _onTrim(Duration start, Duration end, bool active) { + _scrubbing = active; + setState(() { + _edit.start = start; + _edit.end = end; + }); + if (!active) widget.onEditChanged?.call(); + } + + Widget _captionField() { + final l10n = AppLocalizations.of(context)!; + return Container( + decoration: BoxDecoration( + color: kEditorBar, + borderRadius: BorderRadius.circular(28), + ), + padding: const EdgeInsets.fromLTRB(20, 6, 8, 6), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _caption, + style: const TextStyle(color: Colors.white, fontSize: 15), + cursorColor: Colors.white, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: l10n.videoEditorCaptionHint, + hintStyle: const TextStyle(color: Colors.white54, fontSize: 15), + ), + ), + ), + const SizedBox(width: 8), + ValueListenableBuilder>( + valueListenable: widget.selectedIds, + builder: (context, selected, _) => PreviewCountBadge( + count: selected.isEmpty ? 1 : selected.length, + ), + ), + ], + ), + ); + } + + Widget _toolbar() { + final ready = _controller?.value.isInitialized == true; + if (!widget.editable) { + return Row( + children: [ + const Spacer(), + PreviewSendButton(onTap: _send), + ], + ); + } + return Row( + children: [ + Expanded( + child: Container( + height: 52, + decoration: BoxDecoration( + color: kEditorBar, + borderRadius: BorderRadius.circular(28), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + PreviewToolIcon( + icon: Symbols.crop_rotate, + onTap: ready ? _openCrop : () {}, + ), + PreviewToolIcon( + icon: Symbols.brush, + onTap: ready ? _openDraw : () {}, + ), + _QualityBadge( + shortSide: _edit.maxShortSide ?? _naturalShortSide, + onTap: ready ? _openQuality : () {}, + ), + PreviewToolIcon( + icon: Symbols.tune, + onTap: ready ? _openAdjust : () {}, + ), + ], + ), + ), + ), + const SizedBox(width: 10), + PreviewSendButton(onTap: _send), + ], + ); + } +} + +class _PlayBadge extends StatelessWidget { + const _PlayBadge(); + + @override + Widget build(BuildContext context) { + return Container( + width: 62, + height: 62, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.45), + ), + child: const Icon( + Symbols.play_arrow, + color: Colors.white, + size: 34, + fill: 1, + ), + ); + } +} + +class _MuteButton extends StatelessWidget { + final bool muted; + final VoidCallback onTap; + + const _MuteButton({required this.muted, required this.onTap}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onTap, + tooltip: AppLocalizations.of(context)!.videoEditorMuteTooltip, + icon: LottieSlashIcon( + asset: 'assets/lottie/ic_volume_on_to_off.json', + slashed: muted, + color: muted ? MediaAccent.of(context) : Colors.white, + size: 26, + ), + ); + } +} + +class _QualityBadge extends StatelessWidget { + final int shortSide; + final VoidCallback onTap; + + const _QualityBadge({required this.shortSide, required this.onTap}); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onTap, + tooltip: AppLocalizations.of(context)!.videoEditorQualityTooltip, + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$shortSide', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + height: 1, + fontWeight: FontWeight.w700, + ), + ), + const Icon(Symbols.hd, color: Colors.white, size: 22, fill: 1), + ], + ), + ); + } +} + +class TrimBar extends StatefulWidget { + final List frames; + final VideoPlayerController controller; + final Duration duration; + final Duration start; + final Duration end; + final void Function(Duration position, bool active) onScrub; + final void Function(Duration start, Duration end, bool active) onTrim; + + const TrimBar({ + super.key, + required this.frames, + required this.controller, + required this.duration, + required this.start, + required this.end, + required this.onScrub, + required this.onTrim, + }); + + @override + State createState() => _TrimBarState(); +} + +class _TrimBarState extends State { + static const double _handle = 13; + static const double _height = 48; + + int _target = -1; + + double _fraction(Duration value) { + final total = widget.duration.inMilliseconds; + if (total <= 0) return 0; + return (value.inMilliseconds / total).clamp(0.0, 1.0); + } + + Duration _at(double fraction) => Duration( + milliseconds: (fraction.clamp(0.0, 1.0) * widget.duration.inMilliseconds) + .round(), + ); + + void _down(Offset pos, double width) { + final startX = _fraction(widget.start) * width; + final endX = _fraction(widget.end) * width; + final toStart = (pos.dx - startX).abs(); + final toEnd = (pos.dx - endX).abs(); + if (toStart <= toEnd && toStart < 28) { + _target = 0; + } else if (toEnd < 28) { + _target = 1; + } else { + _target = 2; + widget.onScrub(_clamp(_at(pos.dx / width)), true); + } + } + + Duration _clamp(Duration value) { + if (value < widget.start) return widget.start; + if (widget.end > Duration.zero && value > widget.end) return widget.end; + return value; + } + + void _move(Offset pos, double width) { + if (width <= 0) return; + final value = _at(pos.dx / width); + switch (_target) { + case 0: + final limit = widget.end - kMinTrimDuration; + widget.onTrim( + value > limit + ? (limit > Duration.zero ? limit : Duration.zero) + : value, + widget.end, + true, + ); + widget.onScrub(value, true); + case 1: + final limit = widget.start + kMinTrimDuration; + widget.onTrim(widget.start, value < limit ? limit : value, true); + widget.onScrub(value < limit ? limit : value, true); + case 2: + widget.onScrub(_clamp(value), true); + } + } + + void _up() { + if (_target < 0) return; + if (_target != 2) widget.onTrim(widget.start, widget.end, false); + widget.onScrub(_clamp(widget.controller.value.position), false); + _target = -1; + } + + @override + Widget build(BuildContext context) { + final accent = MediaAccent.of(context); + return SizedBox( + height: _height, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final startX = _fraction(widget.start) * width; + final endX = _fraction(widget.end) * width; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (d) => _down(d.localPosition, width), + onPanUpdate: (d) => _move(d.localPosition, width), + onPanEnd: (_) => _up(), + onPanCancel: _up, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + children: [ + Positioned.fill(child: _filmstrip()), + Positioned( + left: 0, + top: 0, + bottom: 0, + width: math.max(0, startX), + child: const ColoredBox(color: Color(0x99000000)), + ), + Positioned( + left: endX, + top: 0, + bottom: 0, + right: 0, + child: const ColoredBox(color: Color(0x99000000)), + ), + Positioned( + left: startX, + right: math.max(0, width - endX), + top: 0, + bottom: 0, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.symmetric( + horizontal: BorderSide(color: accent, width: 2), + ), + ), + ), + ), + ), + ValueListenableBuilder( + valueListenable: widget.controller, + child: const IgnorePointer(child: _Playhead()), + builder: (context, value, child) => Positioned( + left: (_fraction(value.position) * width - 1.5).clamp( + 0.0, + math.max(0, width - 3), + ), + top: 2, + bottom: 2, + width: 3, + child: child!, + ), + ), + Positioned( + left: (startX - _handle).clamp(0.0, width), + top: 0, + bottom: 0, + width: _handle, + child: _Handle(color: accent, leading: true), + ), + Positioned( + left: endX.clamp(0.0, math.max(0, width - _handle)), + top: 0, + bottom: 0, + width: _handle, + child: _Handle(color: accent, leading: false), + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _filmstrip() { + if (widget.frames.isEmpty) { + return const ColoredBox(color: Color(0xFF1E1E1E)); + } + return Row( + children: [ + for (final frame in widget.frames) + Expanded( + child: frame == null + ? const ColoredBox(color: Color(0xFF1E1E1E)) + : Image.memory( + frame, + fit: BoxFit.cover, + height: double.infinity, + gaplessPlayback: true, + ), + ), + ], + ); + } +} + +class _Playhead extends StatelessWidget { + const _Playhead(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(2), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)], + ), + ); + } +} + +class _Handle extends StatelessWidget { + final Color color; + final bool leading; + + const _Handle({required this.color, required this.leading}); + + @override + Widget build(BuildContext context) { + final radius = leading + ? const BorderRadius.horizontal(left: Radius.circular(8)) + : const BorderRadius.horizontal(right: Radius.circular(8)); + return DecoratedBox( + decoration: BoxDecoration(color: color, borderRadius: radius), + child: Center( + child: Container( + width: 2, + height: 16, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/avatar_history_screen.dart b/lib/frontend/widgets/avatar_history_screen.dart index 1f57b89..09afe26 100644 --- a/lib/frontend/widgets/avatar_history_screen.dart +++ b/lib/frontend/widgets/avatar_history_screen.dart @@ -6,6 +6,7 @@ import '../../backend/modules/contacts.dart'; import '../../core/utils/media_saver.dart'; import '../../main.dart'; import 'custom_notification.dart'; +import 'small_spinner.dart'; class AvatarHistoryScreen extends StatefulWidget { final int contactId; @@ -63,9 +64,15 @@ class _AvatarHistoryScreenState extends State { final current = widget.currentAvatarUrl; _current = (current != null && current.isNotEmpty) ? current : null; _rebuildPages(); - _load(); + if (_hasHistory) { + _load(); + } else { + _loading = false; + } } + bool get _hasHistory => widget.contactId > 0; + @override void dispose() { _pageController.dispose(); @@ -99,7 +106,9 @@ class _AvatarHistoryScreenState extends State { } Future _loadMore() async { - if (_loadingMore || _history.length >= _historyTotal) return; + if (!_hasHistory || _loadingMore || _history.length >= _historyTotal) { + return; + } _loadingMore = true; final photos = await ContactsModule.fetchPhotos( api, @@ -210,14 +219,7 @@ class _AvatarHistoryScreenState extends State { Expanded(child: _buildCounter()), IconButton( icon: _saving - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: Colors.white, - ), - ) + ? const SmallSpinner(size: 22, color: Colors.white) : const Icon(Symbols.download, color: Colors.white), onPressed: _pages.isEmpty || _saving ? null : _save, ), @@ -276,7 +278,7 @@ class _AvatarHistoryScreenState extends State { if (_pages.isEmpty) { return Center( child: _loading - ? const CircularProgressIndicator(color: Colors.white) + ? const SmallSpinner(size: 36, color: Colors.white) : const Text( 'Нет фотографий', style: TextStyle(color: Colors.white54, fontSize: 15), @@ -292,9 +294,8 @@ class _AvatarHistoryScreenState extends State { imageUrl: _pages[i], fit: BoxFit.contain, fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => const Center( - child: CircularProgressIndicator(color: Colors.white), - ), + placeholder: (_, _) => + const Center(child: SmallSpinner(size: 36, color: Colors.white)), errorWidget: (_, _, _) => const Icon(Symbols.broken_image, color: Colors.white54, size: 64), ), diff --git a/lib/frontend/widgets/call_video_view.dart b/lib/frontend/widgets/call_video_view.dart new file mode 100644 index 0000000..6bd135c --- /dev/null +++ b/lib/frontend/widgets/call_video_view.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' + show RTCVideoRenderer, RTCVideoView, RTCVideoViewObjectFit; + +class CallVideoView extends StatefulWidget { + const CallVideoView({ + super.key, + required this.renderer, + this.objectFit = RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + this.mirror = false, + this.placeholder, + }); + + final RTCVideoRenderer renderer; + final RTCVideoViewObjectFit objectFit; + final bool mirror; + final Widget? placeholder; + + @override + State createState() => _CallVideoViewState(); +} + +class _CallVideoViewState extends State { + static const Duration _switchCooldown = Duration(milliseconds: 200); + + bool _armed = false; + String? _sourceId; + Timer? _cooldown; + + @override + void initState() { + super.initState(); + _bind(widget.renderer); + } + + @override + void didUpdateWidget(CallVideoView old) { + super.didUpdateWidget(old); + if (identical(old.renderer, widget.renderer)) return; + old.renderer.removeListener(_onRenderer); + _cooldown?.cancel(); + _cooldown = null; + _bind(widget.renderer); + } + + @override + void dispose() { + _cooldown?.cancel(); + widget.renderer.removeListener(_onRenderer); + super.dispose(); + } + + void _bind(RTCVideoRenderer renderer) { + _sourceId = renderer.srcObject?.id; + _armed = _hasFrames; + renderer.addListener(_onRenderer); + } + + bool get _hasFrames { + final renderer = widget.renderer; + return renderer.textureId != null && + renderer.srcObject != null && + renderer.value.width > 0; + } + + void _onRenderer() { + final id = widget.renderer.srcObject?.id; + if (id != _sourceId) { + _sourceId = id; + _cooldown?.cancel(); + _cooldown = Timer(_switchCooldown, () { + _cooldown = null; + _sync(); + }); + if (_armed && mounted) setState(() => _armed = false); + return; + } + if (_cooldown != null) return; + _sync(); + } + + void _sync() { + if (!mounted) return; + final next = _hasFrames; + if (next != _armed) setState(() => _armed = next); + } + + @override + Widget build(BuildContext context) { + if (!_armed) return widget.placeholder ?? const SizedBox.expand(); + return RTCVideoView( + widget.renderer, + objectFit: widget.objectFit, + mirror: widget.mirror, + ); + } +} diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index 1b8e11d..8992e80 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -5,17 +5,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:ogg_opus_player/ogg_opus_player.dart'; -import '../../../backend/modules/messages.dart' show ContactCache; +import '../../../backend/modules/messages.dart' + show CachedMessage, ContactCache; import '../../../backend/modules/shared_content.dart'; import '../../../core/cache/info_cache.dart'; +import '../../../core/media/voice_audio_controller.dart'; +import '../../../core/utils/download_history.dart'; import '../../../core/utils/download_progress.dart'; import '../../../core/utils/file_download.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/link_opener.dart'; import '../../../core/utils/logger.dart'; -import '../../../core/utils/media_cache.dart'; import '../../../core/utils/media_saver.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/attachment.dart'; @@ -23,8 +24,10 @@ import '../../screens/chats/chat_screen.dart'; import '../custom_notification.dart'; import '../komet_avatar.dart'; import '../photo_viewer.dart'; +import '../reload_on_reconnect.dart'; +import '../small_spinner.dart'; import '../swipe_route.dart'; -import '../video_player_screen.dart'; +import '../sheet_helpers.dart'; enum SharedContentKind { media, files, voice, links } @@ -102,13 +105,7 @@ Widget _emptyState(ColorScheme cs, String label, IconData icon) { Widget _loadingState(ColorScheme cs) { return Padding( padding: const EdgeInsets.symmetric(vertical: 56), - child: Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2.5, color: cs.primary), - ), - ), + child: Center(child: SmallSpinner(size: 26, color: cs.primary)), ); } @@ -157,9 +154,7 @@ Future _showItemMenu(BuildContext context, List<_MenuAction> actions) { return showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), + shape: kSheetShape, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -234,6 +229,7 @@ void _notifySave(BuildContext context, MediaSaveResult result) { Future _downloadAttachment( BuildContext context, SharedMediaItem item, + String sourceName, ) async { final att = item.attachment; final now = DateTime.now().millisecondsSinceEpoch; @@ -246,6 +242,16 @@ Future _downloadAttachment( resolveUrl: () async => url, saveName: 'IMG_$now.jpg', kind: SaveMediaKind.image, + download: DownloadMetadata( + cacheName: 'photo_${att.photoId ?? url.hashCode}.jpg', + kind: DownloadKind.photo, + sourceName: sourceName, + thumbnailUrl: url, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (context.mounted) _notifySave(context, result); return; @@ -265,6 +271,16 @@ Future _downloadAttachment( }, saveName: 'VID_$now.mp4', kind: SaveMediaKind.video, + download: DownloadMetadata( + cacheName: 'video_${att.videoId ?? item.messageId}.mp4', + kind: DownloadKind.video, + sourceName: sourceName, + thumbnailUrl: att.thumbnail ?? att.baseUrl ?? att.previewData, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (context.mounted) _notifySave(context, result); return; @@ -274,6 +290,7 @@ Future _downloadAttachment( final fileId = att.fileId; if (fileId == null) return; final name = att.name ?? 'file_$now'; + final downloadKind = downloadKindForName(name); final result = await saveMediaFile( cacheName: '${fileId}_$name', resolveUrl: () => messagesModule.getFileUrl( @@ -283,6 +300,18 @@ Future _downloadAttachment( ), saveName: name, kind: SaveMediaKind.file, + download: DownloadMetadata( + cacheName: '${fileId}_$name', + name: downloadKind == DownloadKind.file ? name : '', + kind: downloadKind, + sourceName: sourceName, + thumbnailUrl: + att.preview?.baseUrl ?? att.preview?.previewData ?? att.previewData, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (context.mounted) _notifySave(context, result); return; @@ -296,6 +325,15 @@ Future _downloadAttachment( resolveUrl: () async => url, saveName: 'AUD_$now.ogg', kind: SaveMediaKind.file, + download: DownloadMetadata( + cacheName: '${att.audioId ?? item.messageId}.ogg', + kind: DownloadKind.audio, + sourceName: sourceName, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); if (context.mounted) _notifySave(context, result); } @@ -315,7 +353,8 @@ class CommonChatsTab extends StatefulWidget { State createState() => _CommonChatsTabState(); } -class _CommonChatsTabState extends State { +class _CommonChatsTabState extends State + with ReloadOnReconnect { bool _loading = true; List _chats = const []; Map _onlineByChat = const {}; @@ -326,6 +365,9 @@ class _CommonChatsTabState extends State { _load(); } + @override + void reloadAfterReconnect() => _load(); + Future _load() async { final chats = await sharedContentModule.fetchCommonChats(widget.userId); @@ -376,7 +418,7 @@ class _CommonChatsTabState extends State { final cs = Theme.of(context).colorScheme; if (_loading) return _loadingState(cs); if (_chats.isEmpty) { - return _emptyState(cs, widget.emptyLabel, Icons.group); + return _emptyState(cs, widget.emptyLabel, Symbols.group); } return Container( @@ -455,6 +497,7 @@ class SharedMediaTab extends StatefulWidget { final int chatId; final String anchorMessageId; final int myId; + final String sourceName; final SharedContentKind kind; final String emptyLabel; final IconData emptyIcon; @@ -466,6 +509,7 @@ class SharedMediaTab extends StatefulWidget { required this.chatId, required this.anchorMessageId, required this.myId, + required this.sourceName, required this.kind, required this.emptyLabel, required this.emptyIcon, @@ -477,7 +521,8 @@ class SharedMediaTab extends StatefulWidget { State createState() => _SharedMediaTabState(); } -class _SharedMediaTabState extends State { +class _SharedMediaTabState extends State + with ReloadOnReconnect { static const int _pageSize = 60; bool _loading = true; @@ -510,6 +555,9 @@ class _SharedMediaTabState extends State { } } + @override + void reloadAfterReconnect() => _load(widget.anchorMessageId, initial: true); + Future _load(String anchor, {required bool initial}) async { final page = await sharedContentModule.fetchMedia( chatId: widget.chatId, @@ -586,7 +634,13 @@ class _SharedMediaTabState extends State { children.add(_mediaGrid(cs, group.items)); case SharedContentKind.files: children.addAll( - group.items.map((i) => _FileRow(item: i, onGoTo: () => _goTo(i))), + group.items.map( + (i) => _FileRow( + item: i, + sourceName: widget.sourceName, + onGoTo: () => _goTo(i), + ), + ), ); case SharedContentKind.voice: children.addAll( @@ -594,6 +648,7 @@ class _SharedMediaTabState extends State { (i) => _ProfileVoiceTile( item: i, senderName: _resolveName(i.senderId), + sourceName: widget.sourceName, onGoTo: () => _goTo(i), ), ), @@ -611,14 +666,7 @@ class _SharedMediaTabState extends State { padding: const EdgeInsets.symmetric(vertical: 12), child: Center( child: _loadingMore - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: cs.primary, - ), - ) + ? SmallSpinner(size: 22, color: cs.primary) : TextButton( onPressed: _loadMore, child: Text(l10n.sharedLoadMore), @@ -645,8 +693,12 @@ class _SharedMediaTabState extends State { crossAxisSpacing: 3, ), itemCount: items.length, - itemBuilder: (context, index) => - _MediaTile(item: items[index], onGoTo: () => _goTo(items[index])), + itemBuilder: (context, index) => _MediaTile( + item: items[index], + onGoTo: () => _goTo(items[index]), + onGoToMessage: widget.onGoToMessage, + sourceName: widget.sourceName, + ), ); } } @@ -654,8 +706,15 @@ class _SharedMediaTabState extends State { class _MediaTile extends StatelessWidget { final SharedMediaItem item; final VoidCallback onGoTo; + final void Function(String messageId, int time) onGoToMessage; + final String sourceName; - const _MediaTile({required this.item, required this.onGoTo}); + const _MediaTile({ + required this.item, + required this.onGoTo, + required this.onGoToMessage, + required this.sourceName, + }); void _menu(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -664,7 +723,7 @@ class _MediaTile extends StatelessWidget { onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { - await _downloadAttachment(context, item); + await _downloadAttachment(context, item, sourceName); }), ]); } @@ -750,20 +809,62 @@ class _MediaTile extends StatelessWidget { showCustomNotification(context, 'Не удалось загрузить видео'); return; } - pushSwipeable(context, (_) => VideoPlayerScreen(sources: sources)); + pushSwipeable( + context, + (_) => PhotoViewerScreen.video( + attachment: att, + initialVideoSources: sources, + chatId: item.chatId, + message: CachedMessage( + id: item.messageId, + accountId: 0, + chatId: item.chatId, + senderId: item.senderId, + text: item.text, + time: item.time, + ), + actions: PhotoViewerActions(goToMessage: onGoToMessage), + sourceName: sourceName, + ), + ); return; } final url = att.baseUrl ?? att.previewData ?? ''; if (url.isEmpty) return; - pushSwipeable(context, (_) => PhotoViewerScreen(baseUrl: url)); + + final photo = att is PhotoAttachment && (att.baseUrl ?? '').isNotEmpty + ? att + : PhotoAttachment(baseUrl: url); + pushSwipeable( + context, + (_) => PhotoViewerScreen( + photos: [photo], + chatId: item.chatId, + message: CachedMessage( + id: item.messageId, + accountId: 0, + chatId: item.chatId, + senderId: item.senderId, + text: item.text, + time: item.time, + ), + actions: PhotoViewerActions(goToMessage: onGoToMessage), + sourceName: sourceName, + ), + ); } } class _FileRow extends StatelessWidget { final SharedMediaItem item; + final String sourceName; final VoidCallback onGoTo; - const _FileRow({required this.item, required this.onGoTo}); + const _FileRow({ + required this.item, + required this.sourceName, + required this.onGoTo, + }); void _menu(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -772,7 +873,7 @@ class _FileRow extends StatelessWidget { onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { - await _downloadAttachment(context, item); + await _downloadAttachment(context, item, sourceName); }), ]); } @@ -896,8 +997,22 @@ class _FileRow extends StatelessWidget { fileId: fileId, ), onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + onReady: () => MediaDownloadProgress.set(cacheName, null), + download: DownloadMetadata( + cacheName: cacheName, + name: downloadKindForName(att.name ?? '') == DownloadKind.file + ? att.name ?? '' + : '', + kind: downloadKindForName(att.name ?? ''), + sourceName: sourceName, + thumbnailUrl: + att.preview?.baseUrl ?? att.preview?.previewData ?? att.previewData, + expectedSize: att.size ?? 0, + chatId: item.chatId, + messageId: item.messageId, + messageTime: item.time, + ), ); - MediaDownloadProgress.set(cacheName, null); if (!context.mounted) return; if (!result.ok) { @@ -1034,11 +1149,13 @@ class _LinkRow extends StatelessWidget { class _ProfileVoiceTile extends StatefulWidget { final SharedMediaItem item; final String senderName; + final String sourceName; final VoidCallback onGoTo; const _ProfileVoiceTile({ required this.item, required this.senderName, + required this.sourceName, required this.onGoTo, }); @@ -1047,81 +1164,39 @@ class _ProfileVoiceTile extends StatefulWidget { } class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { - OggOpusPlayer? _player; - bool _isPlaying = false; - bool _loadingAudio = false; - Timer? _ticker; - final ValueNotifier _progress = ValueNotifier(0.0); + late final VoiceAudioController _player; AudioAttachment get _audio => widget.item.attachment as AudioAttachment; int get _durationSec => ((_audio.duration ?? 0) / 1000).round(); + @override + void initState() { + super.initState(); + _player = VoiceAudioController( + cacheName: '${_audio.audioId ?? widget.item.messageId}.ogg', + resolveUrl: () async => _audio.fileUrl ?? _audio.baseUrl ?? '', + fallbackDuration: Duration(milliseconds: _audio.duration ?? 0), + ); + _player.failure.addListener(_onFailure); + } + @override void dispose() { - _ticker?.cancel(); - _player?.state.removeListener(_onPlayerState); - _player?.dispose(); - _progress.dispose(); + _player.failure.removeListener(_onFailure); + _player.dispose(); super.dispose(); } - Future _togglePlay() async { - if (_loadingAudio) return; - - if (_player != null) { - if (_isPlaying) { - _player!.pause(); - } else { - final dur = _audio.duration ?? 0; - if (dur > 0 && _player!.currentPosition * 1000 >= dur - 50) { - _progress.value = 0; - } - _player!.play(); - } - return; - } - - final url = _audio.fileUrl ?? _audio.baseUrl ?? ''; - if (url.isEmpty) return; - - setState(() => _loadingAudio = true); - try { - final name = '${_audio.audioId ?? widget.item.messageId}.ogg'; - final file = await MediaCache.getOrDownload(name, url); - if (!mounted) return; - if (file == null) { - showCustomNotification(context, 'Не удалось загрузить аудио'); - return; - } - final player = OggOpusPlayer(file.path); - _player = player; - player.state.addListener(_onPlayerState); - _ticker = Timer.periodic( - const Duration(milliseconds: 60), - (_) => _onTick(), - ); - player.play(); - } catch (e) { - logger.w('ProfileVoiceTile._togglePlay: $e'); - if (mounted) showCustomNotification(context, 'Ошибка воспроизведения'); - } finally { - if (mounted) setState(() => _loadingAudio = false); - } - } - - void _onTick() { - final player = _player; - final dur = _audio.duration ?? 0; - if (player == null || dur <= 0) return; - _progress.value = (player.currentPosition * 1000 / dur).clamp(0.0, 1.0); - } - - void _onPlayerState() { + void _onFailure() { if (!mounted) return; - final state = _player?.state.value; - final playing = state == PlayerState.playing; - if (playing != _isPlaying) setState(() => _isPlaying = playing); - if (state == PlayerState.ended) _progress.value = 1.0; + switch (_player.failure.value) { + case VoiceAudioFailure.none: + return; + case VoiceAudioFailure.download: + showCustomNotification(context, 'Не удалось загрузить аудио'); + case VoiceAudioFailure.playback: + showCustomNotification(context, 'Ошибка воспроизведения'); + } } @override @@ -1136,7 +1211,7 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { child: Row( children: [ GestureDetector( - onTap: _togglePlay, + onTap: _player.toggle, child: Container( width: 46, height: 46, @@ -1144,40 +1219,58 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { color: cs.primary, shape: BoxShape.circle, ), - child: _loadingAudio - ? const Padding( - padding: EdgeInsets.all(13), + child: AnimatedBuilder( + animation: Listenable.merge([ + _player.downloaded, + _player.downloadProgress, + _player.playing, + _player.position, + _player.duration, + ]), + builder: (context, _) { + final download = _player.downloadProgress.value; + if (download != null) { + return Padding( + padding: const EdgeInsets.all(11), child: CircularProgressIndicator( strokeWidth: 2, - color: Colors.white, - ), - ) - : ValueListenableBuilder( - valueListenable: _progress, - builder: (context, progress, child) => Stack( - alignment: Alignment.center, - children: [ - if (progress > 0 && progress < 1) - SizedBox( - width: 46, - height: 46, - child: CircularProgressIndicator( - strokeWidth: 2, - value: progress, - color: cs.onPrimary.withValues(alpha: 0.5), - backgroundColor: Colors.transparent, - ), - ), - child!, - ], - ), - child: Icon( - _isPlaying ? Symbols.pause : Symbols.play_arrow, + value: download > 0 ? download : null, color: cs.onPrimary, - size: 24, - fill: 1, + backgroundColor: cs.onPrimary.withValues(alpha: 0.25), ), - ), + ); + } + final total = _player.duration.value; + final progress = total > 0 + ? (_player.position.value / total).clamp(0.0, 1.0) + : 0.0; + final IconData icon; + if (_player.playing.value) { + icon = Symbols.pause; + } else if (_player.downloaded.value) { + icon = Symbols.play_arrow; + } else { + icon = Symbols.arrow_downward; + } + return Stack( + alignment: Alignment.center, + children: [ + if (progress > 0 && progress < 1) + SizedBox( + width: 46, + height: 46, + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress, + color: cs.onPrimary.withValues(alpha: 0.5), + backgroundColor: Colors.transparent, + ), + ), + Icon(icon, color: cs.onPrimary, size: 24, fill: 1), + ], + ); + }, + ), ), ), const SizedBox(width: 12), @@ -1216,7 +1309,7 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { widget.onGoTo(); }), _MenuAction(Symbols.download, l10n.sharedDownload, () async { - await _downloadAttachment(context, widget.item); + await _downloadAttachment(context, widget.item, widget.sourceName); }), ]); } diff --git a/lib/frontend/widgets/chat_menu_overlay.dart b/lib/frontend/widgets/chat_menu_overlay.dart index 656ca06..f9afad6 100644 --- a/lib/frontend/widgets/chat_menu_overlay.dart +++ b/lib/frontend/widgets/chat_menu_overlay.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -26,6 +28,9 @@ void showChatMenu({ required BuildContext context, required Rect anchorRect, required List items, + Widget? header, + Widget? footer, + bool compact = false, }) { final overlay = Overlay.of(context, rootOverlay: true); late OverlayEntry entry; @@ -33,6 +38,9 @@ void showChatMenu({ builder: (ctx) => _ChatMenuLayer( anchorRect: anchorRect, items: items, + header: header, + footer: footer, + compact: compact, onDismiss: () { if (entry.mounted) entry.remove(); }, @@ -45,25 +53,85 @@ void showChatMenu({ class _ChatMenuLayer extends StatefulWidget { final Rect anchorRect; final List items; + final Widget? header; + final Widget? footer; + final bool compact; final VoidCallback onDismiss; const _ChatMenuLayer({ required this.anchorRect, required this.items, required this.onDismiss, + this.header, + this.footer, + this.compact = false, }); @override State<_ChatMenuLayer> createState() => _ChatMenuLayerState(); } +class _MenuLayout extends SingleChildLayoutDelegate { + static const double menuWidth = 290.0; + static const double compactMenuWidth = 226.0; + static const double margin = 8.0; + static const double gap = 6.0; + + final Rect anchor; + final EdgeInsets safeArea; + final bool compact; + + const _MenuLayout({ + required this.anchor, + required this.safeArea, + this.compact = false, + }); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final width = math.min( + compact ? compactMenuWidth : menuWidth, + constraints.maxWidth - margin * 2, + ); + final available = + constraints.maxHeight - safeArea.top - safeArea.bottom - margin * 2; + return BoxConstraints( + minWidth: math.max(0, width), + maxWidth: math.max(0, width), + maxHeight: math.max(120.0, available), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final maxLeft = math.max(margin, size.width - childSize.width - margin); + final left = (anchor.right - childSize.width).clamp(margin, maxLeft); + + final topLimit = safeArea.top + margin; + final bottomLimit = size.height - safeArea.bottom - margin; + final below = anchor.bottom + gap; + final above = anchor.top - gap - childSize.height; + + double top; + if (below + childSize.height <= bottomLimit) { + top = below; + } else if (above >= topLimit) { + top = above; + } else { + top = bottomLimit - childSize.height; + } + return Offset(left, math.max(topLimit, top)); + } + + @override + bool shouldRelayout(_MenuLayout oldDelegate) => + oldDelegate.anchor != anchor || + oldDelegate.safeArea != safeArea || + oldDelegate.compact != compact; +} + class _ChatMenuLayerState extends State<_ChatMenuLayer> with SingleTickerProviderStateMixin, AnimatedOverlayPopup<_ChatMenuLayer> { - static const double _menuWidth = 290.0; - static const double _hMargin = 8.0; - static const double _vMargin = 8.0; - static const double _gap = 6.0; - @override Duration get overlayForwardDuration => const Duration(milliseconds: 220); @@ -78,29 +146,10 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> closeOverlay().then((_) => item.onTap?.call()); } - Rect _resolveRect(Size screen) { - final maxWidth = screen.width - 2 * _hMargin; - final width = maxWidth <= 0 - ? screen.width - : (_menuWidth.clamp(0.0, maxWidth)); - final maxLeft = screen.width - width - _hMargin; - double left = widget.anchorRect.right - width; - if (left > maxLeft) left = maxLeft; - if (left < _hMargin) left = _hMargin; - final top = widget.anchorRect.bottom + _gap; - return Rect.fromLTWH(left, top, width, 0); - } - @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final screen = MediaQuery.sizeOf(context); - final bottomInset = MediaQuery.paddingOf(context).bottom; - final rect = _resolveRect(screen); - final maxHeight = (screen.height - rect.top - bottomInset - _vMargin).clamp( - 120.0, - double.infinity, - ); + final safeArea = MediaQuery.paddingOf(context); return AnimatedBuilder( animation: overlayAnimation, builder: (ctx, child) { @@ -115,16 +164,20 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> child: const SizedBox.expand(), ), ), - Positioned( - left: rect.left, - top: rect.top, - width: rect.width, - child: Opacity( - opacity: t, - child: Transform.scale( - scale: scale, - alignment: Alignment.topRight, - child: child, + Positioned.fill( + child: CustomSingleChildLayout( + delegate: _MenuLayout( + anchor: widget.anchorRect, + safeArea: safeArea, + compact: widget.compact, + ), + child: Opacity( + opacity: t, + child: Transform.scale( + scale: scale, + alignment: Alignment.topRight, + child: child, + ), ), ), ), @@ -137,25 +190,43 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> clipBehavior: Clip.antiAlias, elevation: 12, shadowColor: Colors.black.withValues(alpha: 0.45), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 6), - for (final item in widget.items) ...[ - _ChatMenuRow(item: item, onTap: () => _onItemTap(item)), - if (item.dividerAfter) - Divider( - height: 1, - thickness: 1, - color: cs.onSurface.withValues(alpha: 0.07), - ), - ], - const SizedBox(height: 6), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.header != null) ...[ + widget.header!, + Divider( + height: 1, + thickness: 1, + color: cs.onSurface.withValues(alpha: 0.07), + ), ], - ), + SizedBox(height: widget.compact ? 4 : 6), + for (final item in widget.items) ...[ + _ChatMenuRow( + item: item, + compact: widget.compact, + onTap: () => _onItemTap(item), + ), + if (item.dividerAfter) + Divider( + height: 1, + thickness: 1, + color: cs.onSurface.withValues(alpha: 0.07), + ), + ], + SizedBox(height: widget.compact ? 4 : 6), + if (widget.footer != null) ...[ + Divider( + height: 1, + thickness: 1, + color: cs.onSurface.withValues(alpha: 0.07), + ), + widget.footer!, + ], + ], ), ), ), @@ -166,8 +237,13 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> class _ChatMenuRow extends StatelessWidget { final ChatMenuItem item; final VoidCallback onTap; + final bool compact; - const _ChatMenuRow({required this.item, required this.onTap}); + const _ChatMenuRow({ + required this.item, + required this.onTap, + this.compact = false, + }); @override Widget build(BuildContext context) { @@ -176,11 +252,14 @@ class _ChatMenuRow extends StatelessWidget { return InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15), + padding: EdgeInsets.symmetric( + horizontal: compact ? 14 : 18, + vertical: compact ? 10 : 15, + ), child: Row( children: [ - Icon(item.icon, size: 24, weight: 350, color: fg), - const SizedBox(width: 18), + Icon(item.icon, size: compact ? 20 : 24, weight: 350, color: fg), + SizedBox(width: compact ? 12 : 18), Expanded( child: Text( item.label, @@ -188,7 +267,7 @@ class _ChatMenuRow extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( color: fg, - fontSize: 16, + fontSize: compact ? 14 : 16, fontWeight: FontWeight.w500, ), ), diff --git a/lib/frontend/widgets/chat_wallpaper_sheet.dart b/lib/frontend/widgets/chat_wallpaper_sheet.dart index aa69820..e04471e 100644 --- a/lib/frontend/widgets/chat_wallpaper_sheet.dart +++ b/lib/frontend/widgets/chat_wallpaper_sheet.dart @@ -1,8 +1,13 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/chat_wallpaper_themes.dart'; +import 'package:komet/core/config/app_colors.dart'; import 'package:komet/core/storage/chat_wallpaper_store.dart'; +import 'chat_wallpaper_view.dart'; +import '../../core/config/app_fonts.dart'; enum WallpaperPickType { none, theme, gallery } @@ -10,12 +15,10 @@ class WallpaperPick { final WallpaperPickType type; final ChatWallpaperTheme? theme; - const WallpaperPick.none() - : type = WallpaperPickType.none, - theme = null; + const WallpaperPick.none() : type = WallpaperPickType.none, theme = null; const WallpaperPick.gallery() - : type = WallpaperPickType.gallery, - theme = null; + : type = WallpaperPickType.gallery, + theme = null; const WallpaperPick.theme(this.theme) : type = WallpaperPickType.theme; } @@ -44,20 +47,21 @@ class ChatWallpaperGalleryScreen extends StatefulWidget { class _ChatWallpaperGalleryScreenState extends State { ChatWallpaperTheme? _selected; - bool _isImage = false; + bool _keepsImage = false; @override void initState() { super.initState(); final current = widget.current; - _isImage = current?.isImage ?? false; + _keepsImage = current?.isImage ?? false; _selected = current == null || current.isImage ? null : chatWallpaperThemeById(current.themeId); } bool get _changed { - if (_isImage) return _selected != null; + if (_keepsImage) return false; + if (widget.current?.isImage == true) return true; return _selected?.id != chatWallpaperThemeById(widget.current?.themeId)?.id; } @@ -81,12 +85,12 @@ class _ChatWallpaperGalleryScreenState icon: const Icon(Symbols.arrow_back), onPressed: () => Navigator.pop(context), ), - title: const Text( + title: Text( 'Обои', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), @@ -101,6 +105,7 @@ class _ChatWallpaperGalleryScreenState Widget _preview(ColorScheme cs) { final theme = _selected; + final image = _keepsImage ? widget.current : null; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: ClipRRect( @@ -110,6 +115,8 @@ class _ChatWallpaperGalleryScreenState children: [ if (theme != null) theme.buildBackground() + else if (image != null) + ChatWallpaperView(wallpaper: image) else ColoredBox(color: cs.surfaceContainerHighest), const IgnorePointer(child: _PreviewScrim()), @@ -120,7 +127,21 @@ class _ChatWallpaperGalleryScreenState ); } + Widget? _currentImageTile() { + final current = widget.current; + if (current == null || !current.isImage) return null; + return _CurrentImageTile( + wallpaper: current, + selected: _keepsImage, + onTap: () => setState(() { + _keepsImage = true; + _selected = null; + }), + ); + } + Widget _panel(ColorScheme cs) { + final currentImage = _currentImageTile(); return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -138,11 +159,12 @@ class _ChatWallpaperGalleryScreenState scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), children: [ + ?currentImage, _NoneTile( - selected: _selected == null && !_isImage, + selected: _selected == null && !_keepsImage, onTap: () => setState(() { _selected = null; - _isImage = false; + _keepsImage = false; }), ), for (final theme in kChatWallpaperThemes) @@ -151,7 +173,7 @@ class _ChatWallpaperGalleryScreenState selected: _selected?.id == theme.id, onTap: () => setState(() { _selected = theme; - _isImage = false; + _keepsImage = false; }), ), ], @@ -169,7 +191,9 @@ class _ChatWallpaperGalleryScreenState ), ), const SizedBox(width: 12), - Expanded(child: _ApplyButton(enabled: _changed, onTap: _apply)), + Expanded( + child: _ApplyButton(enabled: _changed, onTap: _apply), + ), ], ), ), @@ -216,6 +240,7 @@ class _SampleBubbles extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _bubble( + context, text: 'Как насчёт новых обоев для этого чата?', color: cs.surfaceContainerHighest.withValues(alpha: 0.94), textColor: cs.onSurface, @@ -223,6 +248,7 @@ class _SampleBubbles extends StatelessWidget { ), const SizedBox(height: 8), _bubble( + context, text: 'Выглядит отлично 🔥', color: cs.primary, textColor: cs.onPrimary, @@ -234,7 +260,8 @@ class _SampleBubbles extends StatelessWidget { ); } - Widget _bubble({ + Widget _bubble( + BuildContext context, { required String text, required Color color, required Color textColor, @@ -255,7 +282,7 @@ class _SampleBubbles extends StatelessWidget { style: TextStyle( color: textColor, fontSize: 15, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), @@ -336,7 +363,7 @@ class _TileFrame extends StatelessWidget { color: selected ? cs.primary : cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ], @@ -363,13 +390,39 @@ class _NoneTile extends StatelessWidget { child: ColoredBox( color: cs.surfaceContainerHighest, child: const Center( - child: Icon(Symbols.block, color: Color(0xFFFF3B30), size: 34), + child: Icon(Symbols.block, color: kDangerRed, size: 34), ), ), ); } } +class _CurrentImageTile extends StatelessWidget { + final ChatWallpaper wallpaper; + final bool selected; + final VoidCallback onTap; + + const _CurrentImageTile({ + required this.wallpaper, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final path = wallpaper.imagePath; + return _TileFrame( + selected: selected, + onTap: onTap, + label: 'Ваше фото', + child: path == null + ? ColoredBox(color: cs.surfaceContainerHighest) + : Image.file(File(path), fit: BoxFit.cover), + ); + } +} + class _ThemeTile extends StatelessWidget { final ChatWallpaperTheme theme; final bool selected; @@ -419,7 +472,7 @@ class _GalleryButton extends StatelessWidget { color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ], @@ -456,7 +509,7 @@ class _ApplyButton extends StatelessWidget { color: cs.onPrimary, fontSize: 16, fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), ), 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/confirm_dialog.dart b/lib/frontend/widgets/confirm_dialog.dart index 36c2ec6..9d56248 100644 --- a/lib/frontend/widgets/confirm_dialog.dart +++ b/lib/frontend/widgets/confirm_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../core/config/app_shape.dart'; /// Shared confirmation dialog. Returns true if confirmed, false otherwise. Future showConfirmDialog( @@ -14,7 +15,7 @@ Future showConfirmDialog( context: context, builder: (context) => AlertDialog( backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + shape: AppShape.dialogBorder, title: title == null ? null : Text(title, style: TextStyle(color: cs.onSurface)), diff --git a/lib/frontend/widgets/connection_status.dart b/lib/frontend/widgets/connection_status.dart index c1ebfef..4aacce2 100644 --- a/lib/frontend/widgets/connection_status.dart +++ b/lib/frontend/widgets/connection_status.dart @@ -5,6 +5,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import '../../backend/api.dart'; import '../../main.dart' show api; +import 'small_spinner.dart'; final ValueNotifier debugForceOffline = ValueNotifier(false); @@ -175,14 +176,7 @@ class ConnectionSpinner extends StatelessWidget { ], ), child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: cs.primary, - ), - ), + child: SmallSpinner(size: 20, color: cs.primary), ), ), ), diff --git a/lib/frontend/widgets/decrypted_photo.dart b/lib/frontend/widgets/decrypted_photo.dart new file mode 100644 index 0000000..6e888c0 --- /dev/null +++ b/lib/frontend/widgets/decrypted_photo.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../core/crypto/encrypted_photo_cache.dart'; +import '../../core/storage/chat_encryption_store.dart'; + +class DecryptedPhoto extends StatefulWidget { + final int accountId; + final int chatId; + final String cacheName; + final int size; + final EncryptedPhotoUrlLoader urlLoader; + final Widget Function(EncryptedPhotoView? view) builder; + + const DecryptedPhoto({ + super.key, + required this.accountId, + required this.chatId, + required this.cacheName, + required this.size, + required this.urlLoader, + required this.builder, + }); + + @override + State createState() => _DecryptedPhotoState(); +} + +class _DecryptedPhotoState extends State { + @override + void initState() { + super.initState(); + _request(); + ChatEncryptionStore.instance.revision.addListener(_onEncryptionChanged); + } + + @override + void dispose() { + ChatEncryptionStore.instance.revision.removeListener(_onEncryptionChanged); + super.dispose(); + } + + @override + void didUpdateWidget(DecryptedPhoto old) { + super.didUpdateWidget(old); + if (old.cacheName != widget.cacheName) _request(); + } + + void _onEncryptionChanged() => scheduleMicrotask(_request); + + void _request() { + if (!mounted) return; + EncryptedPhotoCache.instance.request( + accountId: widget.accountId, + chatId: widget.chatId, + cacheName: widget.cacheName, + urlLoader: widget.urlLoader, + size: widget.size, + ); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: EncryptedPhotoCache.instance.listenableFor( + widget.cacheName, + ), + builder: (context, view, _) => widget.builder(view), + ); + } +} diff --git a/lib/frontend/widgets/decrypted_text.dart b/lib/frontend/widgets/decrypted_text.dart new file mode 100644 index 0000000..343eb4f --- /dev/null +++ b/lib/frontend/widgets/decrypted_text.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../core/crypto/message_decryption_cache.dart'; + +class DecryptedContent extends StatefulWidget { + final int accountId; + final int chatId; + final String messageId; + final String cipherText; + final Widget Function(MessageDecryption? decryption) builder; + + const DecryptedContent({ + super.key, + required this.accountId, + required this.chatId, + required this.messageId, + required this.cipherText, + required this.builder, + }); + + @override + State createState() => _DecryptedContentState(); +} + +class _DecryptedContentState extends State { + @override + void initState() { + super.initState(); + _request(); + } + + @override + void didUpdateWidget(DecryptedContent old) { + super.didUpdateWidget(old); + if (old.messageId != widget.messageId || + old.cipherText != widget.cipherText) { + _request(); + } + } + + void _request() { + MessageDecryptionCache.instance.request( + accountId: widget.accountId, + chatId: widget.chatId, + messageId: widget.messageId, + cipherText: widget.cipherText, + ); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: MessageDecryptionCache.instance.listenableFor( + widget.messageId, + ), + builder: (context, decryption, _) => widget.builder(decryption), + ); + } +} diff --git a/lib/frontend/widgets/rightward_drag_recognizer.dart b/lib/frontend/widgets/directional_drag_recognizer.dart similarity index 67% rename from lib/frontend/widgets/rightward_drag_recognizer.dart rename to lib/frontend/widgets/directional_drag_recognizer.dart index 8ac3e81..e84d655 100644 --- a/lib/frontend/widgets/rightward_drag_recognizer.dart +++ b/lib/frontend/widgets/directional_drag_recognizer.dart @@ -1,12 +1,18 @@ import 'package:flutter/gestures.dart'; -class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { - RightwardDragRecognizer({super.debugOwner}) { +class DirectionalDragRecognizer extends HorizontalDragGestureRecognizer { + DirectionalDragRecognizer({ + required this.direction, + this.minAcceptDistance = 20.0, + this.minAcceptVelocity, + super.debugOwner, + }) { onlyAcceptDragOnThreshold = true; } - static const double _kMinAcceptVelocity = 700.0; - static const double _kMinAcceptDistance = 20.0; + final double direction; + final double minAcceptDistance; + final double? minAcceptVelocity; final Map _initialPositions = {}; final Map _velocityTrackers = {}; @@ -30,7 +36,7 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { ); final initial = _initialPositions[event.pointer]; if (initial != null) { - final dx = event.position.dx - initial.dx; + final dx = (event.position.dx - initial.dx) * direction; _currentDeltaX[event.pointer] = dx; if (dx < -kTouchSlop) { stopTrackingPointer(event.pointer); @@ -57,10 +63,13 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { for (final dx in _currentDeltaX.values) { if (dx > maxDx) maxDx = dx; } - if (maxDx < _kMinAcceptDistance) return false; + if (maxDx < minAcceptDistance) return false; + + final minVelocity = minAcceptVelocity; + if (minVelocity == null) return true; for (final tracker in _velocityTrackers.values) { - final vx = tracker.getVelocity().pixelsPerSecond.dx; - if (vx >= _kMinAcceptVelocity) return true; + final vx = tracker.getVelocity().pixelsPerSecond.dx * direction; + if (vx >= minVelocity) return true; } return false; } @@ -83,3 +92,12 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { super.rejectGesture(pointer); } } + +class RightwardDragRecognizer extends DirectionalDragRecognizer { + RightwardDragRecognizer({super.debugOwner}) + : super(direction: 1, minAcceptVelocity: 700); +} + +class LeftwardDragRecognizer extends DirectionalDragRecognizer { + LeftwardDragRecognizer({super.debugOwner}) : super(direction: -1); +} diff --git a/lib/frontend/widgets/draggable_floating_layer.dart b/lib/frontend/widgets/draggable_floating_layer.dart new file mode 100644 index 0000000..a820214 --- /dev/null +++ b/lib/frontend/widgets/draggable_floating_layer.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; + +import '../../core/utils/haptics.dart'; + +@immutable +class FloatingDockGeometry { + const FloatingDockGeometry({ + required this.bounds, + required this.size, + required this.safeArea, + this.edge = 12, + this.restingBottomGap = 96, + }); + + final Size bounds; + final Size size; + final EdgeInsets safeArea; + final double edge; + final double restingBottomGap; + + static const double flingSeconds = 0.09; + static const double flingThreshold = 320; + + double get minX => edge; + double get maxX => math.max(minX, bounds.width - size.width - edge); + double get minY => safeArea.top + edge; + double get maxY => + math.max(minY, bounds.height - size.height - safeArea.bottom - edge); + + Offset get resting => clamp( + Offset( + maxX, + bounds.height - size.height - safeArea.bottom - restingBottomGap, + ), + ); + + Offset clamp(Offset value) => + Offset(value.dx.clamp(minX, maxX), value.dy.clamp(minY, maxY)); + + Offset snap(Offset value, Offset velocity) { + final center = value.dx + size.width / 2; + final toRight = velocity.dx.abs() > flingThreshold + ? velocity.dx > 0 + : center >= bounds.width / 2; + return clamp( + Offset(toRight ? maxX : minX, value.dy + velocity.dy * flingSeconds), + ); + } +} + +class DraggableFloatingLayer extends StatefulWidget { + const DraggableFloatingLayer({ + super.key, + required this.storageKey, + required this.size, + required this.child, + this.onTap, + this.onDragStart, + this.onDragEnd, + this.edge = 12, + this.restingBottomGap = 96, + }); + + final String storageKey; + final Size size; + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onDragStart; + final VoidCallback? onDragEnd; + final double edge; + final double restingBottomGap; + + @override + State createState() => _DraggableFloatingLayerState(); +} + +class _DraggableFloatingLayerState extends State + with TickerProviderStateMixin { + static final Map _remembered = {}; + + static final SpringDescription _spring = SpringDescription.withDampingRatio( + mass: 1, + stiffness: 520, + ratio: 1.0, + ); + + final ValueNotifier _offset = ValueNotifier(null); + + late final AnimationController _settle = + AnimationController.unbounded(vsync: this) + ..addListener(_onSettle) + ..addStatusListener(_onSettleStatus); + + late final AnimationController _lift = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 160), + reverseDuration: const Duration(milliseconds: 220), + ); + + FloatingDockGeometry _dock = const FloatingDockGeometry( + bounds: Size.zero, + size: Size.zero, + safeArea: EdgeInsets.zero, + ); + + Offset _from = Offset.zero; + Offset _to = Offset.zero; + Timer? _resizeSettle; + bool _dragging = false; + + @override + void initState() { + super.initState(); + _offset.value = _remembered[widget.storageKey]; + } + + @override + void didUpdateWidget(DraggableFloatingLayer old) { + super.didUpdateWidget(old); + if (widget.size == old.size || _dragging) return; + _resizeSettle?.cancel(); + _resizeSettle = Timer(const Duration(milliseconds: 90), _redock); + } + + @override + void dispose() { + _resizeSettle?.cancel(); + _settle.dispose(); + _lift.dispose(); + _offset.dispose(); + super.dispose(); + } + + Offset get _current => _dock.clamp(_offset.value ?? _dock.resting); + + void _redock() { + if (!mounted || _dragging || _dock.bounds.isEmpty) return; + _animateTo(_dock.snap(_current, Offset.zero), Offset.zero); + } + + void _animateTo(Offset target, Offset velocity) { + final start = _current; + final delta = target - start; + final distance = delta.distance; + if (distance < 0.5) { + _apply(target); + return; + } + final along = + (velocity.dx * delta.dx + velocity.dy * delta.dy) / + (distance * distance); + _from = start; + _to = target; + _settle.stop(); + _settle.value = 0; + _settle.animateWith( + SpringSimulation(_spring, 0, 1, along.clamp(-12.0, 12.0)), + ); + } + + void _onSettle() => _apply(Offset.lerp(_from, _to, _settle.value)!); + + void _onSettleStatus(AnimationStatus status) { + if (status.isAnimating) return; + _apply(_to); + } + + void _apply(Offset value) { + final position = _dock.clamp(value); + _offset.value = position; + _remembered[widget.storageKey] = position; + } + + void _onPanStart(DragStartDetails details) { + _settle.stop(); + _resizeSettle?.cancel(); + _dragging = true; + _lift.forward(); + Haptics.selection(); + widget.onDragStart?.call(); + } + + void _onPanUpdate(DragUpdateDetails details) => + _apply(_current + details.delta); + + void _onPanEnd(DragEndDetails details) { + final velocity = details.velocity.pixelsPerSecond; + _dragging = false; + _lift.reverse(); + widget.onDragEnd?.call(); + _animateTo(_dock.snap(_current, velocity), velocity); + } + + void _onPanCancel() { + if (!_dragging) return; + _dragging = false; + _lift.reverse(); + widget.onDragEnd?.call(); + _redock(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + _dock = FloatingDockGeometry( + bounds: constraints.biggest, + size: widget.size, + safeArea: MediaQuery.paddingOf(context), + edge: widget.edge, + restingBottomGap: widget.restingBottomGap, + ); + return ValueListenableBuilder( + valueListenable: _offset, + child: RepaintBoundary( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onPanCancel: _onPanCancel, + child: ScaleTransition( + scale: Tween(begin: 1, end: 1.06).animate( + CurvedAnimation(parent: _lift, curve: Curves.easeOutCubic), + ), + child: widget.child, + ), + ), + ), + builder: (context, offset, child) { + final position = _dock.clamp(offset ?? _dock.resting); + return Stack( + children: [Transform.translate(offset: position, child: child)], + ); + }, + ); + }, + ); + } +} diff --git a/lib/frontend/widgets/encryption_lock_badge.dart b/lib/frontend/widgets/encryption_lock_badge.dart new file mode 100644 index 0000000..c8faf5b --- /dev/null +++ b/lib/frontend/widgets/encryption_lock_badge.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class EncryptionLockBadge extends StatelessWidget { + final double size; + final Color? borderColor; + + const EncryptionLockBadge({super.key, this.size = 16, this.borderColor}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: borderColor ?? cs.surface, width: 1.5), + ), + alignment: Alignment.center, + child: Icon( + Symbols.lock, + size: size * 0.62, + weight: 700, + fill: 1, + color: cs.onPrimary, + ), + ); + } +} diff --git a/lib/frontend/widgets/floating_call_badge.dart b/lib/frontend/widgets/floating_call_badge.dart new file mode 100644 index 0000000..b48ecca --- /dev/null +++ b/lib/frontend/widgets/floating_call_badge.dart @@ -0,0 +1,711 @@ +import 'dart:async'; +import 'dart:ui' show lerpDouble; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' + show MediaStream, RTCVideoRenderer, RTCVideoViewObjectFit; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/calls/active_call.dart'; +import '../../core/calls/call_session.dart'; +import '../../core/config/app_colors.dart'; +import '../../core/config/app_fonts.dart'; +import '../../core/utils/format.dart'; +import '../../core/utils/haptics.dart'; +import '../../l10n/app_localizations.dart'; +import '../../main.dart' show KometApp; +import '../screens/calls/call_screen.dart'; +import 'call_video_view.dart'; +import 'custom_notification.dart'; +import 'draggable_floating_layer.dart'; +import 'glossy_pill.dart'; +import 'lottie_slash_icon.dart'; +import 'small_spinner.dart'; + +class FloatingCallBadgeLayer extends StatelessWidget { + const FloatingCallBadgeLayer({super.key}); + + @override + Widget build(BuildContext context) { + final call = ActiveCall.instance; + return ValueListenableBuilder( + valueListenable: call.current, + builder: (context, active, _) { + if (active == null) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: call.screenVisible, + builder: (context, onScreen, _) => onScreen + ? const SizedBox.shrink() + : _CallBadge(key: ObjectKey(active.session), call: active), + ); + }, + ); + } +} + +typedef _BadgeSnapshot = ({ + bool muted, + bool video, + bool speaking, + bool hasVideo, + bool reconnecting, + CallSessionState state, +}); + +class _CallBadge extends StatefulWidget { + const _CallBadge({super.key, required this.call}); + + final ActiveCallPresentation call; + + @override + State<_CallBadge> createState() => _CallBadgeState(); +} + +class _CallBadgeState extends State<_CallBadge> + with SingleTickerProviderStateMixin { + static const double _collapsedWidth = 120; + static const double _expandedWidth = 152; + static const double _collapsedHeight = 112; + static const double _expandedHeight = 160; + static const double _avatarSize = 56; + static const double _buttonSize = 38; + static const double _controlsWidth = _expandedWidth - 20; + static const Duration _autoCollapse = Duration(seconds: 4); + + late final AnimationController _reveal = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + reverseDuration: const Duration(milliseconds: 200), + ); + + StreamSubscription? _stateSub; + StreamSubscription? _infoSub; + StreamSubscription? _remoteStreamSub; + StreamSubscription? _participantStreamSub; + Timer? _collapseTimer; + bool _chromeMounted = false; + _BadgeSnapshot? _rendered; + + RTCVideoRenderer? _renderer; + MediaStream? _videoStream; + bool _rendererPending = false; + bool _videoBusy = false; + + CallSession get _session => widget.call.session; + + @override + void initState() { + super.initState(); + _stateSub = _session.stateStream.listen((_) => _sync()); + _infoSub = _session.infoUpdates.listen((_) => _sync()); + _remoteStreamSub = _session.remoteStreamStream.listen((_) => _sync()); + _participantStreamSub = _session.participantStreamUpdates.listen( + (_) => _sync(), + ); + _reveal.addStatusListener(_onRevealStatus); + _attachVideo(); + _rendered = _snapshot(); + } + + @override + void dispose() { + _stateSub?.cancel(); + _infoSub?.cancel(); + _remoteStreamSub?.cancel(); + _participantStreamSub?.cancel(); + _collapseTimer?.cancel(); + _renderer?.srcObject = null; + _renderer?.dispose(); + _reveal.dispose(); + super.dispose(); + } + + void _sync() { + if (!mounted) return; + _attachVideo(); + final next = _snapshot(); + if (next == _rendered) return; + _rendered = next; + setState(() {}); + } + + void _redraw() { + if (!mounted) return; + _rendered = _snapshot(); + setState(() {}); + } + + _BadgeSnapshot _snapshot() => ( + muted: _session.isMuted, + video: _session.localVideo, + speaking: _peerSpeaking, + hasVideo: _videoStream != null, + reconnecting: _session.isReconnecting, + state: _session.currentState, + ); + + MediaStream? _pickVideoStream() { + final session = _session; + for (final participant in session.participants) { + if (participant.isSelf) continue; + if (!participant.videoEnabled && !participant.screenSharing) continue; + final stream = session.streamOf(participant.id); + if (stream != null && stream.getVideoTracks().isNotEmpty) return stream; + } + final remote = session.remoteStream; + if (session.peerVideo && + remote != null && + remote.getVideoTracks().isNotEmpty) { + return remote; + } + return null; + } + + void _attachVideo() { + final next = _pickVideoStream(); + if (identical(next, _videoStream)) return; + _videoStream = next; + final renderer = _renderer; + if (renderer != null) { + renderer.srcObject = next; + return; + } + if (next != null && !_rendererPending) { + _rendererPending = true; + unawaited(_createRenderer()); + } + } + + Future _createRenderer() async { + final renderer = RTCVideoRenderer(); + try { + await renderer.initialize(); + } catch (_) { + _rendererPending = false; + return; + } + _rendererPending = false; + if (!mounted || _videoStream == null) { + await renderer.dispose(); + return; + } + renderer.srcObject = _videoStream; + _renderer = renderer; + _redraw(); + } + + void _restartCollapseTimer() { + _collapseTimer?.cancel(); + _collapseTimer = Timer(_autoCollapse, _collapse); + } + + void _mountChrome() { + if (_chromeMounted) return; + setState(() => _chromeMounted = true); + } + + void _onRevealStatus(AnimationStatus status) { + if (status != AnimationStatus.dismissed || !_chromeMounted) return; + setState(() => _chromeMounted = false); + } + + void _expandControls() { + _mountChrome(); + _reveal.forward(); + _restartCollapseTimer(); + } + + void _collapse() { + _collapseTimer?.cancel(); + if (mounted) _reveal.reverse(); + } + + void _toggleControls() { + Haptics.tap(); + if (_reveal.value > 0.5) { + _collapse(); + } else { + _expandControls(); + } + } + + void _onDragStart() { + _collapseTimer?.cancel(); + _mountChrome(); + _reveal.forward(); + } + + void _onDragEnd() => _restartCollapseTimer(); + + Future _toggleMute() async { + Haptics.tap(); + _restartCollapseTimer(); + await _session.setMuted(!_session.isMuted); + _redraw(); + } + + Future _toggleVideo() async { + if (_videoBusy) return; + Haptics.tap(); + _restartCollapseTimer(); + final l10n = AppLocalizations.of(context)!; + setState(() => _videoBusy = true); + await WidgetsBinding.instance.endOfFrame; + try { + await _session.setVideoEnabled(!_session.localVideo); + } catch (e) { + _notify(l10n.callCameraUnavailable(e)); + } finally { + _videoBusy = false; + _redraw(); + } + } + + void _notify(String message) { + final overlay = KometApp.navigatorKey.currentState?.overlay; + if (overlay == null) return; + showCustomNotificationOnOverlay(overlay, message); + } + + Future _hangup() async { + Haptics.medium(); + _collapseTimer?.cancel(); + await _session.hangup(); + } + + void _openCall() { + Haptics.tap(); + _collapse(); + final navigator = KometApp.navigatorKey.currentState; + if (navigator == null) return; + navigator.push( + MaterialPageRoute( + builder: (_) => CallScreen( + name: widget.call.name, + avatarUrl: widget.call.avatarUrl, + session: _session, + isGroup: widget.call.isGroup, + ), + ), + ); + } + + bool get _peerSpeaking => + _session.participants.any((p) => !p.isSelf && _session.isSpeaking(p.id)); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + final text = (theme.textTheme.bodyMedium ?? const TextStyle()).copyWith( + color: cs.onSurface, + ); + final title = _title(cs, l10n); + final face = _face(cs); + final expand = _chromeMounted ? _expandButton(cs, l10n) : null; + final controls = _chromeMounted ? _controls(cs, l10n) : null; + return AnimatedBuilder( + animation: _reveal, + builder: (context, _) { + final t = Curves.easeOutCubic.transform(_reveal.value); + final size = Size( + lerpDouble(_collapsedWidth, _expandedWidth, t)!, + lerpDouble(_collapsedHeight, _expandedHeight, t)!, + ); + return DraggableFloatingLayer( + storageKey: 'call_badge', + size: size, + onTap: _toggleControls, + onDragStart: _onDragStart, + onDragEnd: _onDragEnd, + child: SizedBox.fromSize( + size: size, + child: _card( + cs, + t, + text: text, + title: title, + face: face, + expand: expand, + controls: controls, + ), + ), + ); + }, + ); + } + + Widget _card( + ColorScheme cs, + double t, { + required TextStyle text, + required Widget title, + required Widget face, + required Widget? expand, + required Widget? controls, + }) { + final radius = BorderRadius.circular(26); + return DefaultTextStyle( + style: text, + child: GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: radius, + depth: 10, + child: ClipRRect( + borderRadius: radius, + child: Stack( + children: [ + Positioned( + left: 12, + right: lerpDouble(12, 44, t)!, + top: 10, + child: title, + ), + if (expand != null) + Positioned(top: 7, right: 7, child: _fade(t, expand)), + Positioned( + left: 0, + right: 0, + top: 44, + bottom: lerpDouble(12, 58, t)!, + child: Center(child: face), + ), + if (controls != null) + Positioned( + left: 0, + right: 0, + bottom: 10, + child: _fade( + t, + SizedBox( + height: _buttonSize, + child: OverflowBox( + minWidth: _controlsWidth, + maxWidth: _controlsWidth, + minHeight: _buttonSize, + maxHeight: _buttonSize, + alignment: Alignment.center, + child: controls, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _fade(double t, Widget child) => IgnorePointer( + ignoring: t < 0.5, + child: Opacity( + opacity: t.clamp(0.0, 1.0), + child: Transform.scale(scale: lerpDouble(0.82, 1, t)!, child: child), + ), + ); + + Widget _title(ColorScheme cs, AppLocalizations l10n) { + final name = widget.call.name.isEmpty + ? l10n.callUnknownName + : widget.call.name; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + _CallStatusLine(session: _session, color: cs.onSurfaceVariant), + ], + ); + } + + Widget _expandButton(ColorScheme cs, AppLocalizations l10n) { + return Semantics( + label: l10n.callTooltipExpand, + button: true, + child: SizedBox.square( + dimension: 30, + child: GlossyPill( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(15), + depth: 6, + onTap: _openCall, + child: Center( + child: Icon( + Symbols.open_in_full, + size: 16, + weight: 600, + color: cs.onSurface, + ), + ), + ), + ), + ); + } + + Widget _face(ColorScheme cs) { + final muted = _session.isMuted; + final renderer = _renderer; + final showVideo = _videoStream != null && renderer != null; + return SizedBox.square( + dimension: _avatarSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: _avatarSize, + height: _avatarSize, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surfaceContainerHighest, + border: Border.all( + color: _peerSpeaking + ? kSuccessGreen + : Colors.white.withValues(alpha: 0.10), + width: _peerSpeaking ? 2.5 : 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.32), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: showVideo + ? CallVideoView( + renderer: renderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, + placeholder: _avatar(cs), + ) + : _avatar(cs), + ), + if (muted) + Positioned( + right: -2, + bottom: -2, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all(color: cs.surfaceContainerHigh, width: 2), + ), + child: Icon( + Symbols.mic_off, + size: 12, + fill: 1, + color: cs.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + + Widget _avatar(ColorScheme cs) { + final url = widget.call.avatarUrl; + if (url == null || url.isEmpty) return _avatarFallback(cs); + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + memCacheWidth: 192, + memCacheHeight: 192, + errorWidget: (_, _, _) => _avatarFallback(cs), + ); + } + + Widget _avatarFallback(ColorScheme cs) { + final name = widget.call.name; + final letter = (name.isEmpty ? '?' : name[0]).toUpperCase(); + return ColoredBox( + color: cs.primaryContainer, + child: Center( + child: Text( + letter, + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: _avatarSize * 0.38, + fontWeight: FontWeight.w600, + fontFamily: displayFontOf(context), + ), + ), + ), + ); + } + + Widget _controls(ColorScheme cs, AppLocalizations l10n) { + final muted = _session.isMuted; + final video = _session.localVideo; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _BadgeButton( + size: _buttonSize, + background: muted ? cs.primary : cs.surfaceContainerHighest, + label: muted ? l10n.callUnmute : l10n.callMute, + onTap: _toggleMute, + child: LottieSlashIcon( + asset: 'assets/lottie/ic_mic_on_to_off.json', + slashed: muted, + color: muted ? cs.onPrimary : cs.onSurface, + size: 20, + ), + ), + _BadgeButton( + size: _buttonSize, + background: video ? cs.primary : cs.surfaceContainerHighest, + label: l10n.callVideoLabel, + onTap: _toggleVideo, + child: _videoBusy + ? SmallSpinner( + size: 16, + color: video ? cs.onPrimary : cs.onSurface, + ) + : LottieSlashIcon( + asset: 'assets/lottie/ic_videocam_on_to_off.json', + slashed: !video, + color: video ? cs.onPrimary : cs.onSurface, + size: 20, + ), + ), + _BadgeButton( + size: _buttonSize, + background: kDangerRed, + label: l10n.callEndButton, + onTap: _hangup, + child: const Icon( + Symbols.call_end, + size: 20, + fill: 1, + color: Colors.white, + ), + ), + ], + ); + } +} + +class _BadgeButton extends StatelessWidget { + const _BadgeButton({ + required this.size, + required this.background, + required this.label, + required this.onTap, + required this.child, + }); + + final double size; + final Color background; + final String label; + final VoidCallback onTap; + final Widget child; + + @override + Widget build(BuildContext context) { + return Semantics( + label: label, + button: true, + child: SizedBox.square( + dimension: size, + child: GlossyPill( + color: background, + borderRadius: BorderRadius.circular(size / 2), + depth: 7, + onTap: onTap, + child: Center(child: child), + ), + ), + ); + } +} + +class _CallStatusLine extends StatefulWidget { + const _CallStatusLine({required this.session, required this.color}); + + final CallSession session; + final Color color; + + @override + State<_CallStatusLine> createState() => _CallStatusLineState(); +} + +class _CallStatusLineState extends State<_CallStatusLine> { + Timer? _ticker; + + @override + void initState() { + super.initState(); + _syncTicker(); + } + + @override + void didUpdateWidget(_CallStatusLine old) { + super.didUpdateWidget(old); + _syncTicker(); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + void _syncTicker() { + final counting = + widget.session.currentState == CallSessionState.active && + !widget.session.isReconnecting; + if (counting == (_ticker != null)) return; + if (!counting) { + _ticker?.cancel(); + _ticker = null; + return; + } + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + String _label(AppLocalizations l10n) { + final session = widget.session; + if (session.isReconnecting) return l10n.callStatusConnecting; + return switch (session.currentState) { + CallSessionState.connecting => l10n.callStatusConnecting, + CallSessionState.ringing => l10n.callStatusRinging, + CallSessionState.ended => l10n.callStatusEnded, + CallSessionState.active => formatSecondsMmSs( + session.elapsedSeconds, + padMinutes: true, + ), + }; + } + + @override + Widget build(BuildContext context) { + return Text( + _label(AppLocalizations.of(context)!), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: widget.color, + fontSize: 11, + fontWeight: FontWeight.w500, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + } +} diff --git a/lib/frontend/widgets/floating_video_note.dart b/lib/frontend/widgets/floating_video_note.dart new file mode 100644 index 0000000..4034944 --- /dev/null +++ b/lib/frontend/widgets/floating_video_note.dart @@ -0,0 +1,156 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../core/media/media_playback.dart'; +import '../../core/media/video_note_frame.dart'; +import '../../core/utils/haptics.dart'; +import 'draggable_floating_layer.dart'; + +class FloatingVideoNoteLayer extends StatelessWidget { + const FloatingVideoNoteLayer({super.key}); + + @override + Widget build(BuildContext context) { + final playback = MediaPlayback.instance; + return ValueListenableBuilder( + valueListenable: playback.videoNote, + builder: (context, track, _) { + if (track == null) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: playback.visibleChatId, + builder: (context, chatId, _) => chatId == track.chatId + ? const SizedBox.shrink() + : _DraggableNote(track: track), + ); + }, + ); + } +} + +class _DraggableNote extends StatelessWidget { + const _DraggableNote({required this.track}); + + final VideoNoteTrack track; + + static const double _size = 96; + + void _toggle() { + Haptics.tap(); + final controller = track.controller; + if (controller.value.isPlaying) { + controller.pause(); + } else { + controller.play(); + } + } + + @override + Widget build(BuildContext context) { + return DraggableFloatingLayer( + storageKey: 'video_note', + size: const Size(_size, _size), + onTap: _toggle, + child: _NoteCircle(track: track, size: _size), + ); + } +} + +class _NoteCircle extends StatelessWidget { + const _NoteCircle({required this.track, required this.size}); + + final VideoNoteTrack track; + final double size; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final frame = videoNoteFrameSize(track.controller.value.size, size); + return SizedBox( + width: size, + height: size, + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(3), + child: ClipOval( + child: ColoredBox( + color: cs.surfaceContainerHighest, + child: FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: frame.width, + height: frame.height, + child: VideoPlayer(track.controller), + ), + ), + ), + ), + ), + ), + Positioned.fill( + child: IgnorePointer( + child: ValueListenableBuilder( + valueListenable: track.controller, + builder: (context, value, _) { + final total = value.duration.inMilliseconds; + return CustomPaint( + painter: _RingPainter( + progress: total > 0 + ? (value.position.inMilliseconds / total).clamp( + 0.0, + 1.0, + ) + : 0.0, + color: cs.onSurface, + track: cs.onSurface.withValues(alpha: 0.25), + ), + ); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _RingPainter extends CustomPainter { + const _RingPainter({ + required this.progress, + required this.color, + required this.track, + }); + + final double progress; + final Color color; + final Color track; + + static const double _stroke = 3; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final circle = rect.deflate(_stroke / 2); + final base = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = _stroke + ..color = track; + canvas.drawArc(circle, 0, math.pi * 2, false, base); + if (progress <= 0) return; + final arc = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = _stroke + ..strokeCap = StrokeCap.round + ..color = color; + canvas.drawArc(circle, -math.pi / 2, math.pi * 2 * progress, false, arc); + } + + @override + bool shouldRepaint(_RingPainter old) => + old.progress != progress || old.color != color || old.track != track; +} diff --git a/lib/frontend/widgets/formatted_message_text.dart b/lib/frontend/widgets/formatted_message_text.dart index ec22c2a..cd614b5 100644 --- a/lib/frontend/widgets/formatted_message_text.dart +++ b/lib/frontend/widgets/formatted_message_text.dart @@ -1,16 +1,30 @@ +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../backend/modules/messages.dart' show ContactCache; import '../../core/utils/link_opener.dart'; +import '../../core/utils/text_entities.dart'; import '../../core/utils/text_format.dart'; +import '../screens/contacts/open_contact_profile.dart'; import 'link_text.dart'; import 'lottie_image.dart'; +import 'text_entity_actions.dart'; + +Color mentionTextColor(ColorScheme cs) => cs.primary; + +enum TextEntityMode { menu, copy } class FormattedMessageText extends StatefulWidget { final String text; final List ranges; final TextStyle style; final TextAlign textAlign; + final TextEntityMode entityMode; + final int? maxLines; + final TextOverflow? overflow; const FormattedMessageText({ super.key, @@ -18,34 +32,81 @@ class FormattedMessageText extends StatefulWidget { required this.ranges, required this.style, this.textAlign = TextAlign.start, + this.entityMode = TextEntityMode.menu, + this.maxLines, + this.overflow, }); static bool isFormatted(String? text, List ranges) => text != null && text.isNotEmpty && - (ranges.isNotEmpty || LinkText.hasLinks(text)); + (ranges.isNotEmpty || LinkText.hasLinks(text) || hasTextEntities(text)); static TextSpan buildInlineSpan( String text, List ranges, - TextStyle style, - ) { + TextStyle style, { + Color? mentionColor, + }) => TextSpan( + style: style, + children: buildInlineChildren( + text, + ranges, + style, + mentionColor: mentionColor, + ), + ); + + static List buildInlineChildren( + String text, + List ranges, + TextStyle style, { + Color? mentionColor, + }) { final quoteColor = style.color?.withValues(alpha: 0.85); - final segments = segmentizeFormats(text, ranges); - return TextSpan( - style: style, - children: [ - for (final segment in segments) - TextSpan( - text: text.substring(segment.start, segment.end), - style: applyTextFormats( - style, - segment.formats, - quoteColor: quoteColor, + final fontSize = style.fontSize ?? 16; + final spans = []; + for (final segment in segmentizeFormats(text, ranges)) { + final content = text.substring(segment.start, segment.end); + final animojiUrl = segment.animojiUrl; + if (animojiUrl != null) { + final box = fontSize * 1.35; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox( + width: box, + height: box, + child: Stack( + alignment: Alignment.center, + children: [ + Text(content, style: style.copyWith(fontSize: fontSize)), + LottieImage( + lottieUrl: animojiUrl, + size: box, + memCacheWidth: 96, + shimmer: false, + ), + ], + ), ), ), - ], - ); + ); + continue; + } + spans.add( + TextSpan( + text: content, + style: applyTextFormats( + style, + segment.formats, + quoteColor: quoteColor, + mentionColor: mentionColor, + ), + ), + ); + } + return spans; } @override @@ -53,7 +114,7 @@ class FormattedMessageText extends StatefulWidget { } class _FormattedMessageTextState extends State { - final List _recognizers = []; + final List _recognizers = []; @override void dispose() { @@ -74,7 +135,7 @@ class _FormattedMessageTextState extends State { if (hasExplicitLink) return ranges; for (final match in linkPattern.allMatches(widget.text)) { final raw = match.group(0)!; - final target = raw.startsWith('www.') ? 'https://$raw' : raw; + final target = linkTarget(raw); ranges.add( FormatRange( format: TextFormat.link, @@ -87,40 +148,137 @@ class _FormattedMessageTextState extends State { return ranges; } + void _openMention(int userId) { + unawaited( + openContactDialogProfile( + context, + contactId: userId, + name: ContactCache.get(userId) ?? 'User #$userId', + avatarUrl: ContactCache.getAvatar(userId), + ), + ); + } + + T _track(T recognizer) { + _recognizers.add(recognizer); + return recognizer; + } + + GestureRecognizer? _entityRecognizer(TextEntity entity) { + switch (entity.kind) { + case TextEntityKind.mention: + return _track( + TapGestureRecognizer() + ..onTap = () => + unawaited(openMentionProfile(context, entity.value)), + ); + case TextEntityKind.phone: + if (widget.entityMode == TextEntityMode.copy) { + return _track( + TapGestureRecognizer() + ..onTap = () => unawaited( + copyTextEntity(context, entity.value, 'Номер скопирован'), + ), + ); + } + return _track( + LongPressGestureRecognizer() + ..onLongPressStart = (details) => showPhoneEntityMenu( + context, + entity.value, + at: details.globalPosition, + ), + ); + case TextEntityKind.card: + if (widget.entityMode == TextEntityMode.copy) { + return _track( + TapGestureRecognizer() + ..onTap = () => unawaited( + copyTextEntity(context, entity.value, 'Номер карты скопирован'), + ), + ); + } + return _track( + LongPressGestureRecognizer() + ..onLongPressStart = (details) => showCardEntityMenu( + context, + entity.value, + at: details.globalPosition, + ), + ); + } + } + + List _claimedRanges(List ranges) => [ + for (final range in ranges) + if (range.format == TextFormat.link || + range.format == TextFormat.userMention) + (start: range.start, end: range.end), + ]; + + TextEntity? _entityAt(List entities, int start, int end) { + for (final entity in entities) { + if (entity.start <= start && entity.end >= end) return entity; + } + return null; + } + + List<({int start, int end})> _splitByEntities( + int start, + int end, + List entities, + ) { + final points = {start, end}; + for (final entity in entities) { + if (entity.end <= start || entity.start >= end) continue; + if (entity.start > start) points.add(entity.start); + if (entity.end < end) points.add(entity.end); + } + final sorted = points.toList()..sort(); + return [ + for (var i = 0; i < sorted.length - 1; i++) + (start: sorted[i], end: sorted[i + 1]), + ]; + } + @override Widget build(BuildContext context) { _disposeRecognizers(); - final segments = segmentizeFormats(widget.text, _withAutoLinks()); - final baseColor = widget.style.color ?? Theme.of(context).colorScheme.onSurface; - final barColor = baseColor.withValues(alpha: 0.4); + final ranges = _withAutoLinks(); + final entities = detectTextEntities( + widget.text, + skip: _claimedRanges(ranges), + ); + final segments = segmentizeFormats(widget.text, ranges); + final cs = Theme.of(context).colorScheme; + final baseColor = widget.style.color ?? cs.onSurface; final quoteColor = baseColor.withValues(alpha: 0.85); + final mentionColor = mentionTextColor(cs); + + final blocks = <_TextBlock>[]; + var spans = []; + var blockIsQuote = false; + + void closeBlock() { + _trimBlockEdges(spans); + if (spans.isNotEmpty) { + blocks.add(_TextBlock(quote: blockIsQuote, spans: spans)); + } + spans = []; + } - final spans = []; - var prevQuote = false; for (final segment in segments) { final isQuote = segment.formats.contains(TextFormat.quote); - if (isQuote && !prevQuote) { - spans.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Container( - width: 3, - height: (widget.style.fontSize ?? 16) * 1.15, - margin: const EdgeInsets.only(right: 6, left: 1), - decoration: BoxDecoration( - color: barColor, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - ); + if (isQuote != blockIsQuote) { + closeBlock(); + blockIsQuote = isQuote; } - prevQuote = isQuote; final style = applyTextFormats( widget.style, segment.formats, quoteColor: quoteColor, + mentionColor: mentionColor, ); final content = widget.text.substring(segment.start, segment.end); if (segment.animojiUrl != null) { @@ -153,22 +311,176 @@ class _FormattedMessageTextState extends State { ); continue; } + final mentionId = segment.mentionId; + if (mentionId != null && mentionId != 0) { + spans.add( + TextSpan( + text: content, + style: style, + recognizer: _track( + TapGestureRecognizer()..onTap = () => _openMention(mentionId), + ), + ), + ); + continue; + } + + final mentionName = segment.mentionName; + if (mentionName != null) { + spans.add( + TextSpan( + text: content, + style: style, + recognizer: _track( + TapGestureRecognizer() + ..onTap = () => + unawaited(openMentionProfile(context, mentionName)), + ), + ), + ); + continue; + } + if (segment.url != null) { final url = segment.url!; - final recognizer = TapGestureRecognizer() - ..onTap = () => openExternalUrl(context, url); - _recognizers.add(recognizer); spans.add( - TextSpan(text: content, style: style, recognizer: recognizer), + TextSpan( + text: content, + style: style, + recognizer: _track( + TapGestureRecognizer() + ..onTap = () => openExternalUrl(context, url), + ), + ), + ); + continue; + } + + for (final piece in _splitByEntities( + segment.start, + segment.end, + entities, + )) { + final entity = _entityAt(entities, piece.start, piece.end); + spans.add( + TextSpan( + text: widget.text.substring(piece.start, piece.end), + style: entity == null ? style : style.copyWith(color: mentionColor), + recognizer: entity == null ? null : _entityRecognizer(entity), + ), ); - } else { - spans.add(TextSpan(text: content, style: style)); } } - return Text.rich( - TextSpan(style: widget.style, children: spans), - textAlign: widget.textAlign, + closeBlock(); + + if (blocks.length == 1 && !blocks.first.quote) { + return _paragraph(blocks.first.spans); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < blocks.length; i++) ...[ + if (i > 0) const SizedBox(height: 4), + blocks[i].quote + ? _quoteBlock(blocks[i].spans, baseColor) + : _paragraph(blocks[i].spans), + ], + ], + ); + } + + Widget _paragraph(List spans) => Text.rich( + TextSpan(style: widget.style, children: spans), + textAlign: widget.textAlign, + maxLines: widget.maxLines, + overflow: widget.overflow ?? TextOverflow.clip, + ); + + Widget _quoteBlock(List spans, Color baseColor) { + final glyphSize = (widget.style.fontSize ?? 16) * 0.85; + return Container( + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.fromLTRB(9, 5, 9, 6), + child: Stack( + children: [ + Padding( + padding: EdgeInsets.only(right: glyphSize + 2), + child: _paragraph(spans), + ), + Positioned( + top: 0, + right: 0, + child: Icon( + Symbols.format_quote, + size: glyphSize, + fill: 1, + color: baseColor.withValues(alpha: 0.55), + ), + ), + ], + ), ); } } + +class _TextBlock { + final bool quote; + final List spans; + + const _TextBlock({required this.quote, required this.spans}); +} + +void _trimBlockEdges(List spans) { + while (spans.isNotEmpty) { + final trimmed = _withText( + spans.first, + (t) => t.replaceFirst(_leadingNewlines, ''), + ); + if (trimmed == null) break; + if (_isEmptyText(trimmed)) { + spans.removeAt(0); + continue; + } + spans[0] = trimmed; + break; + } + while (spans.isNotEmpty) { + final trimmed = _withText( + spans.last, + (t) => t.replaceFirst(_trailingNewlines, ''), + ); + if (trimmed == null) break; + if (_isEmptyText(trimmed)) { + spans.removeLast(); + continue; + } + spans[spans.length - 1] = trimmed; + break; + } +} + +final RegExp _leadingNewlines = RegExp(r'^\n+'); +final RegExp _trailingNewlines = RegExp(r'\n+$'); + +InlineSpan? _withText(InlineSpan span, String Function(String) transform) { + if (span is! TextSpan) return null; + final text = span.text; + if (text == null) return null; + final next = transform(text); + if (next == text) return span; + return TextSpan( + text: next, + style: span.style, + recognizer: span.recognizer, + children: span.children, + ); +} + +bool _isEmptyText(InlineSpan span) => + span is TextSpan && (span.text?.isEmpty ?? false) && span.children == null; diff --git a/lib/frontend/widgets/glossy_pill.dart b/lib/frontend/widgets/glossy_pill.dart index 6a1b513..6c687a0 100644 --- a/lib/frontend/widgets/glossy_pill.dart +++ b/lib/frontend/widgets/glossy_pill.dart @@ -1,7 +1,10 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import '../../core/config/app_pill_gradient.dart'; import '../../core/config/app_visual_style.dart'; +import 'liquid_glass.dart'; class _GlossyParts { final bool dark; @@ -90,6 +93,10 @@ class GlossyPill extends StatelessWidget { final double depth; final bool elevated; final BorderSide? borderSide; + final double? blurSigma; + final bool liquid; + final BackdropKey? backdropKey; + final bool keepInkLayer; const GlossyPill({ super.key, @@ -102,15 +109,25 @@ class GlossyPill extends StatelessWidget { this.depth = 10, this.elevated = false, this.borderSide, + 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; + @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: AppVisualStyle.current, builder: (context, style, _) { if (style == VisualStyle.materialYou) return _flat(context); + if (liquid && LiquidGlass.isSupported) return _liquid(context); return ValueListenableBuilder( valueListenable: AppPillGradient.current, builder: (context, gradient, _) => _glossy(context, gradient), @@ -119,11 +136,43 @@ class GlossyPill extends StatelessWidget { ); } + Widget _liquid(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final base = color ?? cs.surfaceContainerHigh; + final content = Padding(padding: padding, child: child); + + return RepaintBoundary( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: borderSide != null + ? Border.fromBorderSide(borderSide!) + : GlossyDecor.rimBorder(base), + boxShadow: [GlossyDecor.dropShadow(base, depth)], + ), + child: LiquidGlassSurface( + borderRadius: borderRadius, + tint: Colors.transparent, + child: _inert + ? content + : Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: content, + ), + ), + ), + ), + ); + } + Widget _flat(BuildContext context) { final cs = Theme.of(context).colorScheme; final base = color ?? cs.surfaceContainerHigh; final content = Padding(padding: padding, child: child); - return Material( + final material = Material( color: base, elevation: elevated ? 3 : 0, shadowColor: Colors.black.withValues(alpha: 0.4), @@ -133,16 +182,27 @@ 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), ); + final sigma = _sigmaFor(base); + if (sigma == null) return material; + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + backdropGroupKey: backdropKey, + child: material, + ), + ); } Widget _glossy(BuildContext context, bool gradient) { final cs = Theme.of(context).colorScheme; final base = color ?? cs.surfaceContainerHigh; final content = Padding(padding: padding, child: child); + final sigma = _sigmaFor(base); return RepaintBoundary( child: DecoratedBox( @@ -150,7 +210,9 @@ class GlossyPill extends StatelessWidget { borderRadius: borderRadius, color: gradient ? null : base, gradient: gradient ? GlossyDecor.fillGradient(base) : null, - border: GlossyDecor.rimBorder(base), + border: borderSide != null + ? Border.fromBorderSide(borderSide!) + : GlossyDecor.rimBorder(base), boxShadow: [GlossyDecor.dropShadow(base, depth)], ), child: ClipRRect( @@ -158,6 +220,14 @@ class GlossyPill extends StatelessWidget { child: Stack( fit: StackFit.passthrough, children: [ + if (sigma != null) + Positioned.fill( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + backdropGroupKey: backdropKey, + child: const SizedBox.expand(), + ), + ), if (gradient) ...[ Positioned.fill( child: IgnorePointer( @@ -178,7 +248,7 @@ class GlossyPill extends StatelessWidget { ), ), ], - if (onTap == null && onLongPress == null) + if (_inert) content else Material( diff --git a/lib/frontend/widgets/info_action_sheet.dart b/lib/frontend/widgets/info_action_sheet.dart index d19a2e6..7ec3235 100644 --- a/lib/frontend/widgets/info_action_sheet.dart +++ b/lib/frontend/widgets/info_action_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'sheet_helpers.dart'; +import '../../core/config/app_shape.dart'; class InfoActionSheetItem { final IconData icon; @@ -188,9 +189,7 @@ class _InfoActionSheetState extends State<_InfoActionSheet> { disabledBackgroundColor: cs.primary.withValues(alpha: 0.45), disabledForegroundColor: cs.onPrimary.withValues(alpha: 0.85), padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(28), - ), + shape: AppShape.buttonBorder, ), child: Text( buttonText, diff --git a/lib/frontend/widgets/informer_banner_tile.dart b/lib/frontend/widgets/informer_banner_tile.dart new file mode 100644 index 0000000..ffd5eb1 --- /dev/null +++ b/lib/frontend/widgets/informer_banner_tile.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../models/animoji.dart'; +import '../../models/informer_banner.dart'; +import 'lottie_image.dart'; + +typedef InformerAnimojiLoader = Future Function(int id); + +class InformerBannerTile extends StatefulWidget { + final InformerBanner banner; + final InformerAnimojiLoader? animojiLoader; + final ValueChanged? onPresented; + final VoidCallback? onTap; + final VoidCallback? onClose; + + const InformerBannerTile({ + super.key, + required this.banner, + this.animojiLoader, + this.onPresented, + this.onTap, + this.onClose, + }); + + @override + State createState() => _InformerBannerTileState(); +} + +class _InformerBannerTileState extends State + with SingleTickerProviderStateMixin { + Future? _animoji; + late final AnimationController _textController; + late final Animation _textOpacity; + late final Animation _textOffset; + + @override + void initState() { + super.initState(); + _textController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + value: widget.banner.animatesText ? 0 : 1, + ); + final curve = CurvedAnimation( + parent: _textController, + curve: Curves.easeOutCubic, + ); + _textOpacity = curve; + _textOffset = Tween( + begin: const Offset(0.035, 0), + end: Offset.zero, + ).animate(curve); + _loadAnimoji(); + _present(); + if (widget.banner.animatesText) _textController.forward(); + } + + @override + void didUpdateWidget(InformerBannerTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.banner.id == widget.banner.id) return; + _loadAnimoji(); + _textController.value = widget.banner.animatesText ? 0 : 1; + _present(); + if (widget.banner.animatesText) _textController.forward(); + } + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + void _loadAnimoji() { + final id = widget.banner.animojiId; + final loader = widget.animojiLoader; + _animoji = id == null || loader == null ? null : loader(id); + } + + void _present() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onPresented?.call(widget.banner); + }); + } + + @override + Widget build(BuildContext context) { + final banner = widget.banner; + final cs = Theme.of(context).colorScheme; + final background = Color.alphaBlend( + cs.primary.withValues(alpha: 0.12), + cs.surfaceContainerLow, + ); + final content = Semantics( + button: widget.onTap != null, + label: [ + banner.title, + banner.description, + ].where((text) => text.isNotEmpty).join('. '), + child: InkWell( + key: ValueKey('informer-banner-${banner.id}'), + onTap: widget.onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 66), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 9, 8, 9), + child: Row( + children: [ + _InformerBannerIcon( + future: _animoji, + tintWithTheme: banner.tintsIconWithTheme, + ), + const SizedBox(width: 14), + Expanded( + child: FadeTransition( + opacity: _textOpacity, + child: SlideTransition( + position: _textOffset, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (banner.title.isNotEmpty) + Text( + banner.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.primary, + fontSize: 14.5, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + if (banner.title.isNotEmpty && + banner.description.isNotEmpty) + const SizedBox(height: 3), + if (banner.description.isNotEmpty) + Text( + banner.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.2, + ), + ), + ], + ), + ), + ), + ), + if (!banner.hidesCloseButton) + IconButton( + key: ValueKey('informer-banner-close-${banner.id}'), + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + onPressed: widget.onClose, + visualDensity: VisualDensity.compact, + iconSize: 19, + color: cs.onSurfaceVariant.withValues(alpha: 0.72), + icon: const Icon(Symbols.cancel, fill: 0, weight: 450), + ), + ], + ), + ), + ), + ), + ); + return Material(color: background, child: content); + } +} + +class _InformerBannerIcon extends StatelessWidget { + final Future? future; + final bool tintWithTheme; + + const _InformerBannerIcon({ + required this.future, + required this.tintWithTheme, + }); + + @override + Widget build(BuildContext context) { + if (future == null) return const _InformerBannerFallbackIcon(); + return FutureBuilder( + future: future, + builder: (context, snapshot) { + final animoji = snapshot.data; + if (animoji == null) return const _InformerBannerFallbackIcon(); + Widget icon = LottieImage( + url: animoji.iconUrl, + lottieUrl: animoji.lottieUrl, + size: 44, + memCacheWidth: 96, + shimmer: false, + eager: true, + ); + if (tintWithTheme) { + icon = ColorFiltered( + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + child: icon, + ); + } + return SizedBox( + key: const ValueKey('informer-banner-animoji'), + width: 44, + height: 44, + child: icon, + ); + }, + ); + } +} + +class _InformerBannerFallbackIcon extends StatelessWidget { + const _InformerBannerFallbackIcon(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + key: const ValueKey('informer-banner-fallback-icon'), + width: 44, + height: 44, + decoration: BoxDecoration(color: cs.primary, shape: BoxShape.circle), + alignment: Alignment.center, + child: Icon( + Symbols.chat_bubble, + color: cs.onPrimary, + size: 23, + fill: 0, + weight: 500, + ), + ); + } +} diff --git a/lib/frontend/widgets/komet_avatar.dart b/lib/frontend/widgets/komet_avatar.dart index 000eea7..447b7cf 100644 --- a/lib/frontend/widgets/komet_avatar.dart +++ b/lib/frontend/widgets/komet_avatar.dart @@ -1,15 +1,19 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import '../../core/config/app_spectrum_background.dart'; +import 'spectrum_tint.dart'; + /// Circular avatar: shows [imageUrl] when available, otherwise the first letter /// of [name] on a colored background. Falls back to the letter on image error. -class KometAvatar extends StatelessWidget { +class KometAvatar extends StatefulWidget { final String name; final String? imageUrl; final double size; final Color? backgroundColor; final Color? foregroundColor; final double? fontSize; + final bool fadeIn; const KometAvatar({ super.key, @@ -19,29 +23,80 @@ class KometAvatar extends StatelessWidget { this.backgroundColor, this.foregroundColor, this.fontSize, + this.fadeIn = true, }); + static const _fadeInDuration = Duration(milliseconds: 500); + static const _fadeOutDuration = Duration(milliseconds: 1000); + + @override + State createState() => _KometAvatarState(); +} + +class _KometAvatarState extends State + implements SpectrumTintSource { + Color _background = const Color(0xFF000000); + bool _registered = false; + + @override + void initState() { + super.initState(); + AppSpectrumBackground.current.addListener(_syncRegistration); + _syncRegistration(); + } + + @override + void dispose() { + AppSpectrumBackground.current.removeListener(_syncRegistration); + if (_registered) SpectrumTintRegistry.instance.unregister(this); + super.dispose(); + } + + void _syncRegistration() { + final shouldRegister = AppSpectrumBackground.isEnabled; + if (shouldRegister == _registered) return; + _registered = shouldRegister; + if (shouldRegister) { + SpectrumTintRegistry.instance.register(this); + } else { + SpectrumTintRegistry.instance.unregister(this); + } + } + + @override + BuildContext? get tintContext => mounted ? context : null; + + @override + String? get tintImageUrl => widget.imageUrl; + + @override + Color get tintFallbackColor => _background; + + @override + double get tintWeight => widget.size; + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final bg = backgroundColor ?? cs.primaryContainer; - final fg = foregroundColor ?? cs.onPrimaryContainer; - final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final bg = widget.backgroundColor ?? cs.primaryContainer; + final fg = widget.foregroundColor ?? cs.onPrimaryContainer; + _background = bg; + final letter = widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?'; final placeholder = Center( child: Text( letter, style: TextStyle( color: fg, - fontSize: fontSize ?? size * 0.4, + fontSize: widget.fontSize ?? widget.size * 0.4, fontWeight: FontWeight.bold, ), ), ); - final url = imageUrl; - final cache = (size * 3).round(); + final url = widget.imageUrl; + final cache = (widget.size * 3).round(); return Container( - width: size, - height: size, + width: widget.size, + height: widget.size, clipBehavior: Clip.antiAlias, decoration: BoxDecoration(shape: BoxShape.circle, color: bg), child: (url != null && url.isNotEmpty) @@ -50,6 +105,12 @@ class KometAvatar extends StatelessWidget { fit: BoxFit.cover, memCacheWidth: cache, memCacheHeight: cache, + fadeInDuration: widget.fadeIn + ? KometAvatar._fadeInDuration + : Duration.zero, + fadeOutDuration: widget.fadeIn + ? KometAvatar._fadeOutDuration + : Duration.zero, errorWidget: (_, _, _) => placeholder, ) : placeholder, diff --git a/lib/frontend/widgets/link_text.dart b/lib/frontend/widgets/link_text.dart index f0bbff3..d2b88b8 100644 --- a/lib/frontend/widgets/link_text.dart +++ b/lib/frontend/widgets/link_text.dart @@ -4,10 +4,14 @@ import 'package:flutter/material.dart'; import '../../core/utils/link_opener.dart'; final RegExp linkPattern = RegExp( - r'(https?://[^\s<>]+|www\.[^\s<>]+)', + r'((?:https?|komet|max)://[^\s<>]+' + r'|www\.[^\s<>]+' + r'|(?]*)?)', caseSensitive: false, ); +String linkTarget(String raw) => raw.contains('://') ? raw : 'https://$raw'; + class LinkText extends StatefulWidget { final String text; final TextStyle style; @@ -46,7 +50,7 @@ class _LinkTextState extends State { spans.add(TextSpan(text: widget.text.substring(cursor, match.start))); } final url = match.group(0)!; - final target = url.startsWith('www.') ? 'https://$url' : url; + final target = linkTarget(url); final recognizer = TapGestureRecognizer() ..onTap = () => openExternalUrl(context, target); _recognizers.add(recognizer); diff --git a/lib/frontend/widgets/liquid_glass.dart b/lib/frontend/widgets/liquid_glass.dart new file mode 100644 index 0000000..a2da565 --- /dev/null +++ b/lib/frontend/widgets/liquid_glass.dart @@ -0,0 +1,408 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../core/config/app_frost.dart'; +import '../../core/config/app_liquid_glass.dart'; +import '../../core/config/app_visual_style.dart'; + +class LiquidGlass { + static const String _asset = 'shaders/liquid_glass.frag'; + + static ui.FragmentProgram? _program; + static bool _loadAttempted = false; + + static bool get isSupported => _program != null; + + static bool get active => + isSupported && AppVisualStyle.current.value == VisualStyle.liquidGlass; + + static Future load() async { + if (_loadAttempted) return; + _loadAttempted = true; + if (!AppLiquidGlass.enabled) return; + if (!ui.ImageFilter.isShaderFilterSupported) return; + try { + _program = await ui.FragmentProgram.fromAsset(_asset); + } catch (_) { + _program = null; + } + } +} + +class GlassSurface extends StatelessWidget { + final bool liquid; + final BorderRadius borderRadius; + final Color frostTint; + final double frostSigma; + final Color liquidTint; + final BoxBorder? border; + final BackdropKey? backdropKey; + final Widget child; + + const GlassSurface({ + super.key, + this.liquid = false, + this.borderRadius = BorderRadius.zero, + required this.frostTint, + this.frostSigma = AppFrost.sigma, + this.liquidTint = Colors.transparent, + this.border, + this.backdropKey, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final glass = liquid && LiquidGlass.isSupported; + final decorated = DecoratedBox( + decoration: BoxDecoration( + color: glass ? null : frostTint, + border: border, + ), + child: child, + ); + if (glass) { + return LiquidGlassSurface( + borderRadius: borderRadius, + tint: liquidTint, + child: decorated, + ); + } + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: frostSigma, sigmaY: frostSigma), + backdropGroupKey: backdropKey, + child: decorated, + ), + ); + } +} + +class LiquidGlassSurface extends StatelessWidget { + final BorderRadius borderRadius; + final Color tint; + final double blurSigma; + final double spread; + final double refraction; + final double chroma; + final double specular; + final Offset light; + final double tintFeather; + final double rimWidth; + final Widget child; + + const LiquidGlassSurface({ + super.key, + required this.borderRadius, + required this.tint, + this.blurSigma = AppLiquidGlass.blurSigma, + this.spread = AppLiquidGlass.spread, + this.refraction = AppLiquidGlass.refraction, + this.chroma = AppLiquidGlass.chroma, + this.specular = AppLiquidGlass.specular, + this.light = AppLiquidGlass.light, + this.tintFeather = AppLiquidGlass.tintFeather, + this.rimWidth = AppLiquidGlass.rimWidth, + this.child = const SizedBox.expand(), + }); + + @override + Widget build(BuildContext context) { + if (!LiquidGlass.isSupported) return child; + return _LiquidGlassBackdrop( + borderRadius: borderRadius, + tint: tint, + blurSigma: blurSigma, + spread: spread, + refraction: refraction, + chroma: chroma, + specular: specular, + light: light, + tintFeather: tintFeather, + rimWidth: rimWidth, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + child: child, + ); + } +} + +class _LiquidGlassBackdrop extends SingleChildRenderObjectWidget { + final BorderRadius borderRadius; + final Color tint; + final double blurSigma; + final double spread; + final double refraction; + final double chroma; + final double specular; + final Offset light; + final double tintFeather; + final double rimWidth; + final double devicePixelRatio; + + const _LiquidGlassBackdrop({ + required this.borderRadius, + required this.tint, + required this.blurSigma, + required this.spread, + required this.refraction, + required this.chroma, + required this.specular, + required this.light, + required this.tintFeather, + required this.rimWidth, + required this.devicePixelRatio, + required super.child, + }); + + @override + _RenderLiquidGlass createRenderObject(BuildContext context) { + return _RenderLiquidGlass( + borderRadius: borderRadius, + tint: tint, + blurSigma: blurSigma, + spread: spread, + refraction: refraction, + chroma: chroma, + specular: specular, + light: light, + tintFeather: tintFeather, + rimWidth: rimWidth, + devicePixelRatio: devicePixelRatio, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderLiquidGlass renderObject, + ) { + renderObject + ..borderRadius = borderRadius + ..tint = tint + ..blurSigma = blurSigma + ..spread = spread + ..refraction = refraction + ..chroma = chroma + ..specular = specular + ..light = light + ..tintFeather = tintFeather + ..rimWidth = rimWidth + ..devicePixelRatio = devicePixelRatio; + } +} + +class _RenderLiquidGlass extends RenderProxyBox { + _RenderLiquidGlass({ + required BorderRadius borderRadius, + required Color tint, + required double blurSigma, + required double spread, + required double refraction, + required double chroma, + required double specular, + required Offset light, + required double tintFeather, + required double rimWidth, + required double devicePixelRatio, + }) : _borderRadius = borderRadius, + _tint = tint, + _blurSigma = blurSigma, + _spread = spread, + _refraction = refraction, + _chroma = chroma, + _specular = specular, + _light = light, + _tintFeather = tintFeather, + _rimWidth = rimWidth, + _devicePixelRatio = devicePixelRatio; + + final LayerHandle _blurClipHandle = + LayerHandle(); + final LayerHandle _blurHandle = + LayerHandle(); + final LayerHandle _clipHandle = LayerHandle(); + final LayerHandle _backdropHandle = + LayerHandle(); + + ui.FragmentShader? _shader; + + BorderRadius _borderRadius; + set borderRadius(BorderRadius value) { + if (_borderRadius == value) return; + _borderRadius = value; + markNeedsPaint(); + } + + Color _tint; + set tint(Color value) { + if (_tint == value) return; + _tint = value; + markNeedsPaint(); + } + + double _blurSigma; + set blurSigma(double value) { + if (_blurSigma == value) return; + _blurSigma = value; + markNeedsPaint(); + } + + double _spread; + set spread(double value) { + if (_spread == value) return; + _spread = value; + markNeedsPaint(); + } + + double _refraction; + set refraction(double value) { + if (_refraction == value) return; + _refraction = value; + markNeedsPaint(); + } + + double _chroma; + set chroma(double value) { + if (_chroma == value) return; + _chroma = value; + markNeedsPaint(); + } + + double _specular; + set specular(double value) { + if (_specular == value) return; + _specular = value; + markNeedsPaint(); + } + + Offset _light; + set light(Offset value) { + if (_light == value) return; + _light = value; + markNeedsPaint(); + } + + double _tintFeather; + set tintFeather(double value) { + if (_tintFeather == value) return; + _tintFeather = value; + markNeedsPaint(); + } + + double _rimWidth; + set rimWidth(double value) { + if (_rimWidth == value) return; + _rimWidth = value; + markNeedsPaint(); + } + + double _devicePixelRatio; + set devicePixelRatio(double value) { + if (_devicePixelRatio == value) return; + _devicePixelRatio = value; + markNeedsPaint(); + } + + @override + bool get alwaysNeedsCompositing => true; + + @override + void dispose() { + _blurClipHandle.layer = null; + _blurHandle.layer = null; + _clipHandle.layer = null; + _backdropHandle.layer = null; + _shader?.dispose(); + _shader = null; + super.dispose(); + } + + ui.ImageFilter? _buildFilter() { + final program = LiquidGlass._program; + if (program == null) return null; + + final shader = _shader ??= program.fragmentShader(); + final dpr = _devicePixelRatio; + final topLeft = localToGlobal(Offset.zero); + final left = (topLeft.dx * dpr).roundToDouble(); + final top = (topLeft.dy * dpr).roundToDouble(); + final width = (size.width * dpr).roundToDouble(); + final height = (size.height * dpr).roundToDouble(); + final radius = _borderRadius.topLeft.x * dpr; + + shader + ..setFloat(2, left) + ..setFloat(3, top) + ..setFloat(4, width) + ..setFloat(5, height) + ..setFloat(6, radius) + ..setFloat(7, _spread) + ..setFloat(8, _refraction * dpr) + ..setFloat(9, _chroma) + ..setFloat(10, _specular) + ..setFloat(11, _tint.r) + ..setFloat(12, _tint.g) + ..setFloat(13, _tint.b) + ..setFloat(14, _tint.a) + ..setFloat(15, _light.dx) + ..setFloat(16, _light.dy) + ..setFloat(17, _tintFeather * dpr) + ..setFloat(18, _rimWidth * dpr); + + return ui.ImageFilter.shader(shader); + } + + @override + void paint(PaintingContext context, Offset offset) { + final filter = size.isEmpty ? null : _buildFilter(); + if (filter == null) { + _blurClipHandle.layer = null; + _blurHandle.layer = null; + _clipHandle.layer = null; + _backdropHandle.layer = null; + super.paint(context, offset); + return; + } + + final bounds = Offset.zero & size; + final shape = _borderRadius.toRRect(bounds); + + if (_blurSigma > 0) { + _blurClipHandle.layer = context.pushClipRRect( + needsCompositing, + offset, + bounds, + shape, + (PaintingContext innerContext, Offset innerOffset) { + final blur = _blurHandle.layer ??= BackdropFilterLayer(); + blur.filter = ui.ImageFilter.blur( + sigmaX: _blurSigma, + sigmaY: _blurSigma, + tileMode: TileMode.mirror, + ); + innerContext.pushLayer(blur, (_, _) {}, innerOffset); + }, + oldLayer: _blurClipHandle.layer, + ); + } else { + _blurClipHandle.layer = null; + _blurHandle.layer = null; + } + + _clipHandle.layer = context.pushClipRRect( + needsCompositing, + offset, + bounds, + shape, + (PaintingContext innerContext, Offset innerOffset) { + final backdrop = _backdropHandle.layer ??= BackdropFilterLayer(); + backdrop.filter = filter; + innerContext.pushLayer(backdrop, super.paint, innerOffset); + }, + oldLayer: _clipHandle.layer, + ); + } +} diff --git a/lib/frontend/widgets/lottie_image.dart b/lib/frontend/widgets/lottie_image.dart index 5192010..399bd67 100644 --- a/lib/frontend/widgets/lottie_image.dart +++ b/lib/frontend/widgets/lottie_image.dart @@ -89,6 +89,9 @@ class LottiePlayer extends StatefulWidget { final int? memCacheWidth; final bool shimmer; final bool eager; + final bool animate; + final bool repeat; + final VoidCallback? onCompleted; const LottiePlayer({ super.key, @@ -98,6 +101,9 @@ class LottiePlayer extends StatefulWidget { this.memCacheWidth, this.shimmer = true, this.eager = false, + this.animate = true, + this.repeat = true, + this.onCompleted, }); @override @@ -119,7 +125,9 @@ class _LottiePlayerState extends State int? _px; bool _started = false; bool _showedFrames = false; + bool _completed = false; Timer? _deferTimer; + Timer? _fallbackCompletionTimer; static const Duration _maxLoadDefer = Duration(milliseconds: 700); @@ -174,12 +182,22 @@ class _LottiePlayerState extends State _speed = 1.0; _targetSpeed = _isScrolling ? _slowSpeed : 1.0; _lastElapsedMs = null; + if (_frameIndex.value != 0) _frameIndex.value = 0; + _completed = false; + _fallbackCompletionTimer?.cancel(); + _fallbackCompletionTimer = null; + } else if (oldWidget.animate != widget.animate || + oldWidget.repeat != widget.repeat) { + _resetPlayback(); + final clip = _clip; + if (clip != null) _maybeStartTicker(clip); } } @override void dispose() { _deferTimer?.cancel(); + _fallbackCompletionTimer?.cancel(); LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged); _scrollState?.removeListener(_onGateChanged); _holdState?.removeListener(_onGateChanged); @@ -218,10 +236,22 @@ class _LottiePlayerState extends State _speed = diff.abs() <= step ? _targetSpeed : _speed + step * diff.sign; } - _playheadMs = (_playheadMs + dt * _speed) % periodMs; + final nextPlayhead = _playheadMs + dt * _speed; + if (!widget.repeat && nextPlayhead >= periodMs) { + _playheadMs = periodMs.toDouble(); + if (_frameIndex.value != clip.frameCount - 1) { + _frameIndex.value = clip.frameCount - 1; + } + _completePlayback(); + return; + } + + _playheadMs = widget.repeat ? nextPlayhead % periodMs : nextPlayhead; final t = _playheadMs / periodMs; - final index = - (t * (clip.frameCount - 1)).round().clamp(0, clip.frameCount - 1); + final index = (t * (clip.frameCount - 1)).round().clamp( + 0, + clip.frameCount - 1, + ); if (index != _frameIndex.value) _frameIndex.value = index; } @@ -244,7 +274,11 @@ class _LottiePlayerState extends State } void _maybeStartTicker(RlottieClip clip) { - if (clip.frameCount <= 1) return; + if (!widget.animate || _completed) return; + if (clip.frameCount <= 1) { + if (!widget.repeat) _completePlayback(); + return; + } final lead = clip.frameCount < _leadFrames ? clip.frameCount : _leadFrames; if (clip.ready.value >= lead && !_ticker.isActive) { _lastElapsedMs = null; @@ -252,6 +286,34 @@ class _LottiePlayerState extends State } } + void _resetPlayback() { + _ticker.stop(); + _fallbackCompletionTimer?.cancel(); + _fallbackCompletionTimer = null; + _completed = false; + _playheadMs = 0; + _lastElapsedMs = null; + if (_frameIndex.value != 0) _frameIndex.value = 0; + } + + void _completePlayback() { + if (_completed) return; + _completed = true; + _ticker.stop(); + _fallbackCompletionTimer?.cancel(); + _fallbackCompletionTimer = null; + final callback = widget.onCompleted; + if (callback == null) return; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) callback(); + }); + } + + void _scheduleFallbackCompletion(Duration duration) { + if (!widget.animate || widget.repeat || _completed) return; + _fallbackCompletionTimer ??= Timer(duration, _completePlayback); + } + void _ensure(double box) { if (_clip != null) return; final dpr = MediaQuery.devicePixelRatioOf(context); @@ -328,6 +390,10 @@ class _LottiePlayerState extends State height: widget.size, fit: BoxFit.contain, frameRate: FrameRate.max, + animate: widget.animate, + repeat: widget.repeat, + onLoaded: (composition) => + _scheduleFallbackCompletion(composition.duration), errorBuilder: (context, _, _) => _staticFallback(widget.size ?? 96.0), ); } @@ -359,6 +425,9 @@ class LottieImage extends StatelessWidget { final int? memCacheWidth; final bool shimmer; final bool eager; + final bool animate; + final bool repeat; + final VoidCallback? onCompleted; const LottieImage({ super.key, @@ -368,6 +437,9 @@ class LottieImage extends StatelessWidget { this.memCacheWidth, this.shimmer = true, this.eager = false, + this.animate = true, + this.repeat = true, + this.onCompleted, }); @override @@ -380,6 +452,9 @@ class LottieImage extends StatelessWidget { memCacheWidth: memCacheWidth, shimmer: shimmer, eager: eager, + animate: animate, + repeat: repeat, + onCompleted: onCompleted, ); } return _static(); @@ -395,7 +470,9 @@ class LottieImage extends StatelessWidget { fit: BoxFit.contain, memCacheWidth: memCacheWidth, fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => LottieShimmer(size: size), + placeholder: (_, _) => shimmer + ? LottieShimmer(size: size) + : SizedBox(width: size, height: size), errorWidget: (_, _, _) => SizedBox(width: size, height: size), ); } diff --git a/lib/frontend/widgets/lottie_slash_icon.dart b/lib/frontend/widgets/lottie_slash_icon.dart new file mode 100644 index 0000000..826f583 --- /dev/null +++ b/lib/frontend/widgets/lottie_slash_icon.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +class LottieSlashIcon extends StatefulWidget { + const LottieSlashIcon({ + super.key, + required this.asset, + required this.slashed, + required this.color, + this.size = 24, + this.duration = const Duration(milliseconds: 320), + }); + + final String asset; + final bool slashed; + final Color color; + final double size; + final Duration duration; + + @override + State createState() => _LottieSlashIconState(); +} + +class _LottieSlashIconState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + value: widget.slashed ? 1 : 0, + ); + + @override + void didUpdateWidget(LottieSlashIcon old) { + super.didUpdateWidget(old); + _controller.duration = widget.duration; + if (widget.slashed == old.slashed) return; + if (widget.slashed) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: widget.size, + child: Lottie.asset( + widget.asset, + controller: _controller, + fit: BoxFit.contain, + delegates: LottieDelegates( + values: [ + ValueDelegate.color(const ['**'], value: widget.color), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart index 4fe7346..83d4def 100644 --- a/lib/frontend/widgets/max_link_handler.dart +++ b/lib/frontend/widgets/max_link_handler.dart @@ -1,16 +1,22 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; import '../../backend/modules/chats.dart'; import '../../backend/modules/links.dart'; +import '../../core/cache/info_cache.dart'; import '../../core/links/max_link.dart'; import '../../core/storage/app_database.dart'; +import '../../core/utils/share_origin.dart'; import '../../main.dart'; import '../screens/chats/chat_screen.dart'; -import '../screens/contacts/contact_profile_screen.dart'; +import '../screens/contacts/open_contact_profile.dart'; import 'call_link_handler.dart'; import 'confirm_dialog.dart'; import 'custom_notification.dart'; -import 'sticker_pack_sheet.dart'; +import 'max_link_nav.dart'; +import 'max_route_handler.dart'; import 'swipe_route.dart'; import 'web_qr_login.dart'; @@ -18,20 +24,47 @@ Future tryHandleMaxLink(BuildContext context, String url) async { final link = MaxLink.parse(url); if (link == null) return false; - if (link.kind == MaxLinkKind.call) { - return tryHandleCallLink(context, url); + switch (link) { + case MaxRootLink(): + popToAppRoot(context); + return true; + case MaxCurrentLink(): + return true; + case MaxAuthLink(:final url): + await confirmAndAuthorizeWebQrLogin(context, url); + return true; + case MaxCallLink(:final url): + return tryHandleCallLink(context, url); + case MaxStickerSetLink(:final path): + return openStickerSetByPath(context, path); + case MaxShareSelfLink(): + return _shareOwnLink(context); + case MaxShareTextLink(:final text): + return shareTextToChat(context, text); + case MaxFolderLink(:final folderId): + return openFolderChatList(context, folderId); + case MaxRouteLink(:final route, :final params): + return openMaxRoute(context, route, params); + case MaxContactIdLink(:final userId): + return openContactById(context, userId); + case MaxChatIdLink(:final chatId, :final messageId): + return openChatById(context, chatId, messageId: messageId); + case MaxWebAppLink(): + return _openWebAppLink(context, link); + case MaxContentLink(): + return _openContentLink(context, link); } +} - if (link.kind == MaxLinkKind.auth) { - await confirmAndAuthorizeWebQrLogin(context, link.url); - return true; - } +Future _resolve(String url, String baseUrl) async { + final resolved = await LinkModule.resolve(api, url); + if (baseUrl == url) return resolved; + if (resolved is ResolvedChat || resolved is ResolvedUser) return resolved; + return LinkModule.resolve(api, baseUrl); +} - if (link.kind == MaxLinkKind.stickerSet) { - return _openStickerSet(context, link.url); - } - - final resolved = await LinkModule.resolve(api, link.url); +Future _openContentLink(BuildContext context, MaxContentLink link) async { + final resolved = await _resolve(link.url, link.baseUrl); if (!context.mounted) return true; switch (resolved) { @@ -41,7 +74,7 @@ Future tryHandleMaxLink(BuildContext context, String url) async { showCustomNotification(context, message); return true; case ResolvedUser(:final contact): - _openContact(context, contact); + await _openContact(context, link, contact); return true; case ResolvedChat(): await _openResolvedChat(context, link, resolved); @@ -49,46 +82,136 @@ Future tryHandleMaxLink(BuildContext context, String url) async { } } -Future _openStickerSet(BuildContext context, String url) async { - final path = url - .replaceFirst( - RegExp(r'^https?://(?:www\.)?max\.ru/', caseSensitive: false), - '', - ) - .split('?') - .first - .split('#') - .first; - final set = await stickersModule.resolveSetByLink(path); +Future _openWebAppLink(BuildContext context, MaxWebAppLink link) async { + final resolved = await _resolve(link.url, link.url); if (!context.mounted) return true; - if (set == null) { - showCustomNotification(context, 'Стикерпак недоступен'); + + final botId = await _botIdOf(resolved); + if (!context.mounted) return true; + if (botId == null) { + final message = resolved is ResolvedLinkError + ? resolved.message + : 'Не удалось открыть приложение'; + showCustomNotification(context, message); return true; } - await showStickerPackSheet(context, knownSetId: set.id); + return openWebAppForBot(context, botId, startParam: link.startApp); +} + +Future _botIdOf(ResolvedLink? resolved) async { + switch (resolved) { + case ResolvedUser(:final contact): + final id = contact['id']; + return id is int ? id : null; + case ResolvedChat(:final chat): + final chatId = chat['id']; + if (chatId is! int) return null; + if ((chat['type'] as String?) != 'DIALOG') return null; + final myId = await currentAccountId(); + return myId == 0 ? null : chatId ^ myId; + default: + return null; + } +} + +Future _shareOwnLink(BuildContext context) async { + final myId = await currentAccountId(); + if (myId == 0) return false; + final info = await ContactInfoFetch.get(myId); + if (!context.mounted) return true; + + final link = (info?.raw['link'] as String?)?.trim(); + if (link == null || link.isEmpty) { + showCustomNotification(context, 'У профиля нет публичной ссылки'); + return true; + } + try { + await Share.share(link, sharePositionOrigin: shareOriginOf(context)); + } catch (_) { + if (context.mounted) { + showCustomNotification(context, 'Не удалось поделиться ссылкой'); + } + } return true; } -void _openContact(BuildContext context, Map contact) { +Future _openContact( + BuildContext context, + MaxContentLink link, + Map contact, +) async { final id = contact['id']; if (id is! int) { showCustomNotification(context, 'Не удалось открыть профиль'); return; } - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ContactProfileScreen( - contactId: id, - initialName: _contactName(contact), - initialAvatarUrl: contact['baseUrl'] as String?, - ), + + final startPayload = link.startPayload; + if (startPayload != null && + await _startBotDialog(context, id, contact, startPayload)) { + return; + } + if (!context.mounted) return; + + unawaited( + openContactDialogProfile( + context, + contactId: id, + name: _contactName(contact), + avatarUrl: contact['baseUrl'] as String?, + ), + ); +} + +Future _startBotDialog( + BuildContext context, + int botId, + Map contact, + String startPayload, +) async { + final myId = await currentAccountId(); + if (myId == 0) return false; + + final chatId = + await AppDatabase.findDialogChatByParticipant(myId, botId) ?? + (myId ^ botId); + if (chatId <= 0 || !context.mounted) return false; + + _openChatAndStartBot( + context, + chatId: chatId, + name: _contactName(contact), + imageUrl: (contact['baseUrl'] as String?) ?? '', + chatType: 'DIALOG', + startPayload: startPayload, + ); + return true; +} + +void _openChatAndStartBot( + BuildContext context, { + required int chatId, + required String name, + required String imageUrl, + required String chatType, + required String startPayload, +}) { + if (ChatScreen.startBotInVisibleChat(chatId, startPayload)) return; + pushSwipeable( + context, + (_) => ChatScreen( + chatId: chatId, + name: name, + imageUrl: imageUrl, + chatType: chatType, + botStartPayload: startPayload, ), ); } Future _openResolvedChat( BuildContext context, - MaxLink link, + MaxContentLink link, ResolvedChat resolved, ) async { final chat = resolved.chat; @@ -103,18 +226,15 @@ Future _openResolvedChat( final icon = (chat['baseIconUrl'] as String?) ?? ''; final access = chat['access']; - final profile = await AppDatabase.loadActiveProfile(); - final myId = profile?.id ?? 0; - final participants = chat['participants']; - final isMember = - myId != 0 && - participants is Map && - participants.containsKey(myId.toString()); + final myId = await currentAccountId(); + var isMember = myId != 0 && await AppDatabase.isChatInList(myId, id); await chats.cacheServerChat(chat, myId, inList: isMember); if (!context.mounted) return; - if (link.kind == MaxLinkKind.invite && access == 'PRIVATE' && !isMember) { + if (link.kind == MaxContentKind.invite && + access == 'PRIVATE' && + !isMember) { final label = title.isEmpty ? 'этот чат' : '«$title»'; final confirmed = await showConfirmDialog( context, @@ -129,15 +249,53 @@ Future _openResolvedChat( if (context.mounted) showCustomNotification(context, error); return; } + isMember = true; + await chats.cacheServerChat(chat, myId, inList: true); if (!context.mounted) return; } + final startPayload = link.startPayload; + if (startPayload != null && type == 'DIALOG') { + _openChatAndStartBot( + context, + chatId: id, + name: title, + imageUrl: icon, + chatType: type, + startPayload: startPayload, + ); + return; + } + + final target = _messageTarget(link, resolved.message); pushSwipeable( context, - (_) => ChatScreen(chatId: id, name: title, imageUrl: icon, chatType: type), + (_) => ChatScreen( + chatId: id, + name: title, + imageUrl: icon, + chatType: type, + channelSubscribed: type == 'CHANNEL' ? isMember : null, + initialMessageId: target?.id, + initialMessageTime: target?.time, + ), ); } +({String id, int? time})? _messageTarget( + MaxContentLink link, + Map? message, +) { + final serverId = message?['id']?.toString(); + final time = message?['time']; + if (serverId != null && serverId.isNotEmpty) { + return (id: serverId, time: time is int ? time : null); + } + final messageId = link.messageId; + if (messageId == null) return null; + return (id: messageId.toString(), time: null); +} + String _contactName(Map contact) { final names = contact['names']; if (names is List && names.isNotEmpty && names.first is Map) { diff --git a/lib/frontend/widgets/max_link_nav.dart b/lib/frontend/widgets/max_link_nav.dart new file mode 100644 index 0000000..ff66f22 --- /dev/null +++ b/lib/frontend/widgets/max_link_nav.dart @@ -0,0 +1,193 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../backend/modules/chats.dart'; +import '../../backend/modules/messages.dart' show ContactCache; +import '../../core/cache/info_cache.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/webview_support.dart'; +import '../../main.dart'; +import '../screens/chats/chat_list_screen.dart'; +import '../screens/chats/chat_screen.dart'; +import '../screens/contacts/open_contact_profile.dart'; +import '../screens/webapp/web_app_bridge.dart'; +import '../screens/webapp/web_app_screen.dart'; +import 'custom_notification.dart'; +import 'sticker_pack_sheet.dart'; +import 'swipe_route.dart'; + +void popToAppRoot(BuildContext context) { + Navigator.of(context).popUntil((route) => route.isFirst); +} + +Future popToAppRootAndSettle(BuildContext context) async { + popToAppRoot(context); + await WidgetsBinding.instance.endOfFrame; + return KometApp.navigatorKey.currentContext; +} + +Future currentAccountId() async { + final profile = await AppDatabase.loadActiveProfile(); + return profile?.id ?? 0; +} + +Future resolveChat(int myId, int chatId) async { + var rows = await chats.getChat(myId, chatId); + if (rows.isEmpty) { + await chats.ensureChatCached(api, myId, chatId); + rows = await chats.getChat(myId, chatId); + } + return rows.isEmpty ? null : rows.first; +} + +Future openChatById( + BuildContext context, + int chatId, { + int? messageId, + int? messageTime, + String? initialText, +}) => openChatAtMessage( + context, + chatId, + messageId: messageId?.toString(), + messageTime: messageTime, + initialText: initialText, +); + +Future openChatAtMessage( + BuildContext context, + int chatId, { + String? messageId, + int? messageTime, + String? initialText, +}) async { + final myId = await currentAccountId(); + if (myId == 0) return false; + final chat = await resolveChat(myId, chatId); + if (!context.mounted) return false; + if (chat == null) { + showCustomNotification(context, 'Чат не найден'); + return true; + } + + final title = chat.title?.trim(); + final peerId = (chat.type == 'DIALOG' && chatId != 0) ? chatId ^ myId : 0; + final name = (title != null && title.isNotEmpty) + ? title + : (ContactCache.get(chatId ^ myId) ?? 'Чат'); + final imageUrl = + (peerId > 0 ? ContactCache.getAvatar(peerId) : null) ?? + chat.iconUrl ?? + ''; + + await pushSwipeable( + context, + (_) => ChatScreen( + chatId: chatId, + name: name, + imageUrl: imageUrl, + chatType: chat.type, + initialMessageId: messageId, + initialMessageTime: messageTime, + initialText: initialText, + ), + ); + return true; +} + +Future openContactById(BuildContext context, int userId) async { + final info = await ContactInfoFetch.get(userId); + if (!context.mounted) return false; + await openContactDialogProfile( + context, + contactId: userId, + name: ContactCache.get(userId) ?? info?.displayName ?? 'Профиль', + avatarUrl: info?.avatarUrl, + ); + return true; +} + +Future openStickerSetByPath(BuildContext context, String path) async { + final set = await stickersModule.resolveSetByLink(path); + if (!context.mounted) return true; + if (set == null) { + showCustomNotification(context, 'Стикерпак недоступен'); + return true; + } + await showStickerPackSheet(context, knownSetId: set.id); + return true; +} + +Future openStickerSetById(BuildContext context, int setId) async { + await showStickerPackSheet(context, knownSetId: setId); + return true; +} + +Future openWebAppForBot( + BuildContext context, + int botId, { + String? startParam, + int? chatId, +}) async { + if (!webViewSupported) { + showCustomNotification(context, 'На вашей платформе это недоступно'); + return true; + } + final myId = await currentAccountId(); + if (!context.mounted) return false; + final dialogId = chatId ?? (myId == 0 ? null : myId ^ botId); + final title = ContactCache.get(botId) ?? 'Приложение'; + + await pushSwipeable( + context, + (_) => WebAppScreen( + title: title, + entryPoint: WebAppEntryPoint.url, + loader: () => webAppModule.fetchLaunch( + botId, + startParam: startParam, + chatId: dialogId, + ), + ), + ); + return true; +} + +Future shareTextToChat(BuildContext context, String text) async { + if (text.isEmpty) { + showCustomNotification(context, 'Нечего отправлять'); + return true; + } + final target = await openForwardScreen(context: context); + if (target == null || !context.mounted) return true; + + await pushSwipeable( + context, + (_) => ChatScreen( + chatId: target.chatId, + name: target.name, + imageUrl: target.imageUrl, + chatType: target.chatType, + initialText: text, + ), + ); + return true; +} + +Future openFolderChatList(BuildContext context, String folderId) async { + final root = await popToAppRootAndSettle(context); + if (ChatListScreen.selectFolder(folderId)) return true; + if (root != null) showCustomNotification(root, 'Папка не найдена'); + return true; +} + +Future openRootTab(BuildContext context, int index) async { + final root = await popToAppRootAndSettle(context); + if (ChatListScreen.selectTab(index)) return true; + if (root != null) notifyNeedsAccount(root); + return true; +} + +void notifyNeedsAccount(BuildContext context) => + showCustomNotification(context, 'Сначала войдите в аккаунт'); diff --git a/lib/frontend/widgets/max_route_handler.dart b/lib/frontend/widgets/max_route_handler.dart new file mode 100644 index 0000000..e6d6bc5 --- /dev/null +++ b/lib/frontend/widgets/max_route_handler.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; + +import '../../core/utils/link_opener.dart'; +import '../screens/chats/chat_info_screen.dart'; +import '../screens/chats/chat_list_screen.dart'; +import '../screens/chats/scheduled_messages_screen.dart'; +import '../screens/profile/appearance_screen.dart'; +import '../screens/profile/debug_menu_screen.dart'; +import '../screens/profile/devices_screen.dart'; +import '../screens/profile/edit_profile_screen.dart'; +import '../screens/profile/info_screen.dart'; +import '../screens/profile/message_actions_screen.dart'; +import '../screens/profile/notifications_screen.dart'; +import '../screens/profile/security_screen.dart'; +import '../screens/profile/web_qr_scan_screen.dart'; +import 'custom_notification.dart'; +import 'max_link_nav.dart'; +import 'swipe_route.dart'; + +Future openMaxRoute( + BuildContext context, + String route, + Map params, +) async { + switch (route) { + case ':chat-list': + case ':settings/folder-list': + return openRootTab(context, 0); + case ':calls-history': + case ':call-list': + return openRootTab(context, 1); + case ':contact-list': + return openRootTab(context, 2); + case ':settings': + return openRootTab(context, 3); + + case ':chats-search': + final root = await popToAppRootAndSettle(context); + if (ChatListScreen.openSearch()) return true; + if (root != null) notifyNeedsAccount(root); + return true; + + case ':saved-messages': + final root = await popToAppRootAndSettle(context); + if (ChatListScreen.openSavedMessages()) return true; + if (root != null) notifyNeedsAccount(root); + return true; + + case ':settings/folder': + final id = params['id']?.trim(); + if (id == null || id.isEmpty) return _badLink(context, route); + return openFolderChatList(context, id); + + case ':chats': + final id = _intOf(params['id']); + if (id == null) return _badLink(context, route); + return openChatById(context, id); + + case ':profile': + case ':profile/members': + case ':profile/avatars': + final id = _intOf(params['id']); + if (id == null) return _badLink(context, route); + final type = (params['type'] ?? '').toUpperCase(); + if (type == 'CHAT' || type == 'CHANNEL') { + return openChatInfoById(context, id); + } + return openContactById(context, id); + + case ':profile/attaches': + final id = _intOf(params['id']); + if (id == null) return _badLink(context, route); + return openChatInfoById(context, id, initialTab: ChatInfoTab.media); + + case ':profile/edit': + return _push(context, const EditProfileScreen()); + + case ':scheduled-messages': + final id = _intOf(params['id']); + if (id == null) return _badLink(context, route); + return openScheduledMessages(context, id); + + case ':stickers/set': + final setId = _intOf(params['set_id']); + if (setId == null) return _badLink(context, route); + return openStickerSetById(context, setId); + + case ':webapp:root': + final botId = _intOf(params['bot_id']); + if (botId == null) return _badLink(context, route); + return openWebAppForBot( + context, + botId, + startParam: params['entry_point'], + chatId: _intOf(params['chat_id']), + ); + + case ':settings/webapp': + final botId = _intOf(params['bot_id']); + if (botId == null) return _badLink(context, route); + return openWebAppForBot(context, botId); + + case ':location/show': + final lat = double.tryParse(params['lat'] ?? ''); + final lon = double.tryParse(params['lon'] ?? ''); + if (lat == null || lon == null) return _badLink(context, route); + await openLocationOnMap( + context, + lat, + lon, + zoom: double.tryParse(params['z'] ?? ''), + ); + return true; + + case ':qr-scanner': + return _push(context, const WebQrScanScreen()); + case ':settings/appearance': + return _push(context, const AppearanceScreen()); + case ':settings/notifications': + case ':settings/notifications/chat': + case ':settings/notifications/dialog': + case ':settings/notifications/other': + return _push(context, const NotificationsScreen()); + case ':settings/devices': + return _push(context, const DevicesScreen()); + case ':settings/aboutapp': + return _push(context, const InfoScreen()); + case ':settings/privacy': + case ':settings/privacy/pincode': + case ':settings/blacklist': + return _push(context, const SecurityScreen()); + case ':settings/messages': + return _push(context, const MessageActionsScreen()); + case ':settings/dev': + case ':settings/dev/logsviewer': + case ':settings/dev/memorydebugger': + case ':settings/dev/showroom': + case ':settings/dev/threadsviewer': + case ':settings/dev/integritylogsviewer': + return _push(context, const DebugMenuScreen()); + + case ':current': + case ':link-intercept': + case ':external_callback': + return true; + } + + showCustomNotification(context, 'Ссылка не поддерживается: $route'); + return true; +} + +Future openChatInfoById( + BuildContext context, + int chatId, { + ChatInfoTab? initialTab, +}) async { + final myId = await currentAccountId(); + if (myId == 0) return false; + final chat = await resolveChat(myId, chatId); + if (!context.mounted) return false; + + final title = chat?.title?.trim(); + return _push( + context, + ChatInfoScreen( + chatId: chatId, + name: (title != null && title.isNotEmpty) ? title : 'Чат', + imageUrl: chat?.iconUrl ?? '', + chatType: chat?.type ?? 'CHAT', + initialTab: initialTab, + ), + ); +} + +Future openScheduledMessages(BuildContext context, int chatId) async { + final myId = await currentAccountId(); + if (myId == 0) return false; + final chat = await resolveChat(myId, chatId); + if (!context.mounted) return false; + + final title = chat?.title?.trim(); + return _push( + context, + ScheduledMessagesScreen( + chatId: chatId, + accountId: myId, + chatName: (title != null && title.isNotEmpty) ? title : 'Чат', + ), + ); +} + +Future _push(BuildContext context, Widget screen) async { + await pushSwipeable(context, (_) => screen); + return true; +} + +bool _badLink(BuildContext context, String route) { + showCustomNotification(context, 'Неполная ссылка: $route'); + return true; +} + +int? _intOf(String? raw) { + final value = int.tryParse(raw?.trim() ?? ''); + return (value == null || value <= 0) ? null : value; +} diff --git a/lib/frontend/widgets/media_playback_pill.dart b/lib/frontend/widgets/media_playback_pill.dart new file mode 100644 index 0000000..efdade7 --- /dev/null +++ b/lib/frontend/widgets/media_playback_pill.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../backend/modules/messages.dart'; +import '../../core/media/media_playback.dart'; +import '../../core/utils/format.dart'; +import '../../core/utils/haptics.dart'; +import '../../l10n/app_localizations.dart'; +import 'max_link_nav.dart'; + +class MediaPlaybackPill extends StatelessWidget { + const MediaPlaybackPill({ + super.key, + this.borderRadius, + this.margin = EdgeInsets.zero, + }); + + final BorderRadius? borderRadius; + final EdgeInsets margin; + + static const double height = 30; + + @override + Widget build(BuildContext context) { + final playback = MediaPlayback.instance; + return ValueListenableBuilder( + valueListenable: playback.primary, + builder: (context, kind, _) { + switch (kind) { + case null: + return const SizedBox.shrink(); + case PlaybackKind.voice: + return ValueListenableBuilder( + valueListenable: playback.voice, + builder: (context, track, _) => track == null + ? const SizedBox.shrink() + : _VoicePill( + track: track, + borderRadius: borderRadius, + margin: margin, + ), + ); + case PlaybackKind.videoNote: + return ValueListenableBuilder( + valueListenable: playback.videoNote, + builder: (context, track, _) => track == null + ? const SizedBox.shrink() + : _VideoNotePill( + track: track, + borderRadius: borderRadius, + margin: margin, + ), + ); + } + }, + ); + } +} + +class _VoicePill extends StatelessWidget { + const _VoicePill({ + required this.track, + required this.borderRadius, + required this.margin, + }); + + final VoiceTrack track; + final BorderRadius? borderRadius; + final EdgeInsets margin; + + @override + Widget build(BuildContext context) { + final playback = MediaPlayback.instance; + return ValueListenableBuilder( + valueListenable: playback.voiceSpeed, + builder: (context, speed, _) { + return _PillSurface( + borderRadius: borderRadius, + margin: margin, + tick: Listenable.merge([ + track.audio.playing, + track.audio.position, + track.audio.duration, + ]), + isPlaying: () => track.audio.playing.value, + progress: () { + final total = track.audio.duration.value; + return total > 0 + ? (track.audio.position.value / total).clamp(0.0, 1.0) + : 0.0; + }, + speed: speed, + senderId: track.senderId, + isMe: track.isMe, + time: track.time, + onToggle: track.audio.toggle, + onSpeed: playback.cycleVoiceSpeed, + onClose: playback.closeVoice, + onOpen: () => openChatAtMessage( + context, + track.chatId, + messageId: track.messageId, + messageTime: track.time, + ), + ); + }, + ); + } +} + +class _VideoNotePill extends StatelessWidget { + const _VideoNotePill({ + required this.track, + required this.borderRadius, + required this.margin, + }); + + final VideoNoteTrack track; + final BorderRadius? borderRadius; + final EdgeInsets margin; + + @override + Widget build(BuildContext context) { + final playback = MediaPlayback.instance; + return ValueListenableBuilder( + valueListenable: playback.videoNoteSpeed, + builder: (context, speed, _) { + return _PillSurface( + borderRadius: borderRadius, + margin: margin, + tick: track.controller, + isPlaying: () => track.controller.value.isPlaying, + progress: () { + final value = track.controller.value; + final total = value.duration.inMilliseconds; + return total > 0 + ? (value.position.inMilliseconds / total).clamp(0.0, 1.0) + : 0.0; + }, + speed: speed, + senderId: track.senderId, + isMe: track.isMe, + time: track.time, + onToggle: () => track.controller.value.isPlaying + ? track.controller.pause() + : track.controller.play(), + onSpeed: playback.cycleVideoNoteSpeed, + onClose: playback.closeVideoNote, + onOpen: () => openChatAtMessage( + context, + track.chatId, + messageId: track.messageId, + messageTime: track.time, + ), + ); + }, + ); + } +} + +class _PillSurface extends StatelessWidget { + const _PillSurface({ + required this.borderRadius, + required this.margin, + required this.tick, + required this.isPlaying, + required this.progress, + required this.speed, + required this.senderId, + required this.isMe, + required this.time, + required this.onToggle, + required this.onSpeed, + required this.onClose, + required this.onOpen, + }); + + final BorderRadius? borderRadius; + final EdgeInsets margin; + final Listenable tick; + final bool Function() isPlaying; + final double Function() progress; + final double speed; + final int senderId; + final bool isMe; + final int time; + final VoidCallback onToggle; + final VoidCallback onSpeed; + final VoidCallback onClose; + final VoidCallback onOpen; + + String _speedLabel() { + final rounded = speed.round(); + final text = speed == rounded ? '$rounded' : '$speed'; + return '${text}X'; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final radius = + borderRadius ?? BorderRadius.circular(MediaPlaybackPill.height / 2); + final author = isMe + ? l10n.playbackPillYou + : (ContactCache.get(senderId) ?? '$senderId'); + final clock = formatClock(DateTime.fromMillisecondsSinceEpoch(time)); + + return Padding( + padding: margin, + child: ClipRRect( + borderRadius: radius, + child: Material( + color: cs.surfaceContainerHigh, + child: InkWell( + onTap: onOpen, + child: SizedBox( + height: MediaPlaybackPill.height, + child: Stack( + children: [ + Positioned.fill( + child: Row( + children: [ + AnimatedBuilder( + animation: tick, + builder: (context, _) => _IconTap( + icon: isPlaying() + ? Symbols.pause + : Symbols.play_arrow, + color: cs.primary, + size: 19, + onTap: onToggle, + ), + ), + Expanded( + child: Text( + '$author ${l10n.playbackPillAt} $clock', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ), + _SpeedChip(label: _speedLabel(), onTap: onSpeed), + _IconTap( + icon: Symbols.close, + color: cs.onSurfaceVariant, + size: 17, + onTap: onClose, + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedBuilder( + animation: tick, + builder: (context, child) => FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress(), + child: child, + ), + child: Container(height: 2, color: cs.primary), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _IconTap extends StatelessWidget { + const _IconTap({ + required this.icon, + required this.color, + required this.size, + required this.onTap, + }); + + final IconData icon; + final Color color; + final double size; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkResponse( + onTap: () { + Haptics.tap(); + onTap(); + }, + radius: 22, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 9), + child: Icon(icon, color: color, size: size, fill: 1), + ), + ); + } +} + +class _SpeedChip extends StatelessWidget { + const _SpeedChip({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return InkResponse( + onTap: () { + Haptics.selection(); + onTap(); + }, + radius: 22, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + border: Border.all( + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + width: 1.2, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/mention_suggestions_panel.dart b/lib/frontend/widgets/mention_suggestions_panel.dart new file mode 100644 index 0000000..63520de --- /dev/null +++ b/lib/frontend/widgets/mention_suggestions_panel.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import '../screens/chats/chat/mention_panel_controller.dart'; +import 'komet_avatar.dart'; +import 'small_spinner.dart'; + +class MentionSuggestionsPanel extends StatefulWidget { + final List candidates; + final double maxHeight; + final bool loadingMore; + final ValueChanged onSelected; + final VoidCallback onLoadMore; + + const MentionSuggestionsPanel({ + super.key, + required this.candidates, + required this.onSelected, + required this.onLoadMore, + this.loadingMore = false, + this.maxHeight = 220, + }); + + @override + State createState() => + _MentionSuggestionsPanelState(); +} + +class _MentionSuggestionsPanelState extends State { + final ScrollController _controller = ScrollController(); + + @override + void initState() { + super.initState(); + _controller.addListener(_onScroll); + } + + @override + void dispose() { + _controller.removeListener(_onScroll); + _controller.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_controller.hasClients) return; + final position = _controller.position; + if (position.pixels >= position.maxScrollExtent - 120) { + widget.onLoadMore(); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final candidates = widget.candidates; + + return Material( + type: MaterialType.transparency, + child: Container( + constraints: BoxConstraints(maxHeight: widget.maxHeight), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), + ), + clipBehavior: Clip.antiAlias, + child: candidates.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Center( + child: SmallSpinner(size: 20, color: cs.onSurfaceVariant), + ), + ) + : ListView.separated( + controller: _controller, + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: candidates.length + (widget.loadingMore ? 1 : 0), + separatorBuilder: (_, _) => Divider( + height: 1, + thickness: 1, + indent: 14, + endIndent: 14, + color: cs.outlineVariant.withValues(alpha: 0.18), + ), + itemBuilder: (context, i) { + if (i >= candidates.length) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Center( + child: SmallSpinner( + size: 18, + color: cs.onSurfaceVariant, + ), + ), + ); + } + final candidate = candidates[i]; + return InkWell( + onTap: () => widget.onSelected(candidate), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + child: Row( + children: [ + KometAvatar( + name: candidate.name, + imageUrl: candidate.avatarUrl, + size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + candidate.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 2ea8f6d..07a1728 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -12,17 +12,29 @@ import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../l10n/app_localizations.dart'; import 'custom_notification.dart'; +import 'komet_avatar.dart'; import 'lottie_image.dart'; +import 'small_spinner.dart'; class ReactionEmoji { final String emoji; final String? animationUrl; final String? staticUrl; - const ReactionEmoji({ - required this.emoji, - this.animationUrl, - this.staticUrl, + const ReactionEmoji({required this.emoji, this.animationUrl, this.staticUrl}); +} + +class MessageReader { + final int id; + final String name; + final String? avatarUrl; + final ReactionEmoji? reaction; + + const MessageReader({ + required this.id, + required this.name, + this.avatarUrl, + this.reaction, }); } @@ -88,13 +100,18 @@ void showMessageActions({ required Offset tapPoint, required bool isMe, required String? messageText, + required String? copyText, required MessageActionsController controller, required MessageActionsStyle style, required VoidCallback onDispose, List>? editHistory, + Future> Function()? loadReadBy, + void Function(int userId)? onReaderTap, Future> Function()? loadReportReasons, Future Function(int reasonId)? onReport, VoidCallback? onDelete, + bool allowDelete = true, + bool allowCopy = true, VoidCallback? onEdit, VoidCallback? onReply, VoidCallback? onForward, @@ -124,13 +141,18 @@ void showMessageActions({ tapPoint: tapPoint, isMe: isMe, messageText: messageText, + copyText: copyText, controller: controller, style: style, interaction: interaction, editHistory: editHistory, + loadReadBy: loadReadBy, + onReaderTap: onReaderTap, loadReportReasons: loadReportReasons, onReport: onReport, onDelete: onDelete, + allowDelete: allowDelete, + allowCopy: allowCopy, onEdit: onEdit, onReply: onReply, onForward: onForward, @@ -156,14 +178,19 @@ class _MessageActionsLayer extends StatefulWidget { final Offset tapPoint; final bool isMe; final String? messageText; + final String? copyText; final MessageActionsController controller; final MessageActionsStyle style; final MessageActionsInteraction interaction; final VoidCallback onDismiss; final List>? editHistory; + final Future> Function()? loadReadBy; + final void Function(int userId)? onReaderTap; final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; final VoidCallback? onDelete; + final bool allowDelete; + final bool allowCopy; final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; @@ -181,14 +208,19 @@ class _MessageActionsLayer extends StatefulWidget { required this.tapPoint, required this.isMe, required this.messageText, + required this.copyText, required this.controller, required this.style, required this.interaction, required this.onDismiss, this.editHistory, + this.loadReadBy, + this.onReaderTap, this.loadReportReasons, this.onReport, this.onDelete, + this.allowDelete = true, + this.allowCopy = true, this.onEdit, this.onReply, this.onForward, @@ -206,7 +238,7 @@ class _MessageActionsLayer extends StatefulWidget { } class _MessageActionsLayerState extends State<_MessageActionsLayer> - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { late final AnimationController _animController; late final Animation _animation; late final AnimationController _expandController; @@ -234,9 +266,14 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> bool _committedFired = false; bool _showHistory = false; bool _showReport = false; + bool _showReadBy = false; bool _reportLoading = false; bool _reportSending = false; + bool _readByLoading = false; List<({int id, String title})>? _reasons; + List? _readers; + + bool get _panelOpen => _showHistory || _showReport || _showReadBy; @override void initState() { @@ -463,22 +500,22 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> List<_Action> _buildActions() { final l10n = AppLocalizations.of(context)!; - final hasText = - widget.messageText != null && widget.messageText!.isNotEmpty; + final copyText = widget.copyText; + final canCopy = widget.allowCopy && copyText != null && copyText.isNotEmpty; return <_Action>[ - if (hasText) _Action(Symbols.content_copy, l10n.msgActionsCopy, _copy), - if (widget.isMe && widget.onEdit != null) - _Action(Symbols.edit, l10n.msgActionsEdit, _edit), if (widget.onReply != null) _Action(Symbols.reply, l10n.msgActionsReply, _reply), + if (widget.onForward != null) + _Action(Symbols.forward, l10n.msgActionsForward, _forward), + if (canCopy) _Action(Symbols.content_copy, l10n.msgActionsCopy, _copy), + if (widget.isMe && widget.onEdit != null) + _Action(Symbols.edit, l10n.msgActionsEdit, _edit), if (widget.onPin != null) _Action( widget.isPinned ? Symbols.keep_off : Symbols.push_pin, widget.isPinned ? l10n.msgActionsUnpin : l10n.msgActionsPin, _pin, ), - if (widget.onForward != null) - _Action(Symbols.forward, l10n.msgActionsForward, _forward), if (widget.onMarkUnread != null) _Action( Symbols.mark_chat_unread, @@ -487,6 +524,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), if (widget.editHistory != null && widget.editHistory!.isNotEmpty) _Action(Symbols.history, l10n.msgActionsEditHistory, _showHistoryView), + if (widget.loadReadBy != null) + _Action(Symbols.visibility, l10n.msgActionsReadBy, _showReadByView), if (widget.onReport != null && widget.loadReportReasons != null) _Action( Symbols.flag, @@ -494,12 +533,13 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _showReportView, destructive: true, ), - _Action( - Symbols.delete, - l10n.msgActionsDelete, - _delete, - destructive: true, - ), + if (widget.allowDelete) + _Action( + Symbols.delete, + l10n.msgActionsDelete, + _delete, + destructive: true, + ), ]; } @@ -508,6 +548,21 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> setState(() => _showHistory = true); } + Future _showReadByView() async { + if (!mounted) return; + setState(() { + _showReadBy = true; + _readByLoading = _readers == null; + }); + if (_readers != null) return; + final loaded = await widget.loadReadBy?.call(); + if (!mounted) return; + setState(() { + _readers = loaded ?? const []; + _readByLoading = false; + }); + } + Future _showReportView() async { if (!mounted) return; setState(() { @@ -541,6 +596,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> setState(() { _showHistory = false; _showReport = false; + _showReadBy = false; }); } @@ -589,7 +645,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } Future _copy() async { - final text = widget.messageText; + final text = widget.copyText; if (text != null && text.isNotEmpty) { await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; @@ -648,7 +704,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final t = _animation.value.clamp(0.0, 1.0); final e = showReactions ? _expandAnim.value.clamp(0.0, 1.0) : 0.0; final bubbleScale = 1.0 + 0.02 * t; - final menuHidden = _showHistory || _showReport || _reactionsExpanded; + final menuHidden = _panelOpen || _reactionsExpanded; return GestureDetector( onTap: _close, @@ -702,9 +758,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), Positioned.fill( child: IgnorePointer( - ignoring: !(_showHistory || _showReport), + ignoring: !_panelOpen, child: AnimatedOpacity( - opacity: (_showHistory || _showReport) ? 1.0 : 0.0, + opacity: _panelOpen ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, child: Stack( @@ -712,14 +768,26 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (_showReport) _buildReportMenu() else if (_showHistory) - _buildHistoryMenu(), + _buildHistoryMenu() + else if (_showReadBy) + _buildReadByMenu(), ], ), ), ), ), if (showReactions) - Positioned.fill(child: _buildReactionStrip(t, e)), + Positioned.fill( + child: IgnorePointer( + ignoring: _panelOpen, + child: AnimatedOpacity( + opacity: _panelOpen ? 0.0 : 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: _buildReactionStrip(t, e), + ), + ), + ), ], ), ); @@ -940,7 +1008,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } - Widget _buildQuickRow(ColorScheme cs, double cell, List quick) { + Widget _buildQuickRow( + ColorScheme cs, + double cell, + List quick, + ) { return Center( child: Row( mainAxisSize: MainAxisSize.min, @@ -1041,10 +1113,14 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } - Widget _buildAnchoredPanel({required String title, required Widget body}) { + Widget _buildAnchoredPanel({ + required String title, + required Widget body, + double width = 220.0, + }) { final cs = Theme.of(context).colorScheme; final size = MediaQuery.sizeOf(context); - const menuWidth = 220.0; + final panelWidth = math.min(width, size.width - 16.0); double left; double top; @@ -1053,21 +1129,21 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> top = _menuRect.top; } else { left = widget.isMe - ? widget.originRect.right - menuWidth + ? widget.originRect.right - panelWidth : widget.originRect.left; top = _showBelow ? widget.originRect.bottom + 10 : widget.originRect.top - 10; } final bottomLimit = size.height - MediaQuery.viewInsetsOf(context).bottom; - left = left.clamp(8.0, size.width - menuWidth - 8.0); + left = left.clamp(8.0, math.max(8.0, size.width - panelWidth - 8.0)); top = top.clamp(8.0, math.max(8.0, bottomLimit - 160.0)); final maxHeight = math.min(size.height * 0.6, bottomLimit - top - 8.0); return Positioned( left: left, top: top, - width: menuWidth, + width: panelWidth, child: GestureDetector( onTap: () {}, child: Material( @@ -1157,6 +1233,87 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } + Widget _buildReadByMenu() { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final Widget body; + if (_readByLoading) { + body = const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center(child: SmallSpinner(size: 24)), + ); + } else { + final readers = _readers ?? const []; + if (readers.isEmpty) { + body = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + child: Text( + l10n.msgActionsReadByEmpty, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ); + } else { + body = SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [for (final reader in readers) _readerRow(cs, reader)], + ), + ); + } + } + return _buildAnchoredPanel( + title: l10n.msgActionsReadBy, + body: body, + width: 250, + ); + } + + Future _openReaderProfile(MessageReader reader) async { + final onTap = widget.onReaderTap; + if (onTap == null) return; + Haptics.tap(); + await _close(); + onTap(reader.id); + } + + Widget _readerRow(ColorScheme cs, MessageReader reader) { + final reaction = reader.reaction; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onReaderTap == null + ? null + : () => _openReaderProfile(reader), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + KometAvatar( + name: reader.name, + imageUrl: reader.avatarUrl, + size: 30, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + reader.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.onSurface, fontSize: 14), + ), + ), + if (reaction != null) ...[ + const SizedBox(width: 8), + _ReactionGlyph(reaction: reaction, size: 20), + ], + ], + ), + ), + ), + ); + } + Widget _buildReportMenu() { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -1164,13 +1321,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (_reportLoading) { body = const Padding( padding: EdgeInsets.symmetric(vertical: 28), - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2.4), - ), - ), + child: Center(child: SmallSpinner(size: 24)), ); } else { final reasons = _reasons ?? const <({int id, String title})>[]; @@ -1497,13 +1648,7 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { _buildSearchField(cs), Expanded( child: !_loaded - ? const Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2.4), - ), - ) + ? const Center(child: SmallSpinner(size: 26)) : _results.isEmpty ? const SizedBox.shrink() : LottieScrollScope( @@ -1618,7 +1763,8 @@ class _ReactionGlyph extends StatelessWidget { final anim = reaction.animationUrl; final still = reaction.staticUrl; final hasAsset = - (anim != null && anim.isNotEmpty) || (still != null && still.isNotEmpty); + (anim != null && anim.isNotEmpty) || + (still != null && still.isNotEmpty); if (!hasAsset) { return Center( child: Text(reaction.emoji, style: TextStyle(fontSize: size * 0.9)), diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 16ba6f2..5aac531 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -8,23 +8,33 @@ import 'package:flutter/rendering.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; +import '../screens/webapp/web_app_bridge.dart'; import '../screens/webapp/web_app_screen.dart'; import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; +import '../../core/crypto/message_decryption_cache.dart'; +import 'decrypted_text.dart'; import '../../core/utils/bubble_radius.dart'; +import '../../core/utils/emoji_keyword_index.dart'; import '../../core/utils/link_opener.dart'; import '../../core/utils/text_format.dart'; import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; import 'formatted_message_text.dart'; +import 'text_entity_actions.dart'; +import 'sending_clock_icon.dart'; +import 'photo_viewer.dart'; +import 'selectable_message_text.dart'; import '../../models/attachment.dart'; +import '../../models/animoji.dart'; import '../../models/reaction_info.dart'; import 'attachment/bubbles/voice_bubble.dart'; import 'attachment/bubbles/bubble_context.dart'; import 'attachment/bubbles/poll_bubble.dart'; import 'attachment/bubbles/share_bubble.dart'; import 'attachment/bubbles/call_bubble.dart'; +import 'attachment/bubbles/control_bubble.dart'; import 'attachment/bubbles/location_bubble.dart'; import 'attachment/bubbles/contact_bubble.dart'; import 'attachment/bubbles/sticker_bubble.dart'; @@ -36,20 +46,360 @@ import 'lottie_image.dart'; final Expando _contentTypeCache = Expando(); -class _ZeroIntrinsicWidth extends SingleChildRenderObjectWidget { - const _ZeroIntrinsicWidth({required Widget super.child}); +class ReactionAnimationEvent { + final String messageId; + final String emoji; + final int token; + + const ReactionAnimationEvent({ + required this.messageId, + required this.emoji, + required this.token, + }); +} + +typedef ReactionAnimojiResolver = Animoji? Function(String emoji); + +class _TextWithMeta extends MultiChildRenderObjectWidget { + _TextWithMeta({required Widget text, required Widget meta}) + : super(children: [text, meta]); @override RenderObject createRenderObject(BuildContext context) => - _RenderZeroIntrinsicWidth(); + _RenderTextWithMeta(); } -class _RenderZeroIntrinsicWidth extends RenderProxyBox { - @override - double computeMinIntrinsicWidth(double height) => 0; +class _TextWithMetaParentData extends ContainerBoxParentData {} + +class _RenderTextWithMeta extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + static const double _gap = 8; + static const double _baselineNudge = 2; + + RenderBox get _text => firstChild!; + RenderBox get _meta => lastChild!; @override - double computeMaxIntrinsicWidth(double height) => 0; + void setupParentData(RenderBox child) { + if (child.parentData is! _TextWithMetaParentData) { + child.parentData = _TextWithMetaParentData(); + } + } + + RenderParagraph? _soleParagraph() { + RenderParagraph? found; + var seen = 0; + void visit(RenderObject node) { + if (node is RenderParagraph) { + found = node; + seen++; + return; + } + node.visitChildren(visit); + } + + _text.visitChildren(visit); + if (_text is RenderParagraph) { + found = _text as RenderParagraph; + seen = 1; + } + return seen == 1 ? found : null; + } + + @override + double computeMinIntrinsicWidth(double height) => + _text.getMinIntrinsicWidth(height); + + @override + double computeMaxIntrinsicWidth(double height) => + _text.getMaxIntrinsicWidth(height) + + _gap + + _meta.getMaxIntrinsicWidth(height); + + @override + double computeMinIntrinsicHeight(double width) => + _text.getMinIntrinsicHeight(width); + + @override + double computeMaxIntrinsicHeight(double width) => + _text.getMaxIntrinsicHeight(width) + _meta.getMaxIntrinsicHeight(width); + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) => + BaselineOffset(_text.getDistanceToActualBaseline(baseline)).offset; + + @override + void performLayout() { + _meta.layout(const BoxConstraints(), parentUsesSize: true); + final metaSize = _meta.size; + + _text.layout(constraints.loosen(), parentUsesSize: true); + final textSize = _text.size; + + final paragraph = _soleParagraph(); + final needed = _gap + metaSize.width; + + double width; + double height; + var metaOnOwnLine = false; + + if (paragraph != null) { + final length = paragraph.text.toPlainText().length; + final caret = paragraph.getOffsetForCaret( + TextPosition(offset: length), + Rect.zero, + ); + final lastLine = caret.dx; + final singleLine = caret.dy < 0.5; + if (lastLine + needed <= textSize.width) { + width = textSize.width; + height = textSize.height; + } else if (singleLine) { + width = lastLine + needed; + height = textSize.height; + } else { + width = textSize.width; + height = textSize.height + metaSize.height; + metaOnOwnLine = true; + } + } else { + width = math.max(textSize.width, metaSize.width); + height = textSize.height + metaSize.height; + metaOnOwnLine = true; + } + + size = constraints.constrain(Size(width, height)); + + (_text.parentData! as _TextWithMetaParentData).offset = Offset.zero; + (_meta.parentData! as _TextWithMetaParentData).offset = Offset( + math.max(0, size.width - metaSize.width), + metaOnOwnLine + ? size.height - metaSize.height + : size.height - metaSize.height - _baselineNudge, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } +} + +class _CapIntrinsicWidth extends SingleChildRenderObjectWidget { + final double cap; + + const _CapIntrinsicWidth({required this.cap, required Widget super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderCapIntrinsicWidth(cap); + + @override + void updateRenderObject( + BuildContext context, + _RenderCapIntrinsicWidth renderObject, + ) { + renderObject.cap = cap; + } +} + +class _RenderCapIntrinsicWidth extends RenderProxyBox { + _RenderCapIntrinsicWidth(this._cap); + + double _cap; + set cap(double value) { + if (value == _cap) return; + _cap = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) => + math.min(super.computeMinIntrinsicWidth(height), _cap); + + @override + double computeMaxIntrinsicWidth(double height) => + math.min(super.computeMaxIntrinsicWidth(height), _cap); +} + +class _HeaderAboveMatchWidth extends MultiChildRenderObjectWidget { + _HeaderAboveMatchWidth({required Widget content, required Widget header}) + : super(children: [content, header]); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderHeaderAboveMatchWidth(); +} + +class _HeaderAboveMatchWidthParentData + extends ContainerBoxParentData {} + +class _RenderHeaderAboveMatchWidth extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin< + RenderBox, + _HeaderAboveMatchWidthParentData + > { + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _HeaderAboveMatchWidthParentData) { + child.parentData = _HeaderAboveMatchWidthParentData(); + } + } + + @override + double computeMinIntrinsicWidth(double height) => + firstChild!.getMinIntrinsicWidth(height); + + @override + double computeMaxIntrinsicWidth(double height) => + firstChild!.getMaxIntrinsicWidth(height); + + @override + double computeMinIntrinsicHeight(double width) => + firstChild!.getMinIntrinsicHeight(width) + + lastChild!.getMinIntrinsicHeight(width); + + @override + double computeMaxIntrinsicHeight(double width) => + firstChild!.getMaxIntrinsicHeight(width) + + lastChild!.getMaxIntrinsicHeight(width); + + @override + void performLayout() { + final RenderBox content = firstChild!; + final RenderBox header = childAfter(content)!; + + content.layout(constraints.loosen(), parentUsesSize: true); + final double width = constraints.constrainWidth(content.size.width); + + header.layout( + BoxConstraints.tightFor(width: width).enforce(constraints.loosen()), + parentUsesSize: true, + ); + + (header.parentData! as _HeaderAboveMatchWidthParentData).offset = + Offset.zero; + (content.parentData! as _HeaderAboveMatchWidthParentData).offset = Offset( + 0, + header.size.height, + ); + + size = constraints.constrain( + Size(width, header.size.height + content.size.height), + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } +} + +/// Stacks [bottom] directly beneath [top] and forces [bottom] to take exactly +/// [top]'s rendered width. Used to keep an inline keyboard and a comments footer +/// pinned to their post's natural width instead of stretching to the bubble max +/// — the latter would otherwise inflate a narrow post (single photo, link +/// preview) to the full bubble width. +class _StackMatchTopWidth extends MultiChildRenderObjectWidget { + _StackMatchTopWidth({ + required Widget top, + required Widget bottom, + this.growForBottom = false, + }) : super(children: [top, bottom]); + + final bool growForBottom; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderStackMatchTopWidth(growForBottom); + + @override + void updateRenderObject( + BuildContext context, + _RenderStackMatchTopWidth renderObject, + ) { + renderObject.growForBottom = growForBottom; + } +} + +class _StackMatchTopWidthParentData extends ContainerBoxParentData {} + +class _RenderStackMatchTopWidth extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin< + RenderBox, + _StackMatchTopWidthParentData + > { + _RenderStackMatchTopWidth(this._growForBottom); + + bool _growForBottom; + set growForBottom(bool value) { + if (value == _growForBottom) return; + _growForBottom = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _StackMatchTopWidthParentData) { + child.parentData = _StackMatchTopWidthParentData(); + } + } + + @override + void performLayout() { + final RenderBox top = firstChild!; + final RenderBox bottom = childAfter(top)!; + + top.layout(constraints.loosen(), parentUsesSize: true); + final Size topSize = top.size; + (top.parentData! as _StackMatchTopWidthParentData).offset = Offset.zero; + + double width = topSize.width; + if (_growForBottom) { + width = math.max(width, bottom.getMaxIntrinsicWidth(double.infinity)); + } + width = constraints.constrainWidth(width); + + bottom.layout( + BoxConstraints.tightFor(width: width).enforce(constraints), + parentUsesSize: true, + ); + final Size bottomSize = bottom.size; + (bottom.parentData! as _StackMatchTopWidthParentData).offset = Offset( + 0, + topSize.height, + ); + + size = constraints.constrain( + Size(math.max(width, topSize.width), topSize.height + bottomSize.height), + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } } /// A [Wrap] that reports its single-line width as the max intrinsic width, so an @@ -107,6 +457,163 @@ class _RenderReactionsWrap extends RenderWrap { } } +class _ReactionAnimojiGlyph extends StatefulWidget { + final String messageId; + final String emoji; + final Animoji animoji; + final ValueListenable? animation; + + const _ReactionAnimojiGlyph({ + super.key, + required this.messageId, + required this.emoji, + required this.animoji, + this.animation, + }); + + @override + State<_ReactionAnimojiGlyph> createState() => _ReactionAnimojiGlyphState(); +} + +class _ReactionAnimojiGlyphState extends State<_ReactionAnimojiGlyph> { + static const double _size = 18; + static const double _effectSize = _size * 2; + + int? _playingToken; + bool _bodyPlaying = false; + bool _effectPlaying = false; + + @override + void initState() { + super.initState(); + widget.animation?.addListener(_onAnimation); + } + + @override + void didUpdateWidget(_ReactionAnimojiGlyph oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.animation, widget.animation)) { + oldWidget.animation?.removeListener(_onAnimation); + widget.animation?.addListener(_onAnimation); + } + if (oldWidget.messageId != widget.messageId || + EmojiKeywordIndex.normalize(oldWidget.emoji) != + EmojiKeywordIndex.normalize(widget.emoji)) { + _playingToken = null; + _bodyPlaying = false; + _effectPlaying = false; + } + } + + @override + void dispose() { + widget.animation?.removeListener(_onAnimation); + super.dispose(); + } + + void _onAnimation() { + final event = widget.animation?.value; + if (event == null || + event.messageId != widget.messageId || + EmojiKeywordIndex.normalize(event.emoji) != + EmojiKeywordIndex.normalize(widget.emoji) || + event.token == _playingToken) { + return; + } + final bodyUrl = widget.animoji.lottieUrl; + final effectUrl = widget.animoji.lottiePlayUrl; + setState(() { + _playingToken = event.token; + _bodyPlaying = bodyUrl != null && bodyUrl.isNotEmpty; + _effectPlaying = effectUrl != null && effectUrl.isNotEmpty; + }); + } + + void _onBodyCompleted() { + if (!mounted) return; + setState(() { + _bodyPlaying = false; + if (!_effectPlaying) _playingToken = null; + }); + } + + void _onEffectCompleted() { + if (!mounted) return; + setState(() { + _effectPlaying = false; + if (!_bodyPlaying) _playingToken = null; + }); + } + + @override + Widget build(BuildContext context) { + final staticUrl = widget.animoji.iconUrl; + final bodyAnimationUrl = widget.animoji.lottieUrl; + final effectAnimationUrl = widget.animoji.lottiePlayUrl; + final Widget body; + if (_bodyPlaying) { + body = LottieImage( + key: ValueKey(('body', _playingToken)), + url: staticUrl, + lottieUrl: bodyAnimationUrl, + size: _size, + memCacheWidth: 64, + shimmer: false, + eager: true, + repeat: false, + onCompleted: _onBodyCompleted, + ); + } else if (staticUrl != null && staticUrl.isNotEmpty) { + body = LottieImage( + url: staticUrl, + size: _size, + memCacheWidth: 64, + shimmer: false, + ); + } else if (bodyAnimationUrl != null && bodyAnimationUrl.isNotEmpty) { + body = LottieImage( + lottieUrl: bodyAnimationUrl, + size: _size, + memCacheWidth: 64, + shimmer: false, + animate: false, + repeat: false, + ); + } else { + body = Text(widget.emoji, style: const TextStyle(fontSize: 13)); + } + + return SizedBox( + width: _size, + height: _size, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + body, + if (_effectPlaying) + Positioned( + left: -(_effectSize - _size) / 2, + top: -(_effectSize - _size) / 2, + width: _effectSize, + height: _effectSize, + child: LottieImage( + key: ValueKey(('effect', _playingToken)), + lottieUrl: effectAnimationUrl, + size: _effectSize, + memCacheWidth: 128, + shimmer: false, + eager: true, + repeat: false, + onCompleted: _onEffectCompleted, + ), + ), + ], + ), + ); + } +} + class MessageBubble extends StatelessWidget { static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); static const BorderRadius _reactionChipRadius = BorderRadius.all( @@ -124,16 +631,28 @@ class MessageBubble extends StatelessWidget { final CachedMessage? prevMessage; final CachedMessage? nextMessage; final String chatType; + final int? chatId; + final PhotoViewerActions? photoActions; final String? overrideStatus; final ValueListenable? otherReadTime; final ValueListenable?>? reactionsListenable; + final ValueListenable? reactionAnimation; + final ReactionAnimojiResolver? reactionAnimojiResolver; final ValueListenable>? uploadProgress; final void Function(String messageId)? onReplyTap; final void Function(int senderId)? onAvatarTap; + final ForwardedSourceTap? onForwardedSourceTap; final void Function(StickerAttachment sticker)? onStickerTap; final void Function(String emoji)? onReactionTap; final String? peerName; final String? peerAvatarUrl; + final String? senderNameOverride; + final String? senderAvatarOverride; + final ValueListenable<({String id, Offset pos})?>? textSelection; + final ValueListenable? textSelectionDrag; + final VoidCallback? onExitTextSelection; + final String? commentsLabel; + final VoidCallback? onCommentsTap; const MessageBubble({ super.key, @@ -143,34 +662,80 @@ class MessageBubble extends StatelessWidget { this.prevMessage, this.nextMessage, required this.chatType, + this.chatId, + this.photoActions, this.overrideStatus, this.otherReadTime, this.reactionsListenable, + this.reactionAnimation, + this.reactionAnimojiResolver, this.uploadProgress, this.onReplyTap, this.onAvatarTap, + this.onForwardedSourceTap, this.onStickerTap, this.onReactionTap, this.peerName, this.peerAvatarUrl, + this.senderNameOverride, + this.senderAvatarOverride, + this.textSelection, + this.textSelectionDrag, + this.onExitTextSelection, + this.commentsLabel, + this.onCommentsTap, }); bool _computeHasPhotoWithCaption() { - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) return false; + final attachments = _contentAttachments; + if (attachments.isEmpty) return false; final hasPhoto = attachments.any((a) => a is PhotoAttachment); - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasCaption = _contentText?.isNotEmpty ?? false; return hasPhoto && hasCaption; } bool _computeHasMultiplePhotosNoCaption() { - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) return false; + final attachments = _contentAttachments; + if (attachments.isEmpty) return false; final photoCount = attachments.whereType().length; - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasCaption = _contentText?.isNotEmpty ?? false; return photoCount >= 2 && !hasCaption; } + ForwardedMessageAttachment? get _forwarded => message.forwardedAttachment; + + List get _contentAttachments { + final forwarded = _forwarded; + if (forwarded != null) { + if (forwarded.originalContact != null) { + return [forwarded.originalContact!]; + } + return forwarded.originalAttachments + ?.where((a) => a is! InlineKeyboardAttachment) + .toList() ?? + const []; + } + return message.attachments + ?.where((a) => a is! InlineKeyboardAttachment) + .toList() ?? + const []; + } + + MessageAttachment? get _primaryAttachment { + final attachments = _contentAttachments; + return attachments.isEmpty ? null : attachments.first; + } + + String? get _contentText { + final forwarded = _forwarded; + return forwarded == null ? message.text : forwarded.originalText; + } + + bool get _showsSenderName => + !isMe && chatType == "CHAT" && prevMessage?.senderId != message.senderId; + + bool get _stretchesTextRow => message.replyInfo != null || _showsSenderName; + BubbleShape _computeShape() { if (message.isControl) return BubbleShape.singleMiddle; @@ -196,22 +761,15 @@ class MessageBubble extends StatelessWidget { } bool get _hasShareAttachment { - final a = message.attachments; - return a != null && a.isNotEmpty && a.first is ShareAttachment; + return _primaryAttachment is ShareAttachment; } bool get _isVideoNote { - final a = message.attachments; - if (a == null || a.isEmpty) return false; - final first = a.first; + final first = _primaryAttachment; return first is VideoAttachment && first.isNote; } - bool get _isSticker { - final a = message.attachments; - if (a == null || a.isEmpty) return false; - return a.first is StickerAttachment; - } + bool get _isSticker => _primaryAttachment is StickerAttachment; static const int _jumboAnimojiLimit = 4; @@ -240,26 +798,14 @@ class MessageBubble extends StatelessWidget { MessageType _computeContentType() { if (message.isControl) return MessageType.control; - final attachments = message.attachments - ?.where((a) => a is! InlineKeyboardAttachment) - .toList(); - if (attachments != null && attachments.isNotEmpty) { + final attachments = _contentAttachments; + if (attachments.isNotEmpty) { final first = attachments.first; - if (first is ForwardedMessageAttachment) { - final fwd = first; - final hasContact = fwd.originalContact != null; - final hasPhoto = - fwd.originalAttachments != null && - fwd.originalAttachments!.any((a) => a is PhotoAttachment); - final hasOther = - fwd.originalAttachments != null && - fwd.originalAttachments!.isNotEmpty; - if (hasContact || hasPhoto || hasOther) return MessageType.attachment; - return MessageType.text; - } if (first is ContactAttachment) return MessageType.attachment; if (first is UnknownAttachment) return MessageType.text; - if (first.type == AttachmentType.audio) return MessageType.voice; + if (first.type == AttachmentType.audio) { + return _forwarded == null ? MessageType.voice : MessageType.attachment; + } if (first is ShareAttachment) { return AppLinkPreview.current.value ? MessageType.attachment @@ -268,6 +814,8 @@ class MessageBubble extends StatelessWidget { return MessageType.attachment; } + if (_forwarded != null) return MessageType.text; + final payload = message.payload; if (payload == null) return MessageType.text; if (payload['voice'] != null) return MessageType.voice; @@ -356,6 +904,8 @@ class MessageBubble extends StatelessWidget { ); } + static const double _replyWidthShare = 0.75; + static const List _senderPalette = [ Color(0xFFE57373), Color(0xFF64B5F6), @@ -371,7 +921,7 @@ class MessageBubble extends StatelessWidget { _senderPalette[id.abs() % _senderPalette.length]; Widget _buildSenderHeader(ColorScheme cs, bool needsInset) { - final name = ContactCache.get(message.senderId); + final name = senderNameOverride ?? ContactCache.get(message.senderId); if (name == null || name.isEmpty) return const SizedBox.shrink(); final header = Padding( padding: needsInset @@ -399,8 +949,10 @@ class MessageBubble extends StatelessWidget { } Widget _buildLeadingAvatar(ColorScheme cs) { - final senderAvatar = ContactCache.getAvatar(message.senderId); - final displaySender = ContactCache.get(message.senderId); + final senderAvatar = + senderAvatarOverride ?? ContactCache.getAvatar(message.senderId); + final displaySender = + senderNameOverride ?? ContactCache.get(message.senderId); final Widget avatar; if (senderAvatar != null && senderAvatar.isNotEmpty) { avatar = CircleAvatar( @@ -450,6 +1002,7 @@ class MessageBubble extends StatelessWidget { final contentType = _contentType; if (message.isControl) { + if (message.isSilentBotStart) return const SizedBox.shrink(); const controlShape = BubbleShape.singleMiddle; return Padding( padding: EdgeInsets.only( @@ -461,7 +1014,14 @@ class MessageBubble extends StatelessWidget { } final shape = _computeShape(); - final hasPhotoCap = _computeHasPhotoWithCaption(); + final hasReactions = _hasReactions(); + final hasPhotoCap = + _computeHasPhotoWithCaption() || + (contentType == MessageType.attachment && + hasReactions && + !_isSticker && + !_isVideoNote && + _jumboAnimojiUrls == null); final hasMultiPhotos = _computeHasMultiplePhotosNoCaption(); final textColor = bubbleTextColor(context); @@ -477,23 +1037,25 @@ class MessageBubble extends StatelessWidget { showAvatarSlot && chatType == "CHAT" && nextMessage?.senderId != message.senderId; - final showSenderName = - showAvatarSlot && - chatType == "CHAT" && - prevMessage?.senderId != message.senderId; + final showSenderName = _showsSenderName; - final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0); final keyboard = _inlineKeyboard; final isVideoNote = _isVideoNote; - final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null; + final screenWidth = MediaQuery.sizeOf(context).width; + final maxBubbleWidth = isVideoNote + ? math.min(screenWidth - 24, 560.0) + : math.min(screenWidth * 0.75, 560.0); + final noBubbleBackground = + isVideoNote || _isSticker || jumboAnimoji != null; final bubbleColor = noBubbleBackground ? Colors.transparent : (isMe ? cs.primaryContainer : cs.surfaceContainerHighest); - BubbleContext makeCtx() => BubbleContext( + BubbleContext makeCtx({bool metaInFooter = false}) => BubbleContext( context: context, cs: cs, text: textColor, + metaInFooter: metaInFooter, shape: shape, contentType: contentType, hasPhotoWithCaption: hasPhotoCap, @@ -502,47 +1064,123 @@ class MessageBubble extends StatelessWidget { isMe: isMe, myId: myId, chatType: chatType, + chatId: chatId, + chatName: peerName, + photoActions: photoActions, overrideStatus: overrideStatus, otherReadTime: otherReadTime, uploadProgress: uploadProgress, onStickerTap: onStickerTap, + onForwardedSourceTap: onForwardedSourceTap, reactionInfo: _resolveReactionInfo(), + selectable: _wrapSelectable, ); - final Widget bubbleContent = - reactionsListenable != null && contentType == MessageType.text + final reactionsInside = contentType != MessageType.text; + + final reply = message.replyInfo; + + final bool hasCommentsFooter = onCommentsTap != null; + final EdgeInsets containerPadding = hasCommentsFooter + ? EdgeInsets.zero + : padding; + + final Widget contentWithReactions = reactionsInside + ? _contentWithReactionsFooter( + cs, + makeCtx, + inset: padding == EdgeInsets.zero + ? const EdgeInsets.fromLTRB(8, 4, 8, 6) + : const EdgeInsets.only(top: 4), + ) + : reactionsListenable != null ? ValueListenableBuilder?>( valueListenable: reactionsListenable!, builder: (context, _, _) => _buildContent(makeCtx()), ) : _buildContent(makeCtx()); - final reactionsUnder = _reactionsUnderBubble(contentType); - final reactionsInside = contentType != MessageType.text && !reactionsUnder; + final Widget? senderHeader = showSenderName + ? _buildSenderHeader(cs, padding == EdgeInsets.zero) + : null; - final reply = message.replyInfo; - Widget withReply(Widget content) { - if (reply == null) return content; - final quote = _buildReplyQuote(context, cs, textColor, reply); - if (contentType != MessageType.text || jumboAnimoji != null) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [quote, const SizedBox(height: 4), content], - ); - } - return IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _ZeroIntrinsicWidth(child: quote), - const SizedBox(height: 4), - content, - ], + final Widget innerContent = + contentType == MessageType.text && + jumboAnimoji == null && + _stretchesTextRow + ? IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (senderHeader != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: senderHeader, + ), + if (reply != null) ...[ + _CapIntrinsicWidth( + cap: maxBubbleWidth * _replyWidthShare, + child: _buildReplyQuote(context, cs, textColor, reply), + ), + const SizedBox(height: 4), + ], + contentWithReactions, + ], + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ?senderHeader, + if (reply == null) + contentWithReactions + else + _HeaderAboveMatchWidth( + content: contentWithReactions, + header: Padding( + padding: EdgeInsets.only( + left: padding == EdgeInsets.zero ? 8 : 0, + right: padding == EdgeInsets.zero ? 8 : 0, + bottom: 4, + ), + child: _buildReplyQuote(context, cs, textColor, reply), + ), + ), + ], + ); + + final Widget bubbleBox = ListenableBuilder( + listenable: Listenable.merge([ + AppBubbleShape.current, + AppBubbleBehavior.current, + ]), + builder: (context, child) => Container( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: noBubbleBackground + ? null + : _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, + ), ), - ); - } + padding: containerPadding, + child: child, + ), + child: hasCommentsFooter + ? _StackMatchTopWidth( + growForBottom: true, + top: Padding(padding: padding, child: innerContent), + bottom: _buildCommentsFooter(cs), + ) + : innerContent, + ); return Padding( padding: EdgeInsets.only( @@ -562,61 +1200,19 @@ class MessageBubble extends StatelessWidget { if (showAvatar) _buildLeadingAvatar(cs) else if (showAvatarSlot && chatType == "CHAT") - const CircleAvatar( - radius: 15, - backgroundColor: Color(0x00000000), - ), + const SizedBox(width: 30), Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - ListenableBuilder( - listenable: Listenable.merge([ - AppBubbleShape.current, - AppBubbleBehavior.current, - ]), - builder: (context, child) => Container( - constraints: BoxConstraints(maxWidth: maxBubbleWidth), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: noBubbleBackground - ? null - : _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, - ), - ), - padding: padding, - child: child, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showSenderName) - _buildSenderHeader(cs, padding == EdgeInsets.zero), - withReply( - reactionsInside - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [bubbleContent, _reactionsBar(cs)], - ) - : bubbleContent, - ), - ], - ), - ), if (keyboard != null) - ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxBubbleWidth), - child: _buildInlineKeyboard(context, cs, keyboard), - ), - if (reactionsUnder) _reactionsBar(cs), + _StackMatchTopWidth( + top: bubbleBox, + bottom: _buildInlineKeyboard(context, cs, keyboard), + ) + else + bubbleBox, ], ), ], @@ -625,6 +1221,51 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildCommentsFooter(ColorScheme cs) { + final label = commentsLabel ?? 'Комментарии'; + final accent = isMe ? cs.onPrimaryContainer : cs.primary; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onCommentsTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Divider( + height: 0.5, + thickness: 0.5, + color: cs.onSurfaceVariant.withValues(alpha: 0.18), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Row( + children: [ + Icon(Symbols.mode_comment, size: 19, color: accent), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: accent, + ), + ), + ), + Icon( + Symbols.chevron_right, + size: 20, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + ), + ], + ), + ), + ], + ), + ), + ); + } + Widget _buildInlineKeyboard( BuildContext context, ColorScheme cs, @@ -671,37 +1312,55 @@ class MessageBubble extends StatelessWidget { 'OPEN_APP' => Symbols.chevron_right, _ => null, }; + final isClipboard = button.type == 'CLIPBOARD'; return Material( color: cs.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => _onInlineButtonTap(context, keyboard, button), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - button.text, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.primary, - fontSize: 14, - fontWeight: FontWeight.w600, + child: Stack( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: isClipboard ? 26 : 12, + vertical: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + button.text, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), ), + if (trailingIcon != null) ...[ + const SizedBox(width: 4), + Icon(trailingIcon, size: 16, color: cs.primary), + ], + ], + ), + ), + if (isClipboard) + Positioned( + top: 6, + right: 8, + child: Icon( + Symbols.content_copy, + size: 15, + weight: 500, + color: cs.primary.withValues(alpha: 0.85), ), ), - if (trailingIcon != null) ...[ - const SizedBox(width: 4), - Icon(trailingIcon, size: 16, color: cs.primary), - ], - ], - ), + ], ), ), ); @@ -722,6 +1381,11 @@ class MessageBubble extends StatelessWidget { case 'OPEN_APP': await _openMiniApp(context, button); return; + case 'CLIPBOARD': + final payload = button.payload; + if (payload == null || payload.isEmpty) return; + await copyTextEntity(context, payload, 'Скопировано'); + return; default: final callbackId = keyboard.callbackId; if (callbackId == null || callbackId.isEmpty) { @@ -783,6 +1447,7 @@ class MessageBubble extends StatelessWidget { MaterialPageRoute( builder: (_) => WebAppScreen( title: button.text, + entryPoint: WebAppEntryPoint.inlineButton, loader: () => webAppModule.fetchLaunch( botId, startParam: startParam, @@ -804,25 +1469,75 @@ class MessageBubble extends StatelessWidget { return null; } - bool _reactionsUnderBubble(MessageType contentType) { - if (contentType != MessageType.attachment) return false; - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) return false; - if (attachments.first is ForwardedMessageAttachment) return false; - if (attachments.any((a) => a is ContactAttachment)) return false; - if (attachments.whereType().length >= 2) return false; - return true; + bool _hasReactions() { + final info = ReactionInfo.fromMap(_resolveReactionInfo()); + return info != null && info.counters.isNotEmpty; } - Widget _reactionsBar(ColorScheme cs) { + Widget _contentWithReactionsFooter( + ColorScheme cs, + BubbleContext Function({bool metaInFooter}) makeCtx, { + required EdgeInsets inset, + }) { final listenable = reactionsListenable; if (listenable != null) { return ValueListenableBuilder?>( valueListenable: listenable, - builder: (context, info, _) => _buildReactionsBarFor(cs, info), + builder: (context, info, _) => + _reactionsFooterLayout(cs, makeCtx, info, inset: inset), ); } - return _buildReactionsBar(cs); + final info = message.payload?['reactionInfo']; + return _reactionsFooterLayout( + cs, + makeCtx, + info is Map ? info : null, + inset: inset, + ); + } + + Widget _reactionsFooterLayout( + ColorScheme cs, + BubbleContext Function({bool metaInFooter}) makeCtx, + Map? info, { + required EdgeInsets inset, + }) { + final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info)); + if (chips.isEmpty) return _buildContent(makeCtx()); + + final carriesMeta = + _contentType == MessageType.attachment || + _contentType == MessageType.voice; + final ctx = makeCtx(metaInFooter: carriesMeta); + + return IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildContent(ctx), + Padding( + padding: inset, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: _ReactionsWrap( + spacing: 4, + runSpacing: 4, + children: chips, + ), + ), + if (carriesMeta) ...[ + const SizedBox(width: 8), + ctx.footerMeta(), + ], + ], + ), + ), + ], + ), + ); } Widget _buildContent(BubbleContext ctx) { @@ -909,7 +1624,10 @@ class MessageBubble extends StatelessWidget { ), if (ctx.isMe) ...[ const SizedBox(width: 3), - Icon(statusVisual.icon, size: 13, color: statusVisual.color), + if (isSendingStatus(status)) + SendingClockIcon(color: statusVisual.color, size: 13) + else + Icon(statusVisual.icon, size: 13, color: statusVisual.color), ], if (ctx.message.deleted) ...[ const SizedBox(width: 3), @@ -920,16 +1638,15 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildReactionsBar(ColorScheme cs) { - final info = message.payload?['reactionInfo']; - return _buildReactionsBarFor(cs, info is Map ? info : null); - } - - Widget _buildReactionsBarFor(ColorScheme cs, Map? info) { + Widget _buildReactionsBarFor( + ColorScheme cs, + Map? info, { + EdgeInsets inset = const EdgeInsets.only(top: 4), + }) { final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info)); if (chips.isEmpty) return const SizedBox.shrink(); return Padding( - padding: const EdgeInsets.only(top: 4), + padding: inset, child: Wrap(spacing: 4, runSpacing: 4, children: chips), ); } @@ -964,7 +1681,16 @@ class MessageBubble extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(c.reaction, style: const TextStyle(fontSize: 13)), + if (_resolveReactionAnimoji(c.reaction) case final animoji?) + _ReactionAnimojiGlyph( + key: ValueKey((message.id, c.reaction)), + messageId: message.id, + emoji: c.reaction, + animoji: animoji, + animation: reactionAnimation, + ) + else + Text(c.reaction, style: const TextStyle(fontSize: 13)), if (c.count > 1) ...[ const SizedBox(width: 3), Text( @@ -995,6 +1721,9 @@ class MessageBubble extends StatelessWidget { return chips; } + Animoji? _resolveReactionAnimoji(String emoji) => + reactionAnimojiResolver?.call(emoji) ?? animojiModule.findByEmoji(emoji); + Widget _reactionAvatar(ColorScheme cs, String? url, String? name) { const double diameter = 17; if (url != null && url.isNotEmpty) { @@ -1021,68 +1750,45 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildControlContent(ColorScheme cs) { - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) { - return const SizedBox.shrink(); + Widget _buildControlContent(ColorScheme cs) => ControlBubble( + key: ValueKey('control_${message.id}'), + message: message, + cs: cs, + onUserTap: onAvatarTap, + ); + + Widget _wrapSelectable(Widget textWidget) { + final listenable = textSelection; + if (listenable == null || message.selectableText == null) { + return textWidget; } - - final control = attachments.first; - if (control is! ControlAttachment) return const SizedBox.shrink(); - - String? text; - switch (control.event) { - case 'system': - text = control.title; - break; - case 'new': - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат'; - break; - case 'add': - final names = (control.userIds ?? []) - .map((id) => ContactCache.get(id) ?? 'Пользователь') - .join(', '); - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names'; - break; - case 'leave': - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат'; - break; - case 'joinByLink': - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату'; - break; - case 'pin': - text = - '${ContactCache.get(message.senderId) ?? 'Пользователь'} закрепил(а) сообщение'; - break; - default: - text = control.title; - } - - if (text == null || text.isEmpty) return const SizedBox.shrink(); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - text, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontStyle: FontStyle.italic, - ), - textAlign: TextAlign.center, - ), + return ValueListenableBuilder<({String id, Offset pos})?>( + valueListenable: listenable, + builder: (context, req, child) { + if (req == null || req.id != message.id) return child!; + return SelectableMessageText( + initialGlobalPosition: req.pos, + dragPosition: textSelectionDrag, + onExit: onExitTextSelection ?? () {}, + child: child!, + ); + }, + child: textWidget, ); } - Widget _buildTextContent(BubbleContext ctx) { + Widget _buildTextContent(BubbleContext ctx) => DecryptedContent( + accountId: message.accountId, + chatId: message.chatId, + messageId: message.id, + cipherText: message.text ?? '', + builder: (decryption) => _buildTextContentBody(ctx, decryption), + ); + + Widget _buildTextContentBody( + BubbleContext ctx, + MessageDecryption? decryption, + ) { final attachments = message.attachments; final isForwardedContact = attachments != null && @@ -1091,7 +1797,7 @@ class MessageBubble extends StatelessWidget { (attachments.first as ForwardedMessageAttachment).originalContact != null; - final forwarded = _getForwardedAttachment(); + final forwarded = message.forwardedAttachment; final isForwarded = forwarded != null && !isForwardedContact; final reactionChips = _buildReactionChipsFor( @@ -1100,22 +1806,64 @@ class MessageBubble extends StatelessWidget { ); final hasReactions = reactionChips.isNotEmpty; - final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); - final ranges = message.formatRanges; - final textWidget = isForwarded - ? _buildForwardedInlineText(ctx, forwarded) - : (FormattedMessageText.isFormatted(message.text, ranges) - ? FormattedMessageText( - text: message.text!, - ranges: ranges, - style: textStyle, - ) - : Text(message.text ?? '', style: textStyle)); - - final metaWidget = Text( - message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText, - style: TextStyle(color: ctx.dim, fontSize: 10), + final activeFontFamily = Theme.of( + ctx.context, + ).textTheme.bodyLarge?.fontFamily; + final textStyle = TextStyle( + color: ctx.text, + fontSize: 16, + height: 1.3, + fontFamily: activeFontFamily, + fontVariations: activeFontFamily == 'Inter' + ? const [FontVariation('wght', 300)] + : null, ); + final ranges = message.formatRanges; + final decryptedText = decryption?.plaintext; + + final metaRow = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (decryption?.isDecrypted ?? false) ...[ + Icon(Symbols.lock, size: 11, weight: 700, fill: 1, color: ctx.dim), + const SizedBox(width: 3), + ], + Text( + message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText, + style: TextStyle(color: ctx.dim, fontSize: 10), + ), + if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()], + if (message.deleted) ...[const SizedBox(width: 4), ctx.deletedIcon()], + ], + ); + + final Widget textWidget; + if (decryption?.state == MessageDecryptionState.wrongKey) { + textWidget = _wrapSelectable( + Text( + 'неверный ключ', + style: textStyle.copyWith( + color: ctx.cs.error, + fontStyle: FontStyle.italic, + ), + ), + ); + } else if (decryptedText != null) { + textWidget = _wrapSelectable(Text(decryptedText, style: textStyle)); + } else if (isForwarded) { + textWidget = _buildForwardedInlineText(ctx, forwarded); + } else if (FormattedMessageText.isFormatted(message.text, ranges)) { + textWidget = _wrapSelectable( + FormattedMessageText( + text: message.text!, + ranges: ranges, + style: textStyle, + ), + ); + } else { + textWidget = _wrapSelectable(Text(message.text ?? '', style: textStyle)); + } if (hasReactions) { return IntrinsicWidth( @@ -1137,13 +1885,8 @@ class MessageBubble extends StatelessWidget { const SizedBox(width: 8), Padding( padding: const EdgeInsets.only(bottom: 2), - child: metaWidget, + child: metaRow, ), - if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()], - if (message.deleted) ...[ - const SizedBox(width: 4), - ctx.deletedIcon(), - ], ], ), ], @@ -1151,28 +1894,7 @@ class MessageBubble extends StatelessWidget { ); } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible(child: textWidget), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: metaWidget, - ), - if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()], - if (message.deleted) ...[ - const SizedBox(width: 4), - ctx.deletedIcon(), - ], - ], - ), - ], - ); + return _TextWithMeta(text: textWidget, meta: metaRow); } Widget _buildReplyQuote( @@ -1181,11 +1903,33 @@ class MessageBubble extends StatelessWidget { Color textColor, ReplyInfo reply, ) { - final accent = isMe ? cs.onPrimaryContainer : cs.primary; + final accent = _senderColor(reply.senderId); final name = reply.senderId == myId ? 'Вы' : (ContactCache.get(reply.senderId) ?? 'Сообщение'); - final preview = reply.previewText(); + final rawPreview = reply.previewText(); + final quotedId = reply.messageId; + + if (reply.missing) { + return Container( + padding: const EdgeInsets.fromLTRB(8, 3, 8, 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: accent.withValues(alpha: 0.10), + border: Border(left: BorderSide(color: accent, width: 3)), + ), + child: Text( + 'сообщение удалено', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: textColor.withValues(alpha: 0.7), + fontSize: 13, + fontStyle: FontStyle.italic, + ), + ), + ); + } final quote = Container( padding: const EdgeInsets.fromLTRB(8, 3, 8, 3), @@ -1208,14 +1952,28 @@ class MessageBubble extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - if (preview.isNotEmpty) - Text( - preview, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: textColor.withValues(alpha: 0.85), - fontSize: 13, + if (rawPreview.isNotEmpty) + DecryptedContent( + accountId: message.accountId, + chatId: message.chatId, + messageId: quotedId ?? '', + cipherText: quotedId == null ? '' : rawPreview, + builder: (decryption) => Text( + decryption?.state == MessageDecryptionState.wrongKey + ? 'неверный ключ' + : (decryption?.plaintext ?? rawPreview), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: decryption?.state == MessageDecryptionState.wrongKey + ? cs.error + : textColor.withValues(alpha: 0.85), + fontSize: 13, + fontStyle: + decryption?.state == MessageDecryptionState.wrongKey + ? FontStyle.italic + : null, + ), ), ), ], @@ -1238,84 +1996,35 @@ class MessageBubble extends StatelessWidget { BubbleContext ctx, ForwardedMessageAttachment forwarded, ) { - final headerColor = ctx.dim; - final displaySender = - forwarded.originalSenderName ?? - ContactCache.get(forwarded.originalSenderId) ?? - forwarded.originalSenderId.toString(); - final senderAvatar = - forwarded.originalSenderAvatar ?? - ContactCache.getAvatar(forwarded.originalSenderId); final origText = forwarded.originalText; final hasOrigText = origText != null && origText.isNotEmpty; + final forwardedCtx = _forwardedContext(ctx, forwarded); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - if (senderAvatar != null && senderAvatar.isNotEmpty) - CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - senderAvatar, - maxWidth: 96, - maxHeight: 96, - ), - backgroundColor: ctx.cs.primaryContainer, - ) - else - CircleAvatar( - radius: 10, - backgroundColor: ctx.cs.primaryContainer, - child: Text( - displaySender.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle( - fontSize: 9, - color: ctx.cs.onPrimaryContainer, - ), - ), - ), - const SizedBox(width: 6), - Text( - displaySender, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], + ForwardedHeader( + ctx: ctx, + forwarded: forwarded, + padding: EdgeInsets.zero, ), if (hasOrigText) ...[ const SizedBox(height: 2), - Text(origText, style: TextStyle(color: ctx.text, fontSize: 14)), + forwardedCtx.caption(), ] else ...[ const SizedBox(height: 2), - Text( - message.text ?? '', - style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), + _wrapSelectable( + Text( + message.text ?? '', + style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), + ), ), ], ], ); } - ForwardedMessageAttachment? _getForwardedAttachment() { - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) return null; - for (final a in attachments) { - if (a is ForwardedMessageAttachment) return a; - } - return null; - } - Widget _buildAttachmentContent(BubbleContext ctx) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { @@ -1324,35 +2033,76 @@ class MessageBubble extends StatelessWidget { final first = attachments.first; if (first is ForwardedMessageAttachment) { - final fwd = first; - if (fwd.originalContact != null) { - return ForwardedContactBubble(ctx: ctx, forwarded: fwd); - } - final photos = fwd.originalAttachments - ?.whereType() - .toList(); - if (photos != null && photos.isNotEmpty) { - return ForwardedPhotoBubble(ctx: ctx, forwarded: fwd, photos: photos); - } - final stickers = fwd.originalAttachments - ?.whereType() - .toList(); - if (stickers != null && stickers.isNotEmpty) { - return ForwardedStickerBubble( - ctx: ctx, - forwarded: fwd, - sticker: stickers.first, - ); - } - final files = fwd.originalAttachments; - if (files != null && files.isNotEmpty) { - return ForwardedGenericBubble( - ctx: ctx, - forwarded: fwd, - attachments: files, - ); - } - return _buildTextContent(ctx); + return _buildForwardedAttachmentContent(ctx, first); + } + + return _buildNativeAttachmentContent(ctx, attachments); + } + + BubbleContext _forwardedContext( + BubbleContext ctx, + ForwardedMessageAttachment forwarded, + ) => ctx.withPresentation( + BubblePresentation( + text: forwarded.originalText, + formatRanges: forwarded.originalFormatRanges, + sourceMessageId: forwarded.originalMessageId, + sourceChatId: forwarded.originalChatId, + ), + ); + + Widget _buildForwardedAttachmentContent( + BubbleContext ctx, + ForwardedMessageAttachment forwarded, + ) { + final forwardedCtx = _forwardedContext(ctx, forwarded); + final attachments = + forwarded.originalAttachments + ?.where((a) => a is! InlineKeyboardAttachment) + .toList() ?? + const []; + final content = _buildNativeAttachmentContent( + forwardedCtx, + attachments, + contact: forwarded.originalContact, + hasContentAbove: true, + ); + final primary = + forwarded.originalContact ?? + (attachments.isEmpty ? null : attachments.first); + final floatingHeader = + primary is StickerAttachment || + (primary is VideoAttachment && primary.isNote); + + if (floatingHeader) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ForwardedHeaderFloating(ctx: ctx, forwarded: forwarded), + const SizedBox(height: 6), + content, + ], + ); + } + + return _HeaderAboveMatchWidth( + content: content, + header: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ForwardedHeader(ctx: ctx, forwarded: forwarded), + ), + ); + } + + Widget _buildNativeAttachmentContent( + BubbleContext ctx, + List attachments, { + ContactAttachment? contact, + bool hasContentAbove = false, + }) { + if (contact != null) { + return ContactBubble(ctx: ctx, contact: contact); } final contacts = attachments.whereType().toList(); @@ -1375,7 +2125,11 @@ class MessageBubble extends StatelessWidget { return _buildGenericAttachment(ctx, attachments.first); } - return PhotoBubble(ctx: ctx, photos: photos); + return PhotoBubble( + ctx: ctx, + photos: photos, + hasContentAbove: hasContentAbove, + ); } Widget _buildGenericAttachment( @@ -1396,30 +2150,35 @@ class MessageBubble extends StatelessWidget { ); case AttachmentType.call: return CallBubble(ctx: ctx, call: attachment as CallAttachment); + case AttachmentType.audio: + return Padding( + padding: _paddingFor(MessageType.voice, ctx.shape), + child: _buildVoiceAttachment(ctx, attachment as AudioAttachment), + ); default: return _buildTextContent(ctx); } } Widget _buildVoiceContent(BubbleContext ctx) { - int duration = 0; - String url = ''; - String? waveData; - int? audioId; - + AudioAttachment? audio; final attaches = message.attachments; if (attaches != null && attaches.isNotEmpty) { for (final a in attaches) { if (a is AudioAttachment) { - duration = ((a.duration ?? 0) / 1000).round(); - url = a.fileUrl ?? a.baseUrl ?? ''; - waveData = a.waveform; - audioId = a.audioId; + audio = a; break; } } } + return _buildVoiceAttachment(ctx, audio); + } + + Widget _buildVoiceAttachment(BubbleContext ctx, AudioAttachment? audio) { + var duration = ((audio?.duration ?? 0) / 1000).round(); + var url = audio?.fileUrl ?? audio?.baseUrl ?? ''; + if (duration == 0 && url.isEmpty) { final payload = message.payload; final voice = payload?['voice'] as Map?; @@ -1427,7 +2186,7 @@ class MessageBubble extends StatelessWidget { url = voice?['url']?.toString() ?? ''; } - final cachedTranscription = TranscriptionCache.get(message.id); + final cachedTranscription = TranscriptionCache.get(ctx.sourceMessageId); return VoiceMessageBubble( duration: duration, @@ -1439,11 +2198,15 @@ class MessageBubble extends StatelessWidget { otherReadTime: otherReadTime, time: message.time, cs: ctx.cs, - waveData: waveData, + waveData: audio?.waveform, chatId: message.chatId, messageId: message.id, - audioId: audioId, + sourceChatId: ctx.sourceChatId, + sourceMessageId: ctx.sourceMessageId, + senderId: message.senderId, + audioId: audio?.audioId, preloadedText: cachedTranscription?.text, + uploadProgress: ctx.uploadProgress, ); } } diff --git a/lib/frontend/widgets/online_dot.dart b/lib/frontend/widgets/online_dot.dart index 14c6bce..2b5b05a 100644 --- a/lib/frontend/widgets/online_dot.dart +++ b/lib/frontend/widgets/online_dot.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/cache/info_cache.dart'; +import '../../core/config/app_colors.dart'; class OnlineDot extends StatelessWidget { final int userId; @@ -14,7 +15,7 @@ class OnlineDot extends StatelessWidget { required this.userId, required this.borderColor, this.size = 12, - this.color = const Color(0xFF2EC36B), + this.color = kSuccessGreen, this.borderWidth = 2, }); diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index a82cc7c..a45abdd 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -1,57 +1,1611 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:video_player/video_player.dart'; -class PhotoViewerScreen extends StatelessWidget { - final String baseUrl; +import '../../backend/modules/messages.dart'; +import '../../backend/modules/shared_content.dart'; +import '../../core/cache/info_cache.dart'; +import '../../core/config/app_frost.dart'; +import '../../core/utils/download_history.dart'; +import '../../core/utils/format.dart'; +import '../../core/utils/media_cache.dart'; +import '../../core/utils/media_saver.dart'; +import '../../core/utils/save_file_as.dart'; +import '../../l10n/app_localizations.dart'; +import '../../core/config/app_colors.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'; +import 'small_spinner.dart'; - const PhotoViewerScreen({super.key, required this.baseUrl}); +class PhotoViewerActions { + final void Function(String messageId, int time)? goToMessage; + final void Function(String messageId)? forward; + final void Function(String messageId, int senderId)? delete; + final VoidCallback? viewAllMedia; - String get _url => baseUrl; + const PhotoViewerActions({ + this.goToMessage, + this.forward, + this.delete, + this.viewAllMedia, + }); + + bool get isEmpty => + goToMessage == null && + forward == null && + delete == null && + viewAllMedia == null; +} + +class _ViewerMedia { + final String id; + final MessageAttachment attachment; + final String messageId; + final int senderId; + final int time; + final String? caption; + + const _ViewerMedia({ + required this.id, + required this.attachment, + required this.messageId, + required this.senderId, + required this.time, + this.caption, + }); + + factory _ViewerMedia.fromFeed(SharedMediaItem item) => _ViewerMedia( + id: item.dedupKey, + attachment: item.attachment, + messageId: item.messageId, + senderId: item.senderId, + time: item.time, + caption: item.text, + ); + + PhotoAttachment? get photo => + attachment is PhotoAttachment ? attachment as PhotoAttachment : null; + + VideoAttachment? get video => + attachment is VideoAttachment ? attachment as VideoAttachment : null; + + bool get isVideo => attachment is VideoAttachment; +} + +class PhotoViewerScreen extends StatefulWidget { + final List photos; + final VideoAttachment? video; + final Map initialVideoSources; + final String? initialVideoQuality; + final int initialIndex; + final int? chatId; + final CachedMessage? message; + final PhotoViewerActions? actions; + final PhotoHeroController? hero; + final bool isFile; + final String? sourceName; + + const PhotoViewerScreen({ + super.key, + required this.photos, + this.initialIndex = 0, + this.chatId, + this.message, + this.actions, + this.hero, + this.isFile = false, + this.sourceName, + }) : video = null, + initialVideoSources = const {}, + initialVideoQuality = null; + + const PhotoViewerScreen.video({ + super.key, + required VideoAttachment attachment, + required this.initialVideoSources, + this.initialVideoQuality, + this.chatId, + this.message, + this.actions, + this.sourceName, + }) : photos = const [], + video = attachment, + initialIndex = 0, + hero = null, + isFile = false; + + PhotoViewerScreen.single(String baseUrl, {super.key}) + : photos = [PhotoAttachment(baseUrl: baseUrl)], + video = null, + initialVideoSources = const {}, + initialVideoQuality = null, + initialIndex = 0, + chatId = null, + message = null, + actions = null, + hero = null, + isFile = false, + sourceName = null; + + @override + State createState() => _PhotoViewerScreenState(); +} + +class _PhotoViewerScreenState extends State { + static const int _prefetchThreshold = 3; + static const int _maxCachedVideoPlayers = 5; + + late PageController _controller; + late List<_ViewerMedia> _items; + late int _index; + late final String _heroId; + late final String _initialMediaId; + int _pager = 0; + final Map _quarterTurns = {}; + final LinkedHashMap _videoSessions = + LinkedHashMap(); + final Map> _videoSourceCache = {}; + final Map>> _videoSourceLoads = {}; + final TransformationController _heroTransform = TransformationController(); + bool _feedLoaded = false; + bool _feedFailed = false; + bool _loadingMore = false; + bool _reachedEnd = false; + bool _chromeVisible = true; + int _total = 0; + bool _saving = false; + + @override + void initState() { + super.initState(); + _heroTransform.addListener(_syncHero); + _items = _localItems(); + _index = widget.video == null + ? (_items.length - 1 - widget.initialIndex).clamp(0, _items.length - 1) + : 0; + _heroId = _items[_index].id; + _initialMediaId = _heroId; + _controller = PageController(initialPage: _index); + unawaited(_loadFeed()); + } + + void _syncHero() { + final hero = widget.hero; + if (hero == null) return; + hero.enabled = + _current.id == _heroId && + !_current.isVideo && + (_quarterTurns[_heroId] ?? 0) == 0 && + _heroTransform.value.getMaxScaleOnAxis() <= 1.01; + } + + @override + void dispose() { + _controller.dispose(); + _heroTransform.dispose(); + for (final session in _videoSessions.values) { + session.dispose(); + } + super.dispose(); + } + + List<_ViewerMedia> _localItems() { + final message = widget.message; + final video = widget.video; + if (video != null) { + return [ + _ViewerMedia( + id: _localId(video, message, 0), + attachment: video, + messageId: message?.id ?? '', + senderId: message?.senderId ?? 0, + time: message?.time ?? 0, + caption: message?.text, + ), + ]; + } + return [ + for (var i = widget.photos.length - 1; i >= 0; i--) + _ViewerMedia( + id: _localId(widget.photos[i], message, i), + attachment: widget.photos[i], + messageId: message?.id ?? '', + senderId: message?.senderId ?? 0, + time: message?.time ?? 0, + caption: message?.text, + ), + ]; + } + + List<_ViewerMedia> _feedItems(List items) { + final out = <_ViewerMedia>[]; + var start = 0; + while (start < items.length) { + var end = start; + while (end + 1 < items.length && + items[end + 1].messageId == items[start].messageId) { + end++; + } + for (var i = end; i >= start; i--) { + out.add(_ViewerMedia.fromFeed(items[i])); + } + start = end + 1; + } + return out; + } + + String _localId( + MessageAttachment attachment, + CachedMessage? message, + int at, + ) { + return _feedKey(attachment, message) ?? 'local:${message?.id ?? ''}:$at'; + } + + String? _feedKey(MessageAttachment attachment, CachedMessage? message) { + if (message == null || widget.chatId == null) return null; + if (attachment is PhotoAttachment && + attachment.photoId == null && + (attachment.baseUrl ?? '').isEmpty) { + return null; + } + if (attachment is VideoAttachment && + attachment.videoId == null && + (attachment.baseUrl ?? '').isEmpty) { + return null; + } + return mediaDedupKey(message.id, attachment); + } + + _ViewerMedia get _current => _items[_index]; + + bool get _feedPending => + !_feedLoaded && + !_feedFailed && + widget.chatId != null && + _feedKey(_items[_index].attachment, widget.message) != null; + + Future _loadFeed() async { + final chatId = widget.chatId; + final key = _feedKey(_items[_index].attachment, widget.message); + if (chatId == null || key == null) return; + + final feed = await sharedContentModule.mediaFeedFor( + chatId: chatId, + mediaKey: key, + resolveAnchor: () => _resolveAnchor(chatId), + ); + if (!mounted) return; + if (feed == null) { + setState(() => _feedFailed = true); + return; + } + + final items = _feedItems(feed.items); + final at = items.indexWhere((i) => i.id == key); + if (at == -1) { + setState(() => _feedFailed = true); + return; + } + + _adoptFeed(items, at, feed); + } + + void _adoptFeed(List<_ViewerMedia> items, int at, ChatMediaFeed feed) { + final movesPage = at != _index; + final previous = _controller; + + setState(() { + _items = items; + _index = at; + _total = feed.total; + _reachedEnd = feed.reachedEnd; + _feedLoaded = true; + if (movesPage) { + _pager++; + _controller = PageController(initialPage: at); + } + }); + + _syncHero(); + if (movesPage) { + WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose()); + } + } + + Future _loadMore() async { + final chatId = widget.chatId; + if (chatId == null || _loadingMore || _reachedEnd || !_feedLoaded) return; + _loadingMore = true; + try { + final feed = await sharedContentModule.loadMoreMedia( + chatId: chatId, + resolveAnchor: () => _resolveAnchor(chatId), + ); + if (!mounted) return; + + final items = _feedItems(feed.items); + final at = items.indexWhere((i) => i.id == _current.id); + if (at == -1) { + setState(() { + _total = feed.total; + _reachedEnd = feed.reachedEnd; + }); + return; + } + _adoptFeed(items, at, feed); + } finally { + _loadingMore = false; + } + } + + Future _resolveAnchor(int chatId) async { + final info = await ChatInfoFetch.get(chatId); + final lastMessage = info?.raw['lastMessage']; + if (lastMessage is Map) { + final id = lastMessage['id']?.toString(); + if (id != null && id.isNotEmpty) return id; + } + return widget.message?.id; + } + + Future> _loadVideoSources(_ViewerMedia item) async { + final cached = _videoSourceCache[item.id]; + if (cached != null) return cached; + final pending = _videoSourceLoads[item.id]; + if (pending != null) return pending; + if (item.id == _initialMediaId && widget.initialVideoSources.isNotEmpty) { + _videoSourceCache[item.id] = widget.initialVideoSources; + return widget.initialVideoSources; + } + final video = item.video; + final videoId = video?.videoId; + final token = video?.videoToken; + final chatId = widget.chatId; + if (videoId == null || token == null || chatId == null) return const {}; + final load = messagesModule.getVideoSources( + messageId: item.messageId, + chatId: chatId, + token: token, + videoId: videoId, + ); + _videoSourceLoads[item.id] = load; + try { + final sources = await load; + if (sources.isNotEmpty) _videoSourceCache[item.id] = sources; + return sources; + } finally { + if (identical(_videoSourceLoads[item.id], load)) { + _videoSourceLoads.remove(item.id); + } + } + } + + void _onPageChanged(int index) { + setState(() => _index = index); + _activateVideoSessions(); + _syncHero(); + if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore()); + } + + void _step(int delta) { + final next = _index + delta; + if (next < 0 || next >= _items.length) return; + _controller.animateToPage( + next, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + } + + void _rotate() { + final delta = _current.isVideo ? 3 : 1; + setState(() { + _quarterTurns[_current.id] = + ((_quarterTurns[_current.id] ?? 0) + delta) % 4; + }); + _syncHero(); + } + + void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible); + + _VideoPlaybackSession _videoSessionFor(_ViewerMedia item) { + final cached = _videoSessions.remove(item.id); + if (cached != null) { + _videoSessions[item.id] = cached; + return cached; + } + final session = _VideoPlaybackSession( + attachment: item.video!, + initialQuality: item.id == _initialMediaId + ? widget.initialVideoQuality + : null, + loadSources: () => _loadVideoSources(item), + active: item.id == _current.id, + ); + _videoSessions[item.id] = session; + _trimVideoSessions(); + return session; + } + + void _activateVideoSessions() { + for (final entry in _videoSessions.entries) { + entry.value.setActive(entry.key == _current.id); + } + } + + void _trimVideoSessions() { + while (_videoSessions.length > _maxCachedVideoPlayers) { + final candidate = _videoSessions.entries.firstWhere( + (entry) => !entry.value.active, + orElse: () => _videoSessions.entries.first, + ); + _videoSessions.remove(candidate.key)?.dispose(); + } + } + + String _cacheNameFor(PhotoAttachment photo, String url) => + 'photo_${photo.photoId ?? (url.hashCode & 0x7fffffff)}.jpg'; + + String _downloadSource(_ViewerMedia item) { + final sourceName = widget.sourceName?.trim(); + if (sourceName != null && sourceName.isNotEmpty) return sourceName; + return ContactCache.get(item.senderId) ?? ''; + } + + DownloadMetadata _photoDownload( + _ViewerMedia item, + PhotoAttachment photo, + String cacheName, + ) => DownloadMetadata( + cacheName: cacheName, + kind: DownloadKind.photo, + sourceName: _downloadSource(item), + thumbnailUrl: photo.baseUrl ?? photo.previewData, + expectedSize: photo.size ?? 0, + chatId: widget.chatId, + messageId: item.messageId.isEmpty ? null : item.messageId, + messageTime: item.time, + ); + + String _videoCacheName(_ViewerMedia item, VideoAttachment video) => + 'video_${video.videoId ?? item.messageId}.mp4'; + + DownloadMetadata _videoDownload( + _ViewerMedia item, + VideoAttachment video, + String cacheName, + ) => DownloadMetadata( + cacheName: cacheName, + kind: DownloadKind.video, + sourceName: _downloadSource(item), + thumbnailUrl: video.thumbnail ?? video.baseUrl ?? video.previewData, + expectedSize: video.size ?? 0, + chatId: widget.chatId, + messageId: item.messageId.isEmpty ? null : item.messageId, + messageTime: item.time, + ); + + Future _fileFor(PhotoAttachment photo) async { + final localPath = photo.localPath; + if (localPath != null) { + final file = File(localPath); + return await file.exists() ? file : null; + } + final url = photo.baseUrl ?? ''; + if (url.isEmpty) return null; + return MediaCache.getOrDownload(_cacheNameFor(photo, url), url); + } + + Future _videoFileFor(_ViewerMedia item) async { + final video = item.video; + if (video == null) return null; + final sources = await _loadVideoSources(item); + if (sources.isEmpty) return null; + final sessionQuality = _videoSessions[item.id]?.quality; + final url = sessionQuality != null + ? sources[sessionQuality] ?? sources.values.first + : sources.values.first; + return MediaCache.getOrDownload(_videoCacheName(item, video), url); + } + + Future _save() async { + final photo = _current.photo; + if (photo == null || _saving) return; + setState(() => _saving = true); + final localPath = photo.localPath; + final url = photo.baseUrl ?? ''; + final cacheName = _cacheNameFor(photo, url); + + final MediaSaveResult result; + if (localPath != null) { + result = await saveLocalImage(localPath); + } else if (url.isEmpty) { + result = const MediaSaveResult(ok: false, error: 'нет ссылки'); + } else { + result = await saveMediaFile( + cacheName: cacheName, + resolveUrl: () async => url, + saveName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', + kind: SaveMediaKind.image, + download: _photoDownload(_current, photo, cacheName), + ); + } + + if (!mounted) return; + setState(() => _saving = false); + if (result.ok) { + showCustomNotification( + context, + result.toGallery ? 'Сохранено в галерею' : 'Файл сохранён', + ); + } else { + showCustomNotification( + context, + 'Не удалось сохранить: ${result.error ?? ''}', + ); + } + } + + Future _saveAs() async { + if (_saving) return; + setState(() => _saving = true); + try { + final item = _current; + final now = DateTime.now().millisecondsSinceEpoch; + File? file; + DownloadMetadata? download; + String saveName; + + final photo = item.photo; + final video = item.video; + if (photo != null) { + file = await _fileFor(photo); + final url = photo.baseUrl ?? ''; + final cacheName = _cacheNameFor(photo, url); + if (url.isNotEmpty) download = _photoDownload(item, photo, cacheName); + saveName = 'IMG_$now.jpg'; + } else if (video != null) { + file = await _videoFileFor(item); + final cacheName = _videoCacheName(item, video); + download = _videoDownload(item, video, cacheName); + saveName = 'VID_$now.mp4'; + } else { + file = null; + saveName = 'media_$now'; + } + + if (!mounted) return; + if (file == null) { + showCustomNotification(context, 'Не удалось загрузить медиа'); + return; + } + final result = await saveFileAs( + source: file, + fileName: saveName, + dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs, + ); + if (!mounted || result.cancelled) return; + if (!result.saved) { + showCustomNotification(context, 'Не удалось сохранить файл'); + return; + } + if (download != null) { + try { + await DownloadHistory.record(download, file); + } catch (_) {} + } + if (mounted) showCustomNotification(context, 'Файл сохранён'); + } catch (_) { + if (mounted) showCustomNotification(context, 'Не удалось сохранить файл'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _openMenu(BuildContext anchorContext) { + final actions = widget.actions; + if (actions == null && !_current.isVideo) return; + final box = anchorContext.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final l10n = AppLocalizations.of(context)!; + final item = _current; + + showChatMenu( + context: context, + anchorRect: box.localToGlobal(Offset.zero) & box.size, + items: [ + if (actions?.goToMessage != null) + ChatMenuItem( + icon: Symbols.visibility, + label: l10n.sharedGoToMessage, + onTap: () => _popThen( + () => actions!.goToMessage!(item.messageId, item.time), + ), + ), + if (actions?.forward != null) + ChatMenuItem( + icon: Symbols.forward, + label: l10n.msgActionsForward, + onTap: () => _popThen(() => actions!.forward!(item.messageId)), + ), + if (actions?.delete != null) + ChatMenuItem( + icon: Symbols.delete, + label: l10n.msgActionsDelete, + destructive: true, + dividerAfter: true, + onTap: () => + _popThen(() => actions!.delete!(item.messageId, item.senderId)), + ), + ChatMenuItem( + icon: Symbols.download, + label: l10n.photoViewerSaveAs, + onTap: _saveAs, + ), + if (actions?.viewAllMedia != null) + ChatMenuItem( + icon: Symbols.grid_view, + label: l10n.mediaViewerViewAll, + onTap: () => _popThen(actions!.viewAllMedia!), + ), + ], + ); + } + + void _popThen(VoidCallback action) { + Navigator.of(context).pop(); + action(); + } @override Widget build(BuildContext context) { + final padding = MediaQuery.of(context).padding; + final hasMenu = _current.isVideo || !(widget.actions?.isEmpty ?? true); + return Scaffold( backgroundColor: Colors.black, - body: Stack( - children: [ - Positioned.fill( - child: InteractiveViewer( - minScale: 1, - maxScale: 5, - child: Center( - child: _url.isEmpty - ? const Icon( - Symbols.broken_image, - color: Colors.white54, - size: 64, - ) - : CachedNetworkImage( - imageUrl: _url, - fit: BoxFit.contain, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => const Center( - child: CircularProgressIndicator(color: Colors.white), - ), - errorWidget: (_, _, _) => const Icon( - Symbols.broken_image, - color: Colors.white54, - size: 64, - ), - ), + body: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(1), + const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(-1), + }, + child: Focus( + autofocus: true, + child: Stack( + children: [ + Positioned.fill( + child: PageView.builder( + key: ValueKey(_pager), + controller: _controller, + reverse: true, + itemCount: _items.length, + onPageChanged: _onPageChanged, + itemBuilder: (_, i) => _buildPage(i), + ), ), + Positioned.fill( + child: IgnorePointer( + ignoring: !_chromeVisible, + child: AnimatedOpacity( + opacity: _chromeVisible ? 1 : 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + child: Stack( + children: [ + if (_index < _items.length - 1) + Align( + alignment: Alignment.centerLeft, + child: _arrow(Symbols.chevron_left, () => _step(1)), + ), + if (_index > 0) + Align( + alignment: Alignment.centerRight, + child: _arrow( + Symbols.chevron_right, + () => _step(-1), + ), + ), + Positioned( + top: padding.top + 8, + left: 8, + right: 8, + child: Row( + children: [ + IconButton( + icon: const Icon( + Symbols.close, + color: Colors.white, + ), + onPressed: () => Navigator.of(context).pop(), + ), + const Spacer(), + if (hasMenu) + Builder( + builder: (btnContext) => IconButton( + icon: const Icon( + Symbols.more_vert, + color: Colors.white, + ), + onPressed: () => _openMenu(btnContext), + ), + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildBottomBar(padding.bottom), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPage(int i) { + final item = _items[i]; + final video = item.video; + if (video != null) { + return _VideoSurface( + key: ValueKey('video:${item.id}'), + session: _videoSessionFor(item), + quarterTurns: _quarterTurns[item.id] ?? 0, + onSurfaceTap: _toggleChrome, + ); + } + + final isHero = widget.hero != null && item.id == _heroId; + final page = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleChrome, + child: InteractiveViewer( + minScale: 1, + maxScale: 5, + transformationController: isHero ? _heroTransform : null, + child: Center( + child: RotatedBox( + quarterTurns: _quarterTurns[item.id] ?? 0, + child: _buildImage(item.photo!), + ), + ), + ), + ); + return isHero ? PhotoHeroTarget(child: page) : page; + } + + Widget _arrow(IconData icon, VoidCallback onTap) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Material( + color: Colors.black.withValues(alpha: 0.35), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, color: Colors.white, size: 28), + ), + ), + ), + ); + } + + Widget _buildBottomBar(double bottomInset) { + final l10n = AppLocalizations.of(context)!; + final caption = _current.caption; + final videoSession = _current.isVideo ? _videoSessionFor(_current) : null; + + return Container( + padding: EdgeInsets.fromLTRB(12, 12, 12, bottomInset + 10), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0xB3000000)], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (videoSession != null) ...[ + _buildVideoAttachment(videoSession, caption), + const SizedBox(height: 12), + ] else if (caption != null && caption.isNotEmpty) ...[ + _buildCaption(caption), + const SizedBox(height: 12), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _buildInfo(l10n)), + if (!_current.isVideo) + IconButton( + icon: _saving + ? const SmallSpinner(size: 20, color: Colors.white) + : const Icon(Symbols.download, color: Colors.white), + onPressed: _saving ? null : _save, + tooltip: l10n.sharedDownload, + ), + IconButton( + icon: const Icon( + Symbols.rotate_90_degrees_ccw, + color: Colors.white, + ), + onPressed: _rotate, + tooltip: l10n.photoViewerRotate, + ), + ], + ), + ], + ), + ); + } + + Widget _buildCaption(String caption) { + return _ViewerGlassSurface(child: _buildCaptionContent(caption)); + } + + Widget _buildVideoAttachment(_VideoPlaybackSession session, String? caption) { + return AnimatedBuilder( + animation: session, + builder: (context, _) => _ViewerGlassSurface( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _VideoControlPanel( + value: session.value, + fallbackDuration: Duration( + milliseconds: session.attachment.duration ?? 0, + ), + dragValue: session.dragValue, + volume: session.volume, + speed: session.speed, + quality: session.quality, + qualities: session.qualities, + onTogglePlay: session.togglePlay, + onVolumeChanged: session.setVolume, + onSpeedChanged: session.setSpeed, + onQualityChanged: session.switchQuality, + onSeekChanged: session.setDragValue, + onSeekEnd: session.seekTo, + ), + if (caption != null && caption.isNotEmpty) ...[ + Divider( + height: 1, + thickness: 0.5, + color: Colors.white.withValues(alpha: 0.12), + ), + _buildCaptionContent(caption), + ], + ], + ), + ), + ); + } + + Widget _buildCaptionContent(String caption) { + return Container( + constraints: const BoxConstraints(maxHeight: 120), + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: SingleChildScrollView( + child: Text( + caption, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + height: 1.3, + ), + ), + ), + ); + } + + Widget _buildInfo(AppLocalizations l10n) { + final item = _current; + if (item.messageId.isEmpty) return const SizedBox.shrink(); + final total = _feedLoaded ? _total : _items.length; + final position = _feedLoaded ? _total - _index : _items.length - _index; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_feedPending) + const _CounterShimmer() + else + Text( + widget.isFile + ? l10n.photoViewerCounterFile(total) + : l10n.mediaViewerCounter(position, total), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - Positioned( - top: MediaQuery.of(context).padding.top + 8, - left: 8, - child: IconButton( - icon: const Icon(Symbols.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), + const SizedBox(height: 2), + Text( + _sentLine(l10n, item), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ); + } + + String _sentLine(AppLocalizations l10n, _ViewerMedia item) { + final sourceName = widget.sourceName?.trim(); + final sender = sourceName != null && sourceName.isNotEmpty + ? sourceName + : ContactCache.get(item.senderId) ?? ''; + final sentAt = DateTime.fromMillisecondsSinceEpoch(item.time); + final now = DateTime.now(); + final time = formatClock(sentAt); + final isToday = + sentAt.year == now.year && + sentAt.month == now.month && + sentAt.day == now.day; + return isToday + ? l10n.photoViewerSentToday(sender, time) + : l10n.photoViewerSentOn(sender, formatDateWords(sentAt), time); + } + + Widget _buildImage(PhotoAttachment photo) { + final localPath = photo.localPath; + if (localPath != null) { + return Image.file( + File(localPath), + fit: BoxFit.contain, + errorBuilder: (_, _, _) => _broken(), + ); + } + + final url = photo.baseUrl ?? ''; + if (url.isEmpty) return _broken(); + + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => + const Center(child: SmallSpinner(size: 36, color: Colors.white)), + errorWidget: (_, _, _) => _broken(), + ); + } + + Widget _broken() => + const Icon(Symbols.broken_image, color: Colors.white54, size: 64); +} + +class _VideoPlaybackSession extends ChangeNotifier { + final VideoAttachment attachment; + final String? initialQuality; + final Future> Function() loadSources; + + VideoPlayerController? _controller; + Map _sources = const {}; + String? _quality; + bool _error = false; + bool _loading = true; + double? _dragValue; + double _volume = 1; + double _speed = 1; + int _loadGeneration = 0; + bool _active; + late bool _hasBeenActive = _active; + late bool _playWhenActive = _active; + bool _wasCompleted = false; + bool _disposed = false; + + _VideoPlaybackSession({ + required this.attachment, + required this.initialQuality, + required this.loadSources, + required bool active, + }) : _active = active { + unawaited(_prepare()); + } + + VideoPlayerValue? get value { + final controller = _controller; + return controller != null && controller.value.isInitialized + ? controller.value + : null; + } + + bool get loading => _loading; + bool get error => _error; + bool get completed => value?.isCompleted ?? false; + bool get buffering => (value?.isBuffering ?? false) && !completed; + bool get active => _active; + double? get dragValue => _dragValue; + double get volume => _volume; + double get speed => _speed; + String? get quality => _quality; + List get qualities => _sources.keys.toList(growable: false); + + Future _prepare() async { + final sources = await loadSources(); + if (_disposed) return; + if (sources.isEmpty) { + _error = true; + _loading = false; + _notify(); + return; + } + _sources = sources; + final initial = initialQuality; + final quality = initial != null && sources.containsKey(initial) + ? initial + : sources.keys.first; + await _load(quality, wasPlaying: _active); + } + + Future _load( + String quality, { + Duration? position, + bool wasPlaying = true, + }) async { + final url = _sources[quality]; + if (url == null) return; + final generation = ++_loadGeneration; + final old = _controller; + final previousQuality = _quality; + final controller = VideoPlayerController.networkUrl(Uri.parse(url)); + var installed = false; + _quality = quality; + _error = false; + _loading = true; + _notify(); + + try { + await controller.initialize(); + if (_disposed) { + await controller.dispose(); + return; + } + if (generation != _loadGeneration) { + await controller.dispose(); + return; + } + await controller.setVolume(_volume); + await controller.setPlaybackSpeed(_speed); + if (position != null) await controller.seekTo(position); + if (generation != _loadGeneration) { + await controller.dispose(); + return; + } + controller.addListener(_onTick); + _controller = controller; + _wasCompleted = controller.value.isCompleted; + installed = true; + old?.removeListener(_onTick); + try { + await old?.dispose(); + } catch (_) {} + _playWhenActive = wasPlaying || _playWhenActive; + if (_playWhenActive && _active) await controller.play(); + _loading = false; + _notify(); + } catch (_) { + if (!installed) await controller.dispose(); + if (generation == _loadGeneration && !_disposed) { + if (!installed) { + _quality = previousQuality; + _error = old == null || !old.value.isInitialized; + } + _loading = false; + _notify(); + } + } + } + + void _onTick() { + final isCompleted = completed; + if (isCompleted && !_wasCompleted) _playWhenActive = false; + _wasCompleted = isCompleted; + _notify(); + } + + Future switchQuality(String quality) async { + if (quality == _quality) return; + final controller = _controller; + await _load( + quality, + position: controller?.value.position, + wasPlaying: controller?.value.isPlaying ?? _active, + ); + } + + Future setSpeed(double speed) async { + _speed = speed; + _notify(); + await _controller?.setPlaybackSpeed(speed); + } + + Future setVolume(double volume) async { + _volume = volume; + _notify(); + await _controller?.setVolume(volume); + } + + void togglePlay() { + final controller = _controller; + if (controller == null || !controller.value.isInitialized) return; + if (controller.value.isPlaying) { + _playWhenActive = false; + controller.pause(); + } else { + _playWhenActive = true; + controller.play(); + } + } + + void setDragValue(double value) { + _dragValue = value; + _notify(); + } + + void seekTo(double value) { + _controller?.seekTo(Duration(milliseconds: value.round())); + _dragValue = null; + _notify(); + } + + void setActive(bool active) { + if (_active == active) return; + _active = active; + final controller = _controller; + if (!active) { + if (controller != null && controller.value.isInitialized) { + _playWhenActive = controller.value.isPlaying; + controller.pause(); + } + return; + } + if (!_hasBeenActive) { + _hasBeenActive = true; + _playWhenActive = true; + } + if (_playWhenActive && + controller != null && + controller.value.isInitialized) { + controller.play(); + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _loadGeneration++; + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } +} + +class _VideoSurface extends StatelessWidget { + final _VideoPlaybackSession session; + final int quarterTurns; + final VoidCallback onSurfaceTap; + + const _VideoSurface({ + super.key, + required this.session, + required this.quarterTurns, + required this.onSurfaceTap, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: session, + builder: (context, _) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onSurfaceTap, + child: Stack( + children: [ + Center( + child: RotatedBox( + key: const ValueKey('video-rotation'), + quarterTurns: quarterTurns, + child: session.error + ? const Icon(Symbols.error, color: Colors.white54, size: 64) + : session.value != null + ? AspectRatio( + aspectRatio: session.value!.aspectRatio, + child: VideoPlayer(session._controller!), + ) + : _buildVideoPreview(session.attachment), + ), ), + if (session.loading || session.buffering) + const Center(child: SmallSpinner(size: 36, color: Colors.white)), + ], + ), + ), + ); + } + + Widget _buildVideoPreview(VideoAttachment attachment) { + final url = + attachment.thumbnail ?? + attachment.baseUrl ?? + attachment.previewData ?? + ''; + if (url.isEmpty) { + return const Icon(Symbols.videocam, color: Colors.white38, size: 64); + } + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + errorWidget: (_, _, _) => + const Icon(Symbols.videocam, color: Colors.white38, size: 64), + ); + } +} + +class _ViewerGlassSurface extends StatelessWidget { + final Widget child; + + const _ViewerGlassSurface({required this.child}); + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: SizedBox( + width: double.infinity, + child: GlassSurface( + borderRadius: BorderRadius.circular(12), + frostTint: Colors.black.withValues(alpha: 0.28), + frostSigma: AppFrost.panelSigma, + liquidTint: Colors.black.withValues(alpha: 0.28), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + width: 0.5, + ), + child: child, + ), + ), + ), + ); + } +} + +class _VideoControlPanel extends StatelessWidget { + final VideoPlayerValue? value; + final Duration fallbackDuration; + final double? dragValue; + final double volume; + final double speed; + final String? quality; + final List qualities; + final VoidCallback onTogglePlay; + final ValueChanged onVolumeChanged; + final ValueChanged onSpeedChanged; + final ValueChanged onQualityChanged; + final ValueChanged onSeekChanged; + final ValueChanged onSeekEnd; + + const _VideoControlPanel({ + required this.value, + required this.fallbackDuration, + required this.dragValue, + required this.volume, + required this.speed, + required this.quality, + required this.qualities, + required this.onTogglePlay, + required this.onVolumeChanged, + required this.onSpeedChanged, + required this.onQualityChanged, + required this.onSeekChanged, + required this.onSeekEnd, + }); + + @override + Widget build(BuildContext context) { + final duration = value?.duration ?? fallbackDuration; + final position = value?.position ?? Duration.zero; + final maxMs = duration.inMilliseconds.toDouble(); + final positionMs = position.inMilliseconds.toDouble().clamp(0, maxMs); + final sliderValue = dragValue ?? positionMs.toDouble(); + final isPlaying = value?.isPlaying ?? false; + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 7, 10, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 48, + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSlashIcon( + icon: Symbols.volume_up, + slashedIcon: Symbols.volume_off, + slashed: volume == 0, + color: Colors.white, + size: 20, + ), + SizedBox( + width: 112, + child: _ViewerSlider( + value: volume, + max: 1, + onChanged: onVolumeChanged, + ), + ), + ], + ), + ), + Center( + child: IconButton( + key: const ValueKey('video-play-toggle'), + icon: Icon( + isPlaying ? Symbols.pause : Symbols.play_arrow, + color: Colors.white, + fill: 1, + ), + onPressed: onTogglePlay, + ), + ), + Align( + alignment: Alignment.centerRight, + child: _VideoSettingsButton( + speed: speed, + quality: quality, + qualities: qualities, + onSpeedChanged: onSpeedChanged, + onQualityChanged: onQualityChanged, + ), + ), + ], + ), + ), + Row( + children: [ + SizedBox( + width: 42, + child: Text( + _formatViewerDuration(position), + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ), + Expanded( + child: _ViewerSlider( + value: maxMs <= 0 + ? 0 + : sliderValue.clamp(0, maxMs).toDouble(), + max: maxMs <= 0 ? 1 : maxMs, + onChanged: maxMs <= 0 ? null : onSeekChanged, + onChangeEnd: maxMs <= 0 ? null : onSeekEnd, + ), + ), + SizedBox( + width: 42, + child: Text( + _formatViewerDuration(duration), + textAlign: TextAlign.end, + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ), + ], ), ], ), ); } } + +class _ViewerSlider extends StatelessWidget { + final double value; + final double max; + final ValueChanged? onChanged; + final ValueChanged? onChangeEnd; + + const _ViewerSlider({ + required this.value, + required this.max, + required this.onChanged, + this.onChangeEnd, + }); + + @override + Widget build(BuildContext context) { + return SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 13), + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white30, + thumbColor: Colors.white, + ), + child: Slider( + min: 0, + max: max, + value: value.clamp(0, max).toDouble(), + onChanged: onChanged, + onChangeEnd: onChangeEnd, + ), + ); + } +} + +String _formatViewerDuration(Duration duration) { + final seconds = duration.inSeconds; + final minutes = seconds ~/ 60; + if (minutes >= 60) { + return '${minutes ~/ 60}:${pad2(minutes % 60)}:${pad2(seconds % 60)}'; + } + return '${pad2(minutes)}:${pad2(seconds % 60)}'; +} + +class _VideoSettingsButton extends StatelessWidget { + static const speeds = [0.5, 1.0, 1.2, 1.5, 1.7, 2.0]; + + final double speed; + final String? quality; + final List qualities; + final ValueChanged onSpeedChanged; + final ValueChanged onQualityChanged; + + const _VideoSettingsButton({ + required this.speed, + required this.quality, + required this.qualities, + required this.onSpeedChanged, + required this.onQualityChanged, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return PopupMenuButton( + key: const ValueKey('video-settings'), + color: MediaAccent.schemeOf(context).surfaceContainerHigh, + tooltip: l10n.videoViewerSettings, + icon: const Icon(Symbols.settings, color: Colors.white), + onSelected: (value) { + if (value.startsWith('speed:')) { + onSpeedChanged(double.parse(value.substring(6))); + } else if (value.startsWith('quality:')) { + onQualityChanged(value.substring(8)); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + enabled: false, + height: 38, + child: Text( + l10n.videoViewerSpeed, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + for (final value in speeds) + PopupMenuItem( + value: 'speed:$value', + height: 38, + child: _SettingChoice( + label: value == 1 + ? '1.0x' + : '${value.toStringAsFixed(value % 1 == 0 ? 0 : 1)}x', + selected: value == speed, + ), + ), + if (qualities.length > 1) const PopupMenuDivider(), + if (qualities.length > 1) + PopupMenuItem( + enabled: false, + height: 38, + child: Text( + l10n.videoViewerQuality, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + if (qualities.length > 1) + for (final value in qualities) + PopupMenuItem( + value: 'quality:$value', + height: 38, + child: _SettingChoice(label: value, selected: value == quality), + ), + ], + ); + } +} + +class _SettingChoice extends StatelessWidget { + final String label; + final bool selected; + + const _SettingChoice({required this.label, required this.selected}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text(label, style: const TextStyle(color: Colors.white)), + ), + if (selected) + Icon(Symbols.check, color: MediaAccent.of(context), size: 18), + ], + ); + } +} + +class _CounterShimmer extends StatefulWidget { + const _CounterShimmer(); + + @override + State<_CounterShimmer> createState() => _CounterShimmerState(); +} + +class _CounterShimmerState extends State<_CounterShimmer> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..repeat(reverse: true); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) => Opacity( + opacity: 0.25 + 0.35 * _controller.value, + child: Container( + width: 112, + height: 17, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/poll_view.dart b/lib/frontend/widgets/poll_view.dart index 6f0d2c8..1a4334e 100644 --- a/lib/frontend/widgets/poll_view.dart +++ b/lib/frontend/widgets/poll_view.dart @@ -6,6 +6,8 @@ import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../models/poll.dart'; import 'custom_notification.dart'; +import 'small_spinner.dart'; +import '../../core/config/app_shape.dart'; class PollView extends StatefulWidget { final int chatId; @@ -222,14 +224,7 @@ class _PollViewState extends State ), ), if (_voting && !multiple) - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: widget.dimColor, - ), - ), + SmallSpinner(size: 14, color: widget.dimColor), ], ), ), @@ -248,19 +243,10 @@ class _PollViewState extends State style: TextButton.styleFrom( foregroundColor: widget.accentColor, backgroundColor: widget.dimColor.withValues(alpha: 0.12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + shape: AppShape.buttonBorder, ), child: _voting - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: widget.accentColor, - ), - ) + ? SmallSpinner(size: 16, color: widget.accentColor) : const Text('Проголосовать'), ), ), @@ -355,12 +341,14 @@ class _PollViewState extends State padding: const EdgeInsets.only(top: 6), child: ClipRRect( borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: fillFactor, - minHeight: 6, - backgroundColor: widget.dimColor.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - widget.accentColor, + child: Container( + height: 6, + color: widget.dimColor.withValues(alpha: 0.2), + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: fillFactor.clamp(0.0, 1.0), + heightFactor: 1, + child: ColoredBox(color: widget.accentColor), ), ), ), diff --git a/lib/frontend/widgets/primary_loading_button.dart b/lib/frontend/widgets/primary_loading_button.dart index 64803fb..cb901dd 100644 --- a/lib/frontend/widgets/primary_loading_button.dart +++ b/lib/frontend/widgets/primary_loading_button.dart @@ -1,6 +1,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'small_spinner.dart'; +import 'springy_tap.dart'; +import '../../core/config/app_shape.dart'; + class PrimaryLoadingButton extends StatelessWidget { final ValueListenable loading; final VoidCallback? onPressed; @@ -23,23 +27,18 @@ class PrimaryLoadingButton extends StatelessWidget { final fg = foreground ?? cs.onPrimary; return ValueListenableBuilder( valueListenable: loading, - builder: (context, isLoading, _) => FilledButton( - onPressed: isLoading ? null : onPressed, - style: FilledButton.styleFrom( - backgroundColor: background ?? cs.primary, - foregroundColor: fg, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + builder: (context, isLoading, _) => SpringyTap( + enabled: !isLoading, + child: FilledButton( + onPressed: isLoading ? null : onPressed, + style: FilledButton.styleFrom( + backgroundColor: background ?? cs.primary, + foregroundColor: fg, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: AppShape.buttonBorder, ), + child: isLoading ? SmallSpinner(size: 20, color: fg) : child, ), - child: isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: fg), - ) - : child, ), ); } diff --git a/lib/frontend/widgets/profile_header_scroll.dart b/lib/frontend/widgets/profile_header_scroll.dart new file mode 100644 index 0000000..8e34b74 --- /dev/null +++ b/lib/frontend/widgets/profile_header_scroll.dart @@ -0,0 +1,143 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' + show OverScrollHeaderStretchConfiguration; + +class HeaderPullScrollPhysics extends ScrollPhysics { + final double delta; + final ValueGetter isArmed; + + const HeaderPullScrollPhysics({ + required this.delta, + required this.isArmed, + super.parent, + }); + + static final SpringDescription _expressiveSpring = + SpringDescription.withDampingRatio(mass: 1, stiffness: 380, ratio: 0.9); + + static const double _flingVelocity = 400; + + @override + HeaderPullScrollPhysics applyTo(ScrollPhysics? ancestor) { + return HeaderPullScrollPhysics( + delta: delta, + isArmed: isArmed, + parent: buildParent(ancestor), + ); + } + + @override + double applyBoundaryConditions(ScrollMetrics position, double value) { + if (delta <= 0) { + if (value < position.pixels && + position.pixels <= position.minScrollExtent) { + return value - position.pixels; + } + if (value < position.minScrollExtent && + position.minScrollExtent < position.pixels) { + return value - position.minScrollExtent; + } + } + return super.applyBoundaryConditions(position, value); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (delta <= 0 || offset <= 0 || position.pixels <= 0) { + return super.applyPhysicsToUserOffset(position, offset); + } + final px = position.pixels; + final free = math.max(0.0, px - delta); + if (offset <= free) return offset; + if (!isArmed()) return free; + final inZone = offset - free; + final expandedFraction = (1 - math.min(px, delta) / delta).clamp(0.0, 1.0); + final friction = lerpDouble(0.58, 0.3, expandedFraction)!; + return free + inZone * friction; + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + if (delta > 0) { + final px = position.pixels; + final tolerance = toleranceFor(position); + if (px > 0 && px < delta) { + final collapsed = math.min(delta, position.maxScrollExtent); + final double target; + if (velocity <= -_flingVelocity) { + target = 0; + } else if (velocity >= _flingVelocity) { + target = collapsed; + } else { + target = px < delta / 2 ? 0 : collapsed; + } + if ((target - px).abs() < tolerance.distance && + velocity.abs() < tolerance.velocity) { + return null; + } + return ScrollSpringSimulation( + _expressiveSpring, + px, + target, + velocity, + tolerance: tolerance, + ); + } + if (px >= delta && velocity < 0) { + return BouncingScrollSimulation( + position: px, + velocity: velocity, + leadingExtent: delta, + trailingExtent: math.max(delta, position.maxScrollExtent), + spring: spring, + tolerance: tolerance, + ); + } + } + return super.createBallisticSimulation(position, velocity); + } +} + +class MorphHeaderDelegate extends SliverPersistentHeaderDelegate { + final double collapsedExtent; + final double expandedExtent; + final Widget Function(BuildContext context, double t) headerBuilder; + + MorphHeaderDelegate({ + required this.collapsedExtent, + required this.expandedExtent, + required this.headerBuilder, + }); + + @override + double get minExtent => collapsedExtent; + + @override + double get maxExtent => expandedExtent; + + @override + OverScrollHeaderStretchConfiguration? get stretchConfiguration => + expandedExtent > collapsedExtent + ? OverScrollHeaderStretchConfiguration() + : null; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + final range = expandedExtent - collapsedExtent; + final t = range <= 0 ? 0.0 : (1 - shrinkOffset / range).clamp(0.0, 1.0); + return headerBuilder(context, t); + } + + @override + bool shouldRebuild(covariant MorphHeaderDelegate oldDelegate) => true; +} diff --git a/lib/frontend/widgets/profile_hero.dart b/lib/frontend/widgets/profile_hero.dart new file mode 100644 index 0000000..d28bb88 --- /dev/null +++ b/lib/frontend/widgets/profile_hero.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +class ProfileHeroAvatar extends StatelessWidget { + const ProfileHeroAvatar({ + super.key, + required this.tag, + required this.size, + required this.child, + }); + + final Object? tag; + final double size; + final Widget child; + + @override + Widget build(BuildContext context) { + final tag = this.tag; + if (tag == null) return child; + return Hero( + tag: ('profile-avatar', tag), + flightShuttleBuilder: _buildFlyingAvatar, + child: _AvatarHeroChild(size: size, child: child), + ); + } + + static Widget _buildFlyingAvatar( + BuildContext flightContext, + Animation animation, + HeroFlightDirection direction, + BuildContext fromHeroContext, + BuildContext toHeroContext, + ) { + final from = _AvatarHeroChild.of(fromHeroContext); + final to = _AvatarHeroChild.of(toHeroContext); + final sharpest = from.size >= to.size ? from : to; + return Material( + type: MaterialType.transparency, + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox.square(dimension: sharpest.size, child: sharpest.child), + ), + ); + } +} + +class ProfileHeroName extends StatelessWidget { + const ProfileHeroName({ + super.key, + required this.tag, + required this.text, + required this.style, + required this.child, + }); + + final Object? tag; + final String text; + final TextStyle style; + final Widget child; + + @override + Widget build(BuildContext context) { + final tag = this.tag; + if (tag == null) return child; + return Hero( + tag: ('profile-name', tag), + flightShuttleBuilder: _buildFlyingName, + child: _NameHeroChild(text: text, style: style, child: child), + ); + } + + static Widget _buildFlyingName( + BuildContext flightContext, + Animation animation, + HeroFlightDirection direction, + BuildContext fromHeroContext, + BuildContext toHeroContext, + ) { + final from = _NameHeroChild.of(fromHeroContext); + final to = _NameHeroChild.of(toHeroContext); + final push = direction == HeroFlightDirection.push; + final style = TextStyleTween( + begin: push ? from.style : to.style, + end: push ? to.style : from.style, + ).animate(animation); + return OverflowBox( + alignment: Alignment.centerLeft, + minWidth: 0, + maxWidth: double.infinity, + minHeight: 0, + maxHeight: double.infinity, + child: DefaultTextStyleTransition( + style: style, + softWrap: false, + maxLines: 1, + child: Text(to.text), + ), + ); + } +} + +class _AvatarHeroChild extends StatelessWidget { + const _AvatarHeroChild({required this.size, required this.child}); + + final double size; + final Widget child; + + static _AvatarHeroChild of(BuildContext heroContext) => + (heroContext.widget as Hero).child as _AvatarHeroChild; + + @override + Widget build(BuildContext context) => child; +} + +class _NameHeroChild extends StatelessWidget { + const _NameHeroChild({ + required this.text, + required this.style, + required this.child, + }); + + final String text; + final TextStyle style; + final Widget child; + + static _NameHeroChild of(BuildContext heroContext) => + (heroContext.widget as Hero).child as _NameHeroChild; + + @override + Widget build(BuildContext context) => child; +} diff --git a/lib/frontend/widgets/prompt_dialog.dart b/lib/frontend/widgets/prompt_dialog.dart index 9eab14f..7d1715d 100644 --- a/lib/frontend/widgets/prompt_dialog.dart +++ b/lib/frontend/widgets/prompt_dialog.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/config/app_fonts.dart'; +import '../../core/config/app_shape.dart'; Future showTextInputDialog( BuildContext context, { @@ -20,12 +22,13 @@ Future showTextInputDialog( final cs = Theme.of(dialogContext).colorScheme; return AlertDialog( backgroundColor: cs.surfaceContainerHigh, + shape: AppShape.dialogBorder, title: title == null ? null : Text( title, style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontWeight: FontWeight.w600, fontSize: 18, color: cs.onSurface, diff --git a/lib/frontend/widgets/reload_on_reconnect.dart b/lib/frontend/widgets/reload_on_reconnect.dart new file mode 100644 index 0000000..ac2ab62 --- /dev/null +++ b/lib/frontend/widgets/reload_on_reconnect.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../../backend/api.dart'; +import '../../main.dart' show api; + +mixin ReloadOnReconnect on State { + StreamSubscription? _reconnectSub; + int _reloadedEpoch = api.sessionEpoch; + + void reloadAfterReconnect(); + + @override + void initState() { + super.initState(); + _reconnectSub = api.stateStream.listen(_onSessionState); + } + + @override + void dispose() { + _reconnectSub?.cancel(); + super.dispose(); + } + + void _onSessionState(SessionState state) { + if (state != SessionState.online) return; + if (api.sessionEpoch == _reloadedEpoch) return; + _reloadedEpoch = api.sessionEpoch; + if (mounted) reloadAfterReconnect(); + } +} diff --git a/lib/frontend/widgets/rich_message_controller.dart b/lib/frontend/widgets/rich_message_controller.dart index 70a82ba..665d6a5 100644 --- a/lib/frontend/widgets/rich_message_controller.dart +++ b/lib/frontend/widgets/rich_message_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../core/utils/text_format.dart'; import '../../models/animoji.dart'; +import 'formatted_message_text.dart'; import 'lottie_image.dart'; const List composerFormats = [ @@ -18,6 +19,13 @@ class _Interval { _Interval(this.start, this.end); } +class _MentionEntity { + int start; + int end; + final int userId; + _MentionEntity(this.start, this.end, this.userId); +} + class _AnimojiEntity { final int uid; int offset; @@ -39,6 +47,7 @@ class RichMessageController extends TextEditingController { final Map> _intervals = {}; final List<_AnimojiEntity> _animoji = []; + final List<_MentionEntity> _mentions = []; int _entitySeq = 0; RichMessageController({super.text}); @@ -73,13 +82,35 @@ class RichMessageController extends TextEditingController { notifyListeners(); } + void insertMention({ + required int userId, + required String name, + required int start, + required int end, + }) { + final oldText = value.text; + if (start < 0 || end > oldText.length || start > end || name.isEmpty) { + return; + } + final inserted = '$name '; + value = TextEditingValue( + text: oldText.replaceRange(start, end, inserted), + selection: TextSelection.collapsed(offset: start + inserted.length), + ); + + _mentions.add(_MentionEntity(start, start + name.length, userId)); + _mentions.sort((a, b) => a.start.compareTo(b.start)); + notifyListeners(); + } + ({String text, List> elements}) buildContent() { final src = value.text; if (_animoji.isEmpty) { return (text: src, elements: elementsForSend()); } - final entities = [..._animoji]..sort((a, b) => a.offset.compareTo(b.offset)); + final entities = [..._animoji] + ..sort((a, b) => a.offset.compareTo(b.offset)); final sb = StringBuffer(); var last = 0; @@ -121,6 +152,7 @@ class RichMessageController extends TextEditingController { 'type': textFormatToServer(range.format), 'from': from, 'length': to - from, + if (range.entityId != null) 'entityId': range.entityId, }); } return (text: glyphText, elements: elements); @@ -136,7 +168,8 @@ class RichMessageController extends TextEditingController { super.value = newValue; } - bool get hasFormatting => _intervals.values.any((list) => list.isNotEmpty); + bool get hasFormatting => + _intervals.values.any((list) => list.isNotEmpty) || _mentions.isNotEmpty; void clearFormatting() { if (_intervals.isEmpty) return; @@ -146,12 +179,21 @@ class RichMessageController extends TextEditingController { void setFormatRanges(Iterable ranges) { _intervals.clear(); + _mentions.clear(); for (final range in ranges) { + if (range.format == TextFormat.userMention) { + final userId = range.entityId; + if (userId != null) { + _mentions.add(_MentionEntity(range.start, range.end, userId)); + } + continue; + } if (!composerFormats.contains(range.format)) continue; _intervals .putIfAbsent(range.format, () => []) .add(_Interval(range.start, range.end)); } + _mentions.sort((a, b) => a.start.compareTo(b.start)); for (final list in _intervals.values) { _normalize(list); } @@ -175,6 +217,16 @@ class RichMessageController extends TextEditingController { ); } }); + for (final mention in _mentions) { + ranges.add( + FormatRange( + format: TextFormat.userMention, + start: mention.start, + length: mention.end - mention.start, + entityId: mention.userId, + ), + ); + } return ranges; } @@ -200,7 +252,7 @@ class RichMessageController extends TextEditingController { } void _remap(String oldText, String newText) { - if (_intervals.isEmpty && _animoji.isEmpty) return; + if (_intervals.isEmpty && _animoji.isEmpty && _mentions.isEmpty) return; final oldLen = oldText.length; final newLen = newText.length; @@ -240,6 +292,16 @@ class RichMessageController extends TextEditingController { } } + if (_mentions.isNotEmpty) { + _mentions.removeWhere( + (mention) => changeStart < mention.end && oldChangeEnd > mention.start, + ); + for (final mention in _mentions) { + mention.start = mapStart(mention.start); + mention.end = mapEnd(mention.end); + } + } + final empty = []; _intervals.forEach((format, list) { for (final interval in list) { @@ -325,6 +387,10 @@ class RichMessageController extends TextEditingController { final ranges = _toFormatRanges(); final baseColor = baseStyle.color; final quoteColor = baseColor?.withValues(alpha: 0.85); + final quoteBackground = baseColor == null + ? null + : (Paint()..color = baseColor.withValues(alpha: 0.12)); + final mentionColor = mentionTextColor(Theme.of(context).colorScheme); final segments = segmentizeFormats(content, ranges); final entityByOffset = {for (final e in _animoji) e.offset: e}; final box = (baseStyle.fontSize ?? 16) * 1.4; @@ -335,6 +401,8 @@ class RichMessageController extends TextEditingController { baseStyle, segment.formats, quoteColor: quoteColor, + mentionColor: mentionColor, + quoteBackground: quoteBackground, ); var runStart = segment.start; var i = segment.start; diff --git a/lib/frontend/widgets/schedule_time_picker.dart b/lib/frontend/widgets/schedule_time_picker.dart index 04e9624..dde55a8 100644 --- a/lib/frontend/widgets/schedule_time_picker.dart +++ b/lib/frontend/widgets/schedule_time_picker.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import '../../core/utils/format.dart'; import 'custom_notification.dart'; import 'sheet_helpers.dart'; +import '../../core/config/app_fonts.dart'; +import '../../core/config/app_shape.dart'; const List _weekdayShort = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']; @@ -132,7 +134,7 @@ class _ScheduleSheetState extends State<_ScheduleSheet> { color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), ), ), const SizedBox(height: 8), @@ -172,9 +174,7 @@ class _ScheduleSheetState extends State<_ScheduleSheet> { child: FilledButton( style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: AppShape.buttonBorder, ), onPressed: _confirm, child: Text( diff --git a/lib/frontend/widgets/selectable_message_text.dart b/lib/frontend/widgets/selectable_message_text.dart new file mode 100644 index 0000000..55d1587 --- /dev/null +++ b/lib/frontend/widgets/selectable_message_text.dart @@ -0,0 +1,693 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +import '../../core/utils/haptics.dart'; +import '../../l10n/app_localizations.dart'; +import 'custom_notification.dart'; + +void _collectParagraphs(RenderObject ro, List out) { + if (ro is RenderParagraph) { + out.add(ro); + return; + } + ro.visitChildren((child) => _collectParagraphs(child, out)); +} + +bool _isSpace(int c) => + c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D || c == 0x0C || c == 0xA0; + +bool _isGlyphOnly(String text) { + if (text.isEmpty) return true; + for (final rune in text.runes) { + final private = + (rune >= 0xE000 && rune <= 0xF8FF) || + (rune >= 0xF0000 && rune <= 0xFFFFD) || + (rune >= 0x100000 && rune <= 0x10FFFD); + if (!private) return false; + } + return true; +} + +class _ParaSlice { + final RenderParagraph rp; + final int start; + final String text; + + const _ParaSlice({required this.rp, required this.start, required this.text}); + + int get end => start + text.length; + + bool get usable => rp.attached && rp.hasSize; + + Offset get origin => rp.localToGlobal(Offset.zero); + + Rect get globalRect => origin & rp.size; + + TextSelection? clip(TextSelection selection) { + final s = selection.start.clamp(start, end) - start; + final e = selection.end.clamp(start, end) - start; + if (e <= s) return null; + return TextSelection(baseOffset: s, extentOffset: e); + } +} + +class SelectableMessageText extends StatefulWidget { + final Widget child; + final Offset initialGlobalPosition; + final VoidCallback onExit; + final ValueListenable? dragPosition; + + const SelectableMessageText({ + super.key, + required this.child, + required this.initialGlobalPosition, + required this.onExit, + this.dragPosition, + }); + + @override + State createState() => _SelectableMessageTextState(); +} + +class _SelectableMessageTextState extends State + with SingleTickerProviderStateMixin { + static const double _ballRadius = 10.0; + static const double _hitInner = 20.0; + static const double _hitOuter = 36.0; + static const double _hitAbove = 18.0; + static const double _hitBelow = 42.0; + + final GlobalKey _textKey = GlobalKey(); + final LayerLink _link = LayerLink(); + final ValueNotifier _toolbarVisible = ValueNotifier(false); + + late final AnimationController _entrance; + OverlayEntry? _overlay; + Timer? _settle; + TextSelection _selection = const TextSelection.collapsed(offset: 0); + bool _dragging = false; + bool _exiting = false; + double? _dragStartGlobalY; + double _dragAnchorY = 0; + double _dragLineHeight = 0; + + List<_ParaSlice>? _cachedSlices; + String _cachedJoined = ''; + ScrollPosition? _scrollPosition; + TextSelection? _anchor; + + List<_ParaSlice> get _slices { + final cached = _cachedSlices; + if (cached != null && cached.isNotEmpty && cached.every((s) => s.usable)) { + return cached; + } + final root = _textKey.currentContext?.findRenderObject(); + final paragraphs = []; + if (root != null) _collectParagraphs(root, paragraphs); + + final slices = <_ParaSlice>[]; + var offset = 0; + for (final rp in paragraphs) { + if (!rp.attached || !rp.hasSize) continue; + final text = rp.text.toPlainText(); + if (_isGlyphOnly(text)) continue; + slices.add(_ParaSlice(rp: rp, start: offset, text: text)); + offset += text.length + 1; + } + _cachedJoined = slices.map((s) => s.text).join('\n'); + return _cachedSlices = slices; + } + + String get _joined { + _slices; + return _cachedJoined; + } + + _ParaSlice? _sliceAt(Offset globalPos) { + final slices = _slices; + if (slices.isEmpty) return null; + _ParaSlice? nearest; + var best = double.infinity; + for (final slice in slices) { + final rect = slice.globalRect; + if (rect.contains(globalPos)) return slice; + final dy = globalPos.dy < rect.top + ? rect.top - globalPos.dy + : globalPos.dy - rect.bottom; + final distance = math.max(0.0, dy); + if (distance < best) { + best = distance; + nearest = slice; + } + } + return nearest; + } + + int? _offsetAt(Offset globalPos) { + final slice = _sliceAt(globalPos); + if (slice == null) return null; + final local = slice.rp.globalToLocal(globalPos); + final off = slice.rp + .getPositionForOffset(local) + .offset + .clamp(0, slice.text.length); + return slice.start + off; + } + + RenderBox? get _rootBox { + final ro = _textKey.currentContext?.findRenderObject(); + if (ro is! RenderBox || !ro.attached || !ro.hasSize) return null; + return ro; + } + + List _localBoxes(TextSelection selection) { + if (!selection.isValid || selection.isCollapsed) return const []; + final root = _rootBox; + if (root == null) return const []; + final rootOrigin = root.localToGlobal(Offset.zero); + final out = []; + for (final slice in _slices) { + final local = slice.clip(selection); + if (local == null) continue; + final delta = slice.origin - rootOrigin; + for (final box in slice.rp.getBoxesForSelection(local)) { + out.add(box.toRect().shift(delta)); + } + } + return out; + } + + List _globalBoxes(TextSelection selection) { + final root = _rootBox; + if (root == null) return const []; + final rootOrigin = root.localToGlobal(Offset.zero); + return [for (final rect in _localBoxes(selection)) rect.shift(rootOrigin)]; + } + + @override + void initState() { + super.initState(); + _entrance = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + )..addListener(() => _overlay?.markNeedsBuild()); + widget.dragPosition?.addListener(_onDragPosition); + WidgetsBinding.instance.addPostFrameCallback((_) => _init(4)); + } + + @override + void didUpdateWidget(SelectableMessageText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.dragPosition == widget.dragPosition) return; + oldWidget.dragPosition?.removeListener(_onDragPosition); + widget.dragPosition?.addListener(_onDragPosition); + } + + void _onDragPosition() { + if (!mounted) return; + final pos = widget.dragPosition?.value; + if (pos == null) { + if (_anchor != null) _toolbarVisible.value = true; + return; + } + final anchor = _anchor; + if (anchor == null) return; + final off = _offsetAt(pos); + if (off == null) return; + _toolbarVisible.value = false; + if (off > anchor.end) { + _applySelection( + TextSelection(baseOffset: anchor.start, extentOffset: off), + ); + } else if (off < anchor.start) { + _applySelection(TextSelection(baseOffset: off, extentOffset: anchor.end)); + } else { + _applySelection(anchor); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final position = Scrollable.maybeOf(context)?.position; + if (identical(position, _scrollPosition)) return; + _scrollPosition?.removeListener(_onScroll); + _scrollPosition = position?..addListener(_onScroll); + } + + void _onScroll() => _overlay?.markNeedsBuild(); + + @override + void dispose() { + _settle?.cancel(); + widget.dragPosition?.removeListener(_onDragPosition); + _scrollPosition?.removeListener(_onScroll); + _entrance.dispose(); + _overlay?.remove(); + _overlay = null; + _toolbarVisible.dispose(); + super.dispose(); + } + + void _init(int retries) { + if (!mounted) return; + if (_slices.isEmpty) { + if (retries > 0) { + WidgetsBinding.instance.addPostFrameCallback((_) => _init(retries - 1)); + } else { + _requestExit(); + } + return; + } + _selectWordAt(widget.initialGlobalPosition); + _anchor = _selection; + Haptics.selection(); + _ensureOverlay(); + _toolbarVisible.value = true; + } + + void _ensureOverlay() { + if (_overlay != null || !mounted) return; + _overlay = OverlayEntry(builder: _buildOverlay); + Overlay.of(context, rootOverlay: true).insert(_overlay!); + } + + void _requestExit() { + if (_exiting) return; + _exiting = true; + _settle?.cancel(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onExit(); + }); + } + + TextSelection _normalize(int a, int b) => + TextSelection(baseOffset: math.min(a, b), extentOffset: math.max(a, b)); + + void _applySelection(TextSelection sel, {bool animate = false}) { + _selection = sel; + if (animate) _entrance.forward(from: 0); + if (mounted) setState(() {}); + _overlay?.markNeedsBuild(); + } + + TextRange _wordRange(Offset globalPos) { + final text = _joined; + final len = text.length; + if (len == 0) return const TextRange.collapsed(0); + var off = (_offsetAt(globalPos) ?? 0).clamp(0, len); + bool ws(int i) => i < 0 || i >= len || _isSpace(text.codeUnitAt(i)); + if (ws(off) && off > 0 && !ws(off - 1)) off -= 1; + if (ws(off)) return TextRange.collapsed(off); + var s = off; + var e = off; + while (s > 0 && !_isSpace(text.codeUnitAt(s - 1))) { + s--; + } + while (e < len && !_isSpace(text.codeUnitAt(e))) { + e++; + } + return TextRange(start: s, end: e); + } + + void _selectWordAt(Offset globalPos) { + final range = _wordRange(globalPos); + if (range.isCollapsed) { + _applySelection(_normalize(0, _joined.length), animate: true); + } else { + _applySelection(_normalize(range.start, range.end), animate: true); + } + } + + void _onBackgroundTap(Offset globalPos) { + final slices = _slices; + if (slices.isEmpty) { + _requestExit(); + return; + } + final inside = slices.any((s) => s.globalRect.contains(globalPos)); + if (inside) { + _dragging = false; + _settle?.cancel(); + _toolbarVisible.value = true; + } else { + _requestExit(); + } + } + + void _onHandleDragStart(Offset globalPos, bool isStart) { + _dragging = true; + _settle?.cancel(); + _entrance.value = 1.0; + _toolbarVisible.value = false; + _dragStartGlobalY = null; + final boxes = _globalBoxes(_selection); + if (boxes.isEmpty) return; + final rect = isStart ? boxes.first : boxes.last; + _dragStartGlobalY = globalPos.dy; + _dragAnchorY = rect.center.dy; + _dragLineHeight = rect.height; + } + + double _lineSnappedY(double fingerGlobalY) { + final startY = _dragStartGlobalY; + if (startY == null || _dragLineHeight <= 0) return fingerGlobalY; + final dragged = fingerGlobalY - startY; + final direction = dragged < 0 ? -1 : 1; + final lines = direction * (dragged.abs() / _dragLineHeight).floor(); + return _dragAnchorY + lines * _dragLineHeight; + } + + void _onHandleDrag(Offset globalPos, bool isStart) { + final len = _joined.length; + if (len == 0) return; + final off = _offsetAt(Offset(globalPos.dx, _lineSnappedY(globalPos.dy))); + if (off == null) return; + if (isStart) { + final ns = off.clamp(0, math.max(0, _selection.end - 1)).toInt(); + _applySelection( + TextSelection(baseOffset: ns, extentOffset: _selection.end), + ); + } else { + final ne = off.clamp(math.min(_selection.start + 1, len), len).toInt(); + _applySelection( + TextSelection(baseOffset: _selection.start, extentOffset: ne), + ); + } + } + + void _onHandleDragEnd() { + _dragging = false; + _dragStartGlobalY = null; + _settle?.cancel(); + _settle = Timer(const Duration(milliseconds: 140), () { + if (!mounted || _dragging) return; + _overlay?.markNeedsBuild(); + _toolbarVisible.value = true; + }); + } + + void _copy() { + if (_selection.isValid && !_selection.isCollapsed) { + final text = _joined; + final sub = text.substring( + _selection.start.clamp(0, text.length), + _selection.end.clamp(0, text.length), + ); + if (sub.isNotEmpty) { + Clipboard.setData(ClipboardData(text: sub)); + Haptics.tap(); + showCustomNotification( + context, + AppLocalizations.of(context)!.msgActionsCopied, + ); + } + } + _requestExit(); + } + + void _selectAll() { + if (_slices.isEmpty) return; + Haptics.tap(); + _applySelection(_normalize(0, _joined.length), animate: true); + _toolbarVisible.value = true; + } + + Widget _buildOverlay(BuildContext ctx) { + if (_slices.isEmpty) return const SizedBox.shrink(); + final cs = Theme.of(ctx).colorScheme; + final rects = _localBoxes(_selection); + + final children = [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTapUp: (d) => _onBackgroundTap(d.globalPosition), + ), + ), + ]; + + if (rects.isNotEmpty) { + final first = rects.first; + final last = rects.last; + + children.add( + _handle(cs, Offset(first.left, first.bottom), isStart: true), + ); + children.add( + _handle(cs, Offset(last.right, last.bottom), isStart: false), + ); + children.add(_toolbar(ctx, first, last)); + } + + return Stack(children: children); + } + + Widget _follow(Offset offset, Widget child) => Positioned( + left: 0, + top: 0, + child: CompositedTransformFollower( + link: _link, + showWhenUnlinked: false, + offset: offset, + child: child, + ), + ); + + Widget _handle( + ColorScheme cs, + Offset lineBottomGlobal, { + required bool isStart, + }) { + final center = Offset( + lineBottomGlobal.dx, + lineBottomGlobal.dy + _ballRadius, + ); + final leftInset = isStart ? _hitOuter : _hitInner; + return _follow( + Offset(center.dx - leftInset, center.dy - _hitAbove), + SizedBox( + width: _hitInner + _hitOuter, + height: _hitAbove + _hitBelow, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart), + onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart), + onPanEnd: (_) => _onHandleDragEnd(), + onPanCancel: _onHandleDragEnd, + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: EdgeInsets.only( + left: leftInset - _ballRadius, + top: _hitAbove - _ballRadius, + ), + child: Transform.scale( + scale: Curves.easeOutBack.transform( + _entrance.value.clamp(0.0, 1.0), + ), + child: Container( + width: _ballRadius * 2, + height: _ballRadius * 2, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _toolbar(BuildContext ctx, Rect first, Rect last) { + final media = MediaQuery.of(ctx); + final size = media.size; + final rootOrigin = _rootBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final safeTop = media.padding.top + 8; + final safeBottom = size.height - media.padding.bottom - 8; + const height = 48.0; + const gap = 10.0; + + double top = rootOrigin.dy + first.top - gap - height; + if (top < safeTop) top = rootOrigin.dy + last.bottom + gap; + top = top.clamp(safeTop, math.max(safeTop, safeBottom - height)); + + return _follow( + Offset(-rootOrigin.dx, top - rootOrigin.dy), + SizedBox( + width: size.width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: ValueListenableBuilder( + valueListenable: _toolbarVisible, + builder: (ctx, visible, _) => IgnorePointer( + ignoring: !visible, + child: AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Center(child: _pill(ctx)), + ), + ), + ), + ), + ), + ); + } + + Widget _pill(BuildContext ctx) { + final cs = Theme.of(ctx).colorScheme; + final l10n = AppLocalizations.of(ctx)!; + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.4), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _toolbarButton(cs, l10n.msgActionsCopy, _copy), + Container( + width: 1, + height: 24, + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + _toolbarButton(cs, l10n.msgActionsSelectAll, _selectAll), + ], + ), + ); + } + + Widget _toolbarButton(ColorScheme cs, String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 13), + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return CompositedTransformTarget( + link: _link, + child: CustomPaint( + painter: _HighlightPainter( + rootKey: _textKey, + slices: _slices, + selection: _selection, + animation: _entrance, + fill: cs.primary.withValues(alpha: 0.28), + stem: cs.primary, + ), + child: KeyedSubtree(key: _textKey, child: widget.child), + ), + ); + } +} + +class _HighlightPainter extends CustomPainter { + final GlobalKey rootKey; + final List<_ParaSlice> slices; + final TextSelection selection; + final Animation animation; + final Color fill; + final Color stem; + + _HighlightPainter({ + required this.rootKey, + required this.slices, + required this.selection, + required this.animation, + required this.fill, + required this.stem, + }) : super(repaint: animation); + + @override + void paint(Canvas canvas, Size size) { + if (!selection.isValid || selection.isCollapsed) return; + final root = rootKey.currentContext?.findRenderObject(); + if (root is! RenderBox || !root.attached || !root.hasSize) return; + final rootOrigin = root.localToGlobal(Offset.zero); + + final rects = []; + for (final slice in slices) { + if (!slice.usable) continue; + final local = slice.clip(selection); + if (local == null) continue; + final delta = slice.origin - rootOrigin; + for (final box in slice.rp.getBoxesForSelection(local)) { + rects.add(box.toRect().shift(delta)); + } + } + if (rects.isEmpty) return; + + final t = animation.value.clamp(0.0, 1.0); + final eased = Curves.easeOut.transform(t); + final grow = 0.72 + 0.28 * eased; + + final fillPaint = Paint()..color = fill.withValues(alpha: fill.a * eased); + for (final rect in rects) { + final inflated = rect.inflate(0.5); + final cy = inflated.center.dy; + final h = inflated.height * grow; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTRB(inflated.left, cy - h / 2, inflated.right, cy + h / 2), + const Radius.circular(3), + ), + fillPaint, + ); + } + + final stemPaint = Paint() + ..color = stem.withValues(alpha: stem.a * eased) + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round; + final first = rects.first; + final last = rects.last; + canvas.drawLine( + Offset(first.left, first.top), + Offset(first.left, first.bottom), + stemPaint, + ); + canvas.drawLine( + Offset(last.right, last.top), + Offset(last.right, last.bottom), + stemPaint, + ); + } + + @override + bool shouldRepaint(_HighlightPainter old) => + old.selection != selection || + old.slices.length != slices.length || + old.fill != fill || + old.stem != stem; +} diff --git a/lib/frontend/widgets/sending_clock_icon.dart b/lib/frontend/widgets/sending_clock_icon.dart new file mode 100644 index 0000000..afbc08d --- /dev/null +++ b/lib/frontend/widgets/sending_clock_icon.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +import '../../core/config/app_animations.dart'; + +class SendingClockIcon extends StatelessWidget { + final Color color; + final double size; + + const SendingClockIcon({super.key, required this.color, this.size = 14}); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: size, + child: Lottie.asset( + AppAnimations.clock, + repeat: true, + fit: BoxFit.contain, + delegates: LottieDelegates( + values: [ + ValueDelegate.color(const ['**'], value: color), + ValueDelegate.strokeColor(const ['**'], value: color), + ], + ), + ), + ); + } +} + +bool isSendingStatus(String? status) => + status == 'sending' || status == 'pending'; diff --git a/lib/frontend/widgets/settings_card.dart b/lib/frontend/widgets/settings_card.dart index b8a1870..8a4d66f 100644 --- a/lib/frontend/widgets/settings_card.dart +++ b/lib/frontend/widgets/settings_card.dart @@ -1,8 +1,34 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../core/config/app_shape.dart'; import 'glossy_pill.dart'; +class SettingsPanel extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry padding; + final Color? color; + + const SettingsPanel({ + super.key, + required this.child, + this.padding = const EdgeInsets.fromLTRB(20, 18, 20, 20), + this.color, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GlossyPill( + color: color ?? cs.surfaceContainerHigh, + borderRadius: AppShape.cardRadius, + padding: padding, + depth: 6, + child: child, + ); + } +} + class SettingsCard extends StatelessWidget { final List children; @@ -13,7 +39,7 @@ class SettingsCard extends StatelessWidget { final cs = Theme.of(context).colorScheme; return GlossyPill( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), + borderRadius: AppShape.cardRadius, depth: 6, child: Column( children: [ @@ -135,7 +161,9 @@ class SettingsNavTile extends StatelessWidget { child: InkWell( onTap: onTap ?? () {}, borderRadius: isLast - ? const BorderRadius.vertical(bottom: Radius.circular(20)) + ? const BorderRadius.vertical( + bottom: Radius.circular(AppShape.card), + ) : null, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), diff --git a/lib/frontend/widgets/sheet_helpers.dart b/lib/frontend/widgets/sheet_helpers.dart index 64909a0..66669c4 100644 --- a/lib/frontend/widgets/sheet_helpers.dart +++ b/lib/frontend/widgets/sheet_helpers.dart @@ -1,9 +1,56 @@ import 'package:flutter/material.dart'; +import '../../core/config/app_shape.dart'; + /// Standard rounded top shape for modal bottom sheets. -const RoundedRectangleBorder kSheetShape = RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), -); +const RoundedRectangleBorder kSheetShape = AppShape.sheetBorder; + +/// Pill-shaped action button for the bottom row of a modal sheet. +class SheetButton extends StatelessWidget { + final String label; + final bool filled; + final VoidCallback? onTap; + final Color? color; + + const SheetButton({ + super.key, + required this.label, + required this.filled, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final disabled = onTap == null; + final fill = color ?? cs.primary; + final labelColor = filled ? cs.onPrimary : (color ?? cs.onSurface); + return GestureDetector( + onTap: onTap, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: filled + ? (disabled ? fill.withValues(alpha: 0.4) : fill) + : cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(22), + ), + child: Text( + label, + style: TextStyle( + color: disabled + ? labelColor.withValues(alpha: filled ? 0.85 : 0.4) + : labelColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} /// The little drag "grabber" pill shown at the top of a bottom sheet. class SheetGrabber extends StatelessWidget { diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart index c877d32..1c3d0b5 100644 --- a/lib/frontend/widgets/sliding_pill_nav.dart +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -1,9 +1,15 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import '../../core/config/app_frost.dart'; +import '../../core/config/app_liquid_glass.dart'; +import '../../core/config/app_nav_pill_style.dart'; import '../../core/config/app_pill_gradient.dart'; import '../../core/config/app_visual_style.dart'; import 'animated_lottie_icon.dart'; import 'glossy_pill.dart'; +import 'liquid_glass.dart'; class PillNavItem { final IconData icon; @@ -50,6 +56,7 @@ class SlidingPillNav extends StatelessWidget { final Color? backgroundColor; final Color? borderColor; final bool iconsOnly; + final BackdropKey? backdropKey; const SlidingPillNav({ super.key, @@ -64,6 +71,7 @@ class SlidingPillNav extends StatelessWidget { this.backgroundColor, this.borderColor, this.iconsOnly = false, + this.backdropKey, }); static const double height = 68; @@ -84,13 +92,28 @@ class SlidingPillNav extends StatelessWidget { return ValueListenableBuilder( valueListenable: AppVisualStyle.current, builder: (context, style, _) { - if (style != VisualStyle.glossy) { - return _buildNav(context, glossy: false, gradient: false); + if (style == VisualStyle.materialYou) { + return _buildNav( + context, + glossy: false, + gradient: false, + frost: false, + liquid: false, + ); } return ValueListenableBuilder( valueListenable: AppPillGradient.current, builder: (context, gradient, _) => - _buildNav(context, glossy: true, gradient: gradient), + ValueListenableBuilder( + valueListenable: AppNavPillStyle.current, + builder: (context, navStyle, _) => _buildNav( + context, + glossy: true, + gradient: gradient, + frost: NavPillMaterial.isFrost(navStyle), + liquid: NavPillMaterial.isLiquid(navStyle), + ), + ), ); }, ); @@ -100,17 +123,24 @@ class SlidingPillNav extends StatelessWidget { BuildContext context, { required bool glossy, required bool gradient, + required bool frost, + required bool liquid, }) { final cs = Theme.of(context).colorScheme; final visualSel = position.round().clamp(0, items.length - 1); - final base = backgroundColor ?? cs.surfaceContainerHigh; - final useGradient = glossy && gradient; + final translucent = backgroundColor != null && backgroundColor!.a < 1; + final base = liquid + ? (translucent ? backgroundColor! : AppLiquidGlass.navTint(cs)) + : (backgroundColor ?? + (frost ? AppFrost.glassTint(cs) : cs.surfaceContainerHigh)); + final useGradient = glossy && gradient && !liquid; + final frosted = frost && !liquid && base.a < 1; return Container( height: height, padding: const EdgeInsets.symmetric(horizontal: 2), decoration: BoxDecoration( - color: useGradient ? null : base, + color: useGradient || liquid ? null : base, gradient: useGradient ? GlossyDecor.fillGradient(base) : null, borderRadius: BorderRadius.circular(34), border: glossy @@ -118,17 +148,44 @@ class SlidingPillNav extends StatelessWidget { : (borderColor != null ? Border.all(color: borderColor!, width: 0.5) : null), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], + boxShadow: frosted + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: liquid ? 0.28 : 0.5), + blurRadius: liquid ? 26 : 20, + offset: const Offset(0, 10), + ), + ], ), child: Stack( clipBehavior: Clip.hardEdge, children: [ + if (liquid) + Positioned.fill( + child: IgnorePointer( + child: LiquidGlassSurface( + borderRadius: BorderRadius.circular(34), + tint: base, + ), + ), + ), + if (frosted) + Positioned.fill( + child: IgnorePointer( + child: ClipRRect( + borderRadius: BorderRadius.circular(34), + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: AppFrost.sigma, + sigmaY: AppFrost.sigma, + ), + backdropGroupKey: backdropKey, + child: const SizedBox.expand(), + ), + ), + ), + ), if (useGradient) Positioned.fill( child: IgnorePointer( diff --git a/lib/frontend/widgets/small_spinner.dart b/lib/frontend/widgets/small_spinner.dart index 0509e3d..d2979dc 100644 --- a/lib/frontend/widgets/small_spinner.dart +++ b/lib/frontend/widgets/small_spinner.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; class SmallSpinner extends StatelessWidget { final double size; - final double strokeWidth; final Color? color; const SmallSpinner({ super.key, this.size = 26, - this.strokeWidth = 2.4, this.color, }); @@ -17,9 +16,12 @@ class SmallSpinner extends StatelessWidget { return SizedBox( width: size, height: size, - child: CircularProgressIndicator( - strokeWidth: strokeWidth, - color: color ?? Theme.of(context).colorScheme.primary, + child: FittedBox( + fit: BoxFit.contain, + child: ExpressiveLoadingIndicator( + color: color ?? Theme.of(context).colorScheme.primary, + constraints: BoxConstraints.tight(const Size.square(48)), + ), ), ); } @@ -33,7 +35,7 @@ class BusyOverlay extends StatelessWidget { return const Positioned.fill( child: ColoredBox( color: Colors.black54, - child: Center(child: CircularProgressIndicator(color: Colors.white)), + child: Center(child: SmallSpinner(size: 44, color: Colors.white)), ), ); } diff --git a/lib/frontend/widgets/spectrum_background.dart b/lib/frontend/widgets/spectrum_background.dart new file mode 100644 index 0000000..c38a18a --- /dev/null +++ b/lib/frontend/widgets/spectrum_background.dart @@ -0,0 +1,497 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import 'spectrum_tint.dart'; + +class SpectrumTuning { + const SpectrumTuning._(); + + static const double barWidth = 1; + static const double barGap = 0.6; + static const double heightFraction = 0.75; + static const double surfaceLift = 0.06; + static const double tintStrength = 0.3; + static const double tintRadius = 190; + static const double tintFollowRate = 3.5; + static const double frameInterval = 1 / 60; + static const double tintInterval = 0.2; + static const double parallax = 0.06; + static const int minBars = 4; + static const int maxBars = 1024; + + static Color baseColor(ColorScheme cs) { + final surface = cs.surface; + final lift = surface.computeLuminance() < 0.5 ? Colors.white : Colors.black; + return Color.alphaBlend(lift.withValues(alpha: surfaceLift), surface); + } +} + +class SpectrumBackground extends StatefulWidget { + const SpectrumBackground({super.key}); + + @override + State createState() => _SpectrumBackgroundState(); +} + +class _SpectrumBackgroundState extends State + with SingleTickerProviderStateMixin { + static const double _maxStep = 0.25; + + final List _samples = []; + + late final Ticker _ticker; + _SpectrumField? _field; + _SpectrumPalette? _palette; + Duration _lastElapsed = Duration.zero; + double _frameAccumulator = 0; + double _tintAccumulator = SpectrumTuning.tintInterval; + double _pitch = SpectrumTuning.barWidth + SpectrumTuning.barGap; + double _leftInset = 0; + Color _baseColor = const Color(0xFF000000); + + @override + void initState() { + super.initState(); + SpectrumTintRegistry.instance.listenForResolvedColors(_onColorResolved); + _ticker = createTicker(_onTick)..start(); + } + + @override + void dispose() { + SpectrumTintRegistry.instance.listenForResolvedColors(null); + _ticker.dispose(); + _field?.dispose(); + super.dispose(); + } + + void _onColorResolved() => _tintAccumulator = SpectrumTuning.tintInterval; + + void _onTick(Duration elapsed) { + final delta = + (elapsed - _lastElapsed).inMicroseconds / + Duration.microsecondsPerSecond; + _lastElapsed = elapsed; + if (delta <= 0) return; + + final step = delta > _maxStep ? _maxStep : delta; + + _tintAccumulator += step; + if (_tintAccumulator >= SpectrumTuning.tintInterval) { + _tintAccumulator = 0; + _refreshTints(); + } + + _frameAccumulator += step; + if (_frameAccumulator < SpectrumTuning.frameInterval) return; + + final frameStep = _frameAccumulator; + _frameAccumulator = 0; + _palette?.advance(frameStep); + _field?.update(frameStep); + } + + void _refreshTints() { + final palette = _palette; + if (palette == null) return; + + final box = context.findRenderObject(); + if (box is! RenderBox || !box.attached || !box.hasSize) return; + + final origin = box.localToGlobal(Offset.zero); + final viewport = (origin & box.size).inflate(SpectrumTuning.tintRadius); + SpectrumTintRegistry.instance.collect(_samples, viewport); + + palette.retarget( + base: _baseColor, + samples: _samples, + zoneLeft: origin.dx + _zoneLeft, + zoneRight: origin.dx + _zoneRight, + baselineY: origin.dy + box.size.height, + ); + } + + double get _zoneLeft => _leftInset; + + double get _zoneRight => + _leftInset + + (_field == null ? 0 : (_field!.barCount - 1) * _pitch) + + SpectrumTuning.barWidth; + + void _syncMetrics(Size size, Color base) { + _baseColor = base; + if (size.width <= 0 || size.height <= 0) return; + + final pitch = SpectrumTuning.barWidth + SpectrumTuning.barGap; + final count = ((size.width + SpectrumTuning.barGap) / pitch).floor().clamp( + SpectrumTuning.minBars, + SpectrumTuning.maxBars, + ); + final occupied = count * pitch - SpectrumTuning.barGap; + + _pitch = pitch; + _leftInset = (size.width - occupied) / 2; + + if (_field?.barCount == count) return; + + _field?.dispose(); + _field = _SpectrumField(count); + _palette = _SpectrumPalette(base); + } + + @override + Widget build(BuildContext context) { + final base = SpectrumTuning.baseColor(Theme.of(context).colorScheme); + + return LayoutBuilder( + builder: (context, constraints) { + _syncMetrics(constraints.biggest, base); + + final field = _field; + final palette = _palette; + if (field == null || palette == null) return const SizedBox.expand(); + + return CustomPaint( + size: Size.infinite, + isComplex: false, + willChange: true, + painter: _SpectrumPainter( + field: field, + palette: palette, + zoneLeft: _zoneLeft, + zoneRight: _zoneRight, + pitch: _pitch, + leftInset: _leftInset, + ), + ); + }, + ); + } +} + +class _SpectrumField extends ChangeNotifier { + _SpectrumField(this.barCount) + : heights = Float32List(barCount), + _targets = Float32List(barCount), + _spread = Float32List(barCount), + _sparks = Float32List(barCount), + _velocities = Float32List(barCount), + _phases = Float32List(barCount), + _rates = Float32List(barCount) { + final random = math.Random(barCount * 7919 + 13); + for (var i = 0; i < barCount; i++) { + _phases[i] = random.nextDouble() * math.pi * 2; + _rates[i] = 0.6 + random.nextDouble() * 1.5; + } + final reach = (barCount * 0.03).clamp(3.0, 40.0); + _spreadDecay = math.pow(_spreadEdge, 1 / reach).toDouble(); + } + + static const double _speed = 2.8; + static const double _ambient = 0.22; + static const double _reach = 1; + static const double _sparkReach = 0.45; + static const double _sparkDecay = 7; + static const double _riseRate = 26; + static const double _gravity = 5.5; + static const double _spreadEdge = 0.1; + + final int barCount; + final Float32List heights; + final Float32List _targets; + final Float32List _spread; + final Float32List _sparks; + final Float32List _velocities; + final Float32List _phases; + final Float32List _rates; + final math.Random _random = math.Random(4409); + + late final double _spreadDecay; + double _elapsed = 0; + double _sparkCountdown = 0.15; + + void update(double dt) { + _elapsed += dt; + _driveTargets(dt); + _spreadToNeighbours(); + _applyGravity(dt); + notifyListeners(); + } + + void _driveTargets(double dt) { + _sparkCountdown -= dt; + if (_sparkCountdown <= 0) { + _sparkCountdown = 0.08 + _random.nextDouble() * 0.3; + _sparks[_random.nextInt(barCount)] = 0.5 + _random.nextDouble() * 0.5; + } + final sparkDecay = math.exp(-dt * _sparkDecay); + + final time = _elapsed * _speed; + final peak = + 0.5 + 0.24 * math.sin(time * 0.11) + 0.09 * math.sin(time * 0.37 + 1.3); + final width = 0.19 + 0.05 * math.sin(time * 0.23); + final last = barCount - 1; + + for (var i = 0; i < barCount; i++) { + final position = last == 0 ? 0.5 : i / last; + final distance = (position - peak) / width; + final envelope = math.exp(-distance * distance); + + final slow = 0.5 + 0.5 * math.sin(time * _rates[i] * 0.55 + _phases[i]); + final fast = + 0.5 + 0.5 * math.sin(time * _rates[i] * 2.3 + _phases[i] * 1.7); + final wobble = slow * fast; + + _sparks[i] *= sparkDecay; + final driven = + envelope * (0.3 + 0.7 * wobble) * _reach + + _ambient * wobble + + _sparks[i] * _sparkReach; + _targets[i] = driven > 1 ? 1 : driven; + } + } + + void _spreadToNeighbours() { + var running = 0.0; + for (var i = 0; i < barCount; i++) { + running *= _spreadDecay; + final value = _targets[i]; + if (value > running) running = value; + _spread[i] = running; + } + running = 0.0; + for (var i = barCount - 1; i >= 0; i--) { + running *= _spreadDecay; + final value = _targets[i]; + if (value > running) running = value; + if (running > _spread[i]) _spread[i] = running; + } + } + + void _applyGravity(double dt) { + final riseFactor = 1 - math.exp(-dt * _riseRate); + for (var i = 0; i < barCount; i++) { + final target = _spread[i]; + final current = heights[i]; + if (target >= current) { + heights[i] = current + (target - current) * riseFactor; + _velocities[i] = 0; + continue; + } + _velocities[i] += _gravity * dt; + final next = current - _velocities[i] * dt; + if (next <= target) { + heights[i] = target; + _velocities[i] = 0; + } else { + heights[i] = next; + } + } + } +} + +class _SpectrumPalette { + _SpectrumPalette(Color base) { + _writeUniform(_current, base); + _writeUniform(_target, base); + for (var i = 0; i < stopCount; i++) { + colors[i] = base; + } + } + + static const int stopCount = 16; + static const double _epsilon = 0.0008; + static final double _radiusSquared = + SpectrumTuning.tintRadius * SpectrumTuning.tintRadius; + static final List _stops = List.generate( + stopCount, + (i) => i / (stopCount - 1), + ); + + final List colors = List.filled( + stopCount, + const Color(0xFF000000), + ); + final Float32List _current = Float32List(stopCount * 3); + final Float32List _target = Float32List(stopCount * 3); + + bool uniform = true; + bool _targetUniform = true; + + static void _writeUniform(Float32List channels, Color color) { + for (var i = 0; i < channels.length; i += 3) { + channels[i] = color.r; + channels[i + 1] = color.g; + channels[i + 2] = color.b; + } + } + + void retarget({ + required Color base, + required List samples, + required double zoneLeft, + required double zoneRight, + required double baselineY, + }) { + final baseRed = base.r; + final baseGreen = base.g; + final baseBlue = base.b; + final span = zoneRight - zoneLeft; + var anyTinted = false; + + for (var i = 0; i < stopCount; i++) { + final x = zoneLeft + span * _stops[i]; + var sumRed = 0.0; + var sumGreen = 0.0; + var sumBlue = 0.0; + var sumWeight = 0.0; + + for (var s = 0; s < samples.length; s++) { + final sample = samples[s]; + final dx = sample.center.dx - x; + final dy = sample.center.dy - baselineY; + final weight = + sample.weight / (1 + (dx * dx + dy * dy) / _radiusSquared); + if (weight < 0.015) continue; + sumRed += sample.color.r * weight; + sumGreen += sample.color.g * weight; + sumBlue += sample.color.b * weight; + sumWeight += weight; + } + + final index = i * 3; + if (sumWeight <= 0) { + _target[index] = baseRed; + _target[index + 1] = baseGreen; + _target[index + 2] = baseBlue; + continue; + } + + final influence = + (sumWeight > 1 ? 1.0 : sumWeight) * SpectrumTuning.tintStrength; + _target[index] = baseRed + (sumRed / sumWeight - baseRed) * influence; + _target[index + 1] = + baseGreen + (sumGreen / sumWeight - baseGreen) * influence; + _target[index + 2] = + baseBlue + (sumBlue / sumWeight - baseBlue) * influence; + anyTinted = true; + } + + _targetUniform = !anyTinted; + } + + void advance(double dt) { + final factor = 1 - math.exp(-dt * SpectrumTuning.tintFollowRate); + var changed = false; + + for (var i = 0; i < _current.length; i++) { + final delta = _target[i] - _current[i]; + if (delta < _epsilon && delta > -_epsilon) { + if (_current[i] != _target[i]) { + _current[i] = _target[i]; + changed = true; + } + continue; + } + _current[i] += delta * factor; + changed = true; + } + + if (!changed) { + uniform = _targetUniform; + return; + } + + for (var i = 0; i < stopCount; i++) { + final index = i * 3; + colors[i] = Color.from( + alpha: 1, + red: _current[index], + green: _current[index + 1], + blue: _current[index + 2], + ); + } + uniform = false; + } + + Color sampleAt(double t) { + if (t <= 0) return colors.first; + if (t >= 1) return colors.last; + final scaled = t * (stopCount - 1); + final lower = scaled.floor(); + final fraction = scaled - lower; + final a = lower * 3; + final b = a + 3; + return Color.from( + alpha: 1, + red: _current[a] + (_current[b] - _current[a]) * fraction, + green: _current[a + 1] + (_current[b + 1] - _current[a + 1]) * fraction, + blue: _current[a + 2] + (_current[b + 2] - _current[a + 2]) * fraction, + ); + } +} + +class _SpectrumPainter extends CustomPainter { + _SpectrumPainter({ + required this.field, + required this.palette, + required this.zoneLeft, + required this.zoneRight, + required this.pitch, + required this.leftInset, + }) : super(repaint: field); + + static const double _minVisibleHeight = 0.6; + + final _SpectrumField field; + final _SpectrumPalette palette; + final double zoneLeft; + final double zoneRight; + final double pitch; + final double leftInset; + + @override + void paint(Canvas canvas, Size size) { + final heights = field.heights; + if (heights.isEmpty) return; + + final maxHeight = size.height * SpectrumTuning.heightFraction; + final baseline = size.height; + final uniform = palette.uniform; + final span = zoneRight - zoneLeft; + final paint = Paint(); + if (uniform) paint.color = palette.colors.first; + + for (var i = 0; i < heights.length; i++) { + final height = heights[i] * maxHeight; + if (height < _minVisibleHeight) continue; + final left = leftInset + pitch * i; + if (!uniform) { + final center = left + SpectrumTuning.barWidth / 2; + paint.color = palette.sampleAt( + span <= 0 ? 0 : (center - zoneLeft) / span, + ); + } + canvas.drawRect( + Rect.fromLTRB( + left, + baseline - height, + left + SpectrumTuning.barWidth, + baseline, + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_SpectrumPainter old) => + old.field != field || + old.palette != palette || + old.pitch != pitch || + old.leftInset != leftInset || + old.zoneLeft != zoneLeft || + old.zoneRight != zoneRight; +} diff --git a/lib/frontend/widgets/spectrum_tint.dart b/lib/frontend/widgets/spectrum_tint.dart new file mode 100644 index 0000000..a78350d --- /dev/null +++ b/lib/frontend/widgets/spectrum_tint.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../../core/config/app_spectrum_background.dart'; +import '../../core/media/dominant_color.dart'; + +class SpectrumTintSample { + const SpectrumTintSample({ + required this.center, + required this.color, + required this.weight, + }); + + final Offset center; + final Color color; + final double weight; +} + +abstract class SpectrumTintSource { + BuildContext? get tintContext; + + String? get tintImageUrl; + + Color get tintFallbackColor; + + double get tintWeight; +} + +class SpectrumTintRegistry { + SpectrumTintRegistry._(); + + static final SpectrumTintRegistry instance = SpectrumTintRegistry._(); + + static const double _referenceWeight = 48; + + final Set _sources = {}; + VoidCallback? _resolutionListener; + + void register(SpectrumTintSource source) => _sources.add(source); + + void unregister(SpectrumTintSource source) => _sources.remove(source); + + void listenForResolvedColors(VoidCallback? listener) => + _resolutionListener = listener; + + void collect(List out, Rect viewport) { + out.clear(); + for (final source in _sources) { + final sourceContext = source.tintContext; + if (sourceContext == null) continue; + + final box = sourceContext.findRenderObject(); + if (box is! RenderBox || !box.attached || !box.hasSize) continue; + + final center = box.localToGlobal(box.size.center(Offset.zero)); + if (!viewport.contains(center)) continue; + + out.add( + SpectrumTintSample( + center: center, + color: _colorFor(source), + weight: source.tintWeight / _referenceWeight, + ), + ); + } + } + + Color _colorFor(SpectrumTintSource source) { + final url = source.tintImageUrl; + if (url == null || url.isEmpty) return source.tintFallbackColor; + + final resolved = DominantColorCache.instance.lookup(url); + if (resolved != null) return resolved; + + final listener = _resolutionListener; + if (listener != null) DominantColorCache.instance.request(url, listener); + return source.tintFallbackColor; + } +} + +mixin SpectrumSurface on State { + @override + void initState() { + super.initState(); + AppSpectrumBackground.current.addListener(_onSpectrumBackgroundChanged); + } + + @override + void dispose() { + AppSpectrumBackground.current.removeListener(_onSpectrumBackgroundChanged); + super.dispose(); + } + + void _onSpectrumBackgroundChanged() { + if (mounted) setState(() {}); + } + + Color spectrumSurfaceColor(ColorScheme cs) => + AppSpectrumBackground.isEnabled ? Colors.transparent : cs.surface; +} diff --git a/lib/frontend/widgets/springy_tap.dart b/lib/frontend/widgets/springy_tap.dart new file mode 100644 index 0000000..ca4f639 --- /dev/null +++ b/lib/frontend/widgets/springy_tap.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; + +class SpringyTap extends StatefulWidget { + final Widget child; + final double pressedScale; + final bool enabled; + + const SpringyTap({ + super.key, + required this.child, + this.pressedScale = 0.98, + this.enabled = true, + }); + + @override + State createState() => _SpringyTapState(); +} + +class _SpringyTapState extends State + with SingleTickerProviderStateMixin { + static const Duration _pressDelay = Duration(milliseconds: 60); + static const Duration _pressDuration = Duration(milliseconds: 90); + + static final SpringDescription _spring = SpringDescription.withDampingRatio( + ratio: 0.8, + stiffness: 350, + mass: 1, + ); + + late final AnimationController _controller = AnimationController.unbounded( + vsync: this, + value: 1.0, + ); + + Timer? _pressTimer; + + @override + void dispose() { + _pressTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _press() { + _controller.stop(); + _controller.animateTo( + widget.pressedScale, + duration: _pressDuration, + curve: Curves.easeOut, + ); + } + + void _release() { + if (_controller.value == 1.0 && !_controller.isAnimating) return; + _controller.animateWith( + SpringSimulation(_spring, _controller.value, 1.0, 1.5), + ); + } + + Future _pulse() async { + _controller.stop(); + await _controller.animateTo( + widget.pressedScale, + duration: _pressDuration, + curve: Curves.easeOut, + ); + if (mounted) _release(); + } + + void _onDown(PointerDownEvent _) { + _pressTimer?.cancel(); + _pressTimer = Timer(_pressDelay, () { + _pressTimer = null; + _press(); + }); + } + + void _onUp(PointerUpEvent _) { + if (_pressTimer != null) { + _pressTimer!.cancel(); + _pressTimer = null; + unawaited(_pulse()); + } else { + _release(); + } + } + + void _onCancel(PointerCancelEvent _) { + _pressTimer?.cancel(); + _pressTimer = null; + _release(); + } + + @override + Widget build(BuildContext context) { + if (!widget.enabled) return widget.child; + return Listener( + behavior: HitTestBehavior.deferToChild, + onPointerDown: _onDown, + onPointerUp: _onUp, + onPointerCancel: _onCancel, + child: ScaleTransition(scale: _controller, child: widget.child), + ); + } +} diff --git a/lib/frontend/widgets/sticker_pack_sheet.dart b/lib/frontend/widgets/sticker_pack_sheet.dart index c727ae1..bea045f 100644 --- a/lib/frontend/widgets/sticker_pack_sheet.dart +++ b/lib/frontend/widgets/sticker_pack_sheet.dart @@ -10,6 +10,7 @@ import 'custom_notification.dart'; import 'small_spinner.dart'; import 'lottie_image.dart'; import 'sticker_peek.dart'; +import '../../core/config/app_shape.dart'; enum _PackAction { forward, copyLink } @@ -304,19 +305,13 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { ? cs.surfaceContainerHighest : cs.primary, foregroundColor: _isFavorite ? cs.onSurface : cs.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: AppShape.buttonBorder, ), onPressed: _busy ? null : _toggle, child: _busy - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: _isFavorite ? cs.onSurface : cs.onPrimary, - ), + ? SmallSpinner( + size: 22, + color: _isFavorite ? cs.onSurface : cs.onPrimary, ) : Text( _isFavorite ? 'Убрать' : 'Добавить', diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index 0fedcdb..14fda4b 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; @@ -17,6 +16,8 @@ import 'segmented_pill_toggle.dart'; import 'small_spinner.dart'; import 'lottie_image.dart'; import 'sticker_peek.dart'; +import '../../core/config/app_frost.dart'; +import 'liquid_glass.dart'; class _DragScrollBehavior extends MaterialScrollBehavior { const _DragScrollBehavior(); @@ -49,12 +50,14 @@ class StickerPanel extends StatefulWidget { final double height; final void Function(StickerItem sticker) onStickerTap; final void Function(Animoji animoji)? onEmojiTap; + final void Function(double delta)? onResize; const StickerPanel({ super.key, required this.height, required this.onStickerTap, this.onEmojiTap, + this.onResize, }); @override @@ -67,6 +70,7 @@ class _StickerPanelState extends State static const double _headerHeight = 34; static const double _searchFieldHeight = 50; static const double _toggleBarHeight = 48; + static const double _resizeHandleHeight = 16; static const int _modeEmoji = 0; static const int _modeStickers = 1; static const String _modePrefKey = 'komet_panel_mode'; @@ -248,28 +252,41 @@ class _StickerPanelState extends State final cs = Theme.of(context).colorScheme; return SizedBox( height: widget.height, - child: ClipRect( - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 34, sigmaY: 34), - child: DecoratedBox( - decoration: BoxDecoration( - color: cs.surface.withValues(alpha: 0.38), - border: Border( - top: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.4), - width: 0.5, - ), + child: GlassSurface( + liquid: false, + frostTint: AppFrost.glassTint(cs), + border: Border(top: AppFrost.hairline(cs)), + child: SafeArea( + top: false, + child: Column( + children: [ + if (widget.onResize != null) _buildResizeHandle(cs), + Expanded( + child: _mode == _modeEmoji && widget.onEmojiTap != null + ? EmojiPanel(onEmojiTap: widget.onEmojiTap!) + : _buildStickerBody(cs), ), - ), - child: Column( - children: [ - Expanded( - child: _mode == _modeEmoji && widget.onEmojiTap != null - ? EmojiPanel(onEmojiTap: widget.onEmojiTap!) - : _buildStickerBody(cs), - ), - if (widget.onEmojiTap != null) _buildToggleBar(cs), - ], + if (widget.onEmojiTap != null) _buildToggleBar(cs), + ], + ), + ), + ), + ); + } + + Widget _buildResizeHandle(ColorScheme cs) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (details) => widget.onResize!(-details.delta.dy), + child: SizedBox( + height: _resizeHandleHeight, + child: Center( + child: Container( + width: 38, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), ), ), ), diff --git a/lib/frontend/widgets/sticker_peek.dart b/lib/frontend/widgets/sticker_peek.dart index 99d5330..aaf08bb 100644 --- a/lib/frontend/widgets/sticker_peek.dart +++ b/lib/frontend/widgets/sticker_peek.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../core/utils/haptics.dart'; import 'lottie_image.dart'; +import '../../core/config/app_frost.dart'; class _PeekData { final String? url; @@ -210,7 +211,10 @@ class _PeekOverlay extends StatelessWidget { children: [ Positioned.fill( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * t, sigmaY: 20 * t), + filter: ImageFilter.blur( + sigmaX: AppFrost.overlaySigma * t, + sigmaY: AppFrost.overlaySigma * t, + ), child: ColoredBox( color: Colors.black.withValues(alpha: 0.3 * t), ), diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart index c13bcd2..c0002c5 100644 --- a/lib/frontend/widgets/swipe_route.dart +++ b/lib/frontend/widgets/swipe_route.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flutter/cupertino.dart'; -import 'rightward_drag_recognizer.dart'; +import 'directional_drag_recognizer.dart'; class SwipeRoute extends PageRoute { SwipeRoute({ diff --git a/lib/frontend/widgets/swipe_to_pop.dart b/lib/frontend/widgets/swipe_to_pop.dart index ea20bf8..b7481a1 100644 --- a/lib/frontend/widgets/swipe_to_pop.dart +++ b/lib/frontend/widgets/swipe_to_pop.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'rightward_drag_recognizer.dart'; +import 'directional_drag_recognizer.dart'; class SwipeToPop extends StatefulWidget { final Widget child; diff --git a/lib/frontend/widgets/text_entity_actions.dart b/lib/frontend/widgets/text_entity_actions.dart new file mode 100644 index 0000000..6e903ed --- /dev/null +++ b/lib/frontend/widgets/text_entity_actions.dart @@ -0,0 +1,213 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../backend/modules/contacts.dart'; +import '../../core/utils/text_entities.dart'; +import '../../main.dart' show api; +import 'chat_menu_overlay.dart'; +import 'custom_notification.dart'; +import 'komet_avatar.dart'; +import 'max_link_handler.dart'; +import 'small_spinner.dart'; + +Future openMentionProfile(BuildContext context, String nickname) async { + final handled = await tryHandleMaxLink(context, 'https://max.ru/$nickname'); + if (handled || !context.mounted) return; + showCustomNotification(context, 'Профиль @$nickname не найден'); +} + +Future copyTextEntity( + BuildContext context, + String value, + String message, +) async { + await Clipboard.setData(ClipboardData(text: value)); + if (!context.mounted) return; + showCustomNotification(context, message); +} + +void showPhoneEntityMenu( + BuildContext context, + String phone, { + required Offset at, +}) { + showChatMenu( + context: context, + anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0), + compact: true, + header: _PhoneOwnerHeader(phone: phone), + items: [ + ChatMenuItem( + icon: Symbols.content_copy, + label: 'Скопировать номер телефона', + onTap: () => copyTextEntity(context, phone, 'Номер скопирован'), + ), + if (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS) + ChatMenuItem( + icon: Symbols.call, + label: 'Позвонить', + onTap: () => _dial(context, phone), + ), + ], + ); +} + +void showCardEntityMenu( + BuildContext context, + String digits, { + required Offset at, +}) { + showChatMenu( + context: context, + anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0), + compact: true, + items: [ + ChatMenuItem( + icon: Symbols.content_copy, + label: 'Скопировать номер карты', + onTap: () => copyTextEntity(context, digits, 'Номер карты скопирован'), + ), + ], + footer: _CardFooter(digits: digits), + ); +} + +Future _dial(BuildContext context, String phone) async { + final uri = Uri(scheme: 'tel', path: phone); + var launched = false; + try { + launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + launched = false; + } + if (launched || !context.mounted) return; + showCustomNotification(context, 'Не удалось открыть приложение звонков'); +} + +class _CardFooter extends StatelessWidget { + final String digits; + + const _CardFooter({required this.digits}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final title = cardBrandTitle(digits); + return Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cardMask(digits), + style: TextStyle(color: cs.onSurface, fontSize: 14), + ), + if (title != null) ...[ + const SizedBox(height: 2), + Text( + title, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + ], + ], + ), + ); + } +} + +class _PhoneOwnerHeader extends StatefulWidget { + final String phone; + + const _PhoneOwnerHeader({required this.phone}); + + @override + State<_PhoneOwnerHeader> createState() => _PhoneOwnerHeaderState(); +} + +class _PhoneOwnerHeaderState extends State<_PhoneOwnerHeader> { + late final Future _lookup = ContactsModule.findByPhone( + api, + widget.phone, + silent: true, + ); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return FutureBuilder( + future: _lookup, + builder: (context, snapshot) { + final Widget content; + if (snapshot.connectionState != ConnectionState.done) { + content = Row( + children: [ + SmallSpinner(size: 18, color: cs.onSurfaceVariant), + const SizedBox(width: 12), + Text( + widget.phone, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + ); + } else { + final found = snapshot.data; + content = found == null + ? Text( + 'Человека ещё нет в MAX', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ) + : _OwnerRow(found: found, phone: widget.phone); + } + return Padding( + padding: const EdgeInsets.fromLTRB(14, 11, 14, 10), + child: content, + ); + }, + ); + } +} + +class _OwnerRow extends StatelessWidget { + final PhoneLookupResult found; + final String phone; + + const _OwnerRow({required this.found, required this.phone}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final resolved = found.name; + final name = (resolved == null || resolved.isEmpty) ? phone : resolved; + return Row( + children: [ + KometAvatar(name: name, size: 36, imageUrl: found.avatarUrl), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + Text( + phone, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/frontend/widgets/update_dialog.dart b/lib/frontend/widgets/update_dialog.dart index 8cb9f9a..56ddf70 100644 --- a/lib/frontend/widgets/update_dialog.dart +++ b/lib/frontend/widgets/update_dialog.dart @@ -5,11 +5,10 @@ import '../../core/utils/update_installer.dart'; import '../../core/utils/link_opener.dart'; import '../../l10n/app_localizations.dart'; import 'custom_notification.dart'; +import '../../core/config/app_fonts.dart'; +import '../../core/config/app_shape.dart'; -Future showUpdateDialog( - BuildContext context, - AppUpdateInfo info, -) async { +Future showUpdateDialog(BuildContext context, AppUpdateInfo info) async { final l10n = AppLocalizations.of(context)!; await showDialog( context: context, @@ -18,10 +17,11 @@ Future showUpdateDialog( final notes = info.notes; return AlertDialog( backgroundColor: cs.surfaceContainerHigh, + shape: AppShape.dialogBorder, title: Text( l10n.updateAvailableTitle, style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontWeight: FontWeight.w600, fontSize: 18, color: cs.onSurface, @@ -152,6 +152,7 @@ class _UpdateProgressDialogState extends State<_UpdateProgressDialog> { final percent = (_progress * 100).round(); return AlertDialog( backgroundColor: cs.surfaceContainerHigh, + shape: AppShape.dialogBorder, content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/frontend/widgets/upload_progress_ring.dart b/lib/frontend/widgets/upload_progress_ring.dart new file mode 100644 index 0000000..1b6785b --- /dev/null +++ b/lib/frontend/widgets/upload_progress_ring.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class UploadProgressRing extends StatelessWidget { + final ValueListenable> progress; + final Color color; + final Color? trackColor; + final double size; + final double strokeWidth; + final double iconSize; + final EdgeInsets padding; + + const UploadProgressRing({ + super.key, + required this.progress, + required this.color, + this.trackColor, + this.size = 54, + this.strokeWidth = 3, + this.iconSize = 22, + this.padding = EdgeInsets.zero, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: progress, + builder: (context, values, _) { + final value = values.isEmpty + ? 0.0 + : values.reduce((a, b) => a + b) / values.length; + final done = value >= 1.0; + return SizedBox.square( + dimension: size, + child: Stack( + alignment: Alignment.center, + children: [ + Padding( + padding: padding, + child: SizedBox.expand( + child: TweenAnimationBuilder( + tween: Tween(end: value.clamp(0.0, 1.0)), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + builder: (context, shown, _) => CircularProgressIndicator( + value: done ? null : shown, + strokeWidth: strokeWidth, + backgroundColor: + trackColor ?? color.withValues(alpha: 0.25), + color: color, + ), + ), + ), + ), + Icon(Symbols.close, size: iconSize, color: color), + ], + ), + ); + }, + ); + } +} diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart deleted file mode 100644 index 6ed451a..0000000 --- a/lib/frontend/widgets/video_player_screen.dart +++ /dev/null @@ -1,325 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:video_player/video_player.dart'; - -import '../../core/utils/format.dart'; - -class VideoPlayerScreen extends StatefulWidget { - final Map sources; - final String? initialQuality; - - const VideoPlayerScreen({ - super.key, - required this.sources, - this.initialQuality, - }); - - @override - State createState() => _VideoPlayerScreenState(); -} - -class _VideoPlayerScreenState extends State { - VideoPlayerController? _controller; - bool _error = false; - bool _controlsVisible = true; - double? _dragValue; - late String _quality; - int _loadGeneration = 0; - - @override - void initState() { - super.initState(); - _quality = - widget.initialQuality != null && - widget.sources.containsKey(widget.initialQuality) - ? widget.initialQuality! - : widget.sources.keys.first; - _load(_quality); - } - - Future _load( - String quality, { - Duration? position, - bool wasPlaying = true, - }) async { - final url = widget.sources[quality]; - if (url == null) { - setState(() => _error = true); - return; - } - - final generation = ++_loadGeneration; - final old = _controller; - final controller = VideoPlayerController.networkUrl(Uri.parse(url)); - _controller = controller; - setState(() { - _quality = quality; - _error = false; - }); - - try { - await controller.initialize(); - old?.removeListener(_onTick); - await old?.dispose(); - if (!mounted) { - await controller.dispose(); - return; - } - if (generation != _loadGeneration) { - return; - } - if (position != null) await controller.seekTo(position); - if (generation != _loadGeneration) { - return; - } - controller.addListener(_onTick); - if (wasPlaying) controller.play(); - setState(() {}); - } catch (_) { - if (generation == _loadGeneration && mounted) { - setState(() => _error = true); - } - } - } - - void _onTick() { - if (mounted) setState(() {}); - } - - Future _switchQuality(String quality) async { - if (quality == _quality) return; - final c = _controller; - final position = c?.value.position; - final wasPlaying = c?.value.isPlaying ?? true; - await _load(quality, position: position, wasPlaying: wasPlaying); - } - - @override - void dispose() { - _controller?.removeListener(_onTick); - _controller?.dispose(); - super.dispose(); - } - - void _togglePlay() { - final c = _controller; - if (c == null || !c.value.isInitialized) return; - setState(() => c.value.isPlaying ? c.pause() : c.play()); - } - - void _toggleControls() { - setState(() => _controlsVisible = !_controlsVisible); - } - - @override - Widget build(BuildContext context) { - final c = _controller; - final ready = c != null && c.value.isInitialized; - final buffering = ready && c.value.isBuffering; - final value = ready ? c.value : null; - - return Scaffold( - backgroundColor: Colors.black, - body: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _toggleControls, - child: Stack( - children: [ - Center( - child: _error - ? const Icon(Symbols.error, color: Colors.white54, size: 64) - : ready - ? AspectRatio( - aspectRatio: c.value.aspectRatio, - child: VideoPlayer(c), - ) - : const CircularProgressIndicator(color: Colors.white), - ), - if (buffering) - const Center( - child: CircularProgressIndicator(color: Colors.white), - ), - if (!_error) - AnimatedOpacity( - opacity: _controlsVisible ? 1 : 0, - duration: const Duration(milliseconds: 150), - child: IgnorePointer( - ignoring: !_controlsVisible, - child: _buildControls(context, value, buffering), - ), - ), - ], - ), - ), - ); - } - - Widget _buildControls( - BuildContext context, - VideoPlayerValue? value, - bool buffering, - ) { - final topPad = MediaQuery.of(context).padding.top; - final bottomPad = MediaQuery.of(context).padding.bottom; - final duration = value?.duration ?? Duration.zero; - final position = value?.position ?? Duration.zero; - final maxMs = duration.inMilliseconds.toDouble(); - final posMs = position.inMilliseconds.toDouble().clamp(0, maxMs); - final sliderValue = _dragValue ?? posMs.toDouble(); - final isPlaying = value?.isPlaying ?? false; - - return Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.black54, Colors.transparent, Colors.black54], - stops: [0, 0.5, 1], - ), - ), - child: Column( - children: [ - Padding( - padding: EdgeInsets.only(top: topPad + 4, left: 4, right: 8), - child: Row( - children: [ - IconButton( - icon: const Icon(Symbols.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - ), - const Spacer(), - if (widget.sources.length > 1) - PopupMenuButton( - color: Colors.black87, - initialValue: _quality, - onSelected: _switchQuality, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Symbols.tune, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 6), - Text( - _quality, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ], - ), - ), - itemBuilder: (_) => widget.sources.keys - .map( - (q) => PopupMenuItem( - value: q, - child: Row( - children: [ - Icon( - q == _quality - ? Symbols.check - : Symbols.check_box_outline_blank, - color: q == _quality - ? Colors.white - : Colors.transparent, - size: 18, - ), - const SizedBox(width: 8), - Text( - q, - style: const TextStyle(color: Colors.white), - ), - ], - ), - ), - ) - .toList(), - ), - ], - ), - ), - Expanded( - child: Center( - child: buffering - ? const SizedBox.shrink() - : IconButton( - iconSize: 64, - icon: Icon( - isPlaying ? Symbols.pause : Symbols.play_arrow, - color: Colors.white, - fill: 1, - ), - onPressed: _togglePlay, - ), - ), - ), - Padding( - padding: EdgeInsets.only( - left: 12, - right: 12, - bottom: bottomPad + 8, - ), - child: Row( - children: [ - Text( - formatDurationClock(position), - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - Expanded( - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 2, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 6, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 14, - ), - activeTrackColor: Colors.white, - inactiveTrackColor: Colors.white30, - thumbColor: Colors.white, - ), - child: Slider( - min: 0, - max: maxMs <= 0 ? 1 : maxMs, - value: maxMs <= 0 - ? 0 - : sliderValue.clamp(0, maxMs).toDouble(), - onChanged: maxMs <= 0 - ? null - : (v) => setState(() => _dragValue = v), - onChangeEnd: maxMs <= 0 - ? null - : (v) { - _controller?.seekTo( - Duration(milliseconds: v.round()), - ); - setState(() => _dragValue = null); - }, - ), - ), - ), - Text( - formatDurationClock(duration), - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/frontend/widgets/web_qr_login.dart b/lib/frontend/widgets/web_qr_login.dart index 96feca7..2af6b0a 100644 --- a/lib/frontend/widgets/web_qr_login.dart +++ b/lib/frontend/widgets/web_qr_login.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import '../../main.dart' show accountModule; import 'custom_notification.dart'; import 'sheet_helpers.dart'; +import 'small_spinner.dart'; +import '../../core/config/app_fonts.dart'; Future showWebQrLoginConfirmSheet(BuildContext context) async { final agreed = await showModalBottomSheet( @@ -23,7 +25,7 @@ Future showWebQrLoginConfirmSheet(BuildContext context) async { Text( 'Вход по QR', style: TextStyle( - fontFamily: 'Outfit', + fontFamily: displayFontOf(context), fontSize: 20, fontWeight: FontWeight.w700, color: cs.onSurface, @@ -88,7 +90,7 @@ Future confirmAndAuthorizeWebQrLogin( color: cs.surfaceContainerHigh, child: const Padding( padding: EdgeInsets.all(28), - child: CircularProgressIndicator(), + child: SmallSpinner(size: 36), ), ), ), diff --git a/lib/frontend/widgets/webview_permission_prompt.dart b/lib/frontend/widgets/webview_permission_prompt.dart index 552ccac..0a77724 100644 --- a/lib/frontend/widgets/webview_permission_prompt.dart +++ b/lib/frontend/widgets/webview_permission_prompt.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import '../../core/config/app_shape.dart'; String _resourceLabel(PermissionResourceType type) { if (type == PermissionResourceType.CAMERA) return 'камера'; @@ -16,9 +17,9 @@ Future askWebViewPermission( PermissionRequest request, ) async { PermissionResponse deny() => PermissionResponse( - resources: request.resources, - action: PermissionResponseAction.DENY, - ); + resources: request.resources, + action: PermissionResponseAction.DENY, + ); if (!context.mounted) return deny(); @@ -32,6 +33,7 @@ Future askWebViewPermission( final granted = await showDialog( context: context, builder: (ctx) => AlertDialog( + shape: AppShape.dialogBorder, title: const Text('Запрос доступа'), content: Text('$host запрашивает доступ к: $labels.'), actions: [ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cc4c732..d747053 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11,13 +11,14 @@ "loginConfirmPhoneTitle": "Is this the correct number?", "loginEdit": "Change", "loginDone": "Done", - "loginReadTermsNotification": "Please read the terms of use first", "loginSpoofRedacted": "Spoofing", "loginProxy": "Proxy", "loginChangeServer": "Change server", "serverSettingsTitle": "Server", "serverHostLabel": "Host", "serverPortLabel": "Port", + "serverTrustMincifryTitle": "Trust the Минцифры CA", + "serverTrustMincifrySubtitle": "Required for api2.oneme.ru: its certificate chains to the Russian Trusted Root CA, which is absent from the standard trust store. The root is bundled with the app; other hosts keep using the usual roots.", "serverApply": "Apply and reconnect", "serverUseDefault": "Reset to default", "serverInvalidHostOrPort": "Enter a valid host and port (1–65535)", @@ -49,7 +50,6 @@ "codeResendSms": "Resend code via SMS", "codeError2faMissing": "Error: missing data for 2FA", "codeConfirmation2faWarning": "MAX may require 2FA on your account to sign in. If you didn't receive the code, set up 2FA from a client where you're already signed in.", - "proxySettingsTitle": "Proxy", "proxyTypeNone": "Disabled", "proxyTypeSocks5": "SOCKS5", @@ -62,7 +62,6 @@ "proxyDisable": "Disable proxy", "proxySettingsSaved": "Proxy settings applied", "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)", - "spoofScreenTitle": "Session spoofing", "spoofEnableTitle": "Device spoofing", "spoofEnableSubtitleOn": "Enabled for this account", @@ -121,8 +120,12 @@ "profileMenuSpoof": "Spoofing", "infoTitle": "Info", "infoAccountSection": "Account", + "infoPacketSection": "Login packet", + "infoChatsSection": "Chats in login packet", + "infoChatSettingsSection": "Per-chat settings", "infoServerSection": "Server", "infoUserSection": "User", + "infoExperimentsSection": "Experiments", "infoYMapSection": "Y-Map", "infoFileUploadTypes": "file-upload-unsupported-types", "infoWhiteListLinks": "white-list-links", @@ -131,7 +134,30 @@ "infoVideoChatHistory": "videoChatHistory", "infoUpdateTime": "updateTime", "infoId": "id", + "infoPhone": "phone", + "infoPhotoId": "photoId", + "infoAccountStatus": "accountStatus", + "infoContactOptions": "contact options", + "infoProfileOptions": "profile options", + "infoNames": "names", + "infoBaseUrl": "baseUrl", + "infoBaseRawUrl": "baseRawUrl", "infoChatMarker": "chatMarker", + "infoServerTime": "server time", + "infoUpdates": "updates", + "infoMessagesCount": "messages in packet", + "infoContactsCount": "contacts in packet", + "infoPresenceCount": "presence records", + "infoConfigHash": "config hash", + "infoChatsCount": "chats loaded", + "infoChatsActive": "active", + "infoChatsHidden": "hidden", + "infoChatsDialogs": "dialogs", + "infoChatsGroups": "groups", + "infoChatsChannels": "channels", + "infoChatsUnread": "unread chats", + "infoChatsNewMessages": "new messages", + "infoChatsMessages": "messages in loaded chats", "infoAccountRemovalEnabled": "account-removal-enabled", "infoImageSize": "image-size", "infoGce": "gce", @@ -158,6 +184,16 @@ "chatInfoLink": "link:", "chatInfoOfficial": "official:", "chatInfoComments": "comments:", + "commentsWrite": "Comment", + "commentsTitle": "Comments", + "commentsCount": "{count, plural, =1{1 comment} other{{count} comments}}", + "@commentsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "chatInfoAplus": "approved by Roskomnadzor:", "chatInfoSignAdmin": "admin signature:", "chatInfoLastChanged": "last changed:", @@ -169,7 +205,6 @@ "chatInfoHasBots": "has bots:", "chatInfoBlockedCount": "blocked in group:", "chatInfoOfficialStatus": "official status:", - "chatInfoLastChanged": "last changed:", "chatInfoJoined": "joined:", "chatInfoGroupCreated": "group created:", "chatInfoGroupOwner": "group owner:", @@ -183,6 +218,7 @@ "registrationSubtitle": "Add your name and pick an avatar", "registrationChooseAvatar": "Choose an avatar", "msgActionsCopy": "Copy", + "msgActionsSelectAll": "Select all", "emojiSearchHint": "Search emoji", "msgActionsEdit": "Edit", "msgActionsReply": "Reply", @@ -192,6 +228,9 @@ "msgActionsUnpin": "Unpin", "pinnedMessageTitle": "Pinned message", "msgActionsEditHistory": "Edit history", + "msgActionsReadBy": "Read by", + "msgActionsReadByEmpty": "Nobody has read it yet", + "msgActionsReadByUnknownUser": "User", "msgActionsReport": "Report", "msgActionsDelete": "Delete", "msgActionsCopied": "Copied", @@ -215,11 +254,18 @@ } }, "notificationsFkmAlreadyHasFcm": "Why? You already have FCM.", - "notificationsFkmDownloadFcm": "Better download the FCM version.", + "notificationsFkmIosUnsupported": "Push notifications are not available on iOS yet.", "notificationsTitle": "Notifications", "notificationsFkmSectionTitle": "FKM", "notificationsFkmEnableLabel": "Enable notifications", "notificationsFkmEnableSubtitle": "For FKM notifications to work, the app will need to keep a notification in the shade.", + "notificationsFkmUnsupported": "FKM is Android-only", + "notificationsFkmBatteryAction": "Open settings", + "notificationsFkmBatteryMessage": "Otherwise the system will put the background connection to sleep and notifications will be late or lost.", + "notificationsFkmBatteryTitle": "Turn off battery saving?", + "notificationsFkmPermissionDenied": "FKM cannot work without the notification permission", + "notificationsFkmConfirmAction": "Enable FKM", + "notificationsFkmConfirmMessage": "Notifications will arrive over the app’s own background connection, and a permanent service notification will stay in the shade. You can turn FKM off right from it.", "notificationsMainSectionTitle": "Notifications", "notificationsAllLabel": "All notifications", "notificationsNewSectionTitle": "New notifications", @@ -284,16 +330,31 @@ "appearanceTitle": "Appearance", "appearanceVisualStyleTitle": "Visual style", "appearanceVisualStyleSubtitle": "Material You or dimensional Glossy capsules", + "appearanceStyleAuto": "Match theme", "appearanceVisualStyleMaterialYou": "Material You", "appearanceVisualStyleGlossy": "Glossy", + "appearanceVisualStyleLiquidGlass": "Liquid Glass", + "appearanceGlassMaterial": "Glass", "appearanceChatChromeTitle": "Chat screen elements", "appearanceChatChromeSubtitle": "Background of the top and bottom panels: color, blur, or transparent. With blur or transparency, messages scroll under the panels", "appearanceChatChromeColor": "Color", "appearanceChatChromeBlur": "Blur", "appearanceChatChromeNone": "None", - "appearanceChatChromeTransparent": "Clear", + "appearanceChatChromeTransparent": "Frost blur", + "appearanceComposerTitle": "Input bar", + "appearanceComposerSubtitle": "Style and background of the message input bar", + "appearanceComposerBackgroundStandard": "Default", + "appearanceComposerBackgroundFrost": "Frost blur", + "appearanceNavPillTitle": "Switcher style", + "appearanceNavPillSubtitle": "Section switcher on the chats screen", + "appearanceNavPillGlossy": "Glossy", + "appearanceNavPillFrost": "G-FrostBlur", + "playbackPillAt": "at", + "playbackPillYou": "You", "appearanceGradientTitle": "Gradient", "appearanceGradientSubtitle": "Depth and highlights in Glossy capsules", + "appearanceSpectrumTitle": "Spectrum background", + "appearanceSpectrumSubtitle": "Experimental — living bars beneath the interface", "appearanceAccentColorTitle": "Accent color", "appearanceAccentColorSystem": "System", "appearanceAccentColorSubtitle": "Main color of the interface and bubbles", @@ -311,14 +372,20 @@ "appearancePreviewHowIsIt": "How do you like it?", "appearancePreviewHmm": "hmm...", "appearancePreviewNotBad": "Not bad at all!", - "callKometDetectedNotification": "This person uses Komet! :3", "callStatusConnecting": "Connecting", "callGroupConnecting": "Connecting…", "callGroupWaitingParticipants": "Waiting for participants…", + "callLinkGroupCall": "Group call", + "callLinkSendInMax": "Send in MAX", + "callLinkStart": "Start call", + "callLinkSent": "Link sent", + "callLinkSendFailed": "Couldn't send the link", + "callLinkCreateFailed": "Couldn't create the call", "callParticipantYou": "You", "callParticipantFallback": "Participant", "callTooltipMinimize": "Minimize", + "callTooltipExpand": "Expand", "callTooltipKometHub": "Komet", "callInfoTitle": "About call", "callPeerMicOff": "Microphone off", @@ -335,6 +402,17 @@ "callUnmute": "Unmute", "callMute": "Mute", "callEndButton": "End", + "callCameraUnavailable": "Camera unavailable: {error}", + "callTooltipMicrophone": "Microphone", + "callMicrophoneTitle": "Microphone", + "callMicrophoneSystem": "System default", + "callMicrophoneEmpty": "No microphones found", + "callMicrophoneRefresh": "Refresh list", + "callMicrophoneMonitors": "Monitors — system audio", + "callMicrophoneFallback": "Microphone {index}", + "callMicrophoneFailed": "Could not switch microphone: {error}", + "callMicStillLive": "Still live", + "callNoMuteHint": "--no-mute: audio keeps going out even while the mic is off", "callInfoClient": "Client", "callInfoPlatform": "Platform", "callInfoCountry": "Country", @@ -373,7 +451,6 @@ "callBadgeNoiseSuppression": "Noise suppression", "callBadgeAnimoji": "Animoji", "callInfoNoDataYet": "Data will appear after connecting…", - "hubTitleMenu": "Komet", "hubChatPageTitle": "Anonymous chat", "hubGamesTitle": "Games", @@ -394,7 +471,6 @@ "hubCheckersLost": "You lost", "hubCheckersYourMove": "Your move", "hubCheckersOpponentMove": "Opponent's move…", - "scheduledPickTimeTitle": "When to send", "scheduledEditTitle": "Edit", "scheduledMessageTextHint": "Message text", @@ -413,7 +489,6 @@ "scheduledAttachLocation": "Location", "scheduledAttachForwarded": "Forwarded", "scheduledAttachGeneric": "Attachment", - "contactProfileLoadError": "Error: {error}", "@contactProfileLoadError": { "placeholders": { @@ -428,6 +503,7 @@ "contactProfileActionChat": "Chat", "contactProfileActionSound": "Sound", "contactProfileActionCall": "Call", + "contactProfileActionAddContact": "Add to contacts", "contactProfileInfoPhone": "Phone", "contactProfileInfoCountry": "Country", "contactProfileInfoGender": "Gender", @@ -437,7 +513,6 @@ "contactProfileInfoDescription": "Description", "contactProfileInfoLink": "Link", "contactProfileInfoFlags": "Flags", - "nfcPeerNameFallback": "Contact #{id}", "@nfcPeerNameFallback": { "placeholders": { @@ -476,7 +551,6 @@ }, "nfcAdded": "Added", "nfcAddContact": "Add contact", - "chatInfoTabGeneralChats": "Common chats", "chatInfoTabMedia": "Media", "chatInfoTabFiles": "Files", @@ -510,9 +584,124 @@ "sharedLoadMore": "Show more", "sharedGoToMessage": "Go to message", "sharedDownload": "Download", + "photoViewerCounter": "Photo {index} of {total}", + "@photoViewerCounter": { + "placeholders": { + "index": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "photoViewerCounterFile": "FILE of {total}", + "@photoViewerCounterFile": { + "placeholders": { + "total": { + "type": "int" + } + } + }, + "photoViewerSentToday": "{sender} • today at {time}", + "@photoViewerSentToday": { + "placeholders": { + "sender": { + "type": "String" + }, + "time": { + "type": "String" + } + } + }, + "photoViewerSentOn": "{sender} • {date} at {time}", + "@photoViewerSentOn": { + "placeholders": { + "sender": { + "type": "String" + }, + "date": { + "type": "String" + }, + "time": { + "type": "String" + } + } + }, + "photoViewerSaveAs": "Save as…", + "photoViewerViewAll": "View all photos", + "photoViewerRotate": "Rotate", + "mediaViewerCounter": "{index} of {total}", + "@mediaViewerCounter": { + "placeholders": { + "index": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "mediaViewerViewAll": "View all media", + "videoViewerSettings": "Settings", + "videoViewerSpeed": "Speed", + "videoViewerQuality": "Quality", "sharedCopyLink": "Copy link", "sharedLinkCopied": "Link copied", "chatInfoActionLeave": "Leave", + "chatInfoActionMuted": "Muted", + "chatInfoNotificationsOn": "Notifications on", + "chatInfoNotificationsOff": "Notifications off", + "chatInfoMenuBlock": "Block", + "chatInfoMenuUnblock": "Unblock", + "chatInfoMenuDeleteChat": "Delete chat", + "chatInfoMenuClearHistory": "Clear history", + "chatInfoClearHistoryTitle": "Clear history", + "chatInfoClearHistoryMessage": "All messages in this chat will be deleted permanently.", + "chatInfoClearHistoryForAll": "For everyone", + "chatInfoClearHistoryConfirm": "Clear", + "chatInfoClearHistoryDone": "History cleared", + "chatInfoDeleteChatTitle": "Delete chat", + "chatInfoDeleteChatMessage": "The chat will be deleted together with the whole conversation.", + "chatInfoDeleteChatConfirm": "Delete", + "chatInfoLeaveGroupTitle": "Leave group", + "chatInfoLeaveGroupMessage": "You will no longer receive messages from this group.", + "chatInfoLeaveChannelTitle": "Leave channel", + "chatInfoLeaveChannelMessage": "You will no longer receive posts from this channel.", + "chatInfoLeaveConfirm": "Leave", + "chatInfoLeaveFailed": "Could not leave the chat", + "chatInfoCallConfirmTitle": "Start a call", + "chatInfoCallConfirmMessage": "Call {name}?", + "@chatInfoCallConfirmMessage": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "chatInfoConfirmYes": "Yes", + "chatInfoConfirmNo": "No", + "chatInfoCallFailed": "Could not start the call", + "chatInfoBlockConfirmTitle": "Block", + "chatInfoBlockConfirmMessage": "Are you sure you want to block {name}?", + "@chatInfoBlockConfirmMessage": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "chatInfoBlockDone": "User blocked", + "chatInfoUnblockDone": "User unblocked", + "chatInfoBlockFailed": "Could not change the block state", + "chatInfoComplaintTitle": "Report", + "chatInfoComplaintSubtitle": "Choose a reason for the report", + "chatInfoComplaintSend": "Report", + "chatInfoComplaintClose": "Close", + "chatInfoComplaintEmpty": "Could not load the report reasons", + "chatInfoComplaintSent": "Report sent", + "chatInfoComplaintFailed": "Could not send the report", + "chatInfoActionCancel": "Cancel", "chatInfoBio": "About", "chatInfoInviteLink": "Invite link", "chatInfoCollapse": "Collapse", @@ -520,9 +709,28 @@ "chatInfoAddMember": "Add member", "chatInfoRoleOwner": "owner", "chatInfoRoleAdmin": "Admin", + "chatInfoMemberDeleted": "Account deleted", + "chatInfoInviteByLink": "Invite via link", + "chatInfoInviteLinkHint": "You can invite anyone with this link", + "chatInfoAddMembersAction": "Add", + "chatInfoMembersSearchHint": "Search", + "chatInfoAddMembersEmpty": "No one to add", + "chatInfoMembersAdded": "Members added", + "chatInfoAddMembersError": "Couldn't add members", "chatInfoNoData": "No data", "chatInfoHideExtra": "Hide", "chatInfoShowMoreExtra": "Details", + "chatSendConfirmMessage": "Send this message to the chat?", + "chatSendConfirmAction": "Send", + "chatInfoRowDisableForward": "Forwarding disabled", + "chatInfoRowCopyDisabled": "Copying disabled", + "chatInfoRowOnlyAdminCall": "Admins can call", + "chatInfoRowAllCanPin": "Anyone can pin", + "chatInfoRowMembersSeeLink": "Members see the link", + "chatInfoRowConfirmBeforeSend": "Confirm before sending", + "chatInfoRowOnlyOwnerIconTitle": "Owner edits title and icon", + "chatInfoRowPromotedDisabled": "Promoted content off", + "chatInfoRowUserId": "User ID", "chatInfoRowId": "Chat ID", "chatInfoRowCreated": "Created", "chatInfoRowModified": "Modified", @@ -540,7 +748,6 @@ "chatInfoRowComments": "Comments", "chatInfoRowRkn": "Roskomnadzor approved", "chatInfoRowOnlyAdmin": "Admins only", - "securityTitle": "Security", "securityLoadError": "Loading error: {error}", "@securityLoadError": { @@ -594,7 +801,6 @@ } } }, - "passwordEntryWrongPassword": "Wrong password", "passwordEntryConfirmTitle": "Confirm password", "passwordEntryCurrentPasswordHint": "Current password", @@ -756,7 +962,7 @@ "digitalIdDocChildOms": "Child's health insurance policy (OMS)", "attachSheetGallery": "Gallery", "attachSheetPoll": "Poll", - "attachSheetCameraComingSoon": "Camera is coming soon", + "attachSheetCameraError": "Couldn't open the camera", "attachSheetSendFileTitle": "Send a file", "attachSheetSendFileSubtitle": "A document, archive, or any other file", "attachSheetChooseFileButton": "Choose file", @@ -768,12 +974,17 @@ "attachSheetNoImagesFound": "No images found", "attachSheetLimitedAccessInfo": "Not all photos are accessible", "attachSheetSectionInProgress": "Section under development", + "attachSheetContact": "Contact", + "attachSheetContactSearchHint": "Search contacts", + "attachSheetNoContacts": "You have no contacts yet", + "attachSheetNoContactsFound": "No contacts found", "attachSheetNoGalleryAccessTitle": "No access to the gallery", "attachSheetNoGalleryAccessSubtitle": "Allow access to photos to pick them from here", "attachSheetAllow": "Allow", "attachSheetSettings": "Settings", "attachSheetAddCaptionHint": "Add a caption...", "attachSheetCamera": "Camera", + "attachSheetCameraAllow": "Allow camera", "photoEditorApplyFailed": "Couldn't apply", "photoEditorFlipTooltip": "Flip", "photoEditorRotateTooltip": "Rotate", @@ -850,5 +1061,159 @@ "updateLater": "Later", "updateSkip": "Skip", "updateDownloading": "Downloading update…", - "updateDownloadFailed": "Failed to download the update" + "updateDownloadFailed": "Failed to download the update", + "updateCheck": "Check for updates", + "updateChecking": "Checking for updates…", + "updateUpToDate": "You have the latest version", + "updateCheckFailed": "Couldn't check for updates. Try again later", + "profileResurrecting": "Oops! The server didn't send your profile. Trying to regenerate…", + "profilePhoneRegenFailed": "Couldn't regenerate your phone number. Please sign in again and report the issue to the developers", + "addContactTitle": "Add contact", + "addContactFirstName": "First name", + "addContactLastName": "Last name (optional)", + "addContactSave": "Save contact", + "addContactNotFound": "{phone} not found", + "@addContactNotFound": { + "placeholders": { + "phone": { + "type": "String" + } + } + }, + "addContactNotFoundSubtitle": "This number isn't on the app yet", + "addContactSearchOther": "Search for other number", + "addContactError": "Couldn't add contact", + "contactBubbleNew": "New contact", + "contactBubbleAlreadyAdded": "Already in your contacts", + "contactBubbleOpenProfile": "Open profile", + "miniAppOpen": "Open", + "miniAppFailed": "Couldn't open the app", + "editContactMenu": "Edit contact", + "editContactTitle": "Edit contact", + "editContactFirstName": "First name", + "editContactLastName": "Last name", + "editContactSave": "Save", + "editContactDelete": "Delete contact", + "editContactDeleteConfirmTitle": "Delete contact?", + "editContactDeleteConfirmBody": "This contact will be removed from your list.", + "editContactDeleteCancel": "Cancel", + "editContactError": "Couldn't save changes", + "downloadsTitle": "Recent downloads", + "downloadsTooltip": "Downloads", + "downloadsSettings": "Settings", + "downloadsEmpty": "Downloaded files will appear here", + "downloadsUnknownSource": "Unknown source", + "downloadsPhoto": "Photo", + "downloadsVideo": "Video", + "downloadsGif": "GIF", + "downloadsAudio": "Audio", + "downloadsFile": "File", + "downloadsOpenFailed": "Couldn't open the file", + "downloadsClearHistory": "Clear download history", + "downloadsClearTitle": "Clear download history?", + "downloadsClearBody": "The files will stay on the device, but this list will be cleared.", + "downloadsClearConfirm": "Clear", + "downloadsHistoryCleared": "Download history cleared", + "uploadNotificationPhotos": "{count, plural, =1{Photo} other{{count} photos}}", + "@uploadNotificationPhotos": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "uploadNotificationVideo": "Video", + "uploadNotificationVideoNote": "Video message", + "uploadNotificationVoice": "Voice message", + "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" + } + } + }, + "savedMessagesEmptyPreview": "Save something here", + "proxyCurrentState": "Currently: {value}", + "@proxyCurrentState": { + "placeholders": { + "value": { + "type": "String" + } + } + }, + "blacklistEmpty": "Nobody is blocked", + "blacklistLoadError": "Failed to load the blacklist", + "videoEditorQualityLow": "Small size", + "videoEditorQualityHigh": "High quality", + "videoEditorCaptionHint": "Add a caption...", + "videoEditorMuteTooltip": "Send without sound", + "videoEditorProcessing": "Processing video…", + "videoEditorExportFailed": "Failed to process the video", + "videoEditorFrameFailed": "Failed to grab a frame", + "videoEditorQualityTooltip": "Quality", + "webPushTitle": "Notifications on iOS", + "webPushIntro": "Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX's own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.", + "webPushConfirm": "Continue", + "webPushPasswordExplainer": "Two-factor protection is enabled on this account.", + "webPushPasswordHintLabel": "Hint: {hint}", + "@webPushPasswordHintLabel": { + "placeholders": { + "hint": { + "type": "String" + } + } + }, + "webPushPasswordHint": "Password", + "webPushInstallTitle": "Install the web app", + "webPushInstallBody": "Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.", + "webPushLinkedTitle": "Notifications connected", + "webPushLinkedBody": "The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.", + "webPushOpenSite": "Open push.komet.pw", + "webPushSignOut": "Disconnect notifications", + "webPushLinked": "Notifications connected", + "webPushLinkFailed": "Could not connect notifications: {error}", + "@webPushLinkFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "webPushNotAuthorized": "Sign in under \"Notifications via PWA\" first", + "webPushConnect": "Connect notifications", + "webPushWaitingBody": "Komet is approving the web session from this device. This usually takes a few seconds.", + "webPushNeedsOnline": "No connection to the server. Wait for it and try again.", + "webPushSignOutConfirm": "The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.", + "webPushSignOutAction": "Disconnect", + "webPushStatusService": "Service", + "webPushStatusToken": "Token", + "webPushStatusLinkedAt": "Linked", + "webPushStatusDevice": "Device" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 683fd31..70a88cb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -164,12 +164,6 @@ abstract class AppLocalizations { /// **'Done'** String get loginDone; - /// No description provided for @loginReadTermsNotification. - /// - /// In en, this message translates to: - /// **'Please read the terms of use first'** - String get loginReadTermsNotification; - /// No description provided for @loginSpoofRedacted. /// /// In en, this message translates to: @@ -206,6 +200,18 @@ abstract class AppLocalizations { /// **'Port'** String get serverPortLabel; + /// No description provided for @serverTrustMincifryTitle. + /// + /// In en, this message translates to: + /// **'Trust the Минцифры CA'** + String get serverTrustMincifryTitle; + + /// No description provided for @serverTrustMincifrySubtitle. + /// + /// In en, this message translates to: + /// **'Required for api2.oneme.ru: its certificate chains to the Russian Trusted Root CA, which is absent from the standard trust store. The root is bundled with the app; other hosts keep using the usual roots.'** + String get serverTrustMincifrySubtitle; + /// No description provided for @serverApply. /// /// In en, this message translates to: @@ -728,6 +734,24 @@ abstract class AppLocalizations { /// **'Account'** String get infoAccountSection; + /// No description provided for @infoPacketSection. + /// + /// In en, this message translates to: + /// **'Login packet'** + String get infoPacketSection; + + /// No description provided for @infoChatsSection. + /// + /// In en, this message translates to: + /// **'Chats in login packet'** + String get infoChatsSection; + + /// No description provided for @infoChatSettingsSection. + /// + /// In en, this message translates to: + /// **'Per-chat settings'** + String get infoChatSettingsSection; + /// No description provided for @infoServerSection. /// /// In en, this message translates to: @@ -740,6 +764,12 @@ abstract class AppLocalizations { /// **'User'** String get infoUserSection; + /// No description provided for @infoExperimentsSection. + /// + /// In en, this message translates to: + /// **'Experiments'** + String get infoExperimentsSection; + /// No description provided for @infoYMapSection. /// /// In en, this message translates to: @@ -788,12 +818,150 @@ abstract class AppLocalizations { /// **'id'** String get infoId; + /// No description provided for @infoPhone. + /// + /// In en, this message translates to: + /// **'phone'** + String get infoPhone; + + /// No description provided for @infoPhotoId. + /// + /// In en, this message translates to: + /// **'photoId'** + String get infoPhotoId; + + /// No description provided for @infoAccountStatus. + /// + /// In en, this message translates to: + /// **'accountStatus'** + String get infoAccountStatus; + + /// No description provided for @infoContactOptions. + /// + /// In en, this message translates to: + /// **'contact options'** + String get infoContactOptions; + + /// No description provided for @infoProfileOptions. + /// + /// In en, this message translates to: + /// **'profile options'** + String get infoProfileOptions; + + /// No description provided for @infoNames. + /// + /// In en, this message translates to: + /// **'names'** + String get infoNames; + + /// No description provided for @infoBaseUrl. + /// + /// In en, this message translates to: + /// **'baseUrl'** + String get infoBaseUrl; + + /// No description provided for @infoBaseRawUrl. + /// + /// In en, this message translates to: + /// **'baseRawUrl'** + String get infoBaseRawUrl; + /// No description provided for @infoChatMarker. /// /// In en, this message translates to: /// **'chatMarker'** String get infoChatMarker; + /// No description provided for @infoServerTime. + /// + /// In en, this message translates to: + /// **'server time'** + String get infoServerTime; + + /// No description provided for @infoUpdates. + /// + /// In en, this message translates to: + /// **'updates'** + String get infoUpdates; + + /// No description provided for @infoMessagesCount. + /// + /// In en, this message translates to: + /// **'messages in packet'** + String get infoMessagesCount; + + /// No description provided for @infoContactsCount. + /// + /// In en, this message translates to: + /// **'contacts in packet'** + String get infoContactsCount; + + /// No description provided for @infoPresenceCount. + /// + /// In en, this message translates to: + /// **'presence records'** + String get infoPresenceCount; + + /// No description provided for @infoConfigHash. + /// + /// In en, this message translates to: + /// **'config hash'** + String get infoConfigHash; + + /// No description provided for @infoChatsCount. + /// + /// In en, this message translates to: + /// **'chats loaded'** + String get infoChatsCount; + + /// No description provided for @infoChatsActive. + /// + /// In en, this message translates to: + /// **'active'** + String get infoChatsActive; + + /// No description provided for @infoChatsHidden. + /// + /// In en, this message translates to: + /// **'hidden'** + String get infoChatsHidden; + + /// No description provided for @infoChatsDialogs. + /// + /// In en, this message translates to: + /// **'dialogs'** + String get infoChatsDialogs; + + /// No description provided for @infoChatsGroups. + /// + /// In en, this message translates to: + /// **'groups'** + String get infoChatsGroups; + + /// No description provided for @infoChatsChannels. + /// + /// In en, this message translates to: + /// **'channels'** + String get infoChatsChannels; + + /// No description provided for @infoChatsUnread. + /// + /// In en, this message translates to: + /// **'unread chats'** + String get infoChatsUnread; + + /// No description provided for @infoChatsNewMessages. + /// + /// In en, this message translates to: + /// **'new messages'** + String get infoChatsNewMessages; + + /// No description provided for @infoChatsMessages. + /// + /// In en, this message translates to: + /// **'messages in loaded chats'** + String get infoChatsMessages; + /// No description provided for @infoAccountRemovalEnabled. /// /// In en, this message translates to: @@ -950,6 +1118,24 @@ abstract class AppLocalizations { /// **'comments:'** String get chatInfoComments; + /// No description provided for @commentsWrite. + /// + /// In en, this message translates to: + /// **'Comment'** + String get commentsWrite; + + /// No description provided for @commentsTitle. + /// + /// In en, this message translates to: + /// **'Comments'** + String get commentsTitle; + + /// No description provided for @commentsCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 comment} other{{count} comments}}'** + String commentsCount(int count); + /// No description provided for @chatInfoAplus. /// /// In en, this message translates to: @@ -1094,6 +1280,12 @@ abstract class AppLocalizations { /// **'Copy'** String get msgActionsCopy; + /// No description provided for @msgActionsSelectAll. + /// + /// In en, this message translates to: + /// **'Select all'** + String get msgActionsSelectAll; + /// No description provided for @emojiSearchHint. /// /// In en, this message translates to: @@ -1148,6 +1340,24 @@ abstract class AppLocalizations { /// **'Edit history'** String get msgActionsEditHistory; + /// No description provided for @msgActionsReadBy. + /// + /// In en, this message translates to: + /// **'Read by'** + String get msgActionsReadBy; + + /// No description provided for @msgActionsReadByEmpty. + /// + /// In en, this message translates to: + /// **'Nobody has read it yet'** + String get msgActionsReadByEmpty; + + /// No description provided for @msgActionsReadByUnknownUser. + /// + /// In en, this message translates to: + /// **'User'** + String get msgActionsReadByUnknownUser; + /// No description provided for @msgActionsReport. /// /// In en, this message translates to: @@ -1202,11 +1412,11 @@ abstract class AppLocalizations { /// **'Why? You already have FCM.'** String get notificationsFkmAlreadyHasFcm; - /// No description provided for @notificationsFkmDownloadFcm. + /// No description provided for @notificationsFkmIosUnsupported. /// /// In en, this message translates to: - /// **'Better download the FCM version.'** - String get notificationsFkmDownloadFcm; + /// **'Push notifications are not available on iOS yet.'** + String get notificationsFkmIosUnsupported; /// No description provided for @notificationsTitle. /// @@ -1232,6 +1442,48 @@ abstract class AppLocalizations { /// **'For FKM notifications to work, the app will need to keep a notification in the shade.'** String get notificationsFkmEnableSubtitle; + /// No description provided for @notificationsFkmUnsupported. + /// + /// In en, this message translates to: + /// **'FKM is Android-only'** + String get notificationsFkmUnsupported; + + /// No description provided for @notificationsFkmBatteryAction. + /// + /// In en, this message translates to: + /// **'Open settings'** + String get notificationsFkmBatteryAction; + + /// No description provided for @notificationsFkmBatteryMessage. + /// + /// In en, this message translates to: + /// **'Otherwise the system will put the background connection to sleep and notifications will be late or lost.'** + String get notificationsFkmBatteryMessage; + + /// No description provided for @notificationsFkmBatteryTitle. + /// + /// In en, this message translates to: + /// **'Turn off battery saving?'** + String get notificationsFkmBatteryTitle; + + /// No description provided for @notificationsFkmPermissionDenied. + /// + /// In en, this message translates to: + /// **'FKM cannot work without the notification permission'** + String get notificationsFkmPermissionDenied; + + /// No description provided for @notificationsFkmConfirmAction. + /// + /// In en, this message translates to: + /// **'Enable FKM'** + String get notificationsFkmConfirmAction; + + /// No description provided for @notificationsFkmConfirmMessage. + /// + /// In en, this message translates to: + /// **'Notifications will arrive over the app’s own background connection, and a permanent service notification will stay in the shade. You can turn FKM off right from it.'** + String get notificationsFkmConfirmMessage; + /// No description provided for @notificationsMainSectionTitle. /// /// In en, this message translates to: @@ -1490,6 +1742,12 @@ abstract class AppLocalizations { /// **'Material You or dimensional Glossy capsules'** String get appearanceVisualStyleSubtitle; + /// No description provided for @appearanceStyleAuto. + /// + /// In en, this message translates to: + /// **'Match theme'** + String get appearanceStyleAuto; + /// No description provided for @appearanceVisualStyleMaterialYou. /// /// In en, this message translates to: @@ -1502,6 +1760,18 @@ abstract class AppLocalizations { /// **'Glossy'** String get appearanceVisualStyleGlossy; + /// No description provided for @appearanceVisualStyleLiquidGlass. + /// + /// In en, this message translates to: + /// **'Liquid Glass'** + String get appearanceVisualStyleLiquidGlass; + + /// No description provided for @appearanceGlassMaterial. + /// + /// In en, this message translates to: + /// **'Glass'** + String get appearanceGlassMaterial; + /// No description provided for @appearanceChatChromeTitle. /// /// In en, this message translates to: @@ -1535,9 +1805,69 @@ abstract class AppLocalizations { /// No description provided for @appearanceChatChromeTransparent. /// /// In en, this message translates to: - /// **'Clear'** + /// **'Frost blur'** String get appearanceChatChromeTransparent; + /// No description provided for @appearanceComposerTitle. + /// + /// In en, this message translates to: + /// **'Input bar'** + String get appearanceComposerTitle; + + /// No description provided for @appearanceComposerSubtitle. + /// + /// In en, this message translates to: + /// **'Style and background of the message input bar'** + String get appearanceComposerSubtitle; + + /// No description provided for @appearanceComposerBackgroundStandard. + /// + /// In en, this message translates to: + /// **'Default'** + String get appearanceComposerBackgroundStandard; + + /// No description provided for @appearanceComposerBackgroundFrost. + /// + /// In en, this message translates to: + /// **'Frost blur'** + String get appearanceComposerBackgroundFrost; + + /// No description provided for @appearanceNavPillTitle. + /// + /// In en, this message translates to: + /// **'Switcher style'** + String get appearanceNavPillTitle; + + /// No description provided for @appearanceNavPillSubtitle. + /// + /// In en, this message translates to: + /// **'Section switcher on the chats screen'** + String get appearanceNavPillSubtitle; + + /// No description provided for @appearanceNavPillGlossy. + /// + /// In en, this message translates to: + /// **'Glossy'** + String get appearanceNavPillGlossy; + + /// No description provided for @appearanceNavPillFrost. + /// + /// In en, this message translates to: + /// **'G-FrostBlur'** + String get appearanceNavPillFrost; + + /// No description provided for @playbackPillAt. + /// + /// In en, this message translates to: + /// **'at'** + String get playbackPillAt; + + /// No description provided for @playbackPillYou. + /// + /// In en, this message translates to: + /// **'You'** + String get playbackPillYou; + /// No description provided for @appearanceGradientTitle. /// /// In en, this message translates to: @@ -1550,6 +1880,18 @@ abstract class AppLocalizations { /// **'Depth and highlights in Glossy capsules'** String get appearanceGradientSubtitle; + /// No description provided for @appearanceSpectrumTitle. + /// + /// In en, this message translates to: + /// **'Spectrum background'** + String get appearanceSpectrumTitle; + + /// No description provided for @appearanceSpectrumSubtitle. + /// + /// In en, this message translates to: + /// **'Experimental — living bars beneath the interface'** + String get appearanceSpectrumSubtitle; + /// No description provided for @appearanceAccentColorTitle. /// /// In en, this message translates to: @@ -1676,6 +2018,42 @@ abstract class AppLocalizations { /// **'Waiting for participants…'** String get callGroupWaitingParticipants; + /// No description provided for @callLinkGroupCall. + /// + /// In en, this message translates to: + /// **'Group call'** + String get callLinkGroupCall; + + /// No description provided for @callLinkSendInMax. + /// + /// In en, this message translates to: + /// **'Send in MAX'** + String get callLinkSendInMax; + + /// No description provided for @callLinkStart. + /// + /// In en, this message translates to: + /// **'Start call'** + String get callLinkStart; + + /// No description provided for @callLinkSent. + /// + /// In en, this message translates to: + /// **'Link sent'** + String get callLinkSent; + + /// No description provided for @callLinkSendFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t send the link'** + String get callLinkSendFailed; + + /// No description provided for @callLinkCreateFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t create the call'** + String get callLinkCreateFailed; + /// No description provided for @callParticipantYou. /// /// In en, this message translates to: @@ -1694,6 +2072,12 @@ abstract class AppLocalizations { /// **'Minimize'** String get callTooltipMinimize; + /// No description provided for @callTooltipExpand. + /// + /// In en, this message translates to: + /// **'Expand'** + String get callTooltipExpand; + /// No description provided for @callTooltipKometHub. /// /// In en, this message translates to: @@ -1790,6 +2174,72 @@ abstract class AppLocalizations { /// **'End'** String get callEndButton; + /// No description provided for @callCameraUnavailable. + /// + /// In en, this message translates to: + /// **'Camera unavailable: {error}'** + String callCameraUnavailable(Object error); + + /// No description provided for @callTooltipMicrophone. + /// + /// In en, this message translates to: + /// **'Microphone'** + String get callTooltipMicrophone; + + /// No description provided for @callMicrophoneTitle. + /// + /// In en, this message translates to: + /// **'Microphone'** + String get callMicrophoneTitle; + + /// No description provided for @callMicrophoneSystem. + /// + /// In en, this message translates to: + /// **'System default'** + String get callMicrophoneSystem; + + /// No description provided for @callMicrophoneEmpty. + /// + /// In en, this message translates to: + /// **'No microphones found'** + String get callMicrophoneEmpty; + + /// No description provided for @callMicrophoneRefresh. + /// + /// In en, this message translates to: + /// **'Refresh list'** + String get callMicrophoneRefresh; + + /// No description provided for @callMicrophoneMonitors. + /// + /// In en, this message translates to: + /// **'Monitors — system audio'** + String get callMicrophoneMonitors; + + /// No description provided for @callMicrophoneFallback. + /// + /// In en, this message translates to: + /// **'Microphone {index}'** + String callMicrophoneFallback(Object index); + + /// No description provided for @callMicrophoneFailed. + /// + /// In en, this message translates to: + /// **'Could not switch microphone: {error}'** + String callMicrophoneFailed(Object error); + + /// No description provided for @callMicStillLive. + /// + /// In en, this message translates to: + /// **'Still live'** + String get callMicStillLive; + + /// No description provided for @callNoMuteHint. + /// + /// In en, this message translates to: + /// **'--no-mute: audio keeps going out even while the mic is off'** + String get callNoMuteHint; + /// No description provided for @callInfoClient. /// /// In en, this message translates to: @@ -2246,6 +2696,12 @@ abstract class AppLocalizations { /// **'Call'** String get contactProfileActionCall; + /// No description provided for @contactProfileActionAddContact. + /// + /// In en, this message translates to: + /// **'Add to contacts'** + String get contactProfileActionAddContact; + /// No description provided for @contactProfileInfoPhone. /// /// In en, this message translates to: @@ -2498,6 +2954,78 @@ abstract class AppLocalizations { /// **'Download'** String get sharedDownload; + /// No description provided for @photoViewerCounter. + /// + /// In en, this message translates to: + /// **'Photo {index} of {total}'** + String photoViewerCounter(int index, int total); + + /// No description provided for @photoViewerCounterFile. + /// + /// In en, this message translates to: + /// **'FILE of {total}'** + String photoViewerCounterFile(int total); + + /// No description provided for @photoViewerSentToday. + /// + /// In en, this message translates to: + /// **'{sender} • today at {time}'** + String photoViewerSentToday(String sender, String time); + + /// No description provided for @photoViewerSentOn. + /// + /// In en, this message translates to: + /// **'{sender} • {date} at {time}'** + String photoViewerSentOn(String sender, String date, String time); + + /// No description provided for @photoViewerSaveAs. + /// + /// In en, this message translates to: + /// **'Save as…'** + String get photoViewerSaveAs; + + /// No description provided for @photoViewerViewAll. + /// + /// In en, this message translates to: + /// **'View all photos'** + String get photoViewerViewAll; + + /// No description provided for @photoViewerRotate. + /// + /// In en, this message translates to: + /// **'Rotate'** + String get photoViewerRotate; + + /// No description provided for @mediaViewerCounter. + /// + /// In en, this message translates to: + /// **'{index} of {total}'** + String mediaViewerCounter(int index, int total); + + /// No description provided for @mediaViewerViewAll. + /// + /// In en, this message translates to: + /// **'View all media'** + String get mediaViewerViewAll; + + /// No description provided for @videoViewerSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get videoViewerSettings; + + /// No description provided for @videoViewerSpeed. + /// + /// In en, this message translates to: + /// **'Speed'** + String get videoViewerSpeed; + + /// No description provided for @videoViewerQuality. + /// + /// In en, this message translates to: + /// **'Quality'** + String get videoViewerQuality; + /// No description provided for @sharedCopyLink. /// /// In en, this message translates to: @@ -2516,6 +3044,240 @@ abstract class AppLocalizations { /// **'Leave'** String get chatInfoActionLeave; + /// No description provided for @chatInfoActionMuted. + /// + /// In en, this message translates to: + /// **'Muted'** + String get chatInfoActionMuted; + + /// No description provided for @chatInfoNotificationsOn. + /// + /// In en, this message translates to: + /// **'Notifications on'** + String get chatInfoNotificationsOn; + + /// No description provided for @chatInfoNotificationsOff. + /// + /// In en, this message translates to: + /// **'Notifications off'** + String get chatInfoNotificationsOff; + + /// No description provided for @chatInfoMenuBlock. + /// + /// In en, this message translates to: + /// **'Block'** + String get chatInfoMenuBlock; + + /// No description provided for @chatInfoMenuUnblock. + /// + /// In en, this message translates to: + /// **'Unblock'** + String get chatInfoMenuUnblock; + + /// No description provided for @chatInfoMenuDeleteChat. + /// + /// In en, this message translates to: + /// **'Delete chat'** + String get chatInfoMenuDeleteChat; + + /// No description provided for @chatInfoMenuClearHistory. + /// + /// In en, this message translates to: + /// **'Clear history'** + String get chatInfoMenuClearHistory; + + /// No description provided for @chatInfoClearHistoryTitle. + /// + /// In en, this message translates to: + /// **'Clear history'** + String get chatInfoClearHistoryTitle; + + /// No description provided for @chatInfoClearHistoryMessage. + /// + /// In en, this message translates to: + /// **'All messages in this chat will be deleted permanently.'** + String get chatInfoClearHistoryMessage; + + /// No description provided for @chatInfoClearHistoryForAll. + /// + /// In en, this message translates to: + /// **'For everyone'** + String get chatInfoClearHistoryForAll; + + /// No description provided for @chatInfoClearHistoryConfirm. + /// + /// In en, this message translates to: + /// **'Clear'** + String get chatInfoClearHistoryConfirm; + + /// No description provided for @chatInfoClearHistoryDone. + /// + /// In en, this message translates to: + /// **'History cleared'** + String get chatInfoClearHistoryDone; + + /// No description provided for @chatInfoDeleteChatTitle. + /// + /// In en, this message translates to: + /// **'Delete chat'** + String get chatInfoDeleteChatTitle; + + /// No description provided for @chatInfoDeleteChatMessage. + /// + /// In en, this message translates to: + /// **'The chat will be deleted together with the whole conversation.'** + String get chatInfoDeleteChatMessage; + + /// No description provided for @chatInfoDeleteChatConfirm. + /// + /// In en, this message translates to: + /// **'Delete'** + String get chatInfoDeleteChatConfirm; + + /// No description provided for @chatInfoLeaveGroupTitle. + /// + /// In en, this message translates to: + /// **'Leave group'** + String get chatInfoLeaveGroupTitle; + + /// No description provided for @chatInfoLeaveGroupMessage. + /// + /// In en, this message translates to: + /// **'You will no longer receive messages from this group.'** + String get chatInfoLeaveGroupMessage; + + /// No description provided for @chatInfoLeaveChannelTitle. + /// + /// In en, this message translates to: + /// **'Leave channel'** + String get chatInfoLeaveChannelTitle; + + /// No description provided for @chatInfoLeaveChannelMessage. + /// + /// In en, this message translates to: + /// **'You will no longer receive posts from this channel.'** + String get chatInfoLeaveChannelMessage; + + /// No description provided for @chatInfoLeaveConfirm. + /// + /// In en, this message translates to: + /// **'Leave'** + String get chatInfoLeaveConfirm; + + /// No description provided for @chatInfoLeaveFailed. + /// + /// In en, this message translates to: + /// **'Could not leave the chat'** + String get chatInfoLeaveFailed; + + /// No description provided for @chatInfoCallConfirmTitle. + /// + /// In en, this message translates to: + /// **'Start a call'** + String get chatInfoCallConfirmTitle; + + /// No description provided for @chatInfoCallConfirmMessage. + /// + /// In en, this message translates to: + /// **'Call {name}?'** + String chatInfoCallConfirmMessage(String name); + + /// No description provided for @chatInfoConfirmYes. + /// + /// In en, this message translates to: + /// **'Yes'** + String get chatInfoConfirmYes; + + /// No description provided for @chatInfoConfirmNo. + /// + /// In en, this message translates to: + /// **'No'** + String get chatInfoConfirmNo; + + /// No description provided for @chatInfoCallFailed. + /// + /// In en, this message translates to: + /// **'Could not start the call'** + String get chatInfoCallFailed; + + /// No description provided for @chatInfoBlockConfirmTitle. + /// + /// In en, this message translates to: + /// **'Block'** + String get chatInfoBlockConfirmTitle; + + /// No description provided for @chatInfoBlockConfirmMessage. + /// + /// In en, this message translates to: + /// **'Are you sure you want to block {name}?'** + String chatInfoBlockConfirmMessage(String name); + + /// No description provided for @chatInfoBlockDone. + /// + /// In en, this message translates to: + /// **'User blocked'** + String get chatInfoBlockDone; + + /// No description provided for @chatInfoUnblockDone. + /// + /// In en, this message translates to: + /// **'User unblocked'** + String get chatInfoUnblockDone; + + /// No description provided for @chatInfoBlockFailed. + /// + /// In en, this message translates to: + /// **'Could not change the block state'** + String get chatInfoBlockFailed; + + /// No description provided for @chatInfoComplaintTitle. + /// + /// In en, this message translates to: + /// **'Report'** + String get chatInfoComplaintTitle; + + /// No description provided for @chatInfoComplaintSubtitle. + /// + /// In en, this message translates to: + /// **'Choose a reason for the report'** + String get chatInfoComplaintSubtitle; + + /// No description provided for @chatInfoComplaintSend. + /// + /// In en, this message translates to: + /// **'Report'** + String get chatInfoComplaintSend; + + /// No description provided for @chatInfoComplaintClose. + /// + /// In en, this message translates to: + /// **'Close'** + String get chatInfoComplaintClose; + + /// No description provided for @chatInfoComplaintEmpty. + /// + /// In en, this message translates to: + /// **'Could not load the report reasons'** + String get chatInfoComplaintEmpty; + + /// No description provided for @chatInfoComplaintSent. + /// + /// In en, this message translates to: + /// **'Report sent'** + String get chatInfoComplaintSent; + + /// No description provided for @chatInfoComplaintFailed. + /// + /// In en, this message translates to: + /// **'Could not send the report'** + String get chatInfoComplaintFailed; + + /// No description provided for @chatInfoActionCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get chatInfoActionCancel; + /// No description provided for @chatInfoBio. /// /// In en, this message translates to: @@ -2558,6 +3320,54 @@ abstract class AppLocalizations { /// **'Admin'** String get chatInfoRoleAdmin; + /// No description provided for @chatInfoMemberDeleted. + /// + /// In en, this message translates to: + /// **'Account deleted'** + String get chatInfoMemberDeleted; + + /// No description provided for @chatInfoInviteByLink. + /// + /// In en, this message translates to: + /// **'Invite via link'** + String get chatInfoInviteByLink; + + /// No description provided for @chatInfoInviteLinkHint. + /// + /// In en, this message translates to: + /// **'You can invite anyone with this link'** + String get chatInfoInviteLinkHint; + + /// No description provided for @chatInfoAddMembersAction. + /// + /// In en, this message translates to: + /// **'Add'** + String get chatInfoAddMembersAction; + + /// No description provided for @chatInfoMembersSearchHint. + /// + /// In en, this message translates to: + /// **'Search'** + String get chatInfoMembersSearchHint; + + /// No description provided for @chatInfoAddMembersEmpty. + /// + /// In en, this message translates to: + /// **'No one to add'** + String get chatInfoAddMembersEmpty; + + /// No description provided for @chatInfoMembersAdded. + /// + /// In en, this message translates to: + /// **'Members added'** + String get chatInfoMembersAdded; + + /// No description provided for @chatInfoAddMembersError. + /// + /// In en, this message translates to: + /// **'Couldn\'t add members'** + String get chatInfoAddMembersError; + /// No description provided for @chatInfoNoData. /// /// In en, this message translates to: @@ -2576,6 +3386,72 @@ abstract class AppLocalizations { /// **'Details'** String get chatInfoShowMoreExtra; + /// No description provided for @chatSendConfirmMessage. + /// + /// In en, this message translates to: + /// **'Send this message to the chat?'** + String get chatSendConfirmMessage; + + /// No description provided for @chatSendConfirmAction. + /// + /// In en, this message translates to: + /// **'Send'** + String get chatSendConfirmAction; + + /// No description provided for @chatInfoRowDisableForward. + /// + /// In en, this message translates to: + /// **'Forwarding disabled'** + String get chatInfoRowDisableForward; + + /// No description provided for @chatInfoRowCopyDisabled. + /// + /// In en, this message translates to: + /// **'Copying disabled'** + String get chatInfoRowCopyDisabled; + + /// No description provided for @chatInfoRowOnlyAdminCall. + /// + /// In en, this message translates to: + /// **'Admins can call'** + String get chatInfoRowOnlyAdminCall; + + /// No description provided for @chatInfoRowAllCanPin. + /// + /// In en, this message translates to: + /// **'Anyone can pin'** + String get chatInfoRowAllCanPin; + + /// No description provided for @chatInfoRowMembersSeeLink. + /// + /// In en, this message translates to: + /// **'Members see the link'** + String get chatInfoRowMembersSeeLink; + + /// No description provided for @chatInfoRowConfirmBeforeSend. + /// + /// In en, this message translates to: + /// **'Confirm before sending'** + String get chatInfoRowConfirmBeforeSend; + + /// No description provided for @chatInfoRowOnlyOwnerIconTitle. + /// + /// In en, this message translates to: + /// **'Owner edits title and icon'** + String get chatInfoRowOnlyOwnerIconTitle; + + /// No description provided for @chatInfoRowPromotedDisabled. + /// + /// In en, this message translates to: + /// **'Promoted content off'** + String get chatInfoRowPromotedDisabled; + + /// No description provided for @chatInfoRowUserId. + /// + /// In en, this message translates to: + /// **'User ID'** + String get chatInfoRowUserId; + /// No description provided for @chatInfoRowId. /// /// In en, this message translates to: @@ -3500,11 +4376,11 @@ abstract class AppLocalizations { /// **'Poll'** String get attachSheetPoll; - /// No description provided for @attachSheetCameraComingSoon. + /// No description provided for @attachSheetCameraError. /// /// In en, this message translates to: - /// **'Camera is coming soon'** - String get attachSheetCameraComingSoon; + /// **'Couldn\'t open the camera'** + String get attachSheetCameraError; /// No description provided for @attachSheetSendFileTitle. /// @@ -3572,6 +4448,30 @@ abstract class AppLocalizations { /// **'Section under development'** String get attachSheetSectionInProgress; + /// No description provided for @attachSheetContact. + /// + /// In en, this message translates to: + /// **'Contact'** + String get attachSheetContact; + + /// No description provided for @attachSheetContactSearchHint. + /// + /// In en, this message translates to: + /// **'Search contacts'** + String get attachSheetContactSearchHint; + + /// No description provided for @attachSheetNoContacts. + /// + /// In en, this message translates to: + /// **'You have no contacts yet'** + String get attachSheetNoContacts; + + /// No description provided for @attachSheetNoContactsFound. + /// + /// In en, this message translates to: + /// **'No contacts found'** + String get attachSheetNoContactsFound; + /// No description provided for @attachSheetNoGalleryAccessTitle. /// /// In en, this message translates to: @@ -3608,6 +4508,12 @@ abstract class AppLocalizations { /// **'Camera'** String get attachSheetCamera; + /// No description provided for @attachSheetCameraAllow. + /// + /// In en, this message translates to: + /// **'Allow camera'** + String get attachSheetCameraAllow; + /// No description provided for @photoEditorApplyFailed. /// /// In en, this message translates to: @@ -3901,6 +4807,552 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Failed to download the update'** String get updateDownloadFailed; + + /// No description provided for @updateCheck. + /// + /// In en, this message translates to: + /// **'Check for updates'** + String get updateCheck; + + /// No description provided for @updateChecking. + /// + /// In en, this message translates to: + /// **'Checking for updates…'** + String get updateChecking; + + /// No description provided for @updateUpToDate. + /// + /// In en, this message translates to: + /// **'You have the latest version'** + String get updateUpToDate; + + /// No description provided for @updateCheckFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t check for updates. Try again later'** + String get updateCheckFailed; + + /// No description provided for @profileResurrecting. + /// + /// In en, this message translates to: + /// **'Oops! The server didn\'t send your profile. Trying to regenerate…'** + String get profileResurrecting; + + /// No description provided for @profilePhoneRegenFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers'** + String get profilePhoneRegenFailed; + + /// No description provided for @addContactTitle. + /// + /// In en, this message translates to: + /// **'Add contact'** + String get addContactTitle; + + /// No description provided for @addContactFirstName. + /// + /// In en, this message translates to: + /// **'First name'** + String get addContactFirstName; + + /// No description provided for @addContactLastName. + /// + /// In en, this message translates to: + /// **'Last name (optional)'** + String get addContactLastName; + + /// No description provided for @addContactSave. + /// + /// In en, this message translates to: + /// **'Save contact'** + String get addContactSave; + + /// No description provided for @addContactNotFound. + /// + /// In en, this message translates to: + /// **'{phone} not found'** + String addContactNotFound(String phone); + + /// No description provided for @addContactNotFoundSubtitle. + /// + /// In en, this message translates to: + /// **'This number isn\'t on the app yet'** + String get addContactNotFoundSubtitle; + + /// No description provided for @addContactSearchOther. + /// + /// In en, this message translates to: + /// **'Search for other number'** + String get addContactSearchOther; + + /// No description provided for @addContactError. + /// + /// In en, this message translates to: + /// **'Couldn\'t add contact'** + String get addContactError; + + /// No description provided for @contactBubbleNew. + /// + /// In en, this message translates to: + /// **'New contact'** + String get contactBubbleNew; + + /// No description provided for @contactBubbleAlreadyAdded. + /// + /// In en, this message translates to: + /// **'Already in your contacts'** + String get contactBubbleAlreadyAdded; + + /// No description provided for @contactBubbleOpenProfile. + /// + /// In en, this message translates to: + /// **'Open profile'** + String get contactBubbleOpenProfile; + + /// No description provided for @miniAppOpen. + /// + /// In en, this message translates to: + /// **'Open'** + String get miniAppOpen; + + /// No description provided for @miniAppFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t open the app'** + String get miniAppFailed; + + /// No description provided for @editContactMenu. + /// + /// In en, this message translates to: + /// **'Edit contact'** + String get editContactMenu; + + /// No description provided for @editContactTitle. + /// + /// In en, this message translates to: + /// **'Edit contact'** + String get editContactTitle; + + /// No description provided for @editContactFirstName. + /// + /// In en, this message translates to: + /// **'First name'** + String get editContactFirstName; + + /// No description provided for @editContactLastName. + /// + /// In en, this message translates to: + /// **'Last name'** + String get editContactLastName; + + /// No description provided for @editContactSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get editContactSave; + + /// No description provided for @editContactDelete. + /// + /// In en, this message translates to: + /// **'Delete contact'** + String get editContactDelete; + + /// No description provided for @editContactDeleteConfirmTitle. + /// + /// In en, this message translates to: + /// **'Delete contact?'** + String get editContactDeleteConfirmTitle; + + /// No description provided for @editContactDeleteConfirmBody. + /// + /// In en, this message translates to: + /// **'This contact will be removed from your list.'** + String get editContactDeleteConfirmBody; + + /// No description provided for @editContactDeleteCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get editContactDeleteCancel; + + /// No description provided for @editContactError. + /// + /// In en, this message translates to: + /// **'Couldn\'t save changes'** + String get editContactError; + + /// No description provided for @downloadsTitle. + /// + /// In en, this message translates to: + /// **'Recent downloads'** + String get downloadsTitle; + + /// No description provided for @downloadsTooltip. + /// + /// In en, this message translates to: + /// **'Downloads'** + String get downloadsTooltip; + + /// No description provided for @downloadsSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get downloadsSettings; + + /// No description provided for @downloadsEmpty. + /// + /// In en, this message translates to: + /// **'Downloaded files will appear here'** + String get downloadsEmpty; + + /// No description provided for @downloadsUnknownSource. + /// + /// In en, this message translates to: + /// **'Unknown source'** + String get downloadsUnknownSource; + + /// No description provided for @downloadsPhoto. + /// + /// In en, this message translates to: + /// **'Photo'** + String get downloadsPhoto; + + /// No description provided for @downloadsVideo. + /// + /// In en, this message translates to: + /// **'Video'** + String get downloadsVideo; + + /// No description provided for @downloadsGif. + /// + /// In en, this message translates to: + /// **'GIF'** + String get downloadsGif; + + /// No description provided for @downloadsAudio. + /// + /// In en, this message translates to: + /// **'Audio'** + String get downloadsAudio; + + /// No description provided for @downloadsFile. + /// + /// In en, this message translates to: + /// **'File'** + String get downloadsFile; + + /// No description provided for @downloadsOpenFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t open the file'** + String get downloadsOpenFailed; + + /// No description provided for @downloadsClearHistory. + /// + /// In en, this message translates to: + /// **'Clear download history'** + String get downloadsClearHistory; + + /// No description provided for @downloadsClearTitle. + /// + /// In en, this message translates to: + /// **'Clear download history?'** + String get downloadsClearTitle; + + /// No description provided for @downloadsClearBody. + /// + /// In en, this message translates to: + /// **'The files will stay on the device, but this list will be cleared.'** + String get downloadsClearBody; + + /// No description provided for @downloadsClearConfirm. + /// + /// In en, this message translates to: + /// **'Clear'** + String get downloadsClearConfirm; + + /// No description provided for @downloadsHistoryCleared. + /// + /// 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 @uploadNotificationVideoNote. + /// + /// In en, this message translates to: + /// **'Video message'** + String get uploadNotificationVideoNote; + + /// No description provided for @uploadNotificationVoice. + /// + /// In en, this message translates to: + /// **'Voice message'** + String get uploadNotificationVoice; + + /// 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); + + /// No description provided for @savedMessagesEmptyPreview. + /// + /// In en, this message translates to: + /// **'Save something here'** + String get savedMessagesEmptyPreview; + + /// No description provided for @proxyCurrentState. + /// + /// In en, this message translates to: + /// **'Currently: {value}'** + String proxyCurrentState(String value); + + /// No description provided for @blacklistEmpty. + /// + /// In en, this message translates to: + /// **'Nobody is blocked'** + String get blacklistEmpty; + + /// No description provided for @blacklistLoadError. + /// + /// In en, this message translates to: + /// **'Failed to load the blacklist'** + String get blacklistLoadError; + + /// No description provided for @videoEditorQualityLow. + /// + /// In en, this message translates to: + /// **'Small size'** + String get videoEditorQualityLow; + + /// No description provided for @videoEditorQualityHigh. + /// + /// In en, this message translates to: + /// **'High quality'** + String get videoEditorQualityHigh; + + /// No description provided for @videoEditorCaptionHint. + /// + /// In en, this message translates to: + /// **'Add a caption...'** + String get videoEditorCaptionHint; + + /// No description provided for @videoEditorMuteTooltip. + /// + /// In en, this message translates to: + /// **'Send without sound'** + String get videoEditorMuteTooltip; + + /// No description provided for @videoEditorProcessing. + /// + /// In en, this message translates to: + /// **'Processing video…'** + String get videoEditorProcessing; + + /// No description provided for @videoEditorExportFailed. + /// + /// In en, this message translates to: + /// **'Failed to process the video'** + String get videoEditorExportFailed; + + /// No description provided for @videoEditorFrameFailed. + /// + /// In en, this message translates to: + /// **'Failed to grab a frame'** + String get videoEditorFrameFailed; + + /// No description provided for @videoEditorQualityTooltip. + /// + /// In en, this message translates to: + /// **'Quality'** + String get videoEditorQualityTooltip; + + /// No description provided for @webPushTitle. + /// + /// In en, this message translates to: + /// **'Notifications on iOS'** + String get webPushTitle; + + /// No description provided for @webPushIntro. + /// + /// In en, this message translates to: + /// **'Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX\'s own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.'** + String get webPushIntro; + + /// No description provided for @webPushConfirm. + /// + /// In en, this message translates to: + /// **'Continue'** + String get webPushConfirm; + + /// No description provided for @webPushPasswordExplainer. + /// + /// In en, this message translates to: + /// **'Two-factor protection is enabled on this account.'** + String get webPushPasswordExplainer; + + /// No description provided for @webPushPasswordHintLabel. + /// + /// In en, this message translates to: + /// **'Hint: {hint}'** + String webPushPasswordHintLabel(String hint); + + /// No description provided for @webPushPasswordHint. + /// + /// In en, this message translates to: + /// **'Password'** + String get webPushPasswordHint; + + /// No description provided for @webPushInstallTitle. + /// + /// In en, this message translates to: + /// **'Install the web app'** + String get webPushInstallTitle; + + /// No description provided for @webPushInstallBody. + /// + /// In en, this message translates to: + /// **'Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.'** + String get webPushInstallBody; + + /// No description provided for @webPushLinkedTitle. + /// + /// In en, this message translates to: + /// **'Notifications connected'** + String get webPushLinkedTitle; + + /// No description provided for @webPushLinkedBody. + /// + /// In en, this message translates to: + /// **'The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.'** + String get webPushLinkedBody; + + /// No description provided for @webPushOpenSite. + /// + /// In en, this message translates to: + /// **'Open push.komet.pw'** + String get webPushOpenSite; + + /// No description provided for @webPushSignOut. + /// + /// In en, this message translates to: + /// **'Disconnect notifications'** + String get webPushSignOut; + + /// No description provided for @webPushLinked. + /// + /// In en, this message translates to: + /// **'Notifications connected'** + String get webPushLinked; + + /// No description provided for @webPushLinkFailed. + /// + /// In en, this message translates to: + /// **'Could not connect notifications: {error}'** + String webPushLinkFailed(String error); + + /// No description provided for @webPushNotAuthorized. + /// + /// In en, this message translates to: + /// **'Sign in under \"Notifications via PWA\" first'** + String get webPushNotAuthorized; + + /// No description provided for @webPushConnect. + /// + /// In en, this message translates to: + /// **'Connect notifications'** + String get webPushConnect; + + /// No description provided for @webPushWaitingBody. + /// + /// In en, this message translates to: + /// **'Komet is approving the web session from this device. This usually takes a few seconds.'** + String get webPushWaitingBody; + + /// No description provided for @webPushNeedsOnline. + /// + /// In en, this message translates to: + /// **'No connection to the server. Wait for it and try again.'** + String get webPushNeedsOnline; + + /// No description provided for @webPushSignOutConfirm. + /// + /// In en, this message translates to: + /// **'The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.'** + String get webPushSignOutConfirm; + + /// No description provided for @webPushSignOutAction. + /// + /// In en, this message translates to: + /// **'Disconnect'** + String get webPushSignOutAction; + + /// No description provided for @webPushStatusService. + /// + /// In en, this message translates to: + /// **'Service'** + String get webPushStatusService; + + /// No description provided for @webPushStatusToken. + /// + /// In en, this message translates to: + /// **'Token'** + String get webPushStatusToken; + + /// No description provided for @webPushStatusLinkedAt. + /// + /// In en, this message translates to: + /// **'Linked'** + String get webPushStatusLinkedAt; + + /// No description provided for @webPushStatusDevice. + /// + /// In en, this message translates to: + /// **'Device'** + String get webPushStatusDevice; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d575ca9..b9231ca 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -42,9 +42,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginDone => 'Done'; - @override - String get loginReadTermsNotification => 'Please read the terms of use first'; - @override String get loginSpoofRedacted => 'Spoofing'; @@ -63,6 +60,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get serverPortLabel => 'Port'; + @override + String get serverTrustMincifryTitle => 'Trust the Минцифры CA'; + + @override + String get serverTrustMincifrySubtitle => + 'Required for api2.oneme.ru: its certificate chains to the Russian Trusted Root CA, which is absent from the standard trust store. The root is bundled with the app; other hosts keep using the usual roots.'; + @override String get serverApply => 'Apply and reconnect'; @@ -341,12 +345,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get infoAccountSection => 'Account'; + @override + String get infoPacketSection => 'Login packet'; + + @override + String get infoChatsSection => 'Chats in login packet'; + + @override + String get infoChatSettingsSection => 'Per-chat settings'; + @override String get infoServerSection => 'Server'; @override String get infoUserSection => 'User'; + @override + String get infoExperimentsSection => 'Experiments'; + @override String get infoYMapSection => 'Y-Map'; @@ -371,9 +387,78 @@ class AppLocalizationsEn extends AppLocalizations { @override String get infoId => 'id'; + @override + String get infoPhone => 'phone'; + + @override + String get infoPhotoId => 'photoId'; + + @override + String get infoAccountStatus => 'accountStatus'; + + @override + String get infoContactOptions => 'contact options'; + + @override + String get infoProfileOptions => 'profile options'; + + @override + String get infoNames => 'names'; + + @override + String get infoBaseUrl => 'baseUrl'; + + @override + String get infoBaseRawUrl => 'baseRawUrl'; + @override String get infoChatMarker => 'chatMarker'; + @override + String get infoServerTime => 'server time'; + + @override + String get infoUpdates => 'updates'; + + @override + String get infoMessagesCount => 'messages in packet'; + + @override + String get infoContactsCount => 'contacts in packet'; + + @override + String get infoPresenceCount => 'presence records'; + + @override + String get infoConfigHash => 'config hash'; + + @override + String get infoChatsCount => 'chats loaded'; + + @override + String get infoChatsActive => 'active'; + + @override + String get infoChatsHidden => 'hidden'; + + @override + String get infoChatsDialogs => 'dialogs'; + + @override + String get infoChatsGroups => 'groups'; + + @override + String get infoChatsChannels => 'channels'; + + @override + String get infoChatsUnread => 'unread chats'; + + @override + String get infoChatsNewMessages => 'new messages'; + + @override + String get infoChatsMessages => 'messages in loaded chats'; + @override String get infoAccountRemovalEnabled => 'account-removal-enabled'; @@ -452,6 +537,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInfoComments => 'comments:'; + @override + String get commentsWrite => 'Comment'; + + @override + String get commentsTitle => 'Comments'; + + @override + String commentsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count comments', + one: '1 comment', + ); + return '$_temp0'; + } + @override String get chatInfoAplus => 'approved by Roskomnadzor:'; @@ -524,6 +626,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get msgActionsCopy => 'Copy'; + @override + String get msgActionsSelectAll => 'Select all'; + @override String get emojiSearchHint => 'Search emoji'; @@ -551,6 +656,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get msgActionsEditHistory => 'Edit history'; + @override + String get msgActionsReadBy => 'Read by'; + + @override + String get msgActionsReadByEmpty => 'Nobody has read it yet'; + + @override + String get msgActionsReadByUnknownUser => 'User'; + @override String get msgActionsReport => 'Report'; @@ -583,7 +697,8 @@ class AppLocalizationsEn extends AppLocalizations { String get notificationsFkmAlreadyHasFcm => 'Why? You already have FCM.'; @override - String get notificationsFkmDownloadFcm => 'Better download the FCM version.'; + String get notificationsFkmIosUnsupported => + 'Push notifications are not available on iOS yet.'; @override String get notificationsTitle => 'Notifications'; @@ -598,6 +713,30 @@ class AppLocalizationsEn extends AppLocalizations { String get notificationsFkmEnableSubtitle => 'For FKM notifications to work, the app will need to keep a notification in the shade.'; + @override + String get notificationsFkmUnsupported => 'FKM is Android-only'; + + @override + String get notificationsFkmBatteryAction => 'Open settings'; + + @override + String get notificationsFkmBatteryMessage => + 'Otherwise the system will put the background connection to sleep and notifications will be late or lost.'; + + @override + String get notificationsFkmBatteryTitle => 'Turn off battery saving?'; + + @override + String get notificationsFkmPermissionDenied => + 'FKM cannot work without the notification permission'; + + @override + String get notificationsFkmConfirmAction => 'Enable FKM'; + + @override + String get notificationsFkmConfirmMessage => + 'Notifications will arrive over the app’s own background connection, and a permanent service notification will stay in the shade. You can turn FKM off right from it.'; + @override String get notificationsMainSectionTitle => 'Notifications'; @@ -740,12 +879,21 @@ class AppLocalizationsEn extends AppLocalizations { String get appearanceVisualStyleSubtitle => 'Material You or dimensional Glossy capsules'; + @override + String get appearanceStyleAuto => 'Match theme'; + @override String get appearanceVisualStyleMaterialYou => 'Material You'; @override String get appearanceVisualStyleGlossy => 'Glossy'; + @override + String get appearanceVisualStyleLiquidGlass => 'Liquid Glass'; + + @override + String get appearanceGlassMaterial => 'Glass'; + @override String get appearanceChatChromeTitle => 'Chat screen elements'; @@ -763,7 +911,39 @@ class AppLocalizationsEn extends AppLocalizations { String get appearanceChatChromeNone => 'None'; @override - String get appearanceChatChromeTransparent => 'Clear'; + String get appearanceChatChromeTransparent => 'Frost blur'; + + @override + String get appearanceComposerTitle => 'Input bar'; + + @override + String get appearanceComposerSubtitle => + 'Style and background of the message input bar'; + + @override + String get appearanceComposerBackgroundStandard => 'Default'; + + @override + String get appearanceComposerBackgroundFrost => 'Frost blur'; + + @override + String get appearanceNavPillTitle => 'Switcher style'; + + @override + String get appearanceNavPillSubtitle => + 'Section switcher on the chats screen'; + + @override + String get appearanceNavPillGlossy => 'Glossy'; + + @override + String get appearanceNavPillFrost => 'G-FrostBlur'; + + @override + String get playbackPillAt => 'at'; + + @override + String get playbackPillYou => 'You'; @override String get appearanceGradientTitle => 'Gradient'; @@ -772,6 +952,13 @@ class AppLocalizationsEn extends AppLocalizations { String get appearanceGradientSubtitle => 'Depth and highlights in Glossy capsules'; + @override + String get appearanceSpectrumTitle => 'Spectrum background'; + + @override + String get appearanceSpectrumSubtitle => + 'Experimental — living bars beneath the interface'; + @override String get appearanceAccentColorTitle => 'Accent color'; @@ -837,6 +1024,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get callGroupWaitingParticipants => 'Waiting for participants…'; + @override + String get callLinkGroupCall => 'Group call'; + + @override + String get callLinkSendInMax => 'Send in MAX'; + + @override + String get callLinkStart => 'Start call'; + + @override + String get callLinkSent => 'Link sent'; + + @override + String get callLinkSendFailed => 'Couldn\'t send the link'; + + @override + String get callLinkCreateFailed => 'Couldn\'t create the call'; + @override String get callParticipantYou => 'You'; @@ -846,6 +1051,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get callTooltipMinimize => 'Minimize'; + @override + String get callTooltipExpand => 'Expand'; + @override String get callTooltipKometHub => 'Komet'; @@ -894,6 +1102,46 @@ class AppLocalizationsEn extends AppLocalizations { @override String get callEndButton => 'End'; + @override + String callCameraUnavailable(Object error) { + return 'Camera unavailable: $error'; + } + + @override + String get callTooltipMicrophone => 'Microphone'; + + @override + String get callMicrophoneTitle => 'Microphone'; + + @override + String get callMicrophoneSystem => 'System default'; + + @override + String get callMicrophoneEmpty => 'No microphones found'; + + @override + String get callMicrophoneRefresh => 'Refresh list'; + + @override + String get callMicrophoneMonitors => 'Monitors — system audio'; + + @override + String callMicrophoneFallback(Object index) { + return 'Microphone $index'; + } + + @override + String callMicrophoneFailed(Object error) { + return 'Could not switch microphone: $error'; + } + + @override + String get callMicStillLive => 'Still live'; + + @override + String get callNoMuteHint => + '--no-mute: audio keeps going out even while the mic is off'; + @override String get callInfoClient => 'Client'; @@ -1127,6 +1375,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contactProfileActionCall => 'Call'; + @override + String get contactProfileActionAddContact => 'Add to contacts'; + @override String get contactProfileInfoPhone => 'Phone'; @@ -1270,6 +1521,52 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sharedDownload => 'Download'; + @override + String photoViewerCounter(int index, int total) { + return 'Photo $index of $total'; + } + + @override + String photoViewerCounterFile(int total) { + return 'FILE of $total'; + } + + @override + String photoViewerSentToday(String sender, String time) { + return '$sender • today at $time'; + } + + @override + String photoViewerSentOn(String sender, String date, String time) { + return '$sender • $date at $time'; + } + + @override + String get photoViewerSaveAs => 'Save as…'; + + @override + String get photoViewerViewAll => 'View all photos'; + + @override + String get photoViewerRotate => 'Rotate'; + + @override + String mediaViewerCounter(int index, int total) { + return '$index of $total'; + } + + @override + String get mediaViewerViewAll => 'View all media'; + + @override + String get videoViewerSettings => 'Settings'; + + @override + String get videoViewerSpeed => 'Speed'; + + @override + String get videoViewerQuality => 'Quality'; + @override String get sharedCopyLink => 'Copy link'; @@ -1279,6 +1576,131 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInfoActionLeave => 'Leave'; + @override + String get chatInfoActionMuted => 'Muted'; + + @override + String get chatInfoNotificationsOn => 'Notifications on'; + + @override + String get chatInfoNotificationsOff => 'Notifications off'; + + @override + String get chatInfoMenuBlock => 'Block'; + + @override + String get chatInfoMenuUnblock => 'Unblock'; + + @override + String get chatInfoMenuDeleteChat => 'Delete chat'; + + @override + String get chatInfoMenuClearHistory => 'Clear history'; + + @override + String get chatInfoClearHistoryTitle => 'Clear history'; + + @override + String get chatInfoClearHistoryMessage => + 'All messages in this chat will be deleted permanently.'; + + @override + String get chatInfoClearHistoryForAll => 'For everyone'; + + @override + String get chatInfoClearHistoryConfirm => 'Clear'; + + @override + String get chatInfoClearHistoryDone => 'History cleared'; + + @override + String get chatInfoDeleteChatTitle => 'Delete chat'; + + @override + String get chatInfoDeleteChatMessage => + 'The chat will be deleted together with the whole conversation.'; + + @override + String get chatInfoDeleteChatConfirm => 'Delete'; + + @override + String get chatInfoLeaveGroupTitle => 'Leave group'; + + @override + String get chatInfoLeaveGroupMessage => + 'You will no longer receive messages from this group.'; + + @override + String get chatInfoLeaveChannelTitle => 'Leave channel'; + + @override + String get chatInfoLeaveChannelMessage => + 'You will no longer receive posts from this channel.'; + + @override + String get chatInfoLeaveConfirm => 'Leave'; + + @override + String get chatInfoLeaveFailed => 'Could not leave the chat'; + + @override + String get chatInfoCallConfirmTitle => 'Start a call'; + + @override + String chatInfoCallConfirmMessage(String name) { + return 'Call $name?'; + } + + @override + String get chatInfoConfirmYes => 'Yes'; + + @override + String get chatInfoConfirmNo => 'No'; + + @override + String get chatInfoCallFailed => 'Could not start the call'; + + @override + String get chatInfoBlockConfirmTitle => 'Block'; + + @override + String chatInfoBlockConfirmMessage(String name) { + return 'Are you sure you want to block $name?'; + } + + @override + String get chatInfoBlockDone => 'User blocked'; + + @override + String get chatInfoUnblockDone => 'User unblocked'; + + @override + String get chatInfoBlockFailed => 'Could not change the block state'; + + @override + String get chatInfoComplaintTitle => 'Report'; + + @override + String get chatInfoComplaintSubtitle => 'Choose a reason for the report'; + + @override + String get chatInfoComplaintSend => 'Report'; + + @override + String get chatInfoComplaintClose => 'Close'; + + @override + String get chatInfoComplaintEmpty => 'Could not load the report reasons'; + + @override + String get chatInfoComplaintSent => 'Report sent'; + + @override + String get chatInfoComplaintFailed => 'Could not send the report'; + + @override + String get chatInfoActionCancel => 'Cancel'; + @override String get chatInfoBio => 'About'; @@ -1300,6 +1722,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInfoRoleAdmin => 'Admin'; + @override + String get chatInfoMemberDeleted => 'Account deleted'; + + @override + String get chatInfoInviteByLink => 'Invite via link'; + + @override + String get chatInfoInviteLinkHint => 'You can invite anyone with this link'; + + @override + String get chatInfoAddMembersAction => 'Add'; + + @override + String get chatInfoMembersSearchHint => 'Search'; + + @override + String get chatInfoAddMembersEmpty => 'No one to add'; + + @override + String get chatInfoMembersAdded => 'Members added'; + + @override + String get chatInfoAddMembersError => 'Couldn\'t add members'; + @override String get chatInfoNoData => 'No data'; @@ -1309,6 +1755,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInfoShowMoreExtra => 'Details'; + @override + String get chatSendConfirmMessage => 'Send this message to the chat?'; + + @override + String get chatSendConfirmAction => 'Send'; + + @override + String get chatInfoRowDisableForward => 'Forwarding disabled'; + + @override + String get chatInfoRowCopyDisabled => 'Copying disabled'; + + @override + String get chatInfoRowOnlyAdminCall => 'Admins can call'; + + @override + String get chatInfoRowAllCanPin => 'Anyone can pin'; + + @override + String get chatInfoRowMembersSeeLink => 'Members see the link'; + + @override + String get chatInfoRowConfirmBeforeSend => 'Confirm before sending'; + + @override + String get chatInfoRowOnlyOwnerIconTitle => 'Owner edits title and icon'; + + @override + String get chatInfoRowPromotedDisabled => 'Promoted content off'; + + @override + String get chatInfoRowUserId => 'User ID'; + @override String get chatInfoRowId => 'Chat ID'; @@ -1806,7 +2285,7 @@ class AppLocalizationsEn extends AppLocalizations { String get attachSheetPoll => 'Poll'; @override - String get attachSheetCameraComingSoon => 'Camera is coming soon'; + String get attachSheetCameraError => 'Couldn\'t open the camera'; @override String get attachSheetSendFileTitle => 'Send a file'; @@ -1842,6 +2321,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get attachSheetSectionInProgress => 'Section under development'; + @override + String get attachSheetContact => 'Contact'; + + @override + String get attachSheetContactSearchHint => 'Search contacts'; + + @override + String get attachSheetNoContacts => 'You have no contacts yet'; + + @override + String get attachSheetNoContactsFound => 'No contacts found'; + @override String get attachSheetNoGalleryAccessTitle => 'No access to the gallery'; @@ -1861,6 +2352,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get attachSheetCamera => 'Camera'; + @override + String get attachSheetCameraAllow => 'Allow camera'; + @override String get photoEditorApplyFailed => 'Couldn\'t apply'; @@ -2016,4 +2510,319 @@ class AppLocalizationsEn extends AppLocalizations { @override String get updateDownloadFailed => 'Failed to download the update'; + + @override + String get updateCheck => 'Check for updates'; + + @override + String get updateChecking => 'Checking for updates…'; + + @override + String get updateUpToDate => 'You have the latest version'; + + @override + String get updateCheckFailed => + 'Couldn\'t check for updates. Try again later'; + + @override + String get profileResurrecting => + 'Oops! The server didn\'t send your profile. Trying to regenerate…'; + + @override + String get profilePhoneRegenFailed => + 'Couldn\'t regenerate your phone number. Please sign in again and report the issue to the developers'; + + @override + String get addContactTitle => 'Add contact'; + + @override + String get addContactFirstName => 'First name'; + + @override + String get addContactLastName => 'Last name (optional)'; + + @override + String get addContactSave => 'Save contact'; + + @override + String addContactNotFound(String phone) { + return '$phone not found'; + } + + @override + String get addContactNotFoundSubtitle => 'This number isn\'t on the app yet'; + + @override + String get addContactSearchOther => 'Search for other number'; + + @override + String get addContactError => 'Couldn\'t add contact'; + + @override + String get contactBubbleNew => 'New contact'; + + @override + String get contactBubbleAlreadyAdded => 'Already in your contacts'; + + @override + String get contactBubbleOpenProfile => 'Open profile'; + + @override + String get miniAppOpen => 'Open'; + + @override + String get miniAppFailed => 'Couldn\'t open the app'; + + @override + String get editContactMenu => 'Edit contact'; + + @override + String get editContactTitle => 'Edit contact'; + + @override + String get editContactFirstName => 'First name'; + + @override + String get editContactLastName => 'Last name'; + + @override + String get editContactSave => 'Save'; + + @override + String get editContactDelete => 'Delete contact'; + + @override + String get editContactDeleteConfirmTitle => 'Delete contact?'; + + @override + String get editContactDeleteConfirmBody => + 'This contact will be removed from your list.'; + + @override + String get editContactDeleteCancel => 'Cancel'; + + @override + String get editContactError => 'Couldn\'t save changes'; + + @override + String get downloadsTitle => 'Recent downloads'; + + @override + String get downloadsTooltip => 'Downloads'; + + @override + String get downloadsSettings => 'Settings'; + + @override + String get downloadsEmpty => 'Downloaded files will appear here'; + + @override + String get downloadsUnknownSource => 'Unknown source'; + + @override + String get downloadsPhoto => 'Photo'; + + @override + String get downloadsVideo => 'Video'; + + @override + String get downloadsGif => 'GIF'; + + @override + String get downloadsAudio => 'Audio'; + + @override + String get downloadsFile => 'File'; + + @override + String get downloadsOpenFailed => 'Couldn\'t open the file'; + + @override + String get downloadsClearHistory => 'Clear download history'; + + @override + String get downloadsClearTitle => 'Clear download history?'; + + @override + String get downloadsClearBody => + 'The files will stay on the device, but this list will be cleared.'; + + @override + String get downloadsClearConfirm => 'Clear'; + + @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 uploadNotificationVideoNote => 'Video message'; + + @override + String get uploadNotificationVoice => 'Voice message'; + + @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'; + } + + @override + String get savedMessagesEmptyPreview => 'Save something here'; + + @override + String proxyCurrentState(String value) { + return 'Currently: $value'; + } + + @override + String get blacklistEmpty => 'Nobody is blocked'; + + @override + String get blacklistLoadError => 'Failed to load the blacklist'; + + @override + String get videoEditorQualityLow => 'Small size'; + + @override + String get videoEditorQualityHigh => 'High quality'; + + @override + String get videoEditorCaptionHint => 'Add a caption...'; + + @override + String get videoEditorMuteTooltip => 'Send without sound'; + + @override + String get videoEditorProcessing => 'Processing video…'; + + @override + String get videoEditorExportFailed => 'Failed to process the video'; + + @override + String get videoEditorFrameFailed => 'Failed to grab a frame'; + + @override + String get videoEditorQualityTooltip => 'Quality'; + + @override + String get webPushTitle => 'Notifications on iOS'; + + @override + String get webPushIntro => + 'Komet has no ordinary push on iOS: Apple issues a notification token only to apps signed with a developer certificate, and a sideloaded build never gets one.\n\nThe way around it is a web app on the Home Screen. MAX\'s own server sends the notifications through Apple, and a separate icon displays them.\n\nThat needs a web session. Komet creates one and approves it itself, from this very device — no phone number or code required.'; + + @override + String get webPushConfirm => 'Continue'; + + @override + String get webPushPasswordExplainer => + 'Two-factor protection is enabled on this account.'; + + @override + String webPushPasswordHintLabel(String hint) { + return 'Hint: $hint'; + } + + @override + String get webPushPasswordHint => 'Password'; + + @override + String get webPushInstallTitle => 'Install the web app'; + + @override + String get webPushInstallBody => + 'Open push.komet.pw in Safari, add it to the Home Screen and launch the icon that appears. Notifications do not work from a browser tab — that is how iOS works.\n\nIn the app, allow notifications, create a subscription and tap \"Open Komet\". The subscription registers itself from there.'; + + @override + String get webPushLinkedTitle => 'Notifications connected'; + + @override + String get webPushLinkedBody => + 'The subscription is registered on the server. Do not delete the Home Screen icon — the notifications go with it.\n\nIf push stops arriving, open the web app and link again: Apple sometimes rotates the subscription address.'; + + @override + String get webPushOpenSite => 'Open push.komet.pw'; + + @override + String get webPushSignOut => 'Disconnect notifications'; + + @override + String get webPushLinked => 'Notifications connected'; + + @override + String webPushLinkFailed(String error) { + return 'Could not connect notifications: $error'; + } + + @override + String get webPushNotAuthorized => + 'Sign in under \"Notifications via PWA\" first'; + + @override + String get webPushConnect => 'Connect notifications'; + + @override + String get webPushWaitingBody => + 'Komet is approving the web session from this device. This usually takes a few seconds.'; + + @override + String get webPushNeedsOnline => + 'No connection to the server. Wait for it and try again.'; + + @override + String get webPushSignOutConfirm => + 'The web session will be terminated and disappear from your device list. To get notifications back you will have to connect again.'; + + @override + String get webPushSignOutAction => 'Disconnect'; + + @override + String get webPushStatusService => 'Service'; + + @override + String get webPushStatusToken => 'Token'; + + @override + String get webPushStatusLinkedAt => 'Linked'; + + @override + String get webPushStatusDevice => 'Device'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 2b72e9a..11b065d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -42,10 +42,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginDone => 'Готово'; - @override - String get loginReadTermsNotification => - 'Сначала прочитайте условия использования'; - @override String get loginSpoofRedacted => 'Подмена данных'; @@ -64,6 +60,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get serverPortLabel => 'Порт'; + @override + String get serverTrustMincifryTitle => 'Доверять сертификату Минцифры'; + + @override + String get serverTrustMincifrySubtitle => + 'Нужно для api2.oneme.ru: его сертификат выпущен под корнем Russian Trusted Root CA, которого нет в обычном хранилище. Корень зашит в приложение, остальные хосты проверяются как раньше.'; + @override String get serverApply => 'Применить и переподключиться'; @@ -94,7 +97,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tokenLoginNote => - 'Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.'; + 'Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, в противном случае он может быть отозван.'; @override String get tokenLoginButton => 'Войти'; @@ -141,7 +144,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get codeConfirmation2faWarning => - 'MAX может требовать 2FA на вашем аккаунте для входа. Если вы не получили код — установите 2FA с клиента, на котором вы авторизованы.'; + 'По умолчанию код приходит в МАХ. Если код не приходит по SMS - не заходите в Komet/MAX 30 минут, и попробуйте заново.'; @override String get proxySettingsTitle => 'Прокси'; @@ -191,7 +194,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spoofEnableSubtitleOff => - 'Выключена — используется реальное устройство'; + 'Выключена. Используется реальное устройство'; @override String get spoofInfoHint => @@ -208,11 +211,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spoofMethodPartialDescription => - 'Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.'; + 'Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль остаются настоящими для правдоподобности.'; @override String get spoofMethodFullDescription => - 'Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!'; + 'Все данные генерируются случайно. Будьте осторожны.'; @override String get spoofDeviceTypeTitle => 'Тип устройства'; @@ -286,7 +289,7 @@ class AppLocalizationsRu extends AppLocalizations { String get spoofButtonApply => 'Применить'; @override - String get spoofDialogUnsureTitle => 'Ты уверен?'; + String get spoofDialogUnsureTitle => 'Уверен?'; @override String get spoofDialogUnsureContent => @@ -305,15 +308,14 @@ class AppLocalizationsRu extends AppLocalizations { String get spoofDialogApplyContent => 'Нужно перезайти в приложение, ок?'; @override - String get spoofDialogApplyWarning => - 'Ваш спуф изменится сразу. Но из-за особенностей МАХ, для того что-бы это стало заметно, вы должны перелогиниться в аккаунт'; + String get spoofDialogApplyWarning => 'Ваш спуф изменится сразу. 😜'; @override String get spoofDialogReloginTitle => 'Готово!'; @override String get spoofDialogReloginContent => - 'Из-за особенности МАХ, ваш спуф изменён, но видны изменения будут только при перезаходе в аккаунт.'; + 'Ваш спуф изменён, но в списке устройств видны изменения только при перезаходе в аккаунт.'; @override String get spoofDialogReloginWarning => 'Перезайти сейчас?'; @@ -344,12 +346,24 @@ class AppLocalizationsRu extends AppLocalizations { @override String get infoAccountSection => 'Аккаунт'; + @override + String get infoPacketSection => 'Пакет входа'; + + @override + String get infoChatsSection => 'Чаты в пакете входа'; + + @override + String get infoChatSettingsSection => 'Настройки отдельных чатов'; + @override String get infoServerSection => 'Сервер'; @override String get infoUserSection => 'Пользователь'; + @override + String get infoExperimentsSection => 'Эксперименты'; + @override String get infoYMapSection => 'Y-Map'; @@ -374,9 +388,78 @@ class AppLocalizationsRu extends AppLocalizations { @override String get infoId => 'id аккаунта:'; + @override + String get infoPhone => 'Телефон:'; + + @override + String get infoPhotoId => 'id аватарки:'; + + @override + String get infoAccountStatus => 'Статус аккаунта:'; + + @override + String get infoContactOptions => 'Опции контакта:'; + + @override + String get infoProfileOptions => 'Опции профиля:'; + + @override + String get infoNames => 'Имена:'; + + @override + String get infoBaseUrl => 'Ссылка на аватарку:'; + + @override + String get infoBaseRawUrl => 'Исходная аватарка:'; + @override String get infoChatMarker => 'chatMarker'; + @override + String get infoServerTime => 'Время сервера:'; + + @override + String get infoUpdates => 'Количество обновлений:'; + + @override + String get infoMessagesCount => 'Сообщений в пакете:'; + + @override + String get infoContactsCount => 'Контактов в пакете:'; + + @override + String get infoPresenceCount => 'Статусов присутствия:'; + + @override + String get infoConfigHash => 'Хеш конфигурации:'; + + @override + String get infoChatsCount => 'Загружено чатов:'; + + @override + String get infoChatsActive => 'Активных:'; + + @override + String get infoChatsHidden => 'Скрытых:'; + + @override + String get infoChatsDialogs => 'Диалогов:'; + + @override + String get infoChatsGroups => 'Групп:'; + + @override + String get infoChatsChannels => 'Каналов:'; + + @override + String get infoChatsUnread => 'Непрочитанных чатов:'; + + @override + String get infoChatsNewMessages => 'Новых сообщений:'; + + @override + String get infoChatsMessages => 'Сообщений в загруженных чатах:'; + @override String get infoAccountRemovalEnabled => 'Мгновенное удаление аккаунта:'; @@ -455,6 +538,25 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatInfoComments => 'комментарии:'; + @override + String get commentsWrite => 'Комментировать'; + + @override + String get commentsTitle => 'Комментарии'; + + @override + String commentsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count комментария', + many: '$count комментариев', + few: '$count комментария', + one: '$count комментарий', + ); + return '$_temp0'; + } + @override String get chatInfoAplus => 'подтверждён Роскомнадзором:'; @@ -527,6 +629,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get msgActionsCopy => 'Копировать'; + @override + String get msgActionsSelectAll => 'Выбрать всё'; + @override String get emojiSearchHint => 'Поиск эмодзи'; @@ -554,6 +659,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get msgActionsEditHistory => 'История изменений'; + @override + String get msgActionsReadBy => 'Кем прочитано'; + + @override + String get msgActionsReadByEmpty => 'Пока никто не прочитал'; + + @override + String get msgActionsReadByUnknownUser => 'Пользователь'; + @override String get msgActionsReport => 'Пожаловаться'; @@ -586,7 +700,8 @@ class AppLocalizationsRu extends AppLocalizations { String get notificationsFkmAlreadyHasFcm => 'А зачем? У тебя уже FCM.'; @override - String get notificationsFkmDownloadFcm => 'Скачай лучше FCM-версию.'; + String get notificationsFkmIosUnsupported => + 'На iOS пуш-уведомления пока недоступны'; @override String get notificationsTitle => 'Уведомления'; @@ -601,6 +716,30 @@ class AppLocalizationsRu extends AppLocalizations { String get notificationsFkmEnableSubtitle => 'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.'; + @override + String get notificationsFkmUnsupported => 'FKM работает только на Android'; + + @override + String get notificationsFkmBatteryAction => 'Настроить'; + + @override + String get notificationsFkmBatteryMessage => + 'Иначе система усыпит фоновое соединение, и уведомления начнут опаздывать или пропадать.'; + + @override + String get notificationsFkmBatteryTitle => 'Отключить экономию батареи?'; + + @override + String get notificationsFkmPermissionDenied => + 'Без разрешения на уведомления FKM не заработает'; + + @override + String get notificationsFkmConfirmAction => 'Включить FKM'; + + @override + String get notificationsFkmConfirmMessage => + 'Уведомления начнут приходить через собственное фоновое соединение, а в шторке будет постоянно висеть уведомление сервиса. Выключить FKM можно прямо в нём.'; + @override String get notificationsMainSectionTitle => 'Уведомления'; @@ -663,7 +802,7 @@ class AppLocalizationsRu extends AppLocalizations { String get devicesTitle => 'Устройства'; @override - String get devicesPromoTitle => 'Устройства в KOMET'; + String get devicesPromoTitle => 'Устройства в Komet'; @override String get devicesPromoSubtitle => 'Кто имеет доступ к вашему аккаунту?'; @@ -743,18 +882,27 @@ class AppLocalizationsRu extends AppLocalizations { String get appearanceVisualStyleSubtitle => 'Material You или объёмные Glossy-капсулы'; + @override + String get appearanceStyleAuto => 'Как в теме'; + @override String get appearanceVisualStyleMaterialYou => 'Material You'; @override String get appearanceVisualStyleGlossy => 'Glossy'; + @override + String get appearanceVisualStyleLiquidGlass => 'Liquid Glass'; + + @override + String get appearanceGlassMaterial => 'Стекло'; + @override String get appearanceChatChromeTitle => 'Элементы экрана чата'; @override String get appearanceChatChromeSubtitle => - 'Фон панелей сверху и снизу: цвет, размытие или прозрачно. При размытии и прозрачности сообщения заходят под панели'; + 'Фон панелей ввода и верхнего бара'; @override String get appearanceChatChromeColor => 'Цвет'; @@ -766,7 +914,38 @@ class AppLocalizationsRu extends AppLocalizations { String get appearanceChatChromeNone => 'Нет'; @override - String get appearanceChatChromeTransparent => 'Прозр.'; + String get appearanceChatChromeTransparent => 'Frost blur'; + + @override + String get appearanceComposerTitle => 'Вид панели ввода'; + + @override + String get appearanceComposerSubtitle => 'Стиль и фон панели ввода сообщений'; + + @override + String get appearanceComposerBackgroundStandard => 'Default'; + + @override + String get appearanceComposerBackgroundFrost => 'Frost blur'; + + @override + String get appearanceNavPillTitle => 'Вид переключателей'; + + @override + String get appearanceNavPillSubtitle => + 'Переключатель разделов на экране чатов'; + + @override + String get appearanceNavPillGlossy => 'Glossy'; + + @override + String get appearanceNavPillFrost => 'G-FrostBlur'; + + @override + String get playbackPillAt => 'в'; + + @override + String get playbackPillYou => 'Вы'; @override String get appearanceGradientTitle => 'Градиент'; @@ -774,6 +953,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceGradientSubtitle => 'Объём и блики в Glossy-капсулах'; + @override + String get appearanceSpectrumTitle => 'Спектр на фоне'; + + @override + String get appearanceSpectrumSubtitle => + 'Экспериментально — живые полосы под интерфейсом'; + @override String get appearanceAccentColorTitle => 'Акцентный цвет'; @@ -832,14 +1018,32 @@ class AppLocalizationsRu extends AppLocalizations { 'Этот человек использует Komet! :3'; @override - String get callStatusConnecting => 'Соединение'; + String get callStatusConnecting => 'Соединение...'; @override - String get callGroupConnecting => 'Соединение…'; + String get callGroupConnecting => 'Соединение...'; @override String get callGroupWaitingParticipants => 'Ожидание участников…'; + @override + String get callLinkGroupCall => 'Групповой звонок'; + + @override + String get callLinkSendInMax => 'Отправить в MAX'; + + @override + String get callLinkStart => 'Начать звонок'; + + @override + String get callLinkSent => 'Ссылка отправлена'; + + @override + String get callLinkSendFailed => 'Не удалось отправить ссылку'; + + @override + String get callLinkCreateFailed => 'Не удалось создать звонок'; + @override String get callParticipantYou => 'Вы'; @@ -849,6 +1053,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get callTooltipMinimize => 'Свернуть'; + @override + String get callTooltipExpand => 'Развернуть'; + @override String get callTooltipKometHub => 'Komet'; @@ -897,6 +1104,46 @@ class AppLocalizationsRu extends AppLocalizations { @override String get callEndButton => 'Завершить'; + @override + String callCameraUnavailable(Object error) { + return 'Камера недоступна: $error'; + } + + @override + String get callTooltipMicrophone => 'Микрофон'; + + @override + String get callMicrophoneTitle => 'Микрофон'; + + @override + String get callMicrophoneSystem => 'Системный по умолчанию'; + + @override + String get callMicrophoneEmpty => 'Микрофоны не найдены'; + + @override + String get callMicrophoneRefresh => 'Обновить список'; + + @override + String get callMicrophoneMonitors => 'Мониторы — звук системы'; + + @override + String callMicrophoneFallback(Object index) { + return 'Микрофон $index'; + } + + @override + String callMicrophoneFailed(Object error) { + return 'Не удалось переключить микрофон: $error'; + } + + @override + String get callMicStillLive => 'Всё равно слышно'; + + @override + String get callNoMuteHint => + '--no-mute: звук идёт даже с выключенным микрофоном'; + @override String get callInfoClient => 'Клиент'; @@ -1014,7 +1261,7 @@ class AppLocalizationsRu extends AppLocalizations { String get hubGamesTileSubtitle => 'Сыграть с собеседником'; @override - String get hubCheckersTileSubtitle => 'Русские шашки'; + String get hubCheckersTileSubtitle => 'Шашки'; @override String get hubMoreSoonTitle => 'Скоро ещё…'; @@ -1131,6 +1378,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get contactProfileActionCall => 'Звонок'; + @override + String get contactProfileActionAddContact => 'Добавить в контакты'; + @override String get contactProfileInfoPhone => 'Телефон'; @@ -1277,6 +1527,52 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sharedDownload => 'Скачать'; + @override + String photoViewerCounter(int index, int total) { + return 'Фото $index из $total'; + } + + @override + String photoViewerCounterFile(int total) { + return 'ФАЙЛ из $total'; + } + + @override + String photoViewerSentToday(String sender, String time) { + return '$sender • сегодня в $time'; + } + + @override + String photoViewerSentOn(String sender, String date, String time) { + return '$sender • $date в $time'; + } + + @override + String get photoViewerSaveAs => 'Сохранить как…'; + + @override + String get photoViewerViewAll => 'Все фото чата'; + + @override + String get photoViewerRotate => 'Повернуть'; + + @override + String mediaViewerCounter(int index, int total) { + return '$index из $total'; + } + + @override + String get mediaViewerViewAll => 'Все медиа чата'; + + @override + String get videoViewerSettings => 'Настройки'; + + @override + String get videoViewerSpeed => 'Скорость'; + + @override + String get videoViewerQuality => 'Качество'; + @override String get sharedCopyLink => 'Копировать ссылку'; @@ -1286,6 +1582,131 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatInfoActionLeave => 'Покинуть'; + @override + String get chatInfoActionMuted => 'Без звука'; + + @override + String get chatInfoNotificationsOn => 'Уведомления включены'; + + @override + String get chatInfoNotificationsOff => 'Уведомления отключены'; + + @override + String get chatInfoMenuBlock => 'Заблокировать'; + + @override + String get chatInfoMenuUnblock => 'Разблокировать'; + + @override + String get chatInfoMenuDeleteChat => 'Удалить чат'; + + @override + String get chatInfoMenuClearHistory => 'Очистить историю'; + + @override + String get chatInfoClearHistoryTitle => 'Очистить историю'; + + @override + String get chatInfoClearHistoryMessage => + 'Все сообщения в этом чате будут удалены без возможности восстановления.'; + + @override + String get chatInfoClearHistoryForAll => 'Для всех'; + + @override + String get chatInfoClearHistoryConfirm => 'Очистить'; + + @override + String get chatInfoClearHistoryDone => 'История очищена'; + + @override + String get chatInfoDeleteChatTitle => 'Удалить чат'; + + @override + String get chatInfoDeleteChatMessage => + 'Чат будет удалён вместе со всей перепиской.'; + + @override + String get chatInfoDeleteChatConfirm => 'Удалить'; + + @override + String get chatInfoLeaveGroupTitle => 'Покинуть группу'; + + @override + String get chatInfoLeaveGroupMessage => + 'Вы больше не будете получать сообщения этой группы.'; + + @override + String get chatInfoLeaveChannelTitle => 'Покинуть канал'; + + @override + String get chatInfoLeaveChannelMessage => + 'Вы больше не будете получать публикации этого канала.'; + + @override + String get chatInfoLeaveConfirm => 'Покинуть'; + + @override + String get chatInfoLeaveFailed => 'Не удалось покинуть чат'; + + @override + String get chatInfoCallConfirmTitle => 'Начать звонок'; + + @override + String chatInfoCallConfirmMessage(String name) { + return 'Позвонить $name?'; + } + + @override + String get chatInfoConfirmYes => 'Да'; + + @override + String get chatInfoConfirmNo => 'Нет'; + + @override + String get chatInfoCallFailed => 'Не удалось начать звонок'; + + @override + String get chatInfoBlockConfirmTitle => 'Заблокировать'; + + @override + String chatInfoBlockConfirmMessage(String name) { + return 'Вы уверены, что хотите заблокировать $name?'; + } + + @override + String get chatInfoBlockDone => 'Пользователь заблокирован'; + + @override + String get chatInfoUnblockDone => 'Пользователь разблокирован'; + + @override + String get chatInfoBlockFailed => 'Не удалось изменить блокировку'; + + @override + String get chatInfoComplaintTitle => 'Пожаловаться'; + + @override + String get chatInfoComplaintSubtitle => 'Выберите причину жалобы'; + + @override + String get chatInfoComplaintSend => 'Пожаловаться'; + + @override + String get chatInfoComplaintClose => 'Закрыть'; + + @override + String get chatInfoComplaintEmpty => 'Не удалось загрузить причины жалобы'; + + @override + String get chatInfoComplaintSent => 'Жалоба отправлена'; + + @override + String get chatInfoComplaintFailed => 'Не удалось отправить жалобу'; + + @override + String get chatInfoActionCancel => 'Отмена'; + @override String get chatInfoBio => 'О себе'; @@ -1302,10 +1723,35 @@ class AppLocalizationsRu extends AppLocalizations { String get chatInfoAddMember => 'Добавить участника'; @override - String get chatInfoRoleOwner => 'владелец'; + String get chatInfoRoleOwner => 'Владелец'; @override - String get chatInfoRoleAdmin => 'Адмін'; + String get chatInfoRoleAdmin => 'Админ'; + + @override + String get chatInfoMemberDeleted => 'Аккаунт удалён'; + + @override + String get chatInfoInviteByLink => 'Пригласить по ссылке'; + + @override + String get chatInfoInviteLinkHint => + 'Вы можете пригласить любого человека по этой ссылке'; + + @override + String get chatInfoAddMembersAction => 'Добавить'; + + @override + String get chatInfoMembersSearchHint => 'Поиск'; + + @override + String get chatInfoAddMembersEmpty => 'Некого добавить'; + + @override + String get chatInfoMembersAdded => 'Участники добавлены'; + + @override + String get chatInfoAddMembersError => 'Не удалось добавить участников'; @override String get chatInfoNoData => 'Нет данных'; @@ -1316,6 +1762,39 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatInfoShowMoreExtra => 'Подробнее'; + @override + String get chatSendConfirmMessage => 'Отправить это сообщение в чат?'; + + @override + String get chatSendConfirmAction => 'Отправить'; + + @override + String get chatInfoRowDisableForward => 'Пересылка запрещена'; + + @override + String get chatInfoRowCopyDisabled => 'Копирование запрещено'; + + @override + String get chatInfoRowOnlyAdminCall => 'Звонить могут админы'; + + @override + String get chatInfoRowAllCanPin => 'Все могут закреплять'; + + @override + String get chatInfoRowMembersSeeLink => 'Ссылка видна участникам'; + + @override + String get chatInfoRowConfirmBeforeSend => 'Подтверждать отправку'; + + @override + String get chatInfoRowOnlyOwnerIconTitle => 'Название меняет владелец'; + + @override + String get chatInfoRowPromotedDisabled => 'Реклама отключена'; + + @override + String get chatInfoRowUserId => 'ID пользователя'; + @override String get chatInfoRowId => 'ID чата'; @@ -1524,7 +2003,7 @@ class AppLocalizationsRu extends AppLocalizations { String get passwordEntryMismatchError => 'Пароли не совпадают'; @override - String get passwordEntryInvalidEmailError => 'Введите корректный email'; + String get passwordEntryInvalidEmailError => 'Введите нормальный email'; @override String get passwordEntryInvalidCodeError => 'Введите 6-значный код'; @@ -1616,7 +2095,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get passwordEntryRemoveWarning => - 'Внимание! После удаления пароля защита вашего аккаунта ослабнет.'; + 'После удаления пароля ваш аккаунт будет менее защищен. Уверены?'; @override String get cloudStorageNoActiveProfile => 'Нет активного профиля'; @@ -1817,7 +2296,7 @@ class AppLocalizationsRu extends AppLocalizations { String get attachSheetPoll => 'Опрос'; @override - String get attachSheetCameraComingSoon => 'Камера скоро появится'; + String get attachSheetCameraError => 'Не удалось открыть камеру'; @override String get attachSheetSendFileTitle => 'Отправить файл'; @@ -1854,6 +2333,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get attachSheetSectionInProgress => 'Раздел в разработке'; + @override + String get attachSheetContact => 'Контакт'; + + @override + String get attachSheetContactSearchHint => 'Поиск по контактам'; + + @override + String get attachSheetNoContacts => 'У вас пока нет контактов'; + + @override + String get attachSheetNoContactsFound => 'Контакты не найдены'; + @override String get attachSheetNoGalleryAccessTitle => 'Нет доступа к галерее'; @@ -1873,6 +2364,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get attachSheetCamera => 'Камера'; + @override + String get attachSheetCameraAllow => 'Разрешите камеру'; + @override String get photoEditorApplyFailed => 'Не удалось применить'; @@ -2028,4 +2522,319 @@ class AppLocalizationsRu extends AppLocalizations { @override String get updateDownloadFailed => 'Не удалось скачать обновление'; + + @override + String get updateCheck => 'Проверить обновление'; + + @override + String get updateChecking => 'Проверяем обновления…'; + + @override + String get updateUpToDate => 'Установлена актуальная версия'; + + @override + String get updateCheckFailed => + 'Не удалось проверить обновления. Повторите позже'; + + @override + String get profileResurrecting => + 'Упс! Сервер не прислал profile. Попробую регенерировать…'; + + @override + String get profilePhoneRegenFailed => + 'Не удалось регенерировать данные об номере. Перезайдите и сообщите об проблеме разработчикам'; + + @override + String get addContactTitle => 'Новый контакт'; + + @override + String get addContactFirstName => 'Имя'; + + @override + String get addContactLastName => 'Фамилия (необязательно)'; + + @override + String get addContactSave => 'Сохранить контакт'; + + @override + String addContactNotFound(String phone) { + return '$phone не найден'; + } + + @override + String get addContactNotFoundSubtitle => 'Этого номера пока нет в приложении'; + + @override + String get addContactSearchOther => 'Искать другой номер'; + + @override + String get addContactError => 'Не удалось добавить контакт'; + + @override + String get contactBubbleNew => 'Новый контакт'; + + @override + String get contactBubbleAlreadyAdded => 'Уже твой контакт'; + + @override + String get contactBubbleOpenProfile => 'Открыть профиль'; + + @override + String get miniAppOpen => 'Открыть'; + + @override + String get miniAppFailed => 'Не удалось открыть приложение'; + + @override + String get editContactMenu => 'Редактировать контакт'; + + @override + String get editContactTitle => 'Редактировать контакт'; + + @override + String get editContactFirstName => 'Имя'; + + @override + String get editContactLastName => 'Фамилия'; + + @override + String get editContactSave => 'Сохранить'; + + @override + String get editContactDelete => 'Удалить контакт'; + + @override + String get editContactDeleteConfirmTitle => 'Удалить контакт?'; + + @override + String get editContactDeleteConfirmBody => + 'Контакт будет удалён из вашего списка.'; + + @override + String get editContactDeleteCancel => 'Отмена'; + + @override + String get editContactError => 'Не удалось сохранить изменения'; + + @override + String get downloadsTitle => 'Недавние загрузки'; + + @override + String get downloadsTooltip => 'Загрузки'; + + @override + String get downloadsSettings => 'Настройки'; + + @override + String get downloadsEmpty => 'Скачанные файлы появятся здесь'; + + @override + String get downloadsUnknownSource => 'Источник неизвестен'; + + @override + String get downloadsPhoto => 'Фото'; + + @override + String get downloadsVideo => 'Видео'; + + @override + String get downloadsGif => 'GIF'; + + @override + String get downloadsAudio => 'Аудио'; + + @override + String get downloadsFile => 'Файл'; + + @override + String get downloadsOpenFailed => 'Не удалось открыть файл'; + + @override + String get downloadsClearHistory => 'Очистить историю загрузок'; + + @override + String get downloadsClearTitle => 'Очистить историю загрузок?'; + + @override + String get downloadsClearBody => + 'Файлы останутся на устройстве, но этот список будет очищен.'; + + @override + String get downloadsClearConfirm => 'Очистить'; + + @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 uploadNotificationVideoNote => 'Кружок'; + + @override + String get uploadNotificationVoice => 'Голосовое сообщение'; + + @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 МБ/с'; + } + + @override + String get savedMessagesEmptyPreview => 'Сохраните что-нибудь'; + + @override + String proxyCurrentState(String value) { + return 'Сейчас: $value'; + } + + @override + String get blacklistEmpty => 'Никто не заблокирован'; + + @override + String get blacklistLoadError => 'Не удалось загрузить чёрный список'; + + @override + String get videoEditorQualityLow => 'Небольшой размер'; + + @override + String get videoEditorQualityHigh => 'Высокое качество'; + + @override + String get videoEditorCaptionHint => 'Добавить подпись...'; + + @override + String get videoEditorMuteTooltip => 'Отправить без звука'; + + @override + String get videoEditorProcessing => 'Обработка видео…'; + + @override + String get videoEditorExportFailed => 'Не удалось обработать видео'; + + @override + String get videoEditorFrameFailed => 'Не удалось получить кадр'; + + @override + String get videoEditorQualityTooltip => 'Качество'; + + @override + String get webPushTitle => 'Уведомления на iOS'; + + @override + String get webPushIntro => + 'На iOS у Комета нет обычных пушей: Apple выдаёт токен уведомлений только приложениям, подписанным сертификатом разработчика, а sideload-сборка такого не получает.\n\nОбход — веб-приложение на экране «Домой». Уведомления шлёт сам сервер MAX через Apple, а показывает их отдельная иконка.\n\nДля этого нужна веб-сессия. Комет создаст её и подтвердит сам, с этого же устройства — вводить номер и код не придётся.'; + + @override + String get webPushConfirm => 'Продолжить'; + + @override + String get webPushPasswordExplainer => + 'На аккаунте включена двухфакторная защита.'; + + @override + String webPushPasswordHintLabel(String hint) { + return 'Подсказка: $hint'; + } + + @override + String get webPushPasswordHint => 'Пароль'; + + @override + String get webPushInstallTitle => 'Установите приложение'; + + @override + String get webPushInstallBody => + 'Откройте push.komet.pw в Safari, добавьте на экран «Домой» и запустите появившуюся иконку. Из вкладки браузера уведомления не работают — так устроена iOS.\n\nВ приложении разрешите уведомления, создайте подписку и нажмите «Открыть Комет». Дальше подписка зарегистрируется сама.'; + + @override + String get webPushLinkedTitle => 'Уведомления подключены'; + + @override + String get webPushLinkedBody => + 'Подписка зарегистрирована на сервере. Не удаляйте иконку с экрана «Домой» — вместе с ней пропадут уведомления.\n\nЕсли пуши перестанут приходить, откройте приложение и свяжите заново: Apple иногда меняет адрес подписки.'; + + @override + String get webPushOpenSite => 'Открыть push.komet.pw'; + + @override + String get webPushSignOut => 'Отключить уведомления'; + + @override + String get webPushLinked => 'Уведомления подключены'; + + @override + String webPushLinkFailed(String error) { + return 'Не удалось подключить уведомления: $error'; + } + + @override + String get webPushNotAuthorized => + 'Сначала войдите в разделе «Уведомления через PWA»'; + + @override + String get webPushConnect => 'Подключить уведомления'; + + @override + String get webPushWaitingBody => + 'Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.'; + + @override + String get webPushNeedsOnline => + 'Нет связи с сервером. Дождитесь подключения и попробуйте снова.'; + + @override + String get webPushSignOutConfirm => + 'Веб-сессия будет завершена и исчезнет из списка устройств. Чтобы вернуть уведомления, подключение придётся пройти заново.'; + + @override + String get webPushSignOutAction => 'Отключить'; + + @override + String get webPushStatusService => 'Сервис'; + + @override + String get webPushStatusToken => 'Токен'; + + @override + String get webPushStatusLinkedAt => 'Привязан'; + + @override + String get webPushStatusDevice => 'Устройство'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2769a06..bbf613b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -11,13 +11,14 @@ "loginConfirmPhoneTitle": "Это правильный номер?", "loginEdit": "Изменить", "loginDone": "Готово", - "loginReadTermsNotification": "Сначала прочитайте условия использования", "loginSpoofRedacted": "Подмена данных", "loginProxy": "Прокси", "loginChangeServer": "Смена сервера", "serverSettingsTitle": "Сервер", "serverHostLabel": "Хост", "serverPortLabel": "Порт", + "serverTrustMincifryTitle": "Доверять сертификату Минцифры", + "serverTrustMincifrySubtitle": "Нужно для api2.oneme.ru: его сертификат выпущен под корнем Russian Trusted Root CA, которого нет в обычном хранилище. Корень зашит в приложение, остальные хосты проверяются как раньше.", "serverApply": "Применить и переподключиться", "serverUseDefault": "Сбросить к умолчанию", "serverInvalidHostOrPort": "Укажите корректный хост и порт (1–65535)", @@ -27,7 +28,7 @@ "loginSignInWithToken": "По токену", "tokenLoginTitle": "Вход по токену", "tokenLoginTokenLabel": "Токен", - "tokenLoginNote": "Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.", + "tokenLoginNote": "Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, в противном случае он может быть отозван.", "tokenLoginButton": "Войти", "tokenLoginError": "Заполните токен, имя устройства, версию ОС и Device ID", "tokenLoginFailed": "Не удалось войти", @@ -48,8 +49,7 @@ }, "codeResendSms": "Отправить код по SMS", "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA", - "codeConfirmation2faWarning": "MAX может требовать 2FA на вашем аккаунте для входа. Если вы не получили код — установите 2FA с клиента, на котором вы авторизованы.", - + "codeConfirmation2faWarning": "По умолчанию код приходит в МАХ. Если код не приходит по SMS - не заходите в Komet/MAX 30 минут, и попробуйте заново.", "proxySettingsTitle": "Прокси", "proxyTypeNone": "Выключен", "proxyTypeSocks5": "SOCKS5", @@ -62,17 +62,16 @@ "proxyDisable": "Отключить прокси", "proxySettingsSaved": "Настройки прокси применены", "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)", - "spoofScreenTitle": "Подмена данных сессии", "spoofEnableTitle": "Подмена устройства", "spoofEnableSubtitleOn": "Включена для этого аккаунта", - "spoofEnableSubtitleOff": "Выключена — используется реальное устройство", + "spoofEnableSubtitleOff": "Выключена. Используется реальное устройство", "spoofInfoHint": "Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.", "spoofMethodTitle": "Метод подмены", "spoofMethodPartial": "Частичный", "spoofMethodFull": "Полный", - "spoofMethodPartialDescription": "Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.", - "spoofMethodFullDescription": "Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!", + "spoofMethodPartialDescription": "Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль остаются настоящими для правдоподобности.", + "spoofMethodFullDescription": "Все данные генерируются случайно. Будьте осторожны.", "spoofDeviceTypeTitle": "Тип устройства", "spoofDeviceTypeDescription": "Определяет, какие устройства генерируются: Android или iOS", "spoofDeviceTypeLabel": "Тип устройства", @@ -96,15 +95,15 @@ "spoofFieldArchitecture": "Архитектура", "spoofButtonGenerate": "Сгенерировать", "spoofButtonApply": "Применить", - "spoofDialogUnsureTitle": "Ты уверен?", + "spoofDialogUnsureTitle": "Уверен?", "spoofDialogUnsureContent": "Приложение может начать работать нестабильно из-за несовместимости API", "spoofDialogCancel": "Отмена", "spoofDialogYes": "Да", "spoofDialogApplyTitle": "Применить настройки?", "spoofDialogApplyContent": "Нужно перезайти в приложение, ок?", - "spoofDialogApplyWarning": "Ваш спуф изменится сразу. Но из-за особенностей МАХ, для того что-бы это стало заметно, вы должны перелогиниться в аккаунт", + "spoofDialogApplyWarning": "Ваш спуф изменится сразу. 😜", "spoofDialogReloginTitle": "Готово!", - "spoofDialogReloginContent": "Из-за особенности МАХ, ваш спуф изменён, но видны изменения будут только при перезаходе в аккаунт.", + "spoofDialogReloginContent": "Ваш спуф изменён, но в списке устройств видны изменения только при перезаходе в аккаунт.", "spoofDialogReloginWarning": "Перезайти сейчас?", "spoofDialogReloginDeny": "Позже", "spoofDialogReloginConfirm": "Перелогиниться сейчас", @@ -121,8 +120,12 @@ "profileMenuSpoof": "Подмена данных", "infoTitle": "Info", "infoAccountSection": "Аккаунт", + "infoPacketSection": "Пакет входа", + "infoChatsSection": "Чаты в пакете входа", + "infoChatSettingsSection": "Настройки отдельных чатов", "infoServerSection": "Сервер", "infoUserSection": "Пользователь", + "infoExperimentsSection": "Эксперименты", "infoYMapSection": "Y-Map", "infoFileUploadTypes": "запрещённые типы файлов", "infoWhiteListLinks": "безопасные ссылки", @@ -131,7 +134,30 @@ "infoVideoChatHistory": "videoChatHistory", "infoUpdateTime": "Последнее обновление аватарки:", "infoId": "id аккаунта:", + "infoPhone": "Телефон:", + "infoPhotoId": "id аватарки:", + "infoAccountStatus": "Статус аккаунта:", + "infoContactOptions": "Опции контакта:", + "infoProfileOptions": "Опции профиля:", + "infoNames": "Имена:", + "infoBaseUrl": "Ссылка на аватарку:", + "infoBaseRawUrl": "Исходная аватарка:", "infoChatMarker": "chatMarker", + "infoServerTime": "Время сервера:", + "infoUpdates": "Количество обновлений:", + "infoMessagesCount": "Сообщений в пакете:", + "infoContactsCount": "Контактов в пакете:", + "infoPresenceCount": "Статусов присутствия:", + "infoConfigHash": "Хеш конфигурации:", + "infoChatsCount": "Загружено чатов:", + "infoChatsActive": "Активных:", + "infoChatsHidden": "Скрытых:", + "infoChatsDialogs": "Диалогов:", + "infoChatsGroups": "Групп:", + "infoChatsChannels": "Каналов:", + "infoChatsUnread": "Непрочитанных чатов:", + "infoChatsNewMessages": "Новых сообщений:", + "infoChatsMessages": "Сообщений в загруженных чатах:", "infoAccountRemovalEnabled": "Мгновенное удаление аккаунта:", "infoImageSize": "image-size", "infoGce": "gce", @@ -158,6 +184,9 @@ "chatInfoLink": "ссылка:", "chatInfoOfficial": "оффициальный:", "chatInfoComments": "комментарии:", + "commentsWrite": "Комментировать", + "commentsTitle": "Комментарии", + "commentsCount": "{count, plural, one{{count} комментарий} few{{count} комментария} many{{count} комментариев} other{{count} комментария}}", "chatInfoAplus": "подтверждён Роскомнадзором:", "chatInfoSignAdmin": "Подпись админов:", "chatInfoLastChanged": "последнее изменение:", @@ -169,7 +198,6 @@ "chatInfoHasBots": "Есть боты:", "chatInfoBlockedCount": "в ЧС группы:", "chatInfoOfficialStatus": "Официальный статус:", - "chatInfoLastChanged": "последнее изменение:", "chatInfoJoined": "Зашли в:", "chatInfoGroupCreated": "Группа создана в:", "chatInfoGroupOwner": "Создатель группы:", @@ -183,6 +211,7 @@ "registrationSubtitle": "Укажите имя и выберите аватар", "registrationChooseAvatar": "Выберите аватар", "msgActionsCopy": "Копировать", + "msgActionsSelectAll": "Выбрать всё", "emojiSearchHint": "Поиск эмодзи", "msgActionsEdit": "Изменить", "msgActionsReply": "Ответить", @@ -192,6 +221,9 @@ "msgActionsUnpin": "Открепить", "pinnedMessageTitle": "Закреплённое сообщение", "msgActionsEditHistory": "История изменений", + "msgActionsReadBy": "Кем прочитано", + "msgActionsReadByEmpty": "Пока никто не прочитал", + "msgActionsReadByUnknownUser": "Пользователь", "msgActionsReport": "Пожаловаться", "msgActionsDelete": "Удалить", "msgActionsCopied": "Скопировано", @@ -201,11 +233,18 @@ "msgActionsNoText": "(без текста)", "notificationsSaveFailed": "Не удалось сохранить: {error}", "notificationsFkmAlreadyHasFcm": "А зачем? У тебя уже FCM.", - "notificationsFkmDownloadFcm": "Скачай лучше FCM-версию.", + "notificationsFkmIosUnsupported": "На iOS пуш-уведомления пока недоступны", "notificationsTitle": "Уведомления", "notificationsFkmSectionTitle": "FKM", "notificationsFkmEnableLabel": "Включить уведомления", "notificationsFkmEnableSubtitle": "Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.", + "notificationsFkmUnsupported": "FKM работает только на Android", + "notificationsFkmBatteryAction": "Настроить", + "notificationsFkmBatteryMessage": "Иначе система усыпит фоновое соединение, и уведомления начнут опаздывать или пропадать.", + "notificationsFkmBatteryTitle": "Отключить экономию батареи?", + "notificationsFkmPermissionDenied": "Без разрешения на уведомления FKM не заработает", + "notificationsFkmConfirmAction": "Включить FKM", + "notificationsFkmConfirmMessage": "Уведомления начнут приходить через собственное фоновое соединение, а в шторке будет постоянно висеть уведомление сервиса. Выключить FKM можно прямо в нём.", "notificationsMainSectionTitle": "Уведомления", "notificationsAllLabel": "Все уведомления", "notificationsNewSectionTitle": "Все новые уведомления", @@ -224,7 +263,7 @@ "devicesGenericError": "Ошибка: {error}", "devicesIpLookupError": "Ошибка IP: {error}", "devicesTitle": "Устройства", - "devicesPromoTitle": "Устройства в KOMET", + "devicesPromoTitle": "Устройства в Komet", "devicesPromoSubtitle": "Кто имеет доступ к вашему аккаунту?", "devicesScanQrButton": "Сканировать QR", "devicesCurrentSuffix": " (текущая)", @@ -249,16 +288,31 @@ "appearanceTitle": "Внешний вид", "appearanceVisualStyleTitle": "Визуал", "appearanceVisualStyleSubtitle": "Material You или объёмные Glossy-капсулы", + "appearanceStyleAuto": "Как в теме", "appearanceVisualStyleMaterialYou": "Material You", "appearanceVisualStyleGlossy": "Glossy", + "appearanceVisualStyleLiquidGlass": "Liquid Glass", + "appearanceGlassMaterial": "Стекло", "appearanceChatChromeTitle": "Элементы экрана чата", - "appearanceChatChromeSubtitle": "Фон панелей сверху и снизу: цвет, размытие или прозрачно. При размытии и прозрачности сообщения заходят под панели", + "appearanceChatChromeSubtitle": "Фон панелей ввода и верхнего бара", "appearanceChatChromeColor": "Цвет", "appearanceChatChromeBlur": "Блюр", "appearanceChatChromeNone": "Нет", - "appearanceChatChromeTransparent": "Прозр.", + "appearanceChatChromeTransparent": "Frost blur", + "appearanceComposerTitle": "Вид панели ввода", + "appearanceComposerSubtitle": "Стиль и фон панели ввода сообщений", + "appearanceComposerBackgroundStandard": "Default", + "appearanceComposerBackgroundFrost": "Frost blur", + "appearanceNavPillTitle": "Вид переключателей", + "appearanceNavPillSubtitle": "Переключатель разделов на экране чатов", + "appearanceNavPillGlossy": "Glossy", + "appearanceNavPillFrost": "G-FrostBlur", + "playbackPillAt": "в", + "playbackPillYou": "Вы", "appearanceGradientTitle": "Градиент", "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", + "appearanceSpectrumTitle": "Спектр на фоне", + "appearanceSpectrumSubtitle": "Экспериментально — живые полосы под интерфейсом", "appearanceAccentColorTitle": "Акцентный цвет", "appearanceAccentColorSystem": "Системный", "appearanceAccentColorSubtitle": "Основной цвет интерфейса и пузырей", @@ -276,14 +330,20 @@ "appearancePreviewHowIsIt": "Как тебе?", "appearancePreviewHmm": "хм...", "appearancePreviewNotBad": "Вполне неплохо!", - "callKometDetectedNotification": "Этот человек использует Komet! :3", - "callStatusConnecting": "Соединение", - "callGroupConnecting": "Соединение…", + "callStatusConnecting": "Соединение...", + "callGroupConnecting": "Соединение...", "callGroupWaitingParticipants": "Ожидание участников…", + "callLinkGroupCall": "Групповой звонок", + "callLinkSendInMax": "Отправить в MAX", + "callLinkStart": "Начать звонок", + "callLinkSent": "Ссылка отправлена", + "callLinkSendFailed": "Не удалось отправить ссылку", + "callLinkCreateFailed": "Не удалось создать звонок", "callParticipantYou": "Вы", "callParticipantFallback": "Участник", "callTooltipMinimize": "Свернуть", + "callTooltipExpand": "Развернуть", "callTooltipKometHub": "Komet", "callInfoTitle": "О звонке", "callPeerMicOff": "Микрофон выключен", @@ -300,6 +360,17 @@ "callUnmute": "Вкл. звук", "callMute": "Выкл. звук", "callEndButton": "Завершить", + "callCameraUnavailable": "Камера недоступна: {error}", + "callTooltipMicrophone": "Микрофон", + "callMicrophoneTitle": "Микрофон", + "callMicrophoneSystem": "Системный по умолчанию", + "callMicrophoneEmpty": "Микрофоны не найдены", + "callMicrophoneRefresh": "Обновить список", + "callMicrophoneMonitors": "Мониторы — звук системы", + "callMicrophoneFallback": "Микрофон {index}", + "callMicrophoneFailed": "Не удалось переключить микрофон: {error}", + "callMicStillLive": "Всё равно слышно", + "callNoMuteHint": "--no-mute: звук идёт даже с выключенным микрофоном", "callInfoClient": "Клиент", "callInfoPlatform": "Платформа", "callInfoCountry": "Страна", @@ -331,7 +402,6 @@ "callBadgeNoiseSuppression": "Шумоподавление", "callBadgeAnimoji": "Анимодзи", "callInfoNoDataYet": "Данные появятся после соединения…", - "hubTitleMenu": "Komet", "hubChatPageTitle": "Анонимный чат", "hubGamesTitle": "Игры", @@ -339,7 +409,7 @@ "hubChatTileTitle": "Чат", "hubChatTileSubtitle": "Анонимные сообщения", "hubGamesTileSubtitle": "Сыграть с собеседником", - "hubCheckersTileSubtitle": "Русские шашки", + "hubCheckersTileSubtitle": "Шашки", "hubMoreSoonTitle": "Скоро ещё…", "hubMoreSoonSubtitle": "В разработке", "hubChatPrivacyNote": "Напрямую через звонок, нигде не сохраняется", @@ -352,7 +422,6 @@ "hubCheckersLost": "Вы проиграли", "hubCheckersYourMove": "Ваш ход", "hubCheckersOpponentMove": "Ход соперника…", - "scheduledPickTimeTitle": "Когда отправить", "scheduledEditTitle": "Изменить", "scheduledMessageTextHint": "Текст сообщения", @@ -371,7 +440,6 @@ "scheduledAttachLocation": "Геопозиция", "scheduledAttachForwarded": "Переслано", "scheduledAttachGeneric": "Вложение", - "contactProfileLoadError": "Ошибка: {error}", "contactProfileBot": "Бот", "contactProfileOnline": "В сети", @@ -379,6 +447,7 @@ "contactProfileActionChat": "Чат", "contactProfileActionSound": "Звук", "contactProfileActionCall": "Звонок", + "contactProfileActionAddContact": "Добавить в контакты", "contactProfileInfoPhone": "Телефон", "contactProfileInfoCountry": "Страна", "contactProfileInfoGender": "Пол", @@ -388,7 +457,6 @@ "contactProfileInfoDescription": "Описание", "contactProfileInfoLink": "Ссылка", "contactProfileInfoFlags": "Флаги", - "nfcPeerNameFallback": "Контакт #{id}", "nfcPeerFirstNameFallback": "Контакт", "nfcContactAdded": "Контакт добавлен", @@ -406,7 +474,6 @@ "nfcPeerIdFallback": "ID {id}", "nfcAdded": "Добавлено", "nfcAddContact": "Добавить контакт", - "chatInfoTabGeneralChats": "Общие чаты", "chatInfoTabMedia": "Медиа", "chatInfoTabFiles": "Файлы", @@ -423,19 +490,89 @@ "sharedLoadMore": "Показать ещё", "sharedGoToMessage": "Перейти к сообщению", "sharedDownload": "Скачать", + "photoViewerCounter": "Фото {index} из {total}", + "photoViewerCounterFile": "ФАЙЛ из {total}", + "photoViewerSentToday": "{sender} • сегодня в {time}", + "photoViewerSentOn": "{sender} • {date} в {time}", + "photoViewerSaveAs": "Сохранить как…", + "photoViewerViewAll": "Все фото чата", + "photoViewerRotate": "Повернуть", + "mediaViewerCounter": "{index} из {total}", + "mediaViewerViewAll": "Все медиа чата", + "videoViewerSettings": "Настройки", + "videoViewerSpeed": "Скорость", + "videoViewerQuality": "Качество", "sharedCopyLink": "Копировать ссылку", "sharedLinkCopied": "Ссылка скопирована", "chatInfoActionLeave": "Покинуть", + "chatInfoActionMuted": "Без звука", + "chatInfoNotificationsOn": "Уведомления включены", + "chatInfoNotificationsOff": "Уведомления отключены", + "chatInfoMenuBlock": "Заблокировать", + "chatInfoMenuUnblock": "Разблокировать", + "chatInfoMenuDeleteChat": "Удалить чат", + "chatInfoMenuClearHistory": "Очистить историю", + "chatInfoClearHistoryTitle": "Очистить историю", + "chatInfoClearHistoryMessage": "Все сообщения в этом чате будут удалены без возможности восстановления.", + "chatInfoClearHistoryForAll": "Для всех", + "chatInfoClearHistoryConfirm": "Очистить", + "chatInfoClearHistoryDone": "История очищена", + "chatInfoDeleteChatTitle": "Удалить чат", + "chatInfoDeleteChatMessage": "Чат будет удалён вместе со всей перепиской.", + "chatInfoDeleteChatConfirm": "Удалить", + "chatInfoLeaveGroupTitle": "Покинуть группу", + "chatInfoLeaveGroupMessage": "Вы больше не будете получать сообщения этой группы.", + "chatInfoLeaveChannelTitle": "Покинуть канал", + "chatInfoLeaveChannelMessage": "Вы больше не будете получать публикации этого канала.", + "chatInfoLeaveConfirm": "Покинуть", + "chatInfoLeaveFailed": "Не удалось покинуть чат", + "chatInfoCallConfirmTitle": "Начать звонок", + "chatInfoCallConfirmMessage": "Позвонить {name}?", + "chatInfoConfirmYes": "Да", + "chatInfoConfirmNo": "Нет", + "chatInfoCallFailed": "Не удалось начать звонок", + "chatInfoBlockConfirmTitle": "Заблокировать", + "chatInfoBlockConfirmMessage": "Вы уверены, что хотите заблокировать {name}?", + "chatInfoBlockDone": "Пользователь заблокирован", + "chatInfoUnblockDone": "Пользователь разблокирован", + "chatInfoBlockFailed": "Не удалось изменить блокировку", + "chatInfoComplaintTitle": "Пожаловаться", + "chatInfoComplaintSubtitle": "Выберите причину жалобы", + "chatInfoComplaintSend": "Пожаловаться", + "chatInfoComplaintClose": "Закрыть", + "chatInfoComplaintEmpty": "Не удалось загрузить причины жалобы", + "chatInfoComplaintSent": "Жалоба отправлена", + "chatInfoComplaintFailed": "Не удалось отправить жалобу", + "chatInfoActionCancel": "Отмена", "chatInfoBio": "О себе", "chatInfoInviteLink": "Ссылка-приглашение", "chatInfoCollapse": "Свернуть", "chatInfoShowMore": "Ещё", "chatInfoAddMember": "Добавить участника", - "chatInfoRoleOwner": "владелец", - "chatInfoRoleAdmin": "Адмін", + "chatInfoRoleOwner": "Владелец", + "chatInfoRoleAdmin": "Админ", + "chatInfoMemberDeleted": "Аккаунт удалён", + "chatInfoInviteByLink": "Пригласить по ссылке", + "chatInfoInviteLinkHint": "Вы можете пригласить любого человека по этой ссылке", + "chatInfoAddMembersAction": "Добавить", + "chatInfoMembersSearchHint": "Поиск", + "chatInfoAddMembersEmpty": "Некого добавить", + "chatInfoMembersAdded": "Участники добавлены", + "chatInfoAddMembersError": "Не удалось добавить участников", "chatInfoNoData": "Нет данных", "chatInfoHideExtra": "Скрыть", "chatInfoShowMoreExtra": "Подробнее", + "chatSendConfirmMessage": "Отправить это сообщение в чат?", + "chatSendConfirmAction": "Отправить", + "chatInfoRowDisableForward": "Пересылка запрещена", + "chatInfoRowCopyDisabled": "Копирование запрещено", + "chatInfoRowOnlyAdminCall": "Звонить могут админы", + "chatInfoRowAllCanPin": "Все могут закреплять", + "chatInfoRowMembersSeeLink": "Ссылка видна участникам", + "chatInfoRowConfirmBeforeSend": "Подтверждать отправку", + "chatInfoRowOnlyOwnerIconTitle": "Название меняет владелец", + "chatInfoRowPromotedDisabled": "Реклама отключена", + "chatInfoRowUserId": "ID пользователя", "chatInfoRowId": "ID чата", "chatInfoRowCreated": "Создан", "chatInfoRowModified": "Изменён", @@ -453,7 +590,6 @@ "chatInfoRowComments": "Комментарии", "chatInfoRowRkn": "РКН", "chatInfoRowOnlyAdmin": "Только адм.", - "securityTitle": "Безопасность", "securityLoadError": "Ошибка загрузки: {error}", "securitySaveError": "Ошибка сохранения: {error}", @@ -486,7 +622,6 @@ "securityAudioTranscription": "Транскрибация аудио", "securityBlacklistTitle": "Чёрный список", "securityBlacklistNotification": "Чёрный список: {count} контактов", - "passwordEntryWrongPassword": "Неверный пароль", "passwordEntryConfirmTitle": "Подтвердите пароль", "passwordEntryCurrentPasswordHint": "Текущий пароль", @@ -503,7 +638,7 @@ "passwordEntryDeleteAction": "Удалить пароль", "passwordEntryMinPasswordError": "Пароль должен быть минимум 6 символов", "passwordEntryMismatchError": "Пароли не совпадают", - "passwordEntryInvalidEmailError": "Введите корректный email", + "passwordEntryInvalidEmailError": "Введите нормальный email", "passwordEntryInvalidCodeError": "Введите 6-значный код", "passwordEntrySetupTitle": "Установка пароля", "passwordEntryStepPassword": "Пароль", @@ -532,7 +667,7 @@ "passwordEntryEmailHint": "example@mail.ru", "passwordEntryRemovedNotif": "Пароль удалён", "passwordEntryRemoveTitle": "Удаление пароля", - "passwordEntryRemoveWarning": "Внимание! После удаления пароля защита вашего аккаунта ослабнет.", + "passwordEntryRemoveWarning": "После удаления пароля ваш аккаунт будет менее защищен. Уверены?", "cloudStorageNoActiveProfile": "Нет активного профиля", "cloudStorageSetupFailed": "Не удалось создать среду", "cloudStorageTitle": "Облачное хранилище", @@ -592,7 +727,7 @@ "digitalIdDocChildOms": "Полис ОМС ребёнка", "attachSheetGallery": "Галерея", "attachSheetPoll": "Опрос", - "attachSheetCameraComingSoon": "Камера скоро появится", + "attachSheetCameraError": "Не удалось открыть камеру", "attachSheetSendFileTitle": "Отправить файл", "attachSheetSendFileSubtitle": "Документ, архив или любой другой файл", "attachSheetChooseFileButton": "Выбрать файл", @@ -604,12 +739,17 @@ "attachSheetNoImagesFound": "Изображений не найдено", "attachSheetLimitedAccessInfo": "Доступны не все фото", "attachSheetSectionInProgress": "Раздел в разработке", + "attachSheetContact": "Контакт", + "attachSheetContactSearchHint": "Поиск по контактам", + "attachSheetNoContacts": "У вас пока нет контактов", + "attachSheetNoContactsFound": "Контакты не найдены", "attachSheetNoGalleryAccessTitle": "Нет доступа к галерее", "attachSheetNoGalleryAccessSubtitle": "Разрешите доступ к фото, чтобы выбрать их отсюда", "attachSheetAllow": "Разрешить", "attachSheetSettings": "Настройки", "attachSheetAddCaptionHint": "Добавить подпись...", "attachSheetCamera": "Камера", + "attachSheetCameraAllow": "Разрешите камеру", "photoEditorApplyFailed": "Не удалось применить", "photoEditorFlipTooltip": "Отразить", "photoEditorRotateTooltip": "Повернуть", @@ -665,5 +805,159 @@ "updateLater": "Позже", "updateSkip": "Пропустить", "updateDownloading": "Загрузка обновления…", - "updateDownloadFailed": "Не удалось скачать обновление" + "updateDownloadFailed": "Не удалось скачать обновление", + "updateCheck": "Проверить обновление", + "updateChecking": "Проверяем обновления…", + "updateUpToDate": "Установлена актуальная версия", + "updateCheckFailed": "Не удалось проверить обновления. Повторите позже", + "profileResurrecting": "Упс! Сервер не прислал profile. Попробую регенерировать…", + "profilePhoneRegenFailed": "Не удалось регенерировать данные об номере. Перезайдите и сообщите об проблеме разработчикам", + "addContactTitle": "Новый контакт", + "addContactFirstName": "Имя", + "addContactLastName": "Фамилия (необязательно)", + "addContactSave": "Сохранить контакт", + "addContactNotFound": "{phone} не найден", + "@addContactNotFound": { + "placeholders": { + "phone": { + "type": "String" + } + } + }, + "addContactNotFoundSubtitle": "Этого номера пока нет в приложении", + "addContactSearchOther": "Искать другой номер", + "addContactError": "Не удалось добавить контакт", + "contactBubbleNew": "Новый контакт", + "contactBubbleAlreadyAdded": "Уже твой контакт", + "contactBubbleOpenProfile": "Открыть профиль", + "miniAppOpen": "Открыть", + "miniAppFailed": "Не удалось открыть приложение", + "editContactMenu": "Редактировать контакт", + "editContactTitle": "Редактировать контакт", + "editContactFirstName": "Имя", + "editContactLastName": "Фамилия", + "editContactSave": "Сохранить", + "editContactDelete": "Удалить контакт", + "editContactDeleteConfirmTitle": "Удалить контакт?", + "editContactDeleteConfirmBody": "Контакт будет удалён из вашего списка.", + "editContactDeleteCancel": "Отмена", + "editContactError": "Не удалось сохранить изменения", + "downloadsTitle": "Недавние загрузки", + "downloadsTooltip": "Загрузки", + "downloadsSettings": "Настройки", + "downloadsEmpty": "Скачанные файлы появятся здесь", + "downloadsUnknownSource": "Источник неизвестен", + "downloadsPhoto": "Фото", + "downloadsVideo": "Видео", + "downloadsGif": "GIF", + "downloadsAudio": "Аудио", + "downloadsFile": "Файл", + "downloadsOpenFailed": "Не удалось открыть файл", + "downloadsClearHistory": "Очистить историю загрузок", + "downloadsClearTitle": "Очистить историю загрузок?", + "downloadsClearBody": "Файлы останутся на устройстве, но этот список будет очищен.", + "downloadsClearConfirm": "Очистить", + "downloadsHistoryCleared": "История загрузок очищена", + "uploadNotificationPhotos": "{count, plural, =1{Фото} other{{count} фото}}", + "@uploadNotificationPhotos": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "uploadNotificationVideo": "Видео", + "uploadNotificationVideoNote": "Кружок", + "uploadNotificationVoice": "Голосовое сообщение", + "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" + } + } + }, + "savedMessagesEmptyPreview": "Сохраните что-нибудь", + "proxyCurrentState": "Сейчас: {value}", + "@proxyCurrentState": { + "placeholders": { + "value": { + "type": "String" + } + } + }, + "blacklistEmpty": "Никто не заблокирован", + "blacklistLoadError": "Не удалось загрузить чёрный список", + "videoEditorQualityLow": "Небольшой размер", + "videoEditorQualityHigh": "Высокое качество", + "videoEditorCaptionHint": "Добавить подпись...", + "videoEditorMuteTooltip": "Отправить без звука", + "videoEditorProcessing": "Обработка видео…", + "videoEditorExportFailed": "Не удалось обработать видео", + "videoEditorFrameFailed": "Не удалось получить кадр", + "videoEditorQualityTooltip": "Качество", + "webPushTitle": "Уведомления на iOS", + "webPushIntro": "На iOS у Комета нет обычных пушей: Apple выдаёт токен уведомлений только приложениям, подписанным сертификатом разработчика, а sideload-сборка такого не получает.\n\nОбход — веб-приложение на экране «Домой». Уведомления шлёт сам сервер MAX через Apple, а показывает их отдельная иконка.\n\nДля этого нужна веб-сессия. Комет создаст её и подтвердит сам, с этого же устройства — вводить номер и код не придётся.", + "webPushConfirm": "Продолжить", + "webPushPasswordExplainer": "На аккаунте включена двухфакторная защита.", + "webPushPasswordHintLabel": "Подсказка: {hint}", + "@webPushPasswordHintLabel": { + "placeholders": { + "hint": { + "type": "String" + } + } + }, + "webPushPasswordHint": "Пароль", + "webPushInstallTitle": "Установите приложение", + "webPushInstallBody": "Откройте push.komet.pw в Safari, добавьте на экран «Домой» и запустите появившуюся иконку. Из вкладки браузера уведомления не работают — так устроена iOS.\n\nВ приложении разрешите уведомления, создайте подписку и нажмите «Открыть Комет». Дальше подписка зарегистрируется сама.", + "webPushLinkedTitle": "Уведомления подключены", + "webPushLinkedBody": "Подписка зарегистрирована на сервере. Не удаляйте иконку с экрана «Домой» — вместе с ней пропадут уведомления.\n\nЕсли пуши перестанут приходить, откройте приложение и свяжите заново: Apple иногда меняет адрес подписки.", + "webPushOpenSite": "Открыть push.komet.pw", + "webPushSignOut": "Отключить уведомления", + "webPushLinked": "Уведомления подключены", + "webPushLinkFailed": "Не удалось подключить уведомления: {error}", + "@webPushLinkFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "webPushNotAuthorized": "Сначала войдите в разделе «Уведомления через PWA»", + "webPushConnect": "Подключить уведомления", + "webPushWaitingBody": "Комет подтверждает вход веб-сессии с этого устройства. Обычно занимает несколько секунд.", + "webPushNeedsOnline": "Нет связи с сервером. Дождитесь подключения и попробуйте снова.", + "webPushSignOutConfirm": "Веб-сессия будет завершена и исчезнет из списка устройств. Чтобы вернуть уведомления, подключение придётся пройти заново.", + "webPushSignOutAction": "Отключить", + "webPushStatusService": "Сервис", + "webPushStatusToken": "Токен", + "webPushStatusLinkedAt": "Привязан", + "webPushStatusDevice": "Устройство" } diff --git a/lib/main.dart b/lib/main.dart index 48f39e0..73dcaf8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder; +import 'package:kolibri/kolibri.dart' show initKolibri; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:video_player_media_kit/video_player_media_kit.dart'; @@ -18,26 +19,36 @@ import 'core/cache/self_presence.dart'; import 'core/storage/app_instance.dart'; import 'core/storage/draft_store.dart'; import 'core/storage/archived_chats_store.dart'; +import 'core/storage/chat_encryption_store.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_show_extra_info.dart'; +import 'core/config/app_spectrum_background.dart'; import 'core/config/app_bubble_behavior.dart'; import 'core/config/komet_settings.dart'; +import 'core/config/call_no_mute.dart'; import 'core/config/debug_test.dart'; import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'core/config/custom_font_service.dart'; import 'core/config/app_message_actions_style.dart'; +import 'core/config/app_microphone.dart'; import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_pranks.dart'; import 'core/config/app_stories.dart'; import 'core/config/app_commands.dart'; +import 'core/config/app_phonebook_names.dart'; +import 'core/contacts/device_contacts_service.dart'; import 'core/config/app_link_preview.dart'; import 'core/config/app_media_cache.dart'; +import 'core/config/app_video_note_quality.dart'; import 'core/config/app_pill_gradient.dart'; import 'core/config/app_visual_style.dart'; import 'core/config/app_chat_chrome.dart'; +import 'core/config/app_composer_background.dart'; +import 'core/config/app_composer_style.dart'; +import 'core/config/app_nav_pill_style.dart'; import 'core/config/app_wallpaper_tint.dart'; import 'core/storage/chat_wallpaper_store.dart'; import 'core/utils/wallpaper_seed.dart'; @@ -46,8 +57,10 @@ import 'core/config/app_theme_schedule.dart'; import 'core/config/app_digital_id_mode.dart'; import 'backend/modules/account.dart'; import 'backend/modules/chats.dart'; +import 'backend/modules/comments.dart'; import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; +import 'backend/modules/folders.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/outbox.dart'; import 'backend/modules/polls.dart'; @@ -62,6 +75,9 @@ import 'core/calls/call_bridge.dart'; import 'core/calls/call_controller.dart'; import 'core/links/deep_link_service.dart'; import 'frontend/screens/calls/call_screen.dart'; +import 'core/push/fkm_controller.dart'; +import 'core/push/notification_bridge.dart'; +import 'core/share/share_intent_bridge.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -75,11 +91,16 @@ import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/widgets/adaptive_shell.dart'; import 'frontend/widgets/custom_notification.dart'; +import 'frontend/widgets/liquid_glass.dart'; +import 'frontend/widgets/small_spinner.dart'; import 'frontend/widgets/theme_reveal.dart'; +import 'frontend/widgets/floating_call_badge.dart'; +import 'frontend/widgets/floating_video_note.dart'; final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); +final commentsModule = CommentsModule(api); final sharedContentModule = SharedContentModule(api); final pollsModule = PollsModule(api); final stickersModule = StickersModule(api); @@ -88,11 +109,15 @@ final webAppModule = WebAppModule(api); final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); final storiesModule = StoriesModule(api); +final bannersModule = accountModule.banners; final RouteObserver> appRouteObserver = RouteObserver>(); bool isOnemeFlavor = false; +const ProgressIndicatorThemeData _expressiveProgressTheme = + ProgressIndicatorThemeData(year2023: false); + const PageTransitionsTheme _appPageTransitions = PageTransitionsTheme( builders: { TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), @@ -156,7 +181,9 @@ void _installLogCapture() { void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + await initKolibri(); DebugTest.parse(args); + CallNoMute.parse(args); _installLogCapture(); VideoPlayerMediaKit.ensureInitialized( windows: true, @@ -166,6 +193,7 @@ void main(List args) async { if (AppInstance.isNamed) { SharedPreferences.setPrefix('flutter.${AppInstance.id}.'); } + await TlsConfig.applyMincifryTrust(); await AppDatabase.init(); final activeAccountId = await TokenStorage.getActiveAccountId(); if (activeAccountId != null) { @@ -173,6 +201,10 @@ void main(List args) async { } attachInfoCacheApi(api); chats.attachGlobalPushHandlers(api); + unawaited(FkmController.instance.init(api)); + FoldersModule.attachGlobalPushHandlers(api); + TranscriptionPushHandler.attach(api); + commentsModule.attachPushHandlers(api); storiesModule.attach(); unawaited(storiesModule.loadCache()); unawaited(DeepLinkService.instance.init()); @@ -189,18 +221,28 @@ void main(List args) async { final amoledFuture = AppAmoled.load(); final pillGradientFuture = AppPillGradient.load(); final visualStyleFuture = AppVisualStyle.load(); + final liquidGlassFuture = LiquidGlass.load(); final chatChromeFuture = AppChatChrome.load(); + final composerStyleFuture = AppComposerStyle.load(); + final composerBackgroundFuture = AppComposerBackground.load(); + final navPillStyleFuture = AppNavPillStyle.load(); final wallpaperTintFuture = AppWallpaperTint.load(); final themeScheduleFuture = AppThemeSchedule.load(); final messageActionsFuture = AppMessageActionsStyle.load(); final swipeBackFuture = AppSwipeBackDesktop.load(); + final microphoneFuture = AppMicrophone.load(); final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); final commandsFuture = AppCommands.load(); + final phonebookNamesFuture = AppPhonebookNames.load(); final linkPreviewFuture = AppLinkPreview.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); + final videoNoteResolutionFuture = AppVideoNoteResolution.load(); + final videoNoteFpsFuture = AppVideoNoteFps.load(); + final videoNoteRearCameraFuture = AppVideoNoteRearCamera.load(); final digitalIdNativeFuture = AppDigitalIdNative.load(); final showExtraInfoFuture = AppShowExtraInfo.load(); + final spectrumBackgroundFuture = AppSpectrumBackground.load(); final trafficCaptureFuture = TrafficMonitor.instance.load(); final debugLogFuture = DebugSessionLog.instance.init(); @@ -215,6 +257,7 @@ void main(List args) async { await FileHistoryCache.load(prefs); await DraftStore.instance.load(); await ArchivedChatsStore.instance.load(); + await ChatEncryptionStore.instance.load(); await KometSettings.load(); if (KometSettings.ghostMode.value) SelfPresence.markOffline(); await ContactCache.load(); @@ -240,19 +283,30 @@ void main(List args) async { amoledFuture, pillGradientFuture, visualStyleFuture, + liquidGlassFuture, chatChromeFuture, + composerStyleFuture, + composerBackgroundFuture, + navPillStyleFuture, wallpaperTintFuture, themeScheduleFuture, messageActionsFuture, swipeBackFuture, + microphoneFuture, pranksFuture, storiesFuture, commandsFuture, + phonebookNamesFuture, linkPreviewFuture, cacheLimitFuture, + videoNoteResolutionFuture, + videoNoteFpsFuture, + videoNoteRearCameraFuture, digitalIdNativeFuture, showExtraInfoFuture, + spectrumBackgroundFuture, ]); + await DeviceContactsService.loadFromStartup(); await trafficCaptureFuture; await debugLogFuture; runApp( @@ -321,6 +375,7 @@ class KometAppState extends State StreamSubscription? _vpnBypassSub; StreamSubscription? _callIncomingSub; StreamSubscription? _serverErrorSub; + StreamSubscription? _accountNoticeSub; Timer? _scheduleTimer; String? _lastVpnNotice; DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0); @@ -374,6 +429,8 @@ class KometAppState extends State _loginStatusSub = accountModule.loginStatusStream.listen((status) async { if (status == LoginStatus.success) { DeepLinkService.instance.markReady(); + NotificationBridge.instance.markReady(); + ShareIntentBridge.instance.markReady(); unawaited(_refreshWallpaperSeed()); CallController.instance.init(api); OutboxService.instance.init(api, messagesModule); @@ -392,8 +449,12 @@ class KometAppState extends State ); CallController.instance.appResumed = true; CallBridge.instance.init(); + NotificationBridge.instance.init(); + ShareIntentBridge.instance.init(); WidgetsBinding.instance.addPostFrameCallback((_) { CallBridge.instance.checkInitialCall(); + unawaited(NotificationBridge.instance.checkInitialChat()); + unawaited(ShareIntentBridge.instance.checkInitialShare()); }); _sessionExpiredSub = api.sessionExpiredStream.listen(( @@ -426,9 +487,9 @@ class KometAppState extends State _vpnBypassSub = VpnBypassService.instance.events.listen((r) { final msg = r.bound - ? 'Соединение через VPN не работает — ' - 'используется ${r.boundInterface ?? r.transport ?? 'прямое подключение'}' - : 'Соединение через VPN не работает, обойти не удалось' + ? 'Обход VPN включён — прямое подключение через ' + '${r.boundInterface ?? r.transport ?? 'сеть без VPN'}' + : 'Обход VPN не удался, подключение через туннель' '${r.reason != null ? ' (${r.reason})' : ''}'; final now = DateTime.now(); @@ -459,6 +520,18 @@ class KometAppState extends State showCustomNotificationOnOverlay(overlay, msg); } }); + + _accountNoticeSub = accountModule.noticeStream.listen((notice) { + final overlay = KometApp.navigatorKey.currentState?.overlay; + final ctx = KometApp.navigatorKey.currentContext; + if (overlay == null || ctx == null || !ctx.mounted) return; + final l10n = AppLocalizations.of(ctx); + if (l10n == null) return; + final message = switch (notice) { + AccountNotice.resurrectingProfile => l10n.profileResurrecting, + }; + showCustomNotificationOnOverlay(overlay, message); + }); } Future _ensureFullScreenIntentPermission() async { @@ -516,12 +589,15 @@ class KometAppState extends State _vpnBypassSub?.cancel(); _callIncomingSub?.cancel(); _serverErrorSub?.cancel(); + _accountNoticeSub?.cancel(); _scheduleTimer?.cancel(); AppThemeModeConfig.current.removeListener(_onThemeModeChanged); AppAmoled.current.removeListener(_onAmoledChanged); AppThemeSchedule.current.removeListener(_onScheduleChanged); AppWallpaperTint.current.removeListener(_onWallpaperTintChanged); - ChatWallpaperStore.instance.revision.removeListener(_onWallpaperTintChanged); + ChatWallpaperStore.instance.revision.removeListener( + _onWallpaperTintChanged, + ); WidgetsBinding.instance.removeObserver(this); _profileUpdateController.close(); fpsOverlayEnabled.dispose(); @@ -536,6 +612,9 @@ class KometAppState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { CallController.instance.appResumed = state == AppLifecycleState.resumed; + if (state == AppLifecycleState.inactive && CallController.instance.isBusy) { + unawaited(CallBridge.instance.ensureOngoing()); + } if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden || state == AppLifecycleState.detached) { @@ -545,7 +624,12 @@ class KometAppState extends State if (state != AppLifecycleState.resumed) return; api.wakeUp(); SelfCheckService.instance.resume(); + if (!CallController.instance.isBusy) { + unawaited(CallBridge.instance.dropOngoing()); + } CallBridge.instance.checkInitialCall(); + unawaited(NotificationBridge.instance.checkInitialChat()); + unawaited(ShareIntentBridge.instance.checkInitialShare()); if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return; _rescheduleSwitch(); final next = _effectiveThemeMode; @@ -746,8 +830,10 @@ class KometAppState extends State return; } await ChatWallpaperStore.instance.load(); - final wallpaper = - ChatWallpaperStore.instance.get(accountId, kGlobalWallpaperChatId); + final wallpaper = ChatWallpaperStore.instance.get( + accountId, + kGlobalWallpaperChatId, + ); final seed = await computeWallpaperSeed(wallpaper); if (!mounted) return; wallpaperSeed.value = seed; @@ -812,11 +898,14 @@ class KometAppState extends State _themeCacheFontId = _fontId; _themeCacheLight = light; _themeCacheDark = dark; + final displayFont = AppDisplayFont(AppFonts.displayFamily(_fontId)); _lightTheme = withM3ETheme( ThemeData( useMaterial3: true, colorScheme: light, pageTransitionsTheme: _appPageTransitions, + progressIndicatorTheme: _expressiveProgressTheme, + extensions: [displayFont], textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.light).textTheme, @@ -828,6 +917,8 @@ class KometAppState extends State useMaterial3: true, colorScheme: dark, pageTransitionsTheme: _appPageTransitions, + progressIndicatorTheme: _expressiveProgressTheme, + extensions: [displayFont], textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.dark).textTheme, @@ -891,8 +982,8 @@ class KometAppState extends State AppWallpaperTint.current, ]), builder: (context, _) { - final seed = AppWallpaperTint.current.value && - wallpaperSeed.value != null + final seed = + AppWallpaperTint.current.value && wallpaperSeed.value != null ? wallpaperSeed.value : accentSeed.value; final ColorScheme lightBase; @@ -933,10 +1024,11 @@ class KometAppState extends State child: child ?? const SizedBox.shrink(), builder: (context, scale, appChild) { Widget scaledChild = appChild!; - if ((scale - 1.0).abs() > 0.001) { + final effective = AppFonts.effectiveScale(_fontId, scale); + if ((effective - 1.0).abs() > 0.001) { scaledChild = MediaQuery.withClampedTextScaling( - minScaleFactor: scale, - maxScaleFactor: scale, + minScaleFactor: effective, + maxScaleFactor: effective, child: scaledChild, ); } @@ -952,6 +1044,12 @@ class KometAppState extends State key: _captureBoundaryKey, child: sChild!, ), + const Positioned.fill( + child: FloatingVideoNoteLayer(), + ), + const Positioned.fill( + child: FloatingCallBadgeLayer(), + ), if (fpsOn) const FpsOverlayLayer(), ], ); @@ -1045,9 +1143,7 @@ class _StartupScreenState extends State<_StartupScreen> { final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, - body: Center( - child: CircularProgressIndicator(color: cs.primary, strokeWidth: 2), - ), + body: Center(child: SmallSpinner(size: 36, color: cs.primary)), ); } } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index b40d021..49110d9 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import '../core/utils/parse.dart'; +import '../core/utils/text_format.dart'; enum AttachmentType { photo, @@ -131,6 +132,7 @@ class VideoAttachment extends MessageAttachment { final int? height; final int? duration; final int? size; + final String? localPath; final int? videoType; @@ -148,6 +150,7 @@ class VideoAttachment extends MessageAttachment { this.duration, this.size, this.videoType, + this.localPath, }) : super(type: AttachmentType.video); factory VideoAttachment.fromMap(Map map) { @@ -428,10 +431,13 @@ class LocationAttachment extends MessageAttachment { } class ControlAttachment extends MessageAttachment { + static const String botStartedEvent = 'botStarted'; + final String? event; final String? title; final List? userIds; final int? userId; + final String? startPayload; const ControlAttachment({ super.previewData, @@ -441,8 +447,11 @@ class ControlAttachment extends MessageAttachment { this.title, this.userIds, this.userId, + this.startPayload, }) : super(type: AttachmentType.control); + bool get isBotStart => event == botStartedEvent; + factory ControlAttachment.fromMap(Map map) { String? title = map['title']?.toString(); if ((title == null || title.isEmpty) && map['shortMessage'] != null) { @@ -458,6 +467,7 @@ class ControlAttachment extends MessageAttachment { userId: map['userId'] is int ? map['userId'] as int : int.tryParse(map['userId']?.toString() ?? ''), + startPayload: map['startPayload']?.toString(), ); } @@ -470,6 +480,7 @@ class ControlAttachment extends MessageAttachment { 'title': title, 'userIds': userIds, 'userId': userId, + 'startPayload': startPayload, }; } @@ -677,10 +688,12 @@ class ForwardedMessageAttachment extends MessageAttachment { final int originalSenderId; final String? originalSenderName; final String? originalSenderAvatar; + final String? originalType; final String? originalMessageId; final int? originalTime; final String? originalText; final int? originalChatId; + final List originalFormatRanges; final List? originalAttachments; final ContactAttachment? originalContact; @@ -688,14 +701,18 @@ class ForwardedMessageAttachment extends MessageAttachment { required this.originalSenderId, this.originalSenderName, this.originalSenderAvatar, + this.originalType, this.originalMessageId, this.originalTime, this.originalText, this.originalChatId, + this.originalFormatRanges = const [], this.originalAttachments, this.originalContact, }) : super(type: AttachmentType.forward); + bool get isChannel => originalType == 'CHANNEL'; + factory ForwardedMessageAttachment.fromMap(Map map) { final linkRaw = map['link']; Map? link; @@ -736,12 +753,27 @@ class ForwardedMessageAttachment extends MessageAttachment { } } + final originalType = message?['type']?.toString().toUpperCase(); + final isChannel = originalType == 'CHANNEL'; + final channelName = link?['chatName']?.toString().trim(); + final channelAvatar = link?['chatIconUrl']?.toString().trim(); + return ForwardedMessageAttachment( - originalSenderId: (message?['sender'] as int?) ?? 0, + originalSenderId: parseIntOrNull(message?['sender']) ?? 0, + originalSenderName: + isChannel && channelName != null && channelName.isNotEmpty + ? channelName + : null, + originalSenderAvatar: + isChannel && channelAvatar != null && channelAvatar.isNotEmpty + ? channelAvatar + : null, + originalType: originalType, originalMessageId: message?['id']?.toString(), - originalTime: message?['time'] as int?, - originalText: message?['text'] as String?, - originalChatId: link?['chatId'] as int?, + originalTime: parseIntOrNull(message?['time']), + originalText: message?['text']?.toString(), + originalChatId: parseIntOrNull(link?['chatId']), + originalFormatRanges: parseFormatElements(message?['elements']), originalAttachments: originalAttaches, originalContact: originalContact, ); @@ -753,10 +785,12 @@ class ForwardedMessageAttachment extends MessageAttachment { 'originalSenderId': originalSenderId, 'originalSenderName': originalSenderName, 'originalSenderAvatar': originalSenderAvatar, + 'originalType': originalType, 'originalMessageId': originalMessageId, 'originalTime': originalTime, 'originalText': originalText, 'originalChatId': originalChatId, + 'originalElements': serializeFormatElements(originalFormatRanges), }; } diff --git a/lib/models/bot_info.dart b/lib/models/bot_info.dart new file mode 100644 index 0000000..3ab0d62 --- /dev/null +++ b/lib/models/bot_info.dart @@ -0,0 +1,48 @@ +import 'contact_info.dart'; + +class BotCommand { + final String name; + final String? description; + + const BotCommand({required this.name, this.description}); + + factory BotCommand.fromMap(Map map) => BotCommand( + name: map['name']?.toString() ?? '', + description: (map['description'] as String?)?.trim().isNotEmpty == true + ? (map['description'] as String).trim() + : null, + ); + + String get slash => '/$name'; +} + +class BotInfo { + final int botId; + final List commands; + final ContactInfo? contact; + + const BotInfo({required this.botId, required this.commands, this.contact}); + + factory BotInfo.fromPayload(int botId, Map payload) { + final rawCommands = payload['commands']; + final commands = []; + if (rawCommands is List) { + for (final c in rawCommands.whereType()) { + final command = BotCommand.fromMap(c); + if (command.name.isNotEmpty) commands.add(command); + } + } + final rawContact = payload['contact']; + return BotInfo( + botId: botId, + commands: commands, + contact: rawContact is Map + ? ContactInfo.fromMap(Map.from(rawContact)) + : null, + ); + } + + String? get description => contact?.raw['description'] as String?; + + String? get link => contact?.raw['link'] as String?; +} diff --git a/lib/models/chat_info.dart b/lib/models/chat_info.dart index 0c4ff35..935f89a 100644 --- a/lib/models/chat_info.dart +++ b/lib/models/chat_info.dart @@ -23,6 +23,24 @@ class ChatInfo { bool isAdmin(int id) => adminIds.contains(id); bool isOwner(int id) => owner != null && id == owner; + bool option(String name) { + final opts = raw['options']; + return opts is Map && opts[name] == true; + } + + bool canSeeInviteLink(int id) => + isAdmin(id) || isOwner(id) || option('MEMBERS_CAN_SEE_PRIVATE_LINK'); + + String? adminAlias(int id) { + final source = raw['adminParticipants']; + if (source is! Map) return null; + final entry = source[id.toString()] ?? source[id]; + if (entry is! Map) return null; + final alias = entry['alias']; + if (alias is String && alias.trim().isNotEmpty) return alias.trim(); + return null; + } + int? get participantsCount => raw['participantsCount'] as int?; int? get blockedParticipantsCount => raw['blockedParticipantsCount'] as int?; String? get link => raw['link'] as String?; diff --git a/lib/models/chat_preview_media.dart b/lib/models/chat_preview_media.dart new file mode 100644 index 0000000..7af6107 --- /dev/null +++ b/lib/models/chat_preview_media.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +enum ChatPreviewKind { + photo, + video, + videoNote, + audio, + file, + sticker, + contact, + location, + poll, + share, + call, + missedCall, + videoCall, + missedVideoCall, + control, + other, +} + +const Map _kindToCode = { + ChatPreviewKind.photo: 'photo', + ChatPreviewKind.video: 'video', + ChatPreviewKind.videoNote: 'videoNote', + ChatPreviewKind.audio: 'audio', + ChatPreviewKind.file: 'file', + ChatPreviewKind.sticker: 'sticker', + ChatPreviewKind.contact: 'contact', + ChatPreviewKind.location: 'location', + ChatPreviewKind.poll: 'poll', + ChatPreviewKind.share: 'share', + ChatPreviewKind.call: 'call', + ChatPreviewKind.missedCall: 'missedCall', + ChatPreviewKind.videoCall: 'videoCall', + ChatPreviewKind.missedVideoCall: 'missedVideoCall', + ChatPreviewKind.control: 'control', + ChatPreviewKind.other: 'other', +}; + +final Map _codeToKind = { + for (final e in _kindToCode.entries) e.value: e.key, +}; + +class ChatPreviewThumb { + final String source; + final bool video; + + const ChatPreviewThumb({required this.source, this.video = false}); + + Map toMap() => {'s': source, if (video) 'v': true}; + + static ChatPreviewThumb? fromMap(dynamic raw) { + if (raw is! Map) return null; + final source = raw['s']?.toString(); + if (source == null || source.isEmpty) return null; + return ChatPreviewThumb(source: source, video: raw['v'] == true); + } +} + +class ChatPreviewMedia { + final ChatPreviewKind kind; + final List thumbs; + final String? label; + final String? detail; + + const ChatPreviewMedia({ + required this.kind, + this.thumbs = const [], + this.label, + this.detail, + }); + + bool get captioned => label == null && detail == null; + + Map toMap() => { + 'k': _kindToCode[kind], + if (thumbs.isNotEmpty) 't': [for (final thumb in thumbs) thumb.toMap()], + if (label != null) 'l': label, + if (detail != null) 'd': detail, + }; + + String encode() => jsonEncode(toMap()); + + static ChatPreviewMedia? decode(String? raw) { + if (raw == null || raw.isEmpty) return null; + try { + return fromMap(jsonDecode(raw)); + } catch (_) { + return null; + } + } + + static ChatPreviewMedia? fromMap(dynamic raw) { + if (raw is! Map) return null; + final kind = _codeToKind[raw['k']?.toString()]; + if (kind == null) return null; + final thumbsRaw = raw['t']; + return ChatPreviewMedia( + kind: kind, + thumbs: thumbsRaw is List + ? [for (final item in thumbsRaw) ?ChatPreviewThumb.fromMap(item)] + : const [], + label: raw['l']?.toString(), + detail: raw['d']?.toString(), + ); + } +} diff --git a/lib/models/contact_info.dart b/lib/models/contact_info.dart index a938c78..9184156 100644 --- a/lib/models/contact_info.dart +++ b/lib/models/contact_info.dart @@ -16,11 +16,17 @@ class ContactName { String? get label { final n = name; if (n != null && n.trim().isNotEmpty) return n.trim(); + return fullName; + } + + String? get fullName { final combined = [firstName, lastName] .where((s) => s != null && s.trim().isNotEmpty) .map((s) => s!.trim()) .join(' '); - return combined.isEmpty ? null : combined; + if (combined.isNotEmpty) return combined; + final n = name; + return (n != null && n.trim().isNotEmpty) ? n.trim() : null; } } @@ -52,6 +58,23 @@ class ContactInfo { return firstLabel; } + String? get customFullName => _fullNameOfType('CUSTOM'); + + String? get onemeFullName => _fullNameOfType('ONEME'); + + String? get fullName => customFullName ?? onemeFullName ?? displayName; + + bool get isSavedContact => customFullName != null; + + String? _fullNameOfType(String type) { + for (final n in names) { + if (n.type != type) continue; + final full = n.fullName; + if (full != null) return full; + } + return null; + } + String? get firstName { for (final n in names) { final f = n.firstName; @@ -69,5 +92,10 @@ class ContactInfo { bool get isBot => options.contains('BOT'); + bool get isDeleted { + final status = raw['accountStatus']; + return status is int && status != 0; + } + int? get id => raw['id'] as int?; } diff --git a/lib/models/informer_banner.dart b/lib/models/informer_banner.dart new file mode 100644 index 0000000..86a76a2 --- /dev/null +++ b/lib/models/informer_banner.dart @@ -0,0 +1,116 @@ +abstract class BannerType { + static const int text = 0; + static const int link = 1; + static const int update = 2; +} + +abstract class BannerSettings { + static const int textAnimation = 1; + static const int hideCloseButton = 2; + static const int hideOnClick = 4; + static const int iconThemeColor = 8; +} + +class InformerBanner { + final String id; + final String title; + final String description; + final int settings; + final int priority; + final int repeat; + final int rerun; + final int? animojiId; + final String? url; + final int type; + + const InformerBanner({ + required this.id, + required this.title, + this.description = '', + this.settings = 0, + this.priority = 0, + this.repeat = 0, + this.rerun = 0, + this.animojiId, + this.url, + this.type = BannerType.text, + }); + + bool get animatesText => settings & BannerSettings.textAnimation != 0; + bool get hidesCloseButton => settings & BannerSettings.hideCloseButton != 0; + bool get closesOnClick => settings & BannerSettings.hideOnClick != 0; + bool get tintsIconWithTheme => + settings & BannerSettings.iconThemeColor != 0; + + bool get isLink => + type == BannerType.link && url != null && url!.trim().isNotEmpty; + bool get isUpdate => type == BannerType.update; + bool get isClickable => isLink || isUpdate; + + static InformerBanner? fromMap(Map map) { + final id = map['id']?.toString(); + if (id == null || id.isEmpty) return null; + final title = map['title']?.toString() ?? ''; + final description = map['description']?.toString() ?? ''; + if (title.isEmpty && description.isEmpty) return null; + return InformerBanner( + id: id, + title: title, + description: description, + settings: _int(map['settings']) ?? 0, + priority: _int(map['priority']) ?? 0, + repeat: _int(map['repeat']) ?? 0, + rerun: _int(map['rerun']) ?? 0, + animojiId: _int(map['animojiId']), + url: map['url']?.toString(), + type: _int(map['type']) ?? BannerType.text, + ); + } + + Map toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'settings': settings, + 'priority': priority, + 'repeat': repeat, + 'rerun': rerun, + 'animojiId': animojiId, + 'url': url, + 'type': type, + }; + + static int? _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } +} + +class BannerShowState { + final int showCounter; + final int? showAt; + final int? closedAt; + + const BannerShowState({this.showCounter = 0, this.showAt, this.closedAt}); + + BannerShowState copyWith({int? showCounter, int? showAt, int? closedAt}) => + BannerShowState( + showCounter: showCounter ?? this.showCounter, + showAt: showAt ?? this.showAt, + closedAt: closedAt ?? this.closedAt, + ); + + static BannerShowState fromJson(Map map) => BannerShowState( + showCounter: InformerBanner._int(map['showCounter']) ?? 0, + showAt: InformerBanner._int(map['showAt']), + closedAt: InformerBanner._int(map['closedTime']), + ); + + Map toJson() => { + 'showCounter': showCounter, + 'showAt': showAt, + 'closedTime': closedAt, + }; +} diff --git a/lib/models/login_info.dart b/lib/models/login_info.dart new file mode 100644 index 0000000..0db80f5 --- /dev/null +++ b/lib/models/login_info.dart @@ -0,0 +1,141 @@ +class LoginInfo { + const LoginInfo._(); + + static Map fromPayload(Map payload) { + final profile = _asMap(payload['profile']); + final contact = _asMap(profile?['contact']); + final chats = _asList(payload['chats']); + final config = _asMap(payload['config']); + final server = _asMap(config?['server']); + final user = _asMap(config?['user']); + final experiments = _asMap(config?['experiments']); + final chatSettings = _asMap(config?['chats']); + + return { + 'registrationTime': contact?['registrationTime'], + 'country': contact?['country'], + 'videoChatHistory': payload['videoChatHistory'], + 'updateTime': contact?['updateTime'], + 'id': contact?['id'], + 'phone': contact?['phone'], + 'photoId': contact?['photoId'], + 'accountStatus': contact?['accountStatus'], + 'contactOptions': _copyJsonValue(contact?['options']), + 'profileOptions': _copyJsonValue(profile?['profileOptions']), + 'names': _copyJsonValue(contact?['names']), + 'baseUrl': contact?['baseUrl'], + 'baseRawUrl': contact?['baseRawUrl'], + 'chatMarker': payload['chatMarker'] ?? _latestChatEventTime(chats), + 'time': payload['time'], + 'updates': payload['updates'], + 'messagesCount': _collectionLength(payload['messages']), + 'contactsCount': _collectionLength(payload['contacts']), + 'presenceCount': _collectionLength(payload['presence']), + 'configHash': config?['hash'], + 'chats': _buildChatsSummary(chats), + 'server': _copyMap(server), + 'user': _copyMap(user), + 'experiments': _copyMap(experiments), + 'chatSettings': _copyMap(chatSettings), + }; + } + + static Map _buildChatsSummary(List chats) { + var active = 0; + var hidden = 0; + var dialogs = 0; + var groups = 0; + var channels = 0; + var unread = 0; + var newMessages = 0; + var messages = 0; + + for (final rawChat in chats) { + final chat = _asMap(rawChat); + if (chat == null) continue; + switch (chat['status']) { + case 'ACTIVE': + active++; + case 'HIDDEN': + hidden++; + } + switch (chat['type']) { + case 'DIALOG': + dialogs++; + case 'CHAT': + groups++; + case 'CHANNEL': + channels++; + } + final chatNewMessages = _asInt(chat['newMessages']) ?? 0; + if (chatNewMessages > 0) unread++; + newMessages += chatNewMessages; + messages += _asInt(chat['messagesCount']) ?? 0; + } + + return { + 'count': chats.length, + 'active': active, + 'hidden': hidden, + 'dialogs': dialogs, + 'groups': groups, + 'channels': channels, + 'unread': unread, + 'newMessages': newMessages, + 'messages': messages, + }; + } + + static int? _latestChatEventTime(List chats) { + int? latest; + for (final rawChat in chats) { + final chat = _asMap(rawChat); + final value = _asInt(chat?['lastEventTime']); + if (value != null && (latest == null || value > latest)) { + latest = value; + } + } + return latest; + } + + static Map? _asMap(dynamic value) { + return value is Map ? value : null; + } + + static List _asList(dynamic value) { + return value is List ? value : const []; + } + + static int? _asInt(dynamic value) { + return switch (value) { + int number => number, + num number => number.toInt(), + String text => int.tryParse(text), + _ => null, + }; + } + + static int _collectionLength(dynamic value) { + return switch (value) { + Map items => items.length, + List items => items.length, + _ => 0, + }; + } + + static Map? _copyMap(Map? value) { + if (value == null) return null; + return value.map( + (key, item) => MapEntry(key.toString(), _copyJsonValue(item)), + ); + } + + static dynamic _copyJsonValue(dynamic value) { + if (value is Map) return _copyMap(value); + if (value is List) return value.map(_copyJsonValue).toList(); + if (value == null || value is String || value is num || value is bool) { + return value; + } + return value.toString(); + } +} diff --git a/lib/models/shared_payload.dart b/lib/models/shared_payload.dart new file mode 100644 index 0000000..1d906dc --- /dev/null +++ b/lib/models/shared_payload.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +enum SharedFileKind { photo, video, file } + +class SharedFile { + final String path; + final String name; + final String mime; + final int size; + + const SharedFile({ + required this.path, + required this.name, + required this.mime, + required this.size, + }); + + static SharedFile? fromMap(Map map) { + final path = map['path']; + if (path is! String || path.isEmpty) return null; + final name = map['name']; + final mime = map['mime']; + final size = map['size']; + return SharedFile( + path: path, + name: name is String && name.isNotEmpty ? name : _basename(path), + mime: mime is String && mime.isNotEmpty + ? mime + : 'application/octet-stream', + size: size is int ? size : 0, + ); + } + + File get file => File(path); + + SharedFileKind get kind { + if (mime.startsWith('image/') && !mime.contains('svg')) { + return SharedFileKind.photo; + } + if (mime.startsWith('video/')) return SharedFileKind.video; + return SharedFileKind.file; + } + + static String _basename(String path) { + final idx = path.lastIndexOf(Platform.pathSeparator); + return idx < 0 ? path : path.substring(idx + 1); + } +} + +class SharedPayload { + final List files; + final String? text; + final String? subject; + + const SharedPayload({this.files = const [], this.text, this.subject}); + + static SharedPayload? fromMap(Object? raw) { + if (raw is! Map) return null; + final rawFiles = raw['files']; + final files = []; + if (rawFiles is List) { + for (final entry in rawFiles) { + if (entry is! Map) continue; + final file = SharedFile.fromMap(entry); + if (file != null && file.file.existsSync()) files.add(file); + } + } + final text = raw['text']; + final subject = raw['subject']; + final payload = SharedPayload( + files: files, + text: text is String && text.trim().isNotEmpty ? text.trim() : null, + subject: subject is String && subject.trim().isNotEmpty + ? subject.trim() + : null, + ); + return payload.isEmpty ? null : payload; + } + + bool get isEmpty => files.isEmpty && text == null; + + bool get isTextOnly => files.isEmpty && text != null; + + List get photos => + files.where((f) => f.kind == SharedFileKind.photo).toList(); + + List get videos => + files.where((f) => f.kind == SharedFileKind.video).toList(); + + List get documents => + files.where((f) => f.kind == SharedFileKind.file).toList(); + + SharedFileKind? get dominantKind { + if (files.isEmpty) return null; + if (documents.isNotEmpty) return SharedFileKind.file; + if (videos.isNotEmpty && photos.isEmpty) return SharedFileKind.video; + if (photos.isNotEmpty && videos.isEmpty) return SharedFileKind.photo; + return SharedFileKind.photo; + } +} diff --git a/linux/runner/main.cc b/linux/runner/main.cc index e7c5c54..92a23a7 100644 --- a/linux/runner/main.cc +++ b/linux/runner/main.cc @@ -1,6 +1,7 @@ #include "my_application.h" int main(int argc, char** argv) { + my_application_configure_windowing(); g_autoptr(MyApplication) app = my_application_new(); return g_application_run(G_APPLICATION(app), argc, argv); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 832e416..43eb6be 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,9 +1,6 @@ #include "my_application.h" #include -#ifdef GDK_WINDOWING_X11 -#include -#endif #include "flutter/generated_plugin_registrant.h" @@ -14,6 +11,32 @@ struct _MyApplication { G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +static gboolean desktop_uses_gtk_header_bar() { + const gchar* desktops = g_getenv("XDG_CURRENT_DESKTOP"); + if (desktops == nullptr || *desktops == '\0') { + desktops = g_getenv("DESKTOP_SESSION"); + } + + if (desktops == nullptr) { + return FALSE; + } + + g_auto(GStrv) values = g_strsplit(desktops, ":", -1); + for (gchar** value = values; *value != nullptr; value++) { + if (g_ascii_strcasecmp(*value, "unity") == 0 || + g_ascii_strcasecmp(*value, "gnome") == 0 || + g_ascii_strncasecmp(*value, "gnome-", 6) == 0) { + return TRUE; + } + } + + return FALSE; +} + +void my_application_configure_windowing() { + g_setenv("GTK_CSD", desktop_uses_gtk_header_bar() ? "1" : "0", TRUE); +} + // Called when first Flutter frame received. static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); @@ -25,24 +48,7 @@ static void my_application_activate(GApplication* application) { GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { + if (desktop_uses_gtk_header_bar()) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); gtk_header_bar_set_title(header_bar, "Komet"); diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h index db16367..157b5eb 100644 --- a/linux/runner/my_application.h +++ b/linux/runner/my_application.h @@ -9,6 +9,8 @@ G_DECLARE_FINAL_TYPE(MyApplication, APPLICATION, GtkApplication) +void my_application_configure_windowing(); + /** * my_application_new: * diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 60a84cb..4b25fca 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -84,6 +84,10 @@ PODS: - GoogleUtilities/UserDefaults (8.1.1): - GoogleUtilities/Logger - GoogleUtilities/Privacy + - kolibri (0.0.1): + - FlutterMacOS + - komet_crypto (0.0.1): + - FlutterMacOS - media_kit_libs_macos_video (1.0.4): - FlutterMacOS - media_kit_video (0.0.1): @@ -108,6 +112,7 @@ PODS: - PromisesObjC (2.4.1) - record_macos (1.2.1): - FlutterMacOS + - rlottie (0.2.0) - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): @@ -139,6 +144,8 @@ DEPENDENCIES: - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) + - kolibri (from `Flutter/ephemeral/.symlinks/plugins/kolibri/macos`) + - komet_crypto (from `Flutter/ephemeral/.symlinks/plugins/komet_crypto/macos`) - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin`) @@ -146,6 +153,7 @@ DEPENDENCIES: - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - photo_manager (from `Flutter/ephemeral/.symlinks/plugins/photo_manager/darwin`) - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) + - rlottie (from `../third_party`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) @@ -194,6 +202,10 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral geolocator_apple: :path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin + kolibri: + :path: Flutter/ephemeral/.symlinks/plugins/kolibri/macos + komet_crypto: + :path: Flutter/ephemeral/.symlinks/plugins/komet_crypto/macos media_kit_libs_macos_video: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos media_kit_video: @@ -208,6 +220,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/photo_manager/darwin record_macos: :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos + rlottie: + :path: "../third_party" share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: @@ -242,6 +256,8 @@ SPEC CHECKSUMS: geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da + kolibri: 93062ece67f68ec0b909876b527aa15e198b4a73 + komet_crypto: 856fa27dc180350f88a7cf6de6b512d2d221b737 media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 @@ -252,6 +268,7 @@ SPEC CHECKSUMS: photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 record_macos: 5d55909f9650314be6424ffd6b123ac75a08c3c1 + rlottie: 206daeeeb0f9dec6594ed248eb0c7e876a289e93 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 @@ -260,6 +277,6 @@ SPEC CHECKSUMS: wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b WebRTC-SDK: e6006119cd730d6315d875e4a421b6cc8bb88833 -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 +PODFILE CHECKSUM: f9f028435b3eb11958579119e016406574b87759 COCOAPODS: 1.16.2 diff --git a/native/komet_crypto/.gitignore b/native/komet_crypto/.gitignore new file mode 100644 index 0000000..b9d7f25 --- /dev/null +++ b/native/komet_crypto/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/native/komet_crypto/analysis_options.yaml b/native/komet_crypto/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/native/komet_crypto/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/native/komet_crypto/android/.gitignore b/native/komet_crypto/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/native/komet_crypto/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/native/komet_crypto/android/build.gradle b/native/komet_crypto/android/build.gradle new file mode 100644 index 0000000..80ee56f --- /dev/null +++ b/native/komet_crypto/android/build.gradle @@ -0,0 +1,56 @@ +// The Android Gradle Plugin builds the native code with the Android NDK. + +group 'com.flutter_rust_bridge.komet_crypto' +version '1.0' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + // The Android Gradle Plugin knows how to build native code with the NDK. + classpath 'com.android.tools.build:gradle:7.3.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'com.flutter_rust_bridge.komet_crypto' + } + + // Bumping the plugin compileSdkVersion requires all clients of this plugin + // to bump the version in their app. + compileSdkVersion 33 + + // Use the NDK version + // declared in /android/app/build.gradle file of the Flutter project. + // Replace it with a version number if this plugin requires a specfic NDK version. + // (e.g. ndkVersion "23.1.7779620") + ndkVersion android.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 19 + } +} + +apply from: "../cargokit/gradle/plugin.gradle" +cargokit { + manifestDir = "../rust" + libname = "komet_crypto" +} diff --git a/native/komet_crypto/android/settings.gradle b/native/komet_crypto/android/settings.gradle new file mode 100644 index 0000000..11d2600 --- /dev/null +++ b/native/komet_crypto/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'komet_crypto' diff --git a/native/komet_crypto/android/src/main/AndroidManifest.xml b/native/komet_crypto/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..023e90a --- /dev/null +++ b/native/komet_crypto/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/native/komet_crypto/cargokit/.gitignore b/native/komet_crypto/cargokit/.gitignore new file mode 100644 index 0000000..cf7bb86 --- /dev/null +++ b/native/komet_crypto/cargokit/.gitignore @@ -0,0 +1,4 @@ +target +.dart_tool +*.iml +!pubspec.lock diff --git a/native/komet_crypto/cargokit/LICENSE b/native/komet_crypto/cargokit/LICENSE new file mode 100644 index 0000000..d33a5fe --- /dev/null +++ b/native/komet_crypto/cargokit/LICENSE @@ -0,0 +1,42 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Copyright 2022 Matej Knopp + +================================================================================ + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +APACHE LICENSE, VERSION 2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/native/komet_crypto/cargokit/README b/native/komet_crypto/cargokit/README new file mode 100644 index 0000000..398474d --- /dev/null +++ b/native/komet_crypto/cargokit/README @@ -0,0 +1,11 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Experimental repository to provide glue for seamlessly integrating cargo build +with flutter plugins and packages. + +See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/ +for a tutorial on how to use Cargokit. + +Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin. + diff --git a/native/komet_crypto/cargokit/build_pod.sh b/native/komet_crypto/cargokit/build_pod.sh new file mode 100755 index 0000000..ed0e0d9 --- /dev/null +++ b/native/komet_crypto/cargokit/build_pod.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -e + +BASEDIR=$(dirname "$0") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +BASEDIR=$(cd "$BASEDIR" ; pwd -P) + +# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project +NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"` + +export PATH=${NEW_PATH%?} # remove trailing : + +env + +# Platform name (macosx, iphoneos, iphonesimulator) +export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME + +# Arctive architectures (arm64, armv7, x86_64), space separated. +export CARGOKIT_DARWIN_ARCHS=$ARCHS + +# Current build configuration (Debug, Release) +export CARGOKIT_CONFIGURATION=$CONFIGURATION + +# Path to directory containing Cargo.toml. +export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1 + +# Temporary directory for build artifacts. +export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR + +# Output directory for final artifacts. +export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME + +# Directory to store built tool artifacts. +export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool + +# Directory inside root project. Not necessarily the top level directory of root project. +export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT + +FLUTTER_EXPORT_BUILD_ENVIRONMENT=( + "$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS + "$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS +) + +for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}" +do + if [[ -f "$path" ]]; then + source "$path" + fi +done + +sh "$BASEDIR/run_build_tool.sh" build-pod "$@" + +# Make a symlink from built framework to phony file, which will be used as input to +# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate +# attribute on custom build phase) +ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony" +ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out" diff --git a/native/komet_crypto/cargokit/build_tool/README.md b/native/komet_crypto/cargokit/build_tool/README.md new file mode 100644 index 0000000..a878c27 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/README.md @@ -0,0 +1,5 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +A sample command-line application with an entrypoint in `bin/`, library code +in `lib/`, and example unit test in `test/`. diff --git a/native/komet_crypto/cargokit/build_tool/analysis_options.yaml b/native/komet_crypto/cargokit/build_tool/analysis_options.yaml new file mode 100644 index 0000000..0e16a8b --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/analysis_options.yaml @@ -0,0 +1,34 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - prefer_relative_imports + - directives_ordering + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/native/komet_crypto/cargokit/build_tool/bin/build_tool.dart b/native/komet_crypto/cargokit/build_tool/bin/build_tool.dart new file mode 100644 index 0000000..268eb52 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/bin/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:build_tool/build_tool.dart' as build_tool; + +void main(List arguments) { + build_tool.runMain(arguments); +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/build_tool.dart b/native/komet_crypto/cargokit/build_tool/lib/build_tool.dart new file mode 100644 index 0000000..7c1bb75 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'src/build_tool.dart' as build_tool; + +Future runMain(List args) async { + return build_tool.runMain(args); +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/android_environment.dart b/native/komet_crypto/cargokit/build_tool/lib/src/android_environment.dart new file mode 100644 index 0000000..15fc9ee --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/android_environment.dart @@ -0,0 +1,195 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; +import 'package:version/version.dart'; + +import 'target.dart'; +import 'util.dart'; + +class AndroidEnvironment { + AndroidEnvironment({ + required this.sdkPath, + required this.ndkVersion, + required this.minSdkVersion, + required this.targetTempDir, + required this.target, + }); + + static void clangLinkerWrapper(List args) { + final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG']; + if (clang == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var"); + } + final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET']; + if (target == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var"); + } + + runCommand(clang, [ + target, + ...args, + ]); + } + + /// Full path to Android SDK. + final String sdkPath; + + /// Full version of Android NDK. + final String ndkVersion; + + /// Minimum supported SDK version. + final int minSdkVersion; + + /// Target directory for build artifacts. + final String targetTempDir; + + /// Target being built. + final Target target; + + bool ndkIsInstalled() { + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final ndkPackageXml = File(path.join(ndkPath, 'package.xml')); + return ndkPackageXml.existsSync(); + } + + void installNdk({ + required String javaHome, + }) { + final sdkManagerExtension = Platform.isWindows ? '.bat' : ''; + final sdkManager = path.join( + sdkPath, + 'cmdline-tools', + 'latest', + 'bin', + 'sdkmanager$sdkManagerExtension', + ); + + log.info('Installing NDK $ndkVersion'); + runCommand(sdkManager, [ + '--install', + 'ndk;$ndkVersion', + ], environment: { + 'JAVA_HOME': javaHome, + }); + } + + Future> buildEnvironment() async { + final hostArch = Platform.isMacOS + ? "darwin-x86_64" + : (Platform.isLinux ? "linux-x86_64" : "windows-x86_64"); + + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final toolchainPath = path.join( + ndkPath, + 'toolchains', + 'llvm', + 'prebuilt', + hostArch, + 'bin', + ); + + final minSdkVersion = + math.max(target.androidMinSdkVersion!, this.minSdkVersion); + + final exe = Platform.isWindows ? '.exe' : ''; + + final arKey = 'AR_${target.rust}'; + final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe'] + .map((e) => path.join(toolchainPath, e)) + .firstWhereOrNull((element) => File(element).existsSync()); + if (arValue == null) { + throw Exception('Failed to find ar for $target in $toolchainPath'); + } + + final targetArg = '--target=${target.rust}$minSdkVersion'; + + final ccKey = 'CC_${target.rust}'; + final ccValue = path.join(toolchainPath, 'clang$exe'); + final cfFlagsKey = 'CFLAGS_${target.rust}'; + final cFlagsValue = targetArg; + + final cxxKey = 'CXX_${target.rust}'; + final cxxValue = path.join(toolchainPath, 'clang++$exe'); + final cxxFlagsKey = 'CXXFLAGS_${target.rust}'; + final cxxFlagsValue = targetArg; + + final linkerKey = + 'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase(); + + final ranlibKey = 'RANLIB_${target.rust}'; + final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe'); + + final ndkVersionParsed = Version.parse(ndkVersion); + final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS'; + final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed); + + final runRustTool = + Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh'; + + final packagePath = (await Isolate.resolvePackageUri( + Uri.parse('package:build_tool/buildtool.dart')))! + .toFilePath(); + final selfPath = path.canonicalize(path.join( + packagePath, + '..', + '..', + '..', + runRustTool, + )); + + // Make sure that run_build_tool is working properly even initially launched directly + // through dart run. + final toolTempDir = + Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir; + + return { + arKey: arValue, + ccKey: ccValue, + cfFlagsKey: cFlagsValue, + cxxKey: cxxValue, + cxxFlagsKey: cxxFlagsValue, + ranlibKey: ranlibValue, + rustFlagsKey: rustFlagsValue, + linkerKey: selfPath, + // Recognized by main() so we know when we're acting as a wrapper + '_CARGOKIT_NDK_LINK_TARGET': targetArg, + '_CARGOKIT_NDK_LINK_CLANG': ccValue, + 'CARGOKIT_TOOL_TEMP_DIR': toolTempDir, + }; + } + + // Workaround for libgcc missing in NDK23, inspired by cargo-ndk + String _libGccWorkaround(String buildDir, Version ndkVersion) { + final workaroundDir = path.join( + buildDir, + 'cargokit', + 'libgcc_workaround', + '${ndkVersion.major}', + ); + Directory(workaroundDir).createSync(recursive: true); + if (ndkVersion.major >= 23) { + File(path.join(workaroundDir, 'libgcc.a')) + .writeAsStringSync('INPUT(-lunwind)'); + } else { + // Other way around, untested, forward libgcc.a from libunwind once Rust + // gets updated for NDK23+. + File(path.join(workaroundDir, 'libunwind.a')) + .writeAsStringSync('INPUT(-lgcc)'); + } + + var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? ''; + if (rustFlags.isNotEmpty) { + rustFlags = '$rustFlags\x1f'; + } + rustFlags = '$rustFlags-L\x1f$workaroundDir'; + return rustFlags; + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/artifacts_provider.dart b/native/komet_crypto/cargokit/build_tool/lib/src/artifacts_provider.dart new file mode 100644 index 0000000..e608cec --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/artifacts_provider.dart @@ -0,0 +1,266 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'builder.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'rustup.dart'; +import 'target.dart'; + +class Artifact { + /// File system location of the artifact. + final String path; + + /// Actual file name that the artifact should have in destination folder. + final String finalFileName; + + AritifactType get type { + if (finalFileName.endsWith('.dll') || + finalFileName.endsWith('.dll.lib') || + finalFileName.endsWith('.pdb') || + finalFileName.endsWith('.so') || + finalFileName.endsWith('.dylib')) { + return AritifactType.dylib; + } else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) { + return AritifactType.staticlib; + } else { + throw Exception('Unknown artifact type for $finalFileName'); + } + } + + Artifact({ + required this.path, + required this.finalFileName, + }); +} + +final _log = Logger('artifacts_provider'); + +class ArtifactProvider { + ArtifactProvider({ + required this.environment, + required this.userOptions, + }); + + final BuildEnvironment environment; + final CargokitUserOptions userOptions; + + Future>> getArtifacts(List targets) async { + final result = await _getPrecompiledArtifacts(targets); + + final pendingTargets = List.of(targets); + pendingTargets.removeWhere((element) => result.containsKey(element)); + + if (pendingTargets.isEmpty) { + return result; + } + + final rustup = Rustup(); + for (final target in targets) { + final builder = RustBuilder(target: target, environment: environment); + builder.prepare(rustup); + _log.info('Building ${environment.crateInfo.packageName} for $target'); + final targetDir = await builder.build(); + // For local build accept both static and dynamic libraries. + final artifactNames = { + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.dylib, + remote: false, + ), + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.staticlib, + remote: false, + ) + }; + final artifacts = artifactNames + .map((artifactName) => Artifact( + path: path.join(targetDir, artifactName), + finalFileName: artifactName, + )) + .where((element) => File(element.path).existsSync()) + .toList(); + result[target] = artifacts; + } + return result; + } + + Future>> _getPrecompiledArtifacts( + List targets) async { + if (userOptions.usePrecompiledBinaries == false) { + _log.info('Precompiled binaries are disabled'); + return {}; + } + if (environment.crateOptions.precompiledBinaries == null) { + _log.fine('Precompiled binaries not enabled for this crate'); + return {}; + } + + final start = Stopwatch()..start(); + final crateHash = CrateHash.compute(environment.manifestDir, + tempStorage: environment.targetTempDir); + _log.fine( + 'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms'); + + final downloadedArtifactsDir = + path.join(environment.targetTempDir, 'precompiled', crateHash); + Directory(downloadedArtifactsDir).createSync(recursive: true); + + final res = >{}; + + for (final target in targets) { + final requiredArtifacts = getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + remote: true, + ); + final artifactsForTarget = []; + + for (final artifact in requiredArtifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final downloadedPath = path.join(downloadedArtifactsDir, fileName); + if (!File(downloadedPath).existsSync()) { + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + await _tryDownloadArtifacts( + crateHash: crateHash, + fileName: fileName, + signatureFileName: signatureFileName, + finalPath: downloadedPath, + ); + } + if (File(downloadedPath).existsSync()) { + artifactsForTarget.add(Artifact( + path: downloadedPath, + finalFileName: artifact, + )); + } else { + break; + } + } + + // Only provide complete set of artifacts. + if (artifactsForTarget.length == requiredArtifacts.length) { + _log.fine('Found precompiled artifacts for $target'); + res[target] = artifactsForTarget; + } + } + + return res; + } + + static Future _get(Uri url, {Map? headers}) async { + int attempt = 0; + const maxAttempts = 10; + while (true) { + try { + return await get(url, headers: headers); + } on SocketException catch (e) { + // Try to detect reset by peer error and retry. + if (attempt++ < maxAttempts && + (e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) { + _log.severe( + 'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...'); + await Future.delayed(Duration(seconds: 1)); + continue; + } else { + rethrow; + } + } + } + } + + Future _tryDownloadArtifacts({ + required String crateHash, + required String fileName, + required String signatureFileName, + required String finalPath, + }) async { + final precompiledBinaries = environment.crateOptions.precompiledBinaries!; + final prefix = precompiledBinaries.uriPrefix; + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName'); + _log.fine('Downloading signature from $signatureUrl'); + final signature = await _get(signatureUrl); + if (signature.statusCode == 404) { + _log.warning( + 'Precompiled binaries not available for crate hash $crateHash ($fileName)'); + return; + } + if (signature.statusCode != 200) { + _log.severe( + 'Failed to download signature $signatureUrl: status ${signature.statusCode}'); + return; + } + _log.fine('Downloading binary from $url'); + final res = await _get(url); + if (res.statusCode != 200) { + _log.severe('Failed to download binary $url: status ${res.statusCode}'); + return; + } + if (verify( + precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) { + File(finalPath).writeAsBytesSync(res.bodyBytes); + } else { + _log.shout('Signature verification failed! Ignoring binary.'); + } + } +} + +enum AritifactType { + staticlib, + dylib, +} + +AritifactType artifactTypeForTarget(Target target) { + if (target.darwinPlatform != null) { + return AritifactType.staticlib; + } else { + return AritifactType.dylib; + } +} + +List getArtifactNames({ + required Target target, + required String libraryName, + required bool remote, + AritifactType? aritifactType, +}) { + aritifactType ??= artifactTypeForTarget(target); + if (target.darwinArch != null) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.dylib']; + } + } else if (target.rust.contains('-windows-')) { + if (aritifactType == AritifactType.staticlib) { + return ['$libraryName.lib']; + } else { + return [ + '$libraryName.dll', + '$libraryName.dll.lib', + if (!remote) '$libraryName.pdb' + ]; + } + } else if (target.rust.contains('-linux-')) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.so']; + } + } else { + throw Exception("Unsupported target: ${target.rust}"); + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/build_cmake.dart b/native/komet_crypto/cargokit/build_tool/lib/src/build_cmake.dart new file mode 100644 index 0000000..6f3b2a4 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/build_cmake.dart @@ -0,0 +1,40 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +class BuildCMake { + final CargokitUserOptions userOptions; + + BuildCMake({required this.userOptions}); + + Future build() async { + final targetPlatform = Environment.targetPlatform; + final target = Target.forFlutterName(Environment.targetPlatform); + if (target == null) { + throw Exception("Unknown target platform: $targetPlatform"); + } + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts([target]); + + final libs = artifacts[target]!; + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path) + .copySync(path.join(Environment.outputDir, lib.finalFileName)); + } + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/build_gradle.dart b/native/komet_crypto/cargokit/build_tool/lib/src/build_gradle.dart new file mode 100644 index 0000000..7e61fcb --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/build_gradle.dart @@ -0,0 +1,49 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +final log = Logger('build_gradle'); + +class BuildGradle { + BuildGradle({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.targetPlatforms.map((arch) { + final target = Target.forFlutterName(arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: true); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + for (final target in targets) { + final libs = artifacts[target]!; + final outputDir = path.join(Environment.outputDir, target.android!); + Directory(outputDir).createSync(recursive: true); + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path).copySync(path.join(outputDir, lib.finalFileName)); + } + } + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/build_pod.dart b/native/komet_crypto/cargokit/build_tool/lib/src/build_pod.dart new file mode 100644 index 0000000..8a9c0db --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/build_pod.dart @@ -0,0 +1,89 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; +import 'util.dart'; + +class BuildPod { + BuildPod({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.darwinArchs.map((arch) { + final target = Target.forDarwin( + platformName: Environment.darwinPlatformName, darwinAarch: arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + void performLipo(String targetFile, Iterable sourceFiles) { + runCommand("lipo", [ + '-create', + ...sourceFiles, + '-output', + targetFile, + ]); + } + + final outputDir = Environment.outputDir; + + Directory(outputDir).createSync(recursive: true); + + final staticLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.staticlib) + .toList(); + final dynamicLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.dylib) + .toList(); + + final libName = environment.crateInfo.packageName; + + // If there is static lib, use it and link it with pod + if (staticLibs.isNotEmpty) { + final finalTargetFile = path.join(outputDir, "lib$libName.a"); + performLipo(finalTargetFile, staticLibs.map((e) => e.path)); + } else { + // Otherwise try to replace bundle dylib with our dylib + final bundlePaths = [ + '$libName.framework/Versions/A/$libName', + '$libName.framework/$libName', + ]; + + for (final bundlePath in bundlePaths) { + final targetFile = path.join(outputDir, bundlePath); + if (File(targetFile).existsSync()) { + performLipo(targetFile, dynamicLibs.map((e) => e.path)); + + // Replace absolute id with @rpath one so that it works properly + // when moved to Frameworks. + runCommand("install_name_tool", [ + '-id', + '@rpath/$bundlePath', + targetFile, + ]); + return; + } + } + throw Exception('Unable to find bundle for dynamic library'); + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/build_tool.dart b/native/komet_crypto/cargokit/build_tool/lib/src/build_tool.dart new file mode 100644 index 0000000..70dfe0e --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/build_tool.dart @@ -0,0 +1,276 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; + +import 'android_environment.dart'; +import 'build_cmake.dart'; +import 'build_gradle.dart'; +import 'build_pod.dart'; +import 'logging.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; +import 'util.dart'; +import 'verify_binaries.dart'; + +final log = Logger('build_tool'); + +abstract class BuildCommand extends Command { + Future runBuildCommand(CargokitUserOptions options); + + @override + Future run() async { + final options = CargokitUserOptions.load(); + + if (options.verboseLogging || + Platform.environment['CARGOKIT_VERBOSE'] == '1') { + enableVerboseLogging(); + } + + await runBuildCommand(options); + } +} + +class BuildPodCommand extends BuildCommand { + @override + final name = 'build-pod'; + + @override + final description = 'Build cocoa pod library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildPod(userOptions: options); + await build.build(); + } +} + +class BuildGradleCommand extends BuildCommand { + @override + final name = 'build-gradle'; + + @override + final description = 'Build android library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildGradle(userOptions: options); + await build.build(); + } +} + +class BuildCMakeCommand extends BuildCommand { + @override + final name = 'build-cmake'; + + @override + final description = 'Build CMake library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildCMake(userOptions: options); + await build.build(); + } +} + +class GenKeyCommand extends Command { + @override + final name = 'gen-key'; + + @override + final description = 'Generate key pair for signing precompiled binaries'; + + @override + void run() { + final kp = generateKey(); + final private = HEX.encode(kp.privateKey.bytes); + final public = HEX.encode(kp.publicKey.bytes); + print("Private Key: $private"); + print("Public Key: $public"); + } +} + +class PrecompileBinariesCommand extends Command { + PrecompileBinariesCommand() { + argParser + ..addOption( + 'repository', + mandatory: true, + help: 'Github repository slug in format owner/name', + ) + ..addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ) + ..addMultiOption('target', + help: 'Rust target triple of artifact to build.\n' + 'Can be specified multiple times or omitted in which case\n' + 'all targets for current platform will be built.') + ..addOption( + 'android-sdk-location', + help: 'Location of Android SDK (if available)', + ) + ..addOption( + 'android-ndk-version', + help: 'Android NDK version (if available)', + ) + ..addOption( + 'android-min-sdk-version', + help: 'Android minimum rquired version (if available)', + ) + ..addOption( + 'temp-dir', + help: 'Directory to store temporary build artifacts', + ) + ..addOption( + 'glibc-version', + help: 'GLIBC version to use for linux builds', + ) + ..addFlag( + "verbose", + abbr: "v", + defaultsTo: false, + help: "Enable verbose logging", + ); + } + + @override + final name = 'precompile-binaries'; + + @override + final description = 'Prebuild and upload binaries\n' + 'Private key must be passed through PRIVATE_KEY environment variable. ' + 'Use gen_key through generate priave key.\n' + 'Github token must be passed as GITHUB_TOKEN environment variable.\n'; + + @override + Future run() async { + final verbose = argResults!['verbose'] as bool; + if (verbose) { + enableVerboseLogging(); + } + + final privateKeyString = Platform.environment['PRIVATE_KEY']; + if (privateKeyString == null) { + throw ArgumentError('Missing PRIVATE_KEY environment variable'); + } + final githubToken = Platform.environment['GITHUB_TOKEN']; + if (githubToken == null) { + throw ArgumentError('Missing GITHUB_TOKEN environment variable'); + } + final privateKey = HEX.decode(privateKeyString); + if (privateKey.length != 64) { + throw ArgumentError('Private key must be 64 bytes long'); + } + final manifestDir = argResults!['manifest-dir'] as String; + if (!Directory(manifestDir).existsSync()) { + throw ArgumentError('Manifest directory does not exist: $manifestDir'); + } + String? androidMinSdkVersionString = + argResults!['android-min-sdk-version'] as String?; + int? androidMinSdkVersion; + if (androidMinSdkVersionString != null) { + androidMinSdkVersion = int.tryParse(androidMinSdkVersionString); + if (androidMinSdkVersion == null) { + throw ArgumentError( + 'Invalid android-min-sdk-version: $androidMinSdkVersionString'); + } + } + final targetStrigns = argResults!['target'] as List; + final targets = targetStrigns.map((target) { + final res = Target.forRustTriple(target); + if (res == null) { + throw ArgumentError('Invalid target: $target'); + } + return res; + }).toList(growable: false); + final precompileBinaries = PrecompileBinaries( + privateKey: PrivateKey(privateKey), + githubToken: githubToken, + manifestDir: manifestDir, + repositorySlug: RepositorySlug.full(argResults!['repository'] as String), + targets: targets, + androidSdkLocation: argResults!['android-sdk-location'] as String?, + androidNdkVersion: argResults!['android-ndk-version'] as String?, + androidMinSdkVersion: androidMinSdkVersion, + tempDir: argResults!['temp-dir'] as String?, + glibcVersion: argResults!['glibc-version'] as String?, + ); + + await precompileBinaries.run(); + } +} + +class VerifyBinariesCommand extends Command { + VerifyBinariesCommand() { + argParser.addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ); + } + + @override + final name = "verify-binaries"; + + @override + final description = 'Verifies published binaries\n' + 'Checks whether there is a binary published for each targets\n' + 'and checks the signature.'; + + @override + Future run() async { + final manifestDir = argResults!['manifest-dir'] as String; + final verifyBinaries = VerifyBinaries( + manifestDir: manifestDir, + ); + await verifyBinaries.run(); + } +} + +Future runMain(List args) async { + try { + // Init logging before options are loaded + initLogging(); + + if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) { + return AndroidEnvironment.clangLinkerWrapper(args); + } + + final runner = CommandRunner('build_tool', 'Cargokit built_tool') + ..addCommand(BuildPodCommand()) + ..addCommand(BuildGradleCommand()) + ..addCommand(BuildCMakeCommand()) + ..addCommand(GenKeyCommand()) + ..addCommand(PrecompileBinariesCommand()) + ..addCommand(VerifyBinariesCommand()); + + await runner.run(args); + } on ArgumentError catch (e) { + stderr.writeln(e.toString()); + exit(1); + } catch (e, s) { + log.severe(kDoubleSeparator); + log.severe('Cargokit BuildTool failed with error:'); + log.severe(kSeparator); + log.severe(e); + // This tells user to install Rust, there's no need to pollute the log with + // stack trace. + if (e is! RustupNotFoundException) { + log.severe(kSeparator); + log.severe(s); + log.severe(kSeparator); + log.severe('BuildTool arguments: $args'); + } + log.severe(kDoubleSeparator); + exit(1); + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/builder.dart b/native/komet_crypto/cargokit/build_tool/lib/src/builder.dart new file mode 100644 index 0000000..cd5269f --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/builder.dart @@ -0,0 +1,209 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'android_environment.dart'; +import 'cargo.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; +import 'util.dart'; + +final _log = Logger('builder'); + +enum BuildConfiguration { + debug, + release, + profile, +} + +extension on BuildConfiguration { + bool get isDebug => this == BuildConfiguration.debug; + String get rustName => switch (this) { + BuildConfiguration.debug => 'debug', + BuildConfiguration.release => 'release', + BuildConfiguration.profile => 'release', + }; +} + +class BuildException implements Exception { + final String message; + + BuildException(this.message); + + @override + String toString() { + return 'BuildException: $message'; + } +} + +class BuildEnvironment { + final BuildConfiguration configuration; + final CargokitCrateOptions crateOptions; + final String targetTempDir; + final String manifestDir; + final CrateInfo crateInfo; + + final bool isAndroid; + final String? androidSdkPath; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? javaHome; + + final String? glibcVersion; + + BuildEnvironment({ + required this.configuration, + required this.crateOptions, + required this.targetTempDir, + required this.manifestDir, + required this.crateInfo, + required this.isAndroid, + this.androidSdkPath, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.javaHome, + this.glibcVersion, + }); + + static BuildConfiguration parseBuildConfiguration(String value) { + // XCode configuration adds the flavor to configuration name. + final firstSegment = value.split('-').first; + final buildConfiguration = BuildConfiguration.values.firstWhereOrNull( + (e) => e.name == firstSegment, + ); + if (buildConfiguration == null) { + _log.warning('Unknown build configuraiton $value, will assume release'); + return BuildConfiguration.release; + } + return buildConfiguration; + } + + static BuildEnvironment fromEnvironment({ + required bool isAndroid, + }) { + final buildConfiguration = + parseBuildConfiguration(Environment.configuration); + final manifestDir = Environment.manifestDir; + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + final crateInfo = CrateInfo.load(manifestDir); + return BuildEnvironment( + configuration: buildConfiguration, + crateOptions: crateOptions, + targetTempDir: Environment.targetTempDir, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: isAndroid, + androidSdkPath: isAndroid ? Environment.sdkPath : null, + androidNdkVersion: isAndroid ? Environment.ndkVersion : null, + androidMinSdkVersion: + isAndroid ? int.parse(Environment.minSdkVersion) : null, + javaHome: isAndroid ? Environment.javaHome : null, + ); + } +} + +class RustBuilder { + final Target target; + final BuildEnvironment environment; + + RustBuilder({ + required this.target, + required this.environment, + }); + + void prepare( + Rustup rustup, + ) { + final toolchain = _toolchain; + if (rustup.installedTargets(toolchain) == null) { + rustup.installToolchain(toolchain); + } + if (toolchain == 'nightly') { + rustup.installRustSrcForNightly(); + } + if (!rustup.installedTargets(toolchain)!.contains(target.rust)) { + rustup.installTarget(target.rust, toolchain: toolchain); + } + if (environment.glibcVersion != null) { + rustup.installZigBuild(toolchain); + } + } + + CargoBuildOptions? get _buildOptions => + environment.crateOptions.cargo[environment.configuration]; + + String get _toolchain => _buildOptions?.toolchain.name ?? 'stable'; + + /// Returns the path of directory containing build artifacts. + Future build() async { + final extraArgs = _buildOptions?.flags ?? []; + final manifestPath = path.join(environment.manifestDir, 'Cargo.toml'); + runCommand( + 'rustup', + [ + 'run', + _toolchain, + 'cargo', + (target.android == null && environment.glibcVersion != null) + ? 'zigbuild' + : 'build', + ...extraArgs, + '--manifest-path', + manifestPath, + '-p', + environment.crateInfo.packageName, + if (!environment.configuration.isDebug) '--release', + '--target', + target.rust + + ((target.android == null && environment.glibcVersion != null) + ? '.${environment.glibcVersion!}' + : ""), + '--target-dir', + environment.targetTempDir, + ], + environment: await _buildEnvironment(), + ); + return path.join( + environment.targetTempDir, + target.rust, + environment.configuration.rustName, + ); + } + + Future> _buildEnvironment() async { + if (target.android == null) { + return {}; + } else { + final sdkPath = environment.androidSdkPath; + final ndkVersion = environment.androidNdkVersion; + final minSdkVersion = environment.androidMinSdkVersion; + if (sdkPath == null) { + throw BuildException('androidSdkPath is not set'); + } + if (ndkVersion == null) { + throw BuildException('androidNdkVersion is not set'); + } + if (minSdkVersion == null) { + throw BuildException('androidMinSdkVersion is not set'); + } + final env = AndroidEnvironment( + sdkPath: sdkPath, + ndkVersion: ndkVersion, + minSdkVersion: minSdkVersion, + targetTempDir: environment.targetTempDir, + target: target, + ); + if (!env.ndkIsInstalled() && environment.javaHome != null) { + env.installNdk(javaHome: environment.javaHome!); + } + return env.buildEnvironment(); + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/cargo.dart b/native/komet_crypto/cargokit/build_tool/lib/src/cargo.dart new file mode 100644 index 0000000..0d8958f --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/cargo.dart @@ -0,0 +1,48 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:toml/toml.dart'; + +class ManifestException { + ManifestException(this.message, {required this.fileName}); + + final String? fileName; + final String message; + + @override + String toString() { + if (fileName != null) { + return 'Failed to parse package manifest at $fileName: $message'; + } else { + return 'Failed to parse package manifest: $message'; + } + } +} + +class CrateInfo { + CrateInfo({required this.packageName}); + + final String packageName; + + static CrateInfo parseManifest(String manifest, {final String? fileName}) { + final toml = TomlDocument.parse(manifest); + final package = toml.toMap()['package']; + if (package == null) { + throw ManifestException('Missing package section', fileName: fileName); + } + final name = package['name']; + if (name == null) { + throw ManifestException('Missing package name', fileName: fileName); + } + return CrateInfo(packageName: name); + } + + static CrateInfo load(String manifestDir) { + final manifestFile = File(path.join(manifestDir, 'Cargo.toml')); + final manifest = manifestFile.readAsStringSync(); + return parseManifest(manifest, fileName: manifestFile.path); + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/crate_hash.dart b/native/komet_crypto/cargokit/build_tool/lib/src/crate_hash.dart new file mode 100644 index 0000000..0c4d88d --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/crate_hash.dart @@ -0,0 +1,124 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as path; + +class CrateHash { + /// Computes a hash uniquely identifying crate content. This takes into account + /// content all all .rs files inside the src directory, as well as Cargo.toml, + /// Cargo.lock, build.rs and cargokit.yaml. + /// + /// If [tempStorage] is provided, computed hash is stored in a file in that directory + /// and reused on subsequent calls if the crate content hasn't changed. + static String compute(String manifestDir, {String? tempStorage}) { + return CrateHash._( + manifestDir: manifestDir, + tempStorage: tempStorage, + )._compute(); + } + + CrateHash._({ + required this.manifestDir, + required this.tempStorage, + }); + + String _compute() { + final files = getFiles(); + final tempStorage = this.tempStorage; + if (tempStorage != null) { + final quickHash = _computeQuickHash(files); + final quickHashFolder = Directory(path.join(tempStorage, 'crate_hash')); + quickHashFolder.createSync(recursive: true); + final quickHashFile = File(path.join(quickHashFolder.path, quickHash)); + if (quickHashFile.existsSync()) { + return quickHashFile.readAsStringSync(); + } + final hash = _computeHash(files); + quickHashFile.writeAsStringSync(hash); + return hash; + } else { + return _computeHash(files); + } + } + + /// Computes a quick hash based on files stat (without reading contents). This + /// is used to cache the real hash, which is slower to compute since it involves + /// reading every single file. + String _computeQuickHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + final data = ByteData(8); + for (final file in files) { + input.add(utf8.encode(file.path)); + final stat = file.statSync(); + data.setUint64(0, stat.size); + input.add(data.buffer.asUint8List()); + data.setUint64(0, stat.modified.millisecondsSinceEpoch); + input.add(data.buffer.asUint8List()); + } + + input.close(); + return base64Url.encode(output.events.single.bytes); + } + + String _computeHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + void addTextFile(File file) { + // text Files are hashed by lines in case we're dealing with github checkout + // that auto-converts line endings. + final splitter = LineSplitter(); + if (file.existsSync()) { + final data = file.readAsStringSync(); + final lines = splitter.convert(data); + for (final line in lines) { + input.add(utf8.encode(line)); + } + } + } + + for (final file in files) { + addTextFile(file); + } + + input.close(); + final res = output.events.single; + + // Truncate to 128bits. + final hash = res.bytes.sublist(0, 16); + return hex.encode(hash); + } + + List getFiles() { + final src = Directory(path.join(manifestDir, 'src')); + final files = src + .listSync(recursive: true, followLinks: false) + .whereType() + .toList(); + files.sortBy((element) => element.path); + void addFile(String relative) { + final file = File(path.join(manifestDir, relative)); + if (file.existsSync()) { + files.add(file); + } + } + + addFile('Cargo.toml'); + addFile('Cargo.lock'); + addFile('build.rs'); + addFile('cargokit.yaml'); + return files; + } + + final String manifestDir; + final String? tempStorage; +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/environment.dart b/native/komet_crypto/cargokit/build_tool/lib/src/environment.dart new file mode 100644 index 0000000..996483a --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/environment.dart @@ -0,0 +1,68 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +extension on String { + String resolveSymlink() => File(this).resolveSymbolicLinksSync(); +} + +class Environment { + /// Current build configuration (debug or release). + static String get configuration => + _getEnv("CARGOKIT_CONFIGURATION").toLowerCase(); + + static bool get isDebug => configuration == 'debug'; + static bool get isRelease => configuration == 'release'; + + /// Temporary directory where Rust build artifacts are placed. + static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR"); + + /// Final output directory where the build artifacts are placed. + static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR'); + + /// Path to the crate manifest (containing Cargo.toml). + static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR'); + + /// Directory inside root project. Not necessarily root folder. Symlinks are + /// not resolved on purpose. + static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR'); + + // Pod + + /// Platform name (macosx, iphoneos, iphonesimulator). + static String get darwinPlatformName => + _getEnv("CARGOKIT_DARWIN_PLATFORM_NAME"); + + /// List of architectures to build for (arm64, armv7, x86_64). + static List get darwinArchs => + _getEnv("CARGOKIT_DARWIN_ARCHS").split(' '); + + // Gradle + static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION"); + static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION"); + static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR"); + static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME"); + static List get targetPlatforms => + _getEnv("CARGOKIT_TARGET_PLATFORMS").split(','); + + // CMAKE + static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM"); + + static String _getEnv(String key) { + final res = Platform.environment[key]; + if (res == null) { + throw Exception("Missing environment variable $key"); + } + return res; + } + + static String _getEnvPath(String key) { + final res = _getEnv(key); + if (Directory(res).existsSync()) { + return res.resolveSymlink(); + } else { + return res; + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/logging.dart b/native/komet_crypto/cargokit/build_tool/lib/src/logging.dart new file mode 100644 index 0000000..5edd4fd --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/logging.dart @@ -0,0 +1,52 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; + +const String kSeparator = "--"; +const String kDoubleSeparator = "=="; + +bool _lastMessageWasSeparator = false; + +void _log(LogRecord rec) { + final prefix = '${rec.level.name}: '; + final out = rec.level == Level.SEVERE ? stderr : stdout; + if (rec.message == kSeparator) { + if (!_lastMessageWasSeparator) { + out.write(prefix); + out.writeln('-' * 80); + _lastMessageWasSeparator = true; + } + return; + } else if (rec.message == kDoubleSeparator) { + out.write(prefix); + out.writeln('=' * 80); + _lastMessageWasSeparator = true; + return; + } + out.write(prefix); + out.writeln(rec.message); + _lastMessageWasSeparator = false; +} + +void initLogging() { + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((LogRecord rec) { + final lines = rec.message.split('\n'); + for (final line in lines) { + if (line.isNotEmpty || lines.length == 1 || line != lines.last) { + _log(LogRecord( + rec.level, + line, + rec.loggerName, + )); + } + } + }); +} + +void enableVerboseLogging() { + Logger.root.level = Level.ALL; +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/options.dart b/native/komet_crypto/cargokit/build_tool/lib/src/options.dart new file mode 100644 index 0000000..22aef1d --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/options.dart @@ -0,0 +1,309 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +import 'builder.dart'; +import 'environment.dart'; +import 'rustup.dart'; + +final _log = Logger('options'); + +/// A class for exceptions that have source span information attached. +class SourceSpanException implements Exception { + // This is a getter so that subclasses can override it. + /// A message describing the exception. + String get message => _message; + final String _message; + + // This is a getter so that subclasses can override it. + /// The span associated with this exception. + /// + /// This may be `null` if the source location can't be determined. + SourceSpan? get span => _span; + final SourceSpan? _span; + + SourceSpanException(this._message, this._span); + + /// Returns a string representation of `this`. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an ANSI terminal color escape that should be used to + /// highlight the span's text. If it's `true`, it indicates that the text + /// should be highlighted using the default color. If it's `false` or `null`, + /// it indicates that the text shouldn't be highlighted. + @override + String toString({Object? color}) { + if (span == null) return message; + return 'Error on ${span!.message(message, color: color)}'; + } +} + +enum Toolchain { + stable, + beta, + nightly, +} + +class CargoBuildOptions { + final Toolchain toolchain; + final List flags; + + CargoBuildOptions({ + required this.toolchain, + required this.flags, + }); + + static Toolchain _toolchainFromNode(YamlNode node) { + if (node case YamlScalar(value: String name)) { + final toolchain = + Toolchain.values.firstWhereOrNull((element) => element.name == name); + if (toolchain != null) { + return toolchain; + } + } + throw SourceSpanException( + 'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.', + node.span); + } + + static CargoBuildOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + Toolchain toolchain = Toolchain.stable; + List flags = []; + for (final MapEntry(:key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: 'toolchain')) { + toolchain = _toolchainFromNode(value); + } else if (key case YamlScalar(value: 'extra_flags')) { + if (value case YamlList(nodes: List list)) { + if (list.every((element) { + if (element case YamlScalar(value: String _)) { + return true; + } + return false; + })) { + flags = list.map((e) => e.value as String).toList(); + continue; + } + } + throw SourceSpanException( + 'Extra flags must be a list of strings', value.span); + } else { + throw SourceSpanException( + 'Unknown cargo option type. Must be "toolchain" or "extra_flags".', + key.span); + } + } + return CargoBuildOptions(toolchain: toolchain, flags: flags); + } +} + +extension on YamlMap { + /// Map that extracts keys so that we can do map case check on them. + Map get valueMap => + nodes.map((key, value) => MapEntry(key.value, value)); +} + +class PrecompiledBinaries { + final String uriPrefix; + final PublicKey publicKey; + + PrecompiledBinaries({ + required this.uriPrefix, + required this.publicKey, + }); + + static PublicKey _publicKeyFromHex(String key, SourceSpan? span) { + final bytes = HEX.decode(key); + if (bytes.length != 32) { + throw SourceSpanException( + 'Invalid public key. Must be 32 bytes long.', span); + } + return PublicKey(bytes); + } + + static PrecompiledBinaries parse(YamlNode node) { + if (node case YamlMap(valueMap: Map map)) { + if (map + case { + 'url_prefix': YamlNode urlPrefixNode, + 'public_key': YamlNode publicKeyNode, + }) { + final urlPrefix = switch (urlPrefixNode) { + YamlScalar(value: String urlPrefix) => urlPrefix, + _ => throw SourceSpanException( + 'Invalid URL prefix value.', urlPrefixNode.span), + }; + final publicKey = switch (publicKeyNode) { + YamlScalar(value: String publicKey) => + _publicKeyFromHex(publicKey, publicKeyNode.span), + _ => throw SourceSpanException( + 'Invalid public key value.', publicKeyNode.span), + }; + return PrecompiledBinaries( + uriPrefix: urlPrefix, + publicKey: publicKey, + ); + } + } + throw SourceSpanException( + 'Invalid precompiled binaries value. ' + 'Expected Map with "url_prefix" and "public_key".', + node.span); + } +} + +/// Cargokit options specified for Rust crate. +class CargokitCrateOptions { + CargokitCrateOptions({ + this.cargo = const {}, + this.precompiledBinaries, + }); + + final Map cargo; + final PrecompiledBinaries? precompiledBinaries; + + static CargokitCrateOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + final options = {}; + PrecompiledBinaries? precompiledBinaries; + + for (final entry in node.nodes.entries) { + if (entry + case MapEntry( + key: YamlScalar(value: 'cargo'), + value: YamlNode node, + )) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: String name)) { + final configuration = BuildConfiguration.values + .firstWhereOrNull((element) => element.name == name); + if (configuration != null) { + options[configuration] = CargoBuildOptions.parse(value); + continue; + } + } + throw SourceSpanException( + 'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.', + key.span); + } + } else if (entry.key case YamlScalar(value: 'precompiled_binaries')) { + precompiledBinaries = PrecompiledBinaries.parse(entry.value); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".', + entry.key.span); + } + } + return CargokitCrateOptions( + cargo: options, + precompiledBinaries: precompiledBinaries, + ); + } + + static CargokitCrateOptions load({ + required String manifestDir, + }) { + final uri = Uri.file(path.join(manifestDir, "cargokit.yaml")); + final file = File.fromUri(uri); + if (file.existsSync()) { + final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri); + return parse(contents); + } else { + return CargokitCrateOptions(); + } + } +} + +class CargokitUserOptions { + // When Rustup is installed always build locally unless user opts into + // using precompiled binaries. + static bool defaultUsePrecompiledBinaries() { + return Rustup.executablePath() == null; + } + + CargokitUserOptions({ + required this.usePrecompiledBinaries, + required this.verboseLogging, + }); + + CargokitUserOptions._() + : usePrecompiledBinaries = defaultUsePrecompiledBinaries(), + verboseLogging = false; + + static CargokitUserOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + bool usePrecompiledBinaries = defaultUsePrecompiledBinaries(); + bool verboseLogging = false; + + for (final entry in node.nodes.entries) { + if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) { + if (entry.value case YamlScalar(value: bool value)) { + usePrecompiledBinaries = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "use_precompiled_binaries". Must be a boolean.', + entry.value.span); + } else if (entry.key case YamlScalar(value: 'verbose_logging')) { + if (entry.value case YamlScalar(value: bool value)) { + verboseLogging = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "verbose_logging". Must be a boolean.', + entry.value.span); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "use_precompiled_binaries" or "verbose_logging".', + entry.key.span); + } + } + return CargokitUserOptions( + usePrecompiledBinaries: usePrecompiledBinaries, + verboseLogging: verboseLogging, + ); + } + + static CargokitUserOptions load() { + String fileName = "cargokit_options.yaml"; + var userProjectDir = Directory(Environment.rootProjectDir); + + while (userProjectDir.parent.path != userProjectDir.path) { + final configFile = File(path.join(userProjectDir.path, fileName)); + if (configFile.existsSync()) { + final contents = loadYamlNode( + configFile.readAsStringSync(), + sourceUrl: configFile.uri, + ); + final res = parse(contents); + if (res.verboseLogging) { + _log.info('Found user options file at ${configFile.path}'); + } + return res; + } + userProjectDir = userProjectDir.parent; + } + return CargokitUserOptions._(); + } + + final bool usePrecompiledBinaries; + final bool verboseLogging; +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/precompile_binaries.dart b/native/komet_crypto/cargokit/build_tool/lib/src/precompile_binaries.dart new file mode 100644 index 0000000..019859c --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/precompile_binaries.dart @@ -0,0 +1,205 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; + +final _log = Logger('precompile_binaries'); + +class PrecompileBinaries { + PrecompileBinaries({ + required this.privateKey, + required this.githubToken, + required this.repositorySlug, + required this.manifestDir, + required this.targets, + this.androidSdkLocation, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.tempDir, + this.glibcVersion, + }); + + final PrivateKey privateKey; + final String githubToken; + final RepositorySlug repositorySlug; + final String manifestDir; + final List targets; + final String? androidSdkLocation; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? tempDir; + final String? glibcVersion; + + static String fileName(Target target, String name) { + return '${target.rust}_$name'; + } + + static String signatureFileName(Target target, String name) { + return '${target.rust}_$name.sig'; + } + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final targets = List.of(this.targets); + if (targets.isEmpty) { + targets.addAll([ + ...Target.buildableTargets(), + if (androidSdkLocation != null) ...Target.androidTargets(), + ]); + } + + _log.info('Precompiling binaries for $targets'); + + final hash = CrateHash.compute(manifestDir); + _log.info('Computed crate hash: $hash'); + + final String tagName = 'precompiled_$hash'; + + final github = GitHub(auth: Authentication.withToken(githubToken)); + final repo = github.repositories; + final release = await _getOrCreateRelease( + repo: repo, + tagName: tagName, + packageName: crateInfo.packageName, + hash: hash, + ); + + final tempDir = this.tempDir != null + ? Directory(this.tempDir!) + : Directory.systemTemp.createTempSync('precompiled_'); + + tempDir.createSync(recursive: true); + + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + + final buildEnvironment = BuildEnvironment( + configuration: BuildConfiguration.release, + crateOptions: crateOptions, + targetTempDir: tempDir.path, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: androidSdkLocation != null, + androidSdkPath: androidSdkLocation, + androidNdkVersion: androidNdkVersion, + androidMinSdkVersion: androidMinSdkVersion, + glibcVersion: glibcVersion, + ); + + final rustup = Rustup(); + + for (final target in targets) { + final artifactNames = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + if (artifactNames.every((name) { + final fileName = PrecompileBinaries.fileName(target, name); + return (release.assets ?? []).any((e) => e.name == fileName); + })) { + _log.info("All artifacts for $target already exist - skipping"); + continue; + } + + _log.info('Building for $target'); + + final builder = + RustBuilder(target: target, environment: buildEnvironment); + builder.prepare(rustup); + final res = await builder.build(); + + final assets = []; + for (final name in artifactNames) { + final file = File(path.join(res, name)); + if (!file.existsSync()) { + throw Exception('Missing artifact: ${file.path}'); + } + + final data = file.readAsBytesSync(); + final create = CreateReleaseAsset( + name: PrecompileBinaries.fileName(target, name), + contentType: "application/octet-stream", + assetData: data, + ); + final signature = sign(privateKey, data); + final signatureCreate = CreateReleaseAsset( + name: signatureFileName(target, name), + contentType: "application/octet-stream", + assetData: signature, + ); + bool verified = verify(public(privateKey), data, signature); + if (!verified) { + throw Exception('Signature verification failed'); + } + assets.add(create); + assets.add(signatureCreate); + } + _log.info('Uploading assets: ${assets.map((e) => e.name)}'); + for (final asset in assets) { + // This seems to be failing on CI so do it one by one + int retryCount = 0; + while (true) { + try { + await repo.uploadReleaseAssets(release, [asset]); + break; + } on Exception catch (e) { + if (retryCount == 10) { + rethrow; + } + ++retryCount; + _log.shout( + 'Upload failed (attempt $retryCount, will retry): ${e.toString()}'); + await Future.delayed(Duration(seconds: 2)); + } + } + } + } + + _log.info('Cleaning up'); + tempDir.deleteSync(recursive: true); + } + + Future _getOrCreateRelease({ + required RepositoriesService repo, + required String tagName, + required String packageName, + required String hash, + }) async { + Release release; + try { + _log.info('Fetching release $tagName'); + release = await repo.getReleaseByTagName(repositorySlug, tagName); + } on ReleaseNotFound { + _log.info('Release not found - creating release $tagName'); + release = await repo.createRelease( + repositorySlug, + CreateRelease.from( + tagName: tagName, + name: 'Precompiled binaries ${hash.substring(0, 8)}', + targetCommitish: null, + isDraft: false, + isPrerelease: false, + body: 'Precompiled binaries for crate $packageName, ' + 'crate hash $hash.', + )); + } + return release; + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/rustup.dart b/native/komet_crypto/cargokit/build_tool/lib/src/rustup.dart new file mode 100644 index 0000000..e46722b --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/rustup.dart @@ -0,0 +1,149 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; + +import 'util.dart'; + +class _Toolchain { + _Toolchain( + this.name, + this.targets, + ); + + final String name; + final List targets; +} + +class Rustup { + List? installedTargets(String toolchain) { + final targets = _installedTargets(toolchain); + return targets != null ? List.unmodifiable(targets) : null; + } + + void installToolchain(String toolchain) { + log.info("Installing Rust toolchain: $toolchain"); + runCommand("rustup", ['toolchain', 'install', toolchain]); + _installedToolchains + .add(_Toolchain(toolchain, _getInstalledTargets(toolchain))); + } + + void installTarget( + String target, { + required String toolchain, + }) { + log.info("Installing Rust target: $target"); + runCommand("rustup", ['target', 'add', '--toolchain', toolchain, target]); + _installedTargets(toolchain)?.add(target); + } + + bool _didInstallZigBuild = false; + + void installZigBuild(String toolchain) { + if (_didInstallZigBuild) { + return; + } + + log.info("Installing Zig build"); + runCommand("rustup", [ + 'run', + toolchain, + 'cargo', + 'install', + '--locked', + 'cargo-zigbuild', + ]); + _didInstallZigBuild = true; + } + + final List<_Toolchain> _installedToolchains; + + Rustup() : _installedToolchains = _getInstalledToolchains(); + + List? _installedTargets(String toolchain) => _installedToolchains + .firstWhereOrNull( + (e) => e.name == toolchain || e.name.startsWith('$toolchain-')) + ?.targets; + + static List<_Toolchain> _getInstalledToolchains() { + String extractToolchainName(String line) { + // ignore (default) after toolchain name + final parts = line.split(' '); + return parts[0]; + } + + final res = runCommand("rustup", ['toolchain', 'list']); + + // To list all non-custom toolchains, we need to filter out lines that + // don't start with "stable", "beta", or "nightly". + Pattern nonCustom = RegExp(r"^(stable|beta|nightly)"); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty && e.startsWith(nonCustom)) + .map(extractToolchainName) + .toList(growable: true); + + return lines + .map( + (name) => _Toolchain( + name, + _getInstalledTargets(name), + ), + ) + .toList(growable: true); + } + + static List _getInstalledTargets(String toolchain) { + final res = runCommand("rustup", [ + 'target', + 'list', + '--toolchain', + toolchain, + '--installed', + ]); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty) + .toList(growable: true); + return lines; + } + + bool _didInstallRustSrcForNightly = false; + + void installRustSrcForNightly() { + if (_didInstallRustSrcForNightly) { + return; + } + // Useful for -Z build-std + runCommand( + "rustup", + ['component', 'add', 'rust-src', '--toolchain', 'nightly'], + ); + _didInstallRustSrcForNightly = true; + } + + static String? executablePath() { + final envPath = Platform.environment['PATH']; + final envPathSeparator = Platform.isWindows ? ';' : ':'; + final home = Platform.isWindows + ? Platform.environment['USERPROFILE'] + : Platform.environment['HOME']; + final paths = [ + if (home != null) path.join(home, '.cargo', 'bin'), + if (envPath != null) ...envPath.split(envPathSeparator), + ]; + for (final p in paths) { + final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup'; + final rustupPath = path.join(p, rustup); + if (File(rustupPath).existsSync()) { + return rustupPath; + } + } + return null; + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/target.dart b/native/komet_crypto/cargokit/build_tool/lib/src/target.dart new file mode 100644 index 0000000..624504e --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/target.dart @@ -0,0 +1,147 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; + +import 'util.dart'; + +class Target { + Target({ + required this.rust, + this.flutter, + this.android, + this.androidMinSdkVersion, + this.darwinPlatform, + this.darwinArch, + }); + + static final all = [ + Target( + rust: 'armv7-linux-androideabi', + flutter: 'android-arm', + android: 'armeabi-v7a', + androidMinSdkVersion: 16, + ), + Target( + rust: 'aarch64-linux-android', + flutter: 'android-arm64', + android: 'arm64-v8a', + androidMinSdkVersion: 21, + ), + Target( + rust: 'i686-linux-android', + flutter: 'android-x86', + android: 'x86', + androidMinSdkVersion: 16, + ), + Target( + rust: 'x86_64-linux-android', + flutter: 'android-x64', + android: 'x86_64', + androidMinSdkVersion: 21, + ), + Target( + rust: 'x86_64-pc-windows-msvc', + flutter: 'windows-x64', + ), + Target( + rust: 'aarch64-pc-windows-msvc', + flutter: 'windows-arm64', + ), + Target( + rust: 'x86_64-unknown-linux-gnu', + flutter: 'linux-x64', + ), + Target( + rust: 'aarch64-unknown-linux-gnu', + flutter: 'linux-arm64', + ), + Target(rust: 'riscv64gc-unknown-linux-gnu', flutter: 'linux-riscv64'), + Target( + rust: 'x86_64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'x86_64', + ), + Target( + rust: 'aarch64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios', + darwinPlatform: 'iphoneos', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios-sim', + darwinPlatform: 'iphonesimulator', + darwinArch: 'arm64', + ), + Target( + rust: 'x86_64-apple-ios', + darwinPlatform: 'iphonesimulator', + darwinArch: 'x86_64', + ), + ]; + + static Target? forFlutterName(String flutterName) { + return all.firstWhereOrNull((element) => element.flutter == flutterName); + } + + static Target? forDarwin({ + required String platformName, + required String darwinAarch, + }) { + return all.firstWhereOrNull((element) => // + element.darwinPlatform == platformName && + element.darwinArch == darwinAarch); + } + + static Target? forRustTriple(String triple) { + return all.firstWhereOrNull((element) => element.rust == triple); + } + + static List androidTargets() { + return all + .where((element) => element.android != null) + .toList(growable: false); + } + + /// Returns buildable targets on current host platform ignoring Android targets. + static List buildableTargets() { + if (Platform.isLinux) { + // Right now we don't support cross-compiling on Linux. So we just return + // the host target. + final arch = (runCommand('arch', []).stdout as String).trim(); + if (arch == 'aarch64') { + return [Target.forRustTriple('aarch64-unknown-linux-gnu')!]; + } else if (arch == 'riscv64') { + return [Target.forRustTriple('riscv64gc-unknown-linux-gnu')!]; + } else { + return [Target.forRustTriple('x86_64-unknown-linux-gnu')!]; + } + } + return all.where((target) { + if (Platform.isWindows) { + return target.rust.contains('-windows-'); + } else if (Platform.isMacOS) { + return target.darwinPlatform != null; + } + return false; + }).toList(growable: false); + } + + @override + String toString() { + return rust; + } + + final String? flutter; + final String rust; + final String? android; + final int? androidMinSdkVersion; + final String? darwinPlatform; + final String? darwinArch; +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/util.dart b/native/komet_crypto/cargokit/build_tool/lib/src/util.dart new file mode 100644 index 0000000..8bb6a87 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/util.dart @@ -0,0 +1,172 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'logging.dart'; +import 'rustup.dart'; + +final log = Logger("process"); + +class CommandFailedException implements Exception { + final String executable; + final List arguments; + final ProcessResult result; + + CommandFailedException({ + required this.executable, + required this.arguments, + required this.result, + }); + + @override + String toString() { + final stdout = result.stdout.toString().trim(); + final stderr = result.stderr.toString().trim(); + return [ + "External Command: $executable ${arguments.map((e) => '"$e"').join(' ')}", + "Returned Exit Code: ${result.exitCode}", + kSeparator, + "STDOUT:", + if (stdout.isNotEmpty) stdout, + kSeparator, + "STDERR:", + if (stderr.isNotEmpty) stderr, + ].join('\n'); + } +} + +class TestRunCommandArgs { + final String executable; + final List arguments; + final String? workingDirectory; + final Map? environment; + final bool includeParentEnvironment; + final bool runInShell; + final Encoding? stdoutEncoding; + final Encoding? stderrEncoding; + + TestRunCommandArgs({ + required this.executable, + required this.arguments, + this.workingDirectory, + this.environment, + this.includeParentEnvironment = true, + this.runInShell = false, + this.stdoutEncoding, + this.stderrEncoding, + }); +} + +class TestRunCommandResult { + TestRunCommandResult({ + this.pid = 1, + this.exitCode = 0, + this.stdout = '', + this.stderr = '', + }); + + final int pid; + final int exitCode; + final String stdout; + final String stderr; +} + +TestRunCommandResult Function(TestRunCommandArgs args)? testRunCommandOverride; + +ProcessResult runCommand( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, +}) { + if (testRunCommandOverride != null) { + final result = testRunCommandOverride!(TestRunCommandArgs( + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + )); + return ProcessResult( + result.pid, + result.exitCode, + result.stdout, + result.stderr, + ); + } + log.finer('Running command $executable ${arguments.join(' ')}'); + final res = Process.runSync( + _resolveExecutable(executable), + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stderrEncoding: stderrEncoding, + stdoutEncoding: stdoutEncoding, + ); + if (res.exitCode != 0) { + throw CommandFailedException( + executable: executable, + arguments: arguments, + result: res, + ); + } else { + return res; + } +} + +class RustupNotFoundException implements Exception { + @override + String toString() { + return [ + ' ', + 'rustup not found in PATH.', + ' ', + 'Maybe you need to install Rust? It only takes a minute:', + ' ', + if (Platform.isWindows) 'https://www.rust-lang.org/tools/install', + if (hasHomebrewRustInPath()) ...[ + '\$ brew unlink rust # Unlink homebrew Rust from PATH', + ], + if (!Platform.isWindows) + "\$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh", + ' ', + ].join('\n'); + } + + static bool hasHomebrewRustInPath() { + if (!Platform.isMacOS) { + return false; + } + final envPath = Platform.environment['PATH'] ?? ''; + final paths = envPath.split(':'); + return paths.any((p) { + return p.contains('homebrew') && File(path.join(p, 'rustc')).existsSync(); + }); + } +} + +String _resolveExecutable(String executable) { + if (executable == 'rustup') { + final resolved = Rustup.executablePath(); + if (resolved != null) { + return resolved; + } + throw RustupNotFoundException(); + } else { + return executable; + } +} diff --git a/native/komet_crypto/cargokit/build_tool/lib/src/verify_binaries.dart b/native/komet_crypto/cargokit/build_tool/lib/src/verify_binaries.dart new file mode 100644 index 0000000..2366b57 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/lib/src/verify_binaries.dart @@ -0,0 +1,84 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; + +import 'artifacts_provider.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; + +class VerifyBinaries { + VerifyBinaries({ + required this.manifestDir, + }); + + final String manifestDir; + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final config = CargokitCrateOptions.load(manifestDir: manifestDir); + final precompiledBinaries = config.precompiledBinaries; + if (precompiledBinaries == null) { + stdout.writeln('Crate does not support precompiled binaries.'); + } else { + final crateHash = CrateHash.compute(manifestDir); + stdout.writeln('Crate hash: $crateHash'); + + for (final target in Target.all) { + final message = 'Checking ${target.rust}...'; + stdout.write(message.padRight(40)); + stdout.flush(); + + final artifacts = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + final prefix = precompiledBinaries.uriPrefix; + + bool ok = true; + + for (final artifact in artifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = + Uri.parse('$prefix$crateHash/$signatureFileName'); + + final signature = await get(signatureUrl); + if (signature.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + final asset = await get(url); + if (asset.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + + if (!verify(precompiledBinaries.publicKey, asset.bodyBytes, + signature.bodyBytes)) { + stdout.writeln('INVALID SIGNATURE'); + ok = false; + } + } + + if (ok) { + stdout.writeln('OK'); + } + } + } + } +} diff --git a/native/komet_crypto/cargokit/build_tool/pubspec.lock b/native/komet_crypto/cargokit/build_tool/pubspec.lock new file mode 100644 index 0000000..343bdd3 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/pubspec.lock @@ -0,0 +1,453 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + url: "https://pub.dev" + source: hosted + version: "64.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + args: + dependency: "direct main" + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: "direct main" + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + url: "https://pub.dev" + source: hosted + version: "1.6.3" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + ed25519_edwards: + dependency: "direct main" + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + github: + dependency: "direct main" + description: + name: github + sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411" + url: "https://pub.dev" + source: hosted + version: "9.17.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + hex: + dependency: "direct main" + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" + description: + name: path + sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: "direct main" + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9" + url: "https://pub.dev" + source: hosted + version: "1.24.6" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + test_core: + dependency: transitive + description: + name: test_core + sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265" + url: "https://pub.dev" + source: hosted + version: "0.5.6" + toml: + dependency: "direct main" + description: + name: toml + sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44" + url: "https://pub.dev" + source: hosted + version: "0.14.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + version: + dependency: "direct main" + description: + name: version + sha256: "2307e23a45b43f96469eeab946208ed63293e8afca9c28cd8b5241ff31c55f55" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d" + url: "https://pub.dev" + source: hosted + version: "11.9.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/native/komet_crypto/cargokit/build_tool/pubspec.yaml b/native/komet_crypto/cargokit/build_tool/pubspec.yaml new file mode 100644 index 0000000..18c61e3 --- /dev/null +++ b/native/komet_crypto/cargokit/build_tool/pubspec.yaml @@ -0,0 +1,33 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +name: build_tool +description: Cargokit build_tool. Facilitates the build of Rust crate during Flutter application build. +publish_to: none +version: 1.0.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +# Add regular dependencies here. +dependencies: + # these are pinned on purpose because the bundle_tool_runner doesn't have + # pubspec.lock. See run_build_tool.sh + logging: 1.2.0 + path: 1.8.0 + version: 3.0.0 + collection: 1.18.0 + ed25519_edwards: 0.3.1 + hex: 0.2.0 + yaml: 3.1.2 + source_span: 1.10.0 + github: 9.17.0 + args: 2.4.2 + crypto: 3.0.3 + convert: 3.1.1 + http: 1.1.0 + toml: 0.14.0 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/native/komet_crypto/cargokit/cmake/cargokit.cmake b/native/komet_crypto/cargokit/cmake/cargokit.cmake new file mode 100644 index 0000000..ddd05df --- /dev/null +++ b/native/komet_crypto/cargokit/cmake/cargokit.cmake @@ -0,0 +1,99 @@ +SET(cargokit_cmake_root "${CMAKE_CURRENT_LIST_DIR}/..") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +get_filename_component(cargokit_cmake_root "${cargokit_cmake_root}" REALPATH) + +if(WIN32) + # REALPATH does not properly resolve symlinks on windows :-/ + execute_process(COMMAND powershell -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_LIST_DIR}/resolve_symlinks.ps1" "${cargokit_cmake_root}" OUTPUT_VARIABLE cargokit_cmake_root OUTPUT_STRIP_TRAILING_WHITESPACE) +endif() + +# Arguments +# - target: CMAKE target to which rust library is linked +# - manifest_dir: relative path from current folder to directory containing cargo manifest +# - lib_name: cargo package name +# - any_symbol_name: name of any exported symbol from the library. +# used on windows to force linking with library. +function(apply_cargokit target manifest_dir lib_name any_symbol_name) + + set(CARGOKIT_LIB_NAME "${lib_name}") + set(CARGOKIT_LIB_FULL_NAME "${CMAKE_SHARED_MODULE_PREFIX}${CARGOKIT_LIB_NAME}${CMAKE_SHARED_MODULE_SUFFIX}") + if (CMAKE_CONFIGURATION_TYPES) + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/$") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/$/${CARGOKIT_LIB_FULL_NAME}") + else() + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/${CARGOKIT_LIB_FULL_NAME}") + endif() + set(CARGOKIT_TEMP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargokit_build") + + if (FLUTTER_TARGET_PLATFORM) + set(CARGOKIT_TARGET_PLATFORM "${FLUTTER_TARGET_PLATFORM}") + else() + set(CARGOKIT_TARGET_PLATFORM "windows-x64") + endif() + + set(CARGOKIT_ENV + "CARGOKIT_CMAKE=${CMAKE_COMMAND}" + "CARGOKIT_CONFIGURATION=$" + "CARGOKIT_MANIFEST_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${manifest_dir}" + "CARGOKIT_TARGET_TEMP_DIR=${CARGOKIT_TEMP_DIR}" + "CARGOKIT_OUTPUT_DIR=${CARGOKIT_OUTPUT_DIR}" + "CARGOKIT_TARGET_PLATFORM=${CARGOKIT_TARGET_PLATFORM}" + "CARGOKIT_TOOL_TEMP_DIR=${CARGOKIT_TEMP_DIR}/tool" + "CARGOKIT_ROOT_PROJECT_DIR=${CMAKE_SOURCE_DIR}" + ) + + if (WIN32) + set(SCRIPT_EXTENSION ".cmd") + set(IMPORT_LIB_EXTENSION ".lib") + else() + set(SCRIPT_EXTENSION ".sh") + set(IMPORT_LIB_EXTENSION "") + execute_process(COMMAND chmod +x "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}") + endif() + + # Using generators in custom command is only supported in CMake 3.20+ + if (CMAKE_CONFIGURATION_TYPES AND ${CMAKE_VERSION} VERSION_LESS "3.20.0") + foreach(CONFIG IN LISTS CMAKE_CONFIGURATION_TYPES) + add_custom_command( + OUTPUT + "${CMAKE_CURRENT_BINARY_DIR}/${CONFIG}/${CARGOKIT_LIB_FULL_NAME}" + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endforeach() + else() + add_custom_command( + OUTPUT + ${OUTPUT_LIB} + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endif() + + + set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/_phony_" PROPERTIES SYMBOLIC TRUE) + + if (TARGET ${target}) + # If we have actual cmake target provided create target and make existing + # target depend on it + add_custom_target("${target}_cargokit" DEPENDS ${OUTPUT_LIB}) + add_dependencies("${target}" "${target}_cargokit") + target_link_libraries("${target}" PRIVATE "${OUTPUT_LIB}${IMPORT_LIB_EXTENSION}") + if(WIN32) + target_link_options(${target} PRIVATE "/INCLUDE:${any_symbol_name}") + endif() + else() + # Otherwise (FFI) just use ALL to force building always + add_custom_target("${target}_cargokit" ALL DEPENDS ${OUTPUT_LIB}) + endif() + + # Allow adding the output library to plugin bundled libraries + set("${target}_cargokit_lib" ${OUTPUT_LIB} PARENT_SCOPE) + +endfunction() diff --git a/native/komet_crypto/cargokit/cmake/resolve_symlinks.ps1 b/native/komet_crypto/cargokit/cmake/resolve_symlinks.ps1 new file mode 100644 index 0000000..2ac593a --- /dev/null +++ b/native/komet_crypto/cargokit/cmake/resolve_symlinks.ps1 @@ -0,0 +1,34 @@ +function Resolve-Symlinks { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [string] $Path + ) + + [string] $separator = '/' + [string[]] $parts = $Path.Split($separator) + + [string] $realPath = '' + foreach ($part in $parts) { + if ($realPath -and !$realPath.EndsWith($separator)) { + $realPath += $separator + } + + $realPath += $part.Replace('\', '/') + + # The slash is important when using Get-Item on Drive letters in pwsh. + if (-not($realPath.Contains($separator)) -and $realPath.EndsWith(':')) { + $realPath += '/' + } + + $item = Get-Item $realPath + if ($item.LinkTarget) { + $realPath = $item.LinkTarget.Replace('\', '/') + } + } + $realPath +} + +$path = Resolve-Symlinks -Path $args[0] +Write-Host $path diff --git a/native/komet_crypto/cargokit/gradle/plugin.gradle b/native/komet_crypto/cargokit/gradle/plugin.gradle new file mode 100644 index 0000000..68ff649 --- /dev/null +++ b/native/komet_crypto/cargokit/gradle/plugin.gradle @@ -0,0 +1,184 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import java.nio.file.Paths +import org.apache.tools.ant.taskdefs.condition.Os + +CargoKitPlugin.file = buildscript.sourceFile + +apply plugin: CargoKitPlugin + +class CargoKitExtension { + String manifestDir; // Relative path to folder containing Cargo.toml + String libname; // Library name within Cargo.toml. Must be a cdylib +} + +abstract class CargoKitBuildTask extends DefaultTask { + + @Input + String buildMode + + @Input + String buildDir + + @Input + String outputDir + + @Input + String ndkVersion + + @Input + String sdkDirectory + + @Input + int compileSdkVersion; + + @Input + int minSdkVersion; + + @Input + String pluginFile + + @Input + List targetPlatforms + + @TaskAction + def build() { + if (project.cargokit.manifestDir == null) { + throw new GradleException("Property 'manifestDir' must be set on cargokit extension"); + } + + if (project.cargokit.libname == null) { + throw new GradleException("Property 'libname' must be set on cargokit extension"); + } + + def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh" + def path = Paths.get(new File(pluginFile).parent, "..", executableName); + + def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir) + + def rootProjectDir = project.rootProject.projectDir + + if (!Os.isFamily(Os.FAMILY_WINDOWS)) { + project.exec { + commandLine 'chmod', '+x', path + } + } + + project.exec { + executable path + args "build-gradle" + environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir + environment "CARGOKIT_TOOL_TEMP_DIR", "${buildDir}/build_tool" + environment "CARGOKIT_MANIFEST_DIR", manifestDir + environment "CARGOKIT_CONFIGURATION", buildMode + environment "CARGOKIT_TARGET_TEMP_DIR", buildDir + environment "CARGOKIT_OUTPUT_DIR", outputDir + environment "CARGOKIT_NDK_VERSION", ndkVersion + environment "CARGOKIT_SDK_DIR", sdkDirectory + environment "CARGOKIT_COMPILE_SDK_VERSION", compileSdkVersion + environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion + environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",") + environment "CARGOKIT_JAVA_HOME", System.properties['java.home'] + } + } +} + +class CargoKitPlugin implements Plugin { + + static String file; + + private Plugin findFlutterPlugin(Project rootProject) { + _findFlutterPlugin(rootProject.childProjects) + } + + private Plugin _findFlutterPlugin(Map projects) { + for (project in projects) { + for (plugin in project.value.getPlugins()) { + if (plugin.class.name == "com.flutter.gradle.FlutterPlugin" || plugin.class.name == "FlutterPlugin") { + return plugin; + } + } + def plugin = _findFlutterPlugin(project.value.childProjects); + if (plugin != null) { + return plugin; + } + } + return null; + } + + @Override + void apply(Project project) { + def plugin = findFlutterPlugin(project.rootProject); + + project.extensions.create("cargokit", CargoKitExtension) + + if (plugin == null) { + print("Flutter plugin not found, CargoKit plugin will not be applied.") + return; + } + + def cargoBuildDir = "${project.buildDir}/build" + + // Determine if the project is an application or library + def isApplication = plugin.project.plugins.hasPlugin('com.android.application') + def variants = isApplication ? plugin.project.android.applicationVariants : plugin.project.android.libraryVariants + + variants.all { variant -> + + final buildType = variant.buildType.name + + def cargoOutputDir = "${project.buildDir}/jniLibs/${buildType}"; + def jniLibs = project.android.sourceSets.maybeCreate(buildType).jniLibs; + jniLibs.srcDir(new File(cargoOutputDir)) + + def List platforms + try { + platforms = com.flutter.gradle.FlutterPluginUtils.getTargetPlatforms(project).collect() + } catch (Exception ignored) { + platforms = plugin.getTargetPlatforms().collect() + } + + // Same thing addFlutterDependencies does in flutter.gradle + if (buildType == "debug") { + platforms.add("android-x86") + platforms.add("android-x64") + } + + // The task name depends on plugin properties, which are not available + // at this point + project.getGradle().afterProject { + def taskName = "cargokitCargoBuild${project.cargokit.libname.capitalize()}${buildType.capitalize()}"; + + if (project.tasks.findByName(taskName)) { + return + } + + if (plugin.project.android.ndkVersion == null) { + throw new GradleException("Please set 'android.ndkVersion' in 'app/build.gradle'.") + } + + def task = project.tasks.create(taskName, CargoKitBuildTask.class) { + buildMode = variant.buildType.name + buildDir = cargoBuildDir + outputDir = cargoOutputDir + ndkVersion = plugin.project.android.ndkVersion + sdkDirectory = plugin.project.android.sdkDirectory + minSdkVersion = plugin.project.android.defaultConfig.minSdkVersion.apiLevel as int + compileSdkVersion = plugin.project.android.compileSdkVersion.substring(8) as int + targetPlatforms = platforms + pluginFile = CargoKitPlugin.file + } + def onTask = { newTask -> + if (newTask.name == "merge${buildType.capitalize()}NativeLibs") { + newTask.dependsOn task + // Fix gradle 7.4.2 not picking up JNI library changes + newTask.outputs.upToDateWhen { false } + } + } + project.tasks.each onTask + project.tasks.whenTaskAdded onTask + } + } + } +} diff --git a/native/komet_crypto/cargokit/run_build_tool.cmd b/native/komet_crypto/cargokit/run_build_tool.cmd new file mode 100755 index 0000000..c45d0aa --- /dev/null +++ b/native/komet_crypto/cargokit/run_build_tool.cmd @@ -0,0 +1,91 @@ +@echo off +setlocal + +setlocal ENABLEDELAYEDEXPANSION + +SET BASEDIR=%~dp0 + +if not exist "%CARGOKIT_TOOL_TEMP_DIR%" ( + mkdir "%CARGOKIT_TOOL_TEMP_DIR%" +) +cd /D "%CARGOKIT_TOOL_TEMP_DIR%" + +SET BUILD_TOOL_PKG_DIR=%BASEDIR%build_tool +SET DART=%FLUTTER_ROOT%\bin\cache\dart-sdk\bin\dart + +set BUILD_TOOL_PKG_DIR_POSIX=%BUILD_TOOL_PKG_DIR:\=/% + +( + echo name: build_tool_runner + echo version: 1.0.0 + echo publish_to: none + echo. + echo environment: + echo sdk: '^>=3.0.0 ^<4.0.0' + echo. + echo dependencies: + echo build_tool: + echo path: %BUILD_TOOL_PKG_DIR_POSIX% +) >pubspec.yaml + +if not exist bin ( + mkdir bin +) + +( + echo import 'package:build_tool/build_tool.dart' as build_tool; + echo void main^(List^ args^) ^{ + echo build_tool.runMain^(args^); + echo ^} +) >bin\build_tool_runner.dart + +SET PRECOMPILED=bin\build_tool_runner.dill + +REM To detect changes in package we compare output of DIR /s (recursive) +set PREV_PACKAGE_INFO=.dart_tool\package_info.prev +set CUR_PACKAGE_INFO=.dart_tool\package_info.cur + +DIR "%BUILD_TOOL_PKG_DIR%" /s > "%CUR_PACKAGE_INFO%_orig" + +REM Last line in dir output is free space on harddrive. That is bound to +REM change between invocation so we need to remove it +( + Set "Line=" + For /F "UseBackQ Delims=" %%A In ("%CUR_PACKAGE_INFO%_orig") Do ( + SetLocal EnableDelayedExpansion + If Defined Line Echo !Line! + EndLocal + Set "Line=%%A") +) >"%CUR_PACKAGE_INFO%" +DEL "%CUR_PACKAGE_INFO%_orig" + +REM Compare current directory listing with previous +FC /B "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" > nul 2>&1 + +If %ERRORLEVEL% neq 0 ( + REM Changed - copy current to previous and remove precompiled kernel + if exist "%PREV_PACKAGE_INFO%" ( + DEL "%PREV_PACKAGE_INFO%" + ) + MOVE /Y "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" + if exist "%PRECOMPILED%" ( + DEL "%PRECOMPILED%" + ) +) + +REM There is no CUR_PACKAGE_INFO it was renamed in previous step to %PREV_PACKAGE_INFO% +REM which means we need to do pub get and precompile +if not exist "%PRECOMPILED%" ( + echo Running pub get in "%cd%" + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart +) + +"%DART%" "%PRECOMPILED%" %* + +REM 253 means invalid snapshot version. +If %ERRORLEVEL% equ 253 ( + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart + "%DART%" "%PRECOMPILED%" %* +) diff --git a/native/komet_crypto/cargokit/run_build_tool.sh b/native/komet_crypto/cargokit/run_build_tool.sh new file mode 100755 index 0000000..24b0ed8 --- /dev/null +++ b/native/komet_crypto/cargokit/run_build_tool.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +set -e + +BASEDIR=$(dirname "$0") + +mkdir -p "$CARGOKIT_TOOL_TEMP_DIR" + +cd "$CARGOKIT_TOOL_TEMP_DIR" + +# Write a very simple bin package in temp folder that depends on build_tool package +# from Cargokit. This is done to ensure that we don't pollute Cargokit folder +# with .dart_tool contents. + +BUILD_TOOL_PKG_DIR="$BASEDIR/build_tool" + +if [[ -z $FLUTTER_ROOT ]]; then # not defined + DART=dart +else + DART="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/dart" +fi + +cat << EOF > "pubspec.yaml" +name: build_tool_runner +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + build_tool: + path: "$BUILD_TOOL_PKG_DIR" +EOF + +mkdir -p "bin" + +cat << EOF > "bin/build_tool_runner.dart" +import 'package:build_tool/build_tool.dart' as build_tool; +void main(List args) { + build_tool.runMain(args); +} +EOF + +# Create alias for `shasum` if it does not exist and `sha1sum` exists +if ! [ -x "$(command -v shasum)" ] && [ -x "$(command -v sha1sum)" ]; then + shopt -s expand_aliases + alias shasum="sha1sum" +fi + +# Dart run will not cache any package that has a path dependency, which +# is the case for our build_tool_runner. So instead we precompile the package +# ourselves. +# To invalidate the cached kernel we use the hash of ls -LR of the build_tool +# package directory. This should be good enough, as the build_tool package +# itself is not meant to have any path dependencies. + +if [[ "$OSTYPE" == "darwin"* ]]; then + PACKAGE_HASH=$(ls -lTR "$BUILD_TOOL_PKG_DIR" | shasum) +else + PACKAGE_HASH=$(ls -lR --full-time "$BUILD_TOOL_PKG_DIR" | shasum) +fi + +PACKAGE_HASH_FILE=".package_hash" + +if [ -f "$PACKAGE_HASH_FILE" ]; then + EXISTING_HASH=$(cat "$PACKAGE_HASH_FILE") + if [ "$PACKAGE_HASH" != "$EXISTING_HASH" ]; then + rm "$PACKAGE_HASH_FILE" + fi +fi + +# Run pub get if needed. +if [ ! -f "$PACKAGE_HASH_FILE" ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + echo "$PACKAGE_HASH" > "$PACKAGE_HASH_FILE" +fi + +# Rebuild the tool if it was deleted by Android Studio +if [ ! -f "bin/build_tool_runner.dill" ]; then + "$DART" compile kernel bin/build_tool_runner.dart +fi + +set +e + +"$DART" bin/build_tool_runner.dill "$@" + +exit_code=$? + +# 253 means invalid snapshot version. +if [ $exit_code == 253 ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + "$DART" bin/build_tool_runner.dill "$@" + exit_code=$? +fi + +exit $exit_code diff --git a/native/komet_crypto/flutter_rust_bridge.yaml b/native/komet_crypto/flutter_rust_bridge.yaml new file mode 100644 index 0000000..e15ed91 --- /dev/null +++ b/native/komet_crypto/flutter_rust_bridge.yaml @@ -0,0 +1,3 @@ +rust_input: crate::api +rust_root: rust/ +dart_output: lib/src/rust \ No newline at end of file diff --git a/native/komet_crypto/ios/Classes/dummy_file.c b/native/komet_crypto/ios/Classes/dummy_file.c new file mode 100644 index 0000000..e06dab9 --- /dev/null +++ b/native/komet_crypto/ios/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/native/komet_crypto/ios/komet_crypto.podspec b/native/komet_crypto/ios/komet_crypto.podspec new file mode 100644 index 0000000..84e4a5c --- /dev/null +++ b/native/komet_crypto/ios/komet_crypto.podspec @@ -0,0 +1,46 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint komet_crypto.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'komet_crypto' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.module_name = 'komet_crypto' + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '11.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust komet_crypto', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${PODS_CONFIGURATION_BUILD_DIR}/komet_crypto/libkomet_crypto.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${PODS_CONFIGURATION_BUILD_DIR}/komet_crypto/libkomet_crypto.a', + } +end \ No newline at end of file diff --git a/native/komet_crypto/lib/komet_crypto.dart b/native/komet_crypto/lib/komet_crypto.dart new file mode 100644 index 0000000..1b81183 --- /dev/null +++ b/native/komet_crypto/lib/komet_crypto.dart @@ -0,0 +1,4 @@ +library; + +export 'src/rust/api/crypto.dart'; +export 'src/rust/frb_generated.dart' show RustLib; diff --git a/native/komet_crypto/lib/src/rust/api/crypto.dart b/native/komet_crypto/lib/src/rust/api/crypto.dart new file mode 100644 index 0000000..149cbd6 --- /dev/null +++ b/native/komet_crypto/lib/src/rust/api/crypto.dart @@ -0,0 +1,40 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +// These functions are ignored because they are not marked as `pub`: `transform_file` + +Future deriveKey({required String password}) => + RustLib.instance.api.crateApiCryptoDeriveKey(password: password); + +Future encryptMessage( + {required String plaintext, required List key}) => + RustLib.instance.api + .crateApiCryptoEncryptMessage(plaintext: plaintext, key: key); + +Future decryptMessage({required String text, required List key}) => + RustLib.instance.api.crateApiCryptoDecryptMessage(text: text, key: key); + +Future looksEncrypted({required String text}) => + RustLib.instance.api.crateApiCryptoLooksEncrypted(text: text); + +Future encryptImageFile( + {required String sourcePath, + required String destPath, + required List key}) => + RustLib.instance.api.crateApiCryptoEncryptImageFile( + sourcePath: sourcePath, destPath: destPath, key: key); + +Future decryptImageFile( + {required String sourcePath, + required String destPath, + required List key}) => + RustLib.instance.api.crateApiCryptoDecryptImageFile( + sourcePath: sourcePath, destPath: destPath, key: key); + +Future looksEncryptedImageFile({required String path}) => + RustLib.instance.api.crateApiCryptoLooksEncryptedImageFile(path: path); diff --git a/native/komet_crypto/lib/src/rust/frb_generated.dart b/native/komet_crypto/lib/src/rust/frb_generated.dart new file mode 100644 index 0000000..e80d936 --- /dev/null +++ b/native/komet_crypto/lib/src/rust/frb_generated.dart @@ -0,0 +1,427 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/crypto.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({ + required RustLibApi api, + }) { + instance.initMockImpl( + api: api, + ); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.12.0'; + + @override + int get rustContentHash => -2021377439; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'komet_crypto', + ioDirectory: 'rust/target/release/', + webPrefix: 'pkg/', + wasmBindgenName: 'wasm_bindgen', + ); +} + +abstract class RustLibApi extends BaseApi { + Future crateApiCryptoDecryptImageFile( + {required String sourcePath, + required String destPath, + required List key}); + + Future crateApiCryptoDecryptMessage( + {required String text, required List key}); + + Future crateApiCryptoDeriveKey({required String password}); + + Future crateApiCryptoEncryptImageFile( + {required String sourcePath, + required String destPath, + required List key}); + + Future crateApiCryptoEncryptMessage( + {required String plaintext, required List key}); + + Future crateApiCryptoLooksEncrypted({required String text}); + + Future crateApiCryptoLooksEncryptedImageFile({required String path}); +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + Future crateApiCryptoDecryptImageFile( + {required String sourcePath, + required String destPath, + required List key}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(sourcePath, serializer); + sse_encode_String(destPath, serializer); + sse_encode_list_prim_u_8_loose(key, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 1, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoDecryptImageFileConstMeta, + argValues: [sourcePath, destPath, key], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoDecryptImageFileConstMeta => + const TaskConstMeta( + debugName: "decrypt_image_file", + argNames: ["sourcePath", "destPath", "key"], + ); + + @override + Future crateApiCryptoDecryptMessage( + {required String text, required List key}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(text, serializer); + sse_encode_list_prim_u_8_loose(key, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 2, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoDecryptMessageConstMeta, + argValues: [text, key], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoDecryptMessageConstMeta => + const TaskConstMeta( + debugName: "decrypt_message", + argNames: ["text", "key"], + ); + + @override + Future crateApiCryptoDeriveKey({required String password}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(password, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 3, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_prim_u_8_strict, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoDeriveKeyConstMeta, + argValues: [password], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoDeriveKeyConstMeta => const TaskConstMeta( + debugName: "derive_key", + argNames: ["password"], + ); + + @override + Future crateApiCryptoEncryptImageFile( + {required String sourcePath, + required String destPath, + required List key}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(sourcePath, serializer); + sse_encode_String(destPath, serializer); + sse_encode_list_prim_u_8_loose(key, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 4, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoEncryptImageFileConstMeta, + argValues: [sourcePath, destPath, key], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoEncryptImageFileConstMeta => + const TaskConstMeta( + debugName: "encrypt_image_file", + argNames: ["sourcePath", "destPath", "key"], + ); + + @override + Future crateApiCryptoEncryptMessage( + {required String plaintext, required List key}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(plaintext, serializer); + sse_encode_list_prim_u_8_loose(key, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 5, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCryptoEncryptMessageConstMeta, + argValues: [plaintext, key], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoEncryptMessageConstMeta => + const TaskConstMeta( + debugName: "encrypt_message", + argNames: ["plaintext", "key"], + ); + + @override + Future crateApiCryptoLooksEncrypted({required String text}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(text, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 6, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateApiCryptoLooksEncryptedConstMeta, + argValues: [text], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoLooksEncryptedConstMeta => + const TaskConstMeta( + debugName: "looks_encrypted", + argNames: ["text"], + ); + + @override + Future crateApiCryptoLooksEncryptedImageFile({required String path}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(path, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 7, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateApiCryptoLooksEncryptedImageFileConstMeta, + argValues: [path], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCryptoLooksEncryptedImageFileConstMeta => + const TaskConstMeta( + debugName: "looks_encrypted_image_file", + argNames: ["path"], + ); + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as List; + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_list_prim_u_8_loose( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer + .putUint8List(self is Uint8List ? self : Uint8List.fromList(self)); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } +} diff --git a/native/komet_crypto/lib/src/rust/frb_generated.io.dart b/native/komet_crypto/lib/src/rust/frb_generated.io.dart new file mode 100644 index 0000000..e7d16ae --- /dev/null +++ b/native/komet_crypto/lib/src/rust/frb_generated.io.dart @@ -0,0 +1,96 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'api/crypto.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; +} diff --git a/native/komet_crypto/lib/src/rust/frb_generated.web.dart b/native/komet_crypto/lib/src/rust/frb_generated.web.dart new file mode 100644 index 0000000..080aa95 --- /dev/null +++ b/native/komet_crypto/lib/src/rust/frb_generated.web.dart @@ -0,0 +1,96 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'api/crypto.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @protected + String dco_decode_String(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + List dco_decode_list_prim_u_8_loose(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, SseSerializer serializer); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject {} diff --git a/native/komet_crypto/linux/CMakeLists.txt b/native/komet_crypto/linux/CMakeLists.txt new file mode 100644 index 0000000..b3f456d --- /dev/null +++ b/native/komet_crypto/linux/CMakeLists.txt @@ -0,0 +1,19 @@ +# The Flutter tooling requires that developers have CMake 3.10 or later +# installed. You should not increase this version, as doing so will cause +# the plugin to fail to compile for some customers of the plugin. +cmake_minimum_required(VERSION 3.10) + +# Project-level configuration. +set(PROJECT_NAME "komet_crypto") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../rust komet_crypto "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(komet_crypto_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/native/komet_crypto/macos/Classes/dummy_file.c b/native/komet_crypto/macos/Classes/dummy_file.c new file mode 100644 index 0000000..e06dab9 --- /dev/null +++ b/native/komet_crypto/macos/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/native/komet_crypto/macos/komet_crypto.podspec b/native/komet_crypto/macos/komet_crypto.podspec new file mode 100644 index 0000000..874d58e --- /dev/null +++ b/native/komet_crypto/macos/komet_crypto.podspec @@ -0,0 +1,45 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint komet_crypto.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'komet_crypto' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.module_name = 'komet_crypto' + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust komet_crypto', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${PODS_CONFIGURATION_BUILD_DIR}/komet_crypto/libkomet_crypto.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${PODS_CONFIGURATION_BUILD_DIR}/komet_crypto/libkomet_crypto.a', + } +end \ No newline at end of file diff --git a/native/komet_crypto/pubspec.yaml b/native/komet_crypto/pubspec.yaml new file mode 100644 index 0000000..49f73bd --- /dev/null +++ b/native/komet_crypto/pubspec.yaml @@ -0,0 +1,32 @@ +name: komet_crypto +description: "Message encryption core for Komet (Argon2id + ChaCha20-Poly1305 + Cyrillic base32)." +version: 0.1.0 +publish_to: none + +environment: + sdk: ">=3.3.0 <4.0.0" + flutter: ">=3.3.0" + +dependencies: + flutter: + sdk: flutter + flutter_rust_bridge: 2.12.0 + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + ffiPlugin: true + ios: + ffiPlugin: true + linux: + ffiPlugin: true + macos: + ffiPlugin: true + windows: + ffiPlugin: true diff --git a/native/komet_crypto/rust/.gitignore b/native/komet_crypto/rust/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/native/komet_crypto/rust/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/native/komet_crypto/rust/Cargo.toml b/native/komet_crypto/rust/Cargo.toml new file mode 100644 index 0000000..9bbf845 --- /dev/null +++ b/native/komet_crypto/rust/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "komet_crypto" +version = "0.1.0" +edition = "2021" +description = "Message encryption core for Komet (Argon2id + ChaCha20-Poly1305 + Cyrillic base32)" +license = "MIT" + +[lib] +crate-type = ["cdylib", "staticlib", "rlib"] + +[dependencies] +argon2 = "0.5" +chacha20poly1305 = "0.10" +data-encoding = "2" +flutter_rust_bridge = "=2.12.0" +png = "0.17" +rand_core = { version = "0.6", features = ["getrandom"] } +sha2 = "0.10" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } + +[workspace] + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "unwind" diff --git a/native/komet_crypto/rust/examples/demo.rs b/native/komet_crypto/rust/examples/demo.rs new file mode 100644 index 0000000..47d148f --- /dev/null +++ b/native/komet_crypto/rust/examples/demo.rs @@ -0,0 +1,35 @@ +use std::time::Instant; + +use komet_crypto::cipher; + +fn main() { + let password = "мой ключ 2026"; + let started = Instant::now(); + let key = cipher::derive_key(password).expect("derive"); + println!("derive_key: {:?}", started.elapsed()); + + for text in [ + "привет", + "встречаемся в 19:00 у метро", + "Hello! Это смешанный текст с эмодзи 🔐", + ] { + let encrypted = cipher::encrypt(text, &key).expect("encrypt"); + let decrypted = cipher::decrypt(&encrypted, &key).expect("decrypt"); + println!( + "\n{} символов -> {} символов", + text.chars().count(), + encrypted.chars().count() + ); + println!(" {text}"); + println!(" {encrypted}"); + assert_eq!(decrypted, text); + } + + let wrong = cipher::derive_key("не тот ключ").expect("derive"); + let sample = cipher::encrypt("секрет", &key).expect("encrypt"); + println!("\nчужой ключ: {:?}", cipher::decrypt(&sample, &wrong)); + println!( + "обычный текст: {:?}", + cipher::decrypt("привет как дела", &key) + ); +} diff --git a/native/komet_crypto/rust/src/alphabet.rs b/native/komet_crypto/rust/src/alphabet.rs new file mode 100644 index 0000000..4548653 --- /dev/null +++ b/native/komet_crypto/rust/src/alphabet.rs @@ -0,0 +1,135 @@ +use data_encoding::BASE32_NOPAD; + +use crate::error::CryptoError; + +pub const RU_LOWERCASE_WITHOUT_YO: [char; 32] = [ + 'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', + 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ъ', 'ы', 'ь', 'э', 'ю', 'я', +]; + +const MIN_WORD_LEN: usize = 4; +const WORD_LEN_SPREAD: usize = 5; + +fn base32_symbol(index: usize) -> u8 { + if index < 26 { + b'A' + index as u8 + } else { + b'2' + (index - 26) as u8 + } +} + +fn base32_index(symbol: u8) -> Option { + match symbol { + b'A'..=b'Z' => Some((symbol - b'A') as usize), + b'2'..=b'7' => Some((symbol - b'2') as usize + 26), + _ => None, + } +} + +fn letter_index(letter: char) -> Option { + RU_LOWERCASE_WITHOUT_YO.iter().position(|&l| l == letter) +} + +pub fn encode(bytes: &[u8]) -> String { + let base32 = BASE32_NOPAD.encode(bytes); + let mut out = String::with_capacity(base32.len() * 3); + let mut run = 0usize; + let mut word_len = MIN_WORD_LEN; + for symbol in base32.bytes() { + let Some(index) = base32_index(symbol) else { + continue; + }; + if run == word_len { + out.push(' '); + run = 0; + word_len = MIN_WORD_LEN + index % WORD_LEN_SPREAD; + } + out.push(RU_LOWERCASE_WITHOUT_YO[index]); + run += 1; + } + out +} + +pub fn decode(text: &str) -> Result, CryptoError> { + let mut symbols = Vec::with_capacity(text.len()); + for raw in text.chars() { + if raw.is_whitespace() { + continue; + } + let letter = raw.to_lowercase().next().unwrap_or(raw); + let index = letter_index(letter).ok_or(CryptoError::NotEncrypted)?; + symbols.push(base32_symbol(index)); + } + if symbols.is_empty() { + return Err(CryptoError::NotEncrypted); + } + BASE32_NOPAD + .decode(&symbols) + .map_err(|_| CryptoError::Malformed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alphabet_has_no_duplicates_and_no_yo() { + let mut sorted = RU_LOWERCASE_WITHOUT_YO.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), 32); + assert!(!RU_LOWERCASE_WITHOUT_YO.contains(&'ё')); + } + + #[test] + fn roundtrip_preserves_bytes() { + for len in 1..64usize { + let bytes: Vec = (0..len).map(|i| (i * 37 + 11) as u8).collect(); + let encoded = encode(&bytes); + assert_eq!(decode(&encoded).unwrap(), bytes, "len {len}"); + } + } + + #[test] + fn empty_input_encodes_to_empty_string() { + assert_eq!(encode(&[]), ""); + assert_eq!(decode(""), Err(CryptoError::NotEncrypted)); + } + + #[test] + fn output_is_lowercase_cyrillic_and_spaces() { + let encoded = encode(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + for ch in encoded.chars() { + assert!(ch == ' ' || RU_LOWERCASE_WITHOUT_YO.contains(&ch), "{ch}"); + } + assert!(encoded.contains(' ')); + assert!(!encoded.contains(" ")); + } + + #[test] + fn spaces_are_decorative_only() { + let bytes = b"komet encryption core"; + let encoded = encode(bytes); + let stripped: String = encoded.chars().filter(|c| *c != ' ').collect(); + let padded = format!(" {} ", encoded.replace(' ', " ")); + let newlined = encoded.replace(' ', "\n"); + assert_eq!(decode(&stripped).unwrap(), bytes); + assert_eq!(decode(&padded).unwrap(), bytes); + assert_eq!(decode(&newlined).unwrap(), bytes); + } + + #[test] + fn decode_is_case_insensitive() { + let bytes = b"autocapitalized"; + let encoded = encode(bytes); + assert_eq!(decode(&encoded.to_uppercase()).unwrap(), bytes); + } + + #[test] + fn foreign_characters_are_rejected() { + assert_eq!(decode("привет!"), Err(CryptoError::NotEncrypted)); + assert_eq!(decode("hello"), Err(CryptoError::NotEncrypted)); + assert_eq!(decode("ёжик"), Err(CryptoError::NotEncrypted)); + assert_eq!(decode(" "), Err(CryptoError::NotEncrypted)); + } +} diff --git a/native/komet_crypto/rust/src/api/crypto.rs b/native/komet_crypto/rust/src/api/crypto.rs new file mode 100644 index 0000000..cee0b78 --- /dev/null +++ b/native/komet_crypto/rust/src/api/crypto.rs @@ -0,0 +1,57 @@ +use std::fs; + +use crate::cipher; +use crate::error::CryptoError; +use crate::image; + +pub fn derive_key(password: String) -> Result, String> { + cipher::derive_key(&password).map_err(|e| e.code().to_string()) +} + +pub fn encrypt_message(plaintext: String, key: Vec) -> Result { + cipher::encrypt(&plaintext, &key).map_err(|e| e.code().to_string()) +} + +pub fn decrypt_message(text: String, key: Vec) -> Result { + cipher::decrypt(&text, &key).map_err(|e| e.code().to_string()) +} + +pub fn looks_encrypted(text: String) -> bool { + cipher::looks_encrypted(&text) +} + +pub fn encrypt_image_file( + source_path: String, + dest_path: String, + key: Vec, +) -> Result<(), String> { + transform_file(&source_path, &dest_path, |bytes| { + image::encrypt(bytes, &key) + }) +} + +pub fn decrypt_image_file( + source_path: String, + dest_path: String, + key: Vec, +) -> Result<(), String> { + transform_file(&source_path, &dest_path, |bytes| { + image::decrypt(bytes, &key) + }) +} + +pub fn looks_encrypted_image_file(path: String) -> bool { + fs::read(&path) + .map(|bytes| image::looks_encrypted(&bytes)) + .unwrap_or(false) +} + +fn transform_file( + source_path: &str, + dest_path: &str, + transform: impl FnOnce(&[u8]) -> Result, CryptoError>, +) -> Result<(), String> { + let bytes = fs::read(source_path).map_err(|e| format!("read: {e}"))?; + let out = transform(&bytes).map_err(|e| e.code().to_string())?; + fs::write(dest_path, out).map_err(|e| format!("write: {e}")) +} diff --git a/native/komet_crypto/rust/src/api/mod.rs b/native/komet_crypto/rust/src/api/mod.rs new file mode 100644 index 0000000..274f0ed --- /dev/null +++ b/native/komet_crypto/rust/src/api/mod.rs @@ -0,0 +1 @@ +pub mod crypto; diff --git a/native/komet_crypto/rust/src/cipher.rs b/native/komet_crypto/rust/src/cipher.rs new file mode 100644 index 0000000..d52989f --- /dev/null +++ b/native/komet_crypto/rust/src/cipher.rs @@ -0,0 +1,245 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use sha2::{Digest, Sha256}; + +use crate::alphabet; +use crate::error::CryptoError; + +pub const KEY_LEN: usize = 32; + +pub(crate) const MAGIC: u8 = 0x4B; +const VERSION: u8 = 0x01; +const HEADER_LEN: usize = 2; +pub(crate) const NONCE_LEN: usize = 12; +pub(crate) const TAG_LEN: usize = 16; +const MIN_BLOB_LEN: usize = HEADER_LEN + NONCE_LEN + TAG_LEN; + +const SALT_CONTEXT: &[u8] = b"komet-enc-v1"; +const SALT_LEN: usize = 16; +const ARGON_MEMORY_KIB: u32 = 65536; +const ARGON_ITERATIONS: u32 = 3; +const ARGON_PARALLELISM: u32 = 1; + +pub fn derive_key(password: &str) -> Result, CryptoError> { + if password.is_empty() { + return Err(CryptoError::EmptyPassword); + } + let mut hasher = Sha256::new(); + hasher.update(SALT_CONTEXT); + hasher.update(password.as_bytes()); + let digest = hasher.finalize(); + + let params = Params::new( + ARGON_MEMORY_KIB, + ARGON_ITERATIONS, + ARGON_PARALLELISM, + Some(KEY_LEN), + ) + .map_err(|_| CryptoError::Internal)?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + + let mut key = vec![0u8; KEY_LEN]; + argon + .hash_password_into(password.as_bytes(), &digest[..SALT_LEN], &mut key) + .map_err(|_| CryptoError::Internal)?; + Ok(key) +} + +pub fn encrypt(plaintext: &str, key: &[u8]) -> Result { + let cipher = cipher_from(key)?; + let header = [MAGIC, VERSION]; + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let sealed = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext.as_bytes(), + aad: &header, + }, + ) + .map_err(|_| CryptoError::Internal)?; + + let mut blob = Vec::with_capacity(HEADER_LEN + NONCE_LEN + sealed.len()); + blob.extend_from_slice(&header); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&sealed); + Ok(alphabet::encode(&blob)) +} + +pub fn decrypt(text: &str, key: &[u8]) -> Result { + let blob = alphabet::decode(text)?; + if !has_envelope(&blob) { + return Err(CryptoError::NotEncrypted); + } + let cipher = cipher_from(key)?; + let nonce = Nonce::from_slice(&blob[HEADER_LEN..HEADER_LEN + NONCE_LEN]); + let plain = cipher + .decrypt( + nonce, + Payload { + msg: &blob[HEADER_LEN + NONCE_LEN..], + aad: &blob[..HEADER_LEN], + }, + ) + .map_err(|_| CryptoError::WrongKey)?; + String::from_utf8(plain).map_err(|_| CryptoError::Malformed) +} + +pub fn looks_encrypted(text: &str) -> bool { + alphabet::decode(text) + .map(|b| has_envelope(&b)) + .unwrap_or(false) +} + +fn has_envelope(blob: &[u8]) -> bool { + blob.len() >= MIN_BLOB_LEN && blob[0] == MAGIC && blob[1] == VERSION +} + +pub(crate) fn cipher_from(key: &[u8]) -> Result { + if key.len() != KEY_LEN { + return Err(CryptoError::BadKeyLength); + } + Ok(ChaCha20Poly1305::new(Key::from_slice(key))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; KEY_LEN] = [7u8; KEY_LEN]; + const OTHER_KEY: [u8; KEY_LEN] = [8u8; KEY_LEN]; + + #[test] + fn roundtrip_returns_original_text() { + for text in [ + "привет", + "Hello, World!", + "эмодзи 🔐 и переносы\nстрок", + "a", + "\u{0}\u{1}", + ] { + let encrypted = encrypt(text, &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &KEY).unwrap(), text); + } + } + + #[test] + fn empty_plaintext_roundtrips() { + let encrypted = encrypt("", &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &KEY).unwrap(), ""); + } + + #[test] + fn ciphertext_looks_like_russian_words() { + let encrypted = encrypt("привет", &KEY).unwrap(); + for ch in encrypted.chars() { + assert!( + ch == ' ' || alphabet::RU_LOWERCASE_WITHOUT_YO.contains(&ch), + "unexpected char {ch}" + ); + } + assert!(encrypted.split(' ').count() > 1); + } + + #[test] + fn same_plaintext_produces_different_ciphertext() { + let a = encrypt("одно и то же", &KEY).unwrap(); + let b = encrypt("одно и то же", &KEY).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn wrong_key_is_rejected() { + let encrypted = encrypt("секрет", &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &OTHER_KEY), Err(CryptoError::WrongKey)); + } + + fn swap_letter_at(text: &str, position: usize) -> String { + text.chars() + .enumerate() + .map(|(i, c)| { + if i != position { + c + } else if c == 'а' { + 'б' + } else { + 'а' + } + }) + .collect() + } + + #[test] + fn tampered_ciphertext_is_rejected() { + let encrypted = encrypt("секрет", &KEY).unwrap(); + let letters: Vec = encrypted + .char_indices() + .enumerate() + .filter(|(_, (_, c))| *c != ' ') + .map(|(i, _)| i) + .collect(); + for position in &letters { + assert!( + decrypt(&swap_letter_at(&encrypted, *position), &KEY).is_err(), + "tamper at {position} slipped through" + ); + } + let middle = letters[letters.len() / 2]; + assert_eq!( + decrypt(&swap_letter_at(&encrypted, middle), &KEY), + Err(CryptoError::WrongKey) + ); + } + + #[test] + fn whitespace_mangling_survives() { + let encrypted = encrypt("пробелы декоративные", &KEY).unwrap(); + let no_spaces: String = encrypted.chars().filter(|c| *c != ' ').collect(); + let doubled = encrypted.replace(' ', " "); + let trimmed = format!(" {encrypted}\n"); + for variant in [no_spaces, doubled, trimmed] { + assert_eq!(decrypt(&variant, &KEY).unwrap(), "пробелы декоративные"); + } + } + + #[test] + fn plain_text_is_not_mistaken_for_ciphertext() { + for text in ["привет как дела", "ёлка", "hello", "", "12345"] { + assert!(!looks_encrypted(text), "{text}"); + } + let encrypted = encrypt("настоящее", &KEY).unwrap(); + assert!(looks_encrypted(&encrypted)); + } + + #[test] + fn plain_text_decrypt_reports_not_encrypted() { + assert_eq!( + decrypt("привет как дела", &KEY), + Err(CryptoError::NotEncrypted) + ); + } + + #[test] + fn bad_key_length_is_reported() { + assert_eq!(encrypt("x", &[0u8; 8]), Err(CryptoError::BadKeyLength)); + } + + #[test] + fn overhead_is_48_letters() { + let encrypted = encrypt("", &KEY).unwrap(); + let letters = encrypted.chars().filter(|c| *c != ' ').count(); + assert_eq!(letters, 48); + } + + #[test] + fn key_derivation_is_deterministic_and_password_bound() { + let a = derive_key("общий ключ").unwrap(); + let b = derive_key("общий ключ").unwrap(); + let c = derive_key("другой ключ").unwrap(); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!(a.len(), KEY_LEN); + assert_eq!(derive_key(""), Err(CryptoError::EmptyPassword)); + } +} diff --git a/native/komet_crypto/rust/src/error.rs b/native/komet_crypto/rust/src/error.rs new file mode 100644 index 0000000..3aa42a6 --- /dev/null +++ b/native/komet_crypto/rust/src/error.rs @@ -0,0 +1,32 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CryptoError { + EmptyPassword, + BadKeyLength, + NotEncrypted, + Malformed, + WrongKey, + Internal, +} + +impl CryptoError { + pub fn code(self) -> &'static str { + match self { + CryptoError::EmptyPassword => "empty_password", + CryptoError::BadKeyLength => "bad_key_length", + CryptoError::NotEncrypted => "not_encrypted", + CryptoError::Malformed => "malformed", + CryptoError::WrongKey => "wrong_key", + CryptoError::Internal => "internal", + } + } +} + +impl fmt::Display for CryptoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for CryptoError {} diff --git a/native/komet_crypto/rust/src/frb_generated.rs b/native/komet_crypto/rust/src/frb_generated.rs new file mode 100644 index 0000000..69db823 --- /dev/null +++ b/native/komet_crypto/rust/src/frb_generated.rs @@ -0,0 +1,471 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::uninlined_format_args, + clippy::needless_borrow +)] + +// Section: imports + +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -2021377439; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__api__crypto__decrypt_image_file_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "decrypt_image_file", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_source_path = ::sse_decode(&mut deserializer); + let api_dest_path = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::decrypt_image_file( + api_source_path, + api_dest_path, + api_key, + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__decrypt_message_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "decrypt_message", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_text = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::decrypt_message(api_text, api_key)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__derive_key_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "derive_key", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_password = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::derive_key(api_password)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__encrypt_image_file_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "encrypt_image_file", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_source_path = ::sse_decode(&mut deserializer); + let api_dest_path = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::encrypt_image_file( + api_source_path, + api_dest_path, + api_key, + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__encrypt_message_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "encrypt_message", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_plaintext = ::sse_decode(&mut deserializer); + let api_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::crypto::encrypt_message(api_plaintext, api_key)?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__looks_encrypted_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "looks_encrypted", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_text = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::api::crypto::looks_encrypted(api_text))?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__crypto__looks_encrypted_image_file_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "looks_encrypted_image_file", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok( + crate::api::crypto::looks_encrypted_image_file(api_path), + )?; + Ok(output_ok) + })()) + } + }, + ) +} + +// Section: dart2rust + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__api__crypto__decrypt_image_file_impl(port, ptr, rust_vec_len, data_len), + 2 => wire__crate__api__crypto__decrypt_message_impl(port, ptr, rust_vec_len, data_len), + 3 => wire__crate__api__crypto__derive_key_impl(port, ptr, rust_vec_len, data_len), + 4 => wire__crate__api__crypto__encrypt_image_file_impl(port, ptr, rust_vec_len, data_len), + 5 => wire__crate__api__crypto__encrypt_message_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__crypto__looks_encrypted_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__crypto__looks_encrypted_image_file_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + _ => unreachable!(), + } +} + +// Section: rust2dart + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.12.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.12.0. + + // Section: imports + + use super::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/native/komet_crypto/rust/src/image.rs b/native/komet_crypto/rust/src/image.rs new file mode 100644 index 0000000..541e673 --- /dev/null +++ b/native/komet_crypto/rust/src/image.rs @@ -0,0 +1,218 @@ +use chacha20poly1305::aead::{Aead, AeadCore, OsRng, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use rand_core::RngCore; + +use crate::cipher::{cipher_from, MAGIC, NONCE_LEN, TAG_LEN}; +use crate::error::CryptoError; + +const VERSION_IMAGE: u8 = 0x02; +const HEADER_LEN: usize = 6; +const CHANNELS: usize = 3; +const MAX_DIMENSION: u32 = 16384; + +fn header(payload_len: u32) -> [u8; HEADER_LEN] { + let n = payload_len.to_be_bytes(); + [MAGIC, VERSION_IMAGE, n[0], n[1], n[2], n[3]] +} + +pub fn encrypt(plain_png: &[u8], key: &[u8]) -> Result, CryptoError> { + let cipher = cipher_from(key)?; + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let payload_len = NONCE_LEN + plain_png.len() + TAG_LEN; + if u32::try_from(payload_len).is_err() { + return Err(CryptoError::Malformed); + } + let head = header(payload_len as u32); + + let sealed = cipher + .encrypt( + &nonce, + Payload { + msg: plain_png, + aad: &head, + }, + ) + .map_err(|_| CryptoError::Internal)?; + + let mut blob = Vec::with_capacity(HEADER_LEN + payload_len); + blob.extend_from_slice(&head); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&sealed); + + to_noise_png(&blob) +} + +pub fn decrypt(noise_png: &[u8], key: &[u8]) -> Result, CryptoError> { + let blob = from_noise_png(noise_png)?; + let payload_len = envelope_len(&blob).ok_or(CryptoError::NotEncrypted)?; + let end = HEADER_LEN + payload_len; + if blob.len() < end { + return Err(CryptoError::Malformed); + } + + let cipher = cipher_from(key)?; + let nonce = Nonce::from_slice(&blob[HEADER_LEN..HEADER_LEN + NONCE_LEN]); + cipher + .decrypt( + nonce, + Payload { + msg: &blob[HEADER_LEN + NONCE_LEN..end], + aad: &blob[..HEADER_LEN], + }, + ) + .map_err(|_| CryptoError::WrongKey) +} + +pub fn looks_encrypted(noise_png: &[u8]) -> bool { + from_noise_png(noise_png) + .ok() + .and_then(|blob| envelope_len(&blob)) + .is_some() +} + +fn envelope_len(blob: &[u8]) -> Option { + if blob.len() < HEADER_LEN || blob[0] != MAGIC || blob[1] != VERSION_IMAGE { + return None; + } + let len = u32::from_be_bytes([blob[2], blob[3], blob[4], blob[5]]) as usize; + if len < NONCE_LEN + TAG_LEN || HEADER_LEN + len > blob.len() { + return None; + } + Some(len) +} + +fn to_noise_png(blob: &[u8]) -> Result, CryptoError> { + let pixels = blob.len().div_ceil(CHANNELS); + let width = (pixels as f64).sqrt().ceil().max(1.0) as u32; + if width > MAX_DIMENSION { + return Err(CryptoError::Malformed); + } + let height = (pixels as u32).div_ceil(width).max(1); + + let mut raw = vec![0u8; width as usize * height as usize * CHANNELS]; + raw[..blob.len()].copy_from_slice(blob); + OsRng.fill_bytes(&mut raw[blob.len()..]); + + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_compression(png::Compression::Fast); + let mut writer = encoder + .write_header() + .map_err(|_| CryptoError::Internal)?; + writer + .write_image_data(&raw) + .map_err(|_| CryptoError::Internal)?; + } + Ok(out) +} + +fn from_noise_png(noise_png: &[u8]) -> Result, CryptoError> { + let decoder = png::Decoder::new(noise_png); + let mut reader = decoder.read_info().map_err(|_| CryptoError::NotEncrypted)?; + let info = reader.info(); + if info.color_type != png::ColorType::Rgb || info.bit_depth != png::BitDepth::Eight { + return Err(CryptoError::NotEncrypted); + } + let mut raw = vec![0u8; reader.output_buffer_size()]; + let frame = reader + .next_frame(&mut raw) + .map_err(|_| CryptoError::Malformed)?; + raw.truncate(frame.buffer_size()); + Ok(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [7u8; 32]; + const OTHER_KEY: [u8; 32] = [8u8; 32]; + + fn sample_png(width: u32, height: u32) -> Vec { + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + let raw: Vec = (0..width as usize * height as usize * 3) + .map(|i| (i * 7 % 251) as u8) + .collect(); + writer.write_image_data(&raw).unwrap(); + } + out + } + + #[test] + fn roundtrip_returns_original_bytes() { + for (w, h) in [(1, 1), (16, 9), (64, 64), (200, 137)] { + let original = sample_png(w, h); + let encrypted = encrypt(&original, &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &KEY).unwrap(), original, "{w}x{h}"); + } + } + + #[test] + fn output_is_a_valid_rgb_png() { + let encrypted = encrypt(&sample_png(32, 32), &KEY).unwrap(); + assert_eq!(&encrypted[1..4], b"PNG"); + let decoder = png::Decoder::new(encrypted.as_slice()); + let reader = decoder.read_info().unwrap(); + let info = reader.info(); + assert_eq!(info.color_type, png::ColorType::Rgb); + assert_eq!(info.bit_depth, png::BitDepth::Eight); + assert!(info.width >= 1 && info.height >= 1); + } + + #[test] + fn same_input_produces_different_output() { + let original = sample_png(16, 16); + let a = encrypt(&original, &KEY).unwrap(); + let b = encrypt(&original, &KEY).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn wrong_key_is_rejected() { + let encrypted = encrypt(&sample_png(16, 16), &KEY).unwrap(); + assert_eq!(decrypt(&encrypted, &OTHER_KEY), Err(CryptoError::WrongKey)); + } + + #[test] + fn tampered_pixels_are_rejected() { + let mut encrypted = encrypt(&sample_png(16, 16), &KEY).unwrap(); + let raw = from_noise_png(&encrypted).unwrap(); + let mut tampered = raw.clone(); + tampered[HEADER_LEN + NONCE_LEN + 4] ^= 0x01; + encrypted = to_noise_png(&tampered).unwrap(); + assert_eq!(decrypt(&encrypted, &KEY), Err(CryptoError::WrongKey)); + } + + #[test] + fn plain_png_is_not_mistaken_for_ciphertext() { + let plain = sample_png(24, 24); + assert!(!looks_encrypted(&plain)); + assert_eq!(decrypt(&plain, &KEY), Err(CryptoError::NotEncrypted)); + assert!(looks_encrypted(&encrypt(&plain, &KEY).unwrap())); + } + + #[test] + fn garbage_input_is_rejected() { + assert!(!looks_encrypted(b"not a png at all")); + assert_eq!( + decrypt(b"not a png at all", &KEY), + Err(CryptoError::NotEncrypted) + ); + } + + #[test] + fn bad_key_length_is_reported() { + assert_eq!( + encrypt(&sample_png(8, 8), &[0u8; 8]), + Err(CryptoError::BadKeyLength) + ); + } +} diff --git a/native/komet_crypto/rust/src/lib.rs b/native/komet_crypto/rust/src/lib.rs new file mode 100644 index 0000000..65b2403 --- /dev/null +++ b/native/komet_crypto/rust/src/lib.rs @@ -0,0 +1,7 @@ +pub mod alphabet; +pub mod api; +pub mod cipher; +pub mod error; +pub mod image; + +mod frb_generated; diff --git a/native/komet_crypto/windows/.gitignore b/native/komet_crypto/windows/.gitignore new file mode 100644 index 0000000..b3eb2be --- /dev/null +++ b/native/komet_crypto/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/native/komet_crypto/windows/CMakeLists.txt b/native/komet_crypto/windows/CMakeLists.txt new file mode 100644 index 0000000..d28c66d --- /dev/null +++ b/native/komet_crypto/windows/CMakeLists.txt @@ -0,0 +1,20 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "komet_crypto") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../rust komet_crypto "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(komet_crypto_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/pubspec.lock b/pubspec.lock index 94c9e4f..6efbc84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "1.0.4" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" button_group_m3e: dependency: transitive description: @@ -121,6 +129,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.dev" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.dev" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "1245a480a113437f8d46d19c0fb90cea9db921436d9cf2ba5fb11854a1312693" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" characters: dependency: transitive description: @@ -193,14 +241,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - dart_lz4: - dependency: "direct main" - description: - name: dart_lz4 - sha256: e2e9c30fdf83a7a1e63bc6c4d90786cf795c38eefee40b1f4c35ea6480c91db9 - url: "https://pub.dev" - source: hosted - version: "1.2.0" dart_webrtc: dependency: transitive description: @@ -297,6 +337,38 @@ packages: url: "https://pub.dev" source: hosted version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" firebase_core: dependency: "direct main" description: @@ -366,6 +438,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_contacts: + dependency: "direct main" + description: + name: flutter_contacts + sha256: "388d32cd33f16640ee169570128c933b45f3259bddbfae7a100bb49e5ffea9ae" + url: "https://pub.dev" + source: hosted + version: "1.1.9+2" flutter_inappwebview: dependency: "direct main" description: @@ -499,6 +579,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.35" + flutter_rust_bridge: + dependency: "direct dev" + description: + name: flutter_rust_bridge + sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a + url: "https://pub.dev" + source: hosted + version: "2.12.0" flutter_secure_storage: dependency: "direct main" description: @@ -581,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" geolocator: dependency: "direct main" description: @@ -693,6 +789,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.1" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -733,6 +893,21 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + kolibri: + dependency: "direct main" + description: + name: kolibri + sha256: e59a569756652b5a68d1260b52d9b2ae6f4c0fc831244f9d8aefa15bde03b370 + url: "https://pub.dev" + source: hosted + version: "0.1.4" + komet_crypto: + dependency: "direct main" + description: + path: "native/komet_crypto" + relative: true + source: path + version: "0.1.0" leak_tracker: dependency: transitive description: @@ -757,14 +932,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - libcompress: - dependency: "direct main" - description: - name: libcompress - sha256: "1f55be8dc9e622efa1584ad899e05880d71469b7107f35be1858e91d0fadf1d4" - url: "https://pub.dev" - source: hosted - version: "1.0.0" lints: dependency: transitive description: @@ -917,14 +1084,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" - msgpack_dart: - dependency: "direct main" - description: - name: msgpack_dart - sha256: c2d235ed01f364719b5296aecf43ac330f0d7bc865fa134d0d7910a40454dffb - url: "https://pub.dev" - source: hosted - version: "1.0.1" native_toolchain_c: dependency: transitive description: @@ -1093,6 +1252,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" + url: "https://pub.dev" + source: hosted + version: "9.4.10" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -1434,6 +1641,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 97dc056..78c1dc2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.0+14 +version: 0.5.19+19 environment: sdk: ^3.10.4 @@ -34,11 +34,19 @@ dependencies: sdk: flutter intl: any + # Rust networking core (kolibri) — FFI plugin, replaces the Dart transport. + # Published from the KometTeam/kolibri repo; the native core is compiled at + # app build time and pulled from that repo by git tag. + kolibri: ^0.1.4 + + # Rust message-encryption core — Argon2id + ChaCha20-Poly1305, output encoded + # as lowercase Cyrillic base32. Separate from kolibri: that is vendored + # transport, this is Komet's own crypto. + komet_crypto: + path: native/komet_crypto + # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - dart_lz4: ^1.0.0 - libcompress: ^1.0.0 - msgpack_dart: ^1.0.1 crypto: ^3.0.7 ffi: ^2.1.0 logger: ^2.6.2 @@ -46,6 +54,10 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + image_picker: ^1.1.2 + camera: ^0.11.0 + permission_handler: ^11.3.1 + archive: ^4.0.9 geolocator: ^13.0.0 photo_manager: ^3.0.0 image: ^4.3.0 @@ -59,6 +71,7 @@ dependencies: flutter_secure_storage: ^10.3.1 package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 + flutter_contacts: ^1.1.9+2 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 lottie: ^3.3.1 @@ -89,6 +102,10 @@ dev_dependencies: flutter_test: sdk: flutter + # Needed by test/chat_crypto_roundtrip_test.dart to open the built + # komet_crypto shared library directly. + flutter_rust_bridge: 2.12.0 + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your @@ -122,6 +139,9 @@ flutter_launcher_icons: flutter: generate: true + shaders: + - shaders/liquid_glass.frag + # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. @@ -133,6 +153,7 @@ flutter: - assets/emoji_keywords.json - assets/lottie/ - assets/wallpapers/patterns/ + - assets/debug/ fonts: - family: Inter diff --git a/shaders/liquid_glass.frag b/shaders/liquid_glass.frag new file mode 100644 index 0000000..55da549 --- /dev/null +++ b/shaders/liquid_glass.frag @@ -0,0 +1,104 @@ +#version 460 core + +#include + +precision highp float; + +uniform vec2 uSize; +uniform vec2 uRectOrigin; +uniform vec2 uRectSize; +uniform float uRadius; +uniform float uSpread; +uniform float uRefraction; +uniform float uChroma; +uniform float uSpecular; +uniform vec4 uTint; +uniform vec2 uLight; +uniform float uTintFeather; +uniform float uRimWidth; + +uniform sampler2D uBackdrop; + +out vec4 fragColor; + +float roundedBoxSdf(vec2 p, vec2 halfSize, float radius) { + vec2 q = abs(p) - halfSize + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - radius; +} + +vec2 surfaceNormal(vec2 p, vec2 halfSize, float radius) { + vec2 unit = vec2(1.0, 0.0); + float dx = roundedBoxSdf(p + unit.xy, halfSize, radius) - + roundedBoxSdf(p - unit.xy, halfSize, radius); + float dy = roundedBoxSdf(p + unit.yx, halfSize, radius) - + roundedBoxSdf(p - unit.yx, halfSize, radius); + return normalize(vec2(dx, dy) + vec2(1e-6)); +} + +const int TAPS = 5; + +float displacement(float distance, float reach) { + float bevel = 1.0 - clamp(-distance / reach, 0.0, 1.0); + return uRefraction * bevel * bevel * (1.0 + bevel); +} + +vec3 sampleBackdrop(vec2 coord) { + vec2 uv = coord / uSize; +#ifdef IMPELLER_TARGET_OPENGLES + uv.y = 1.0 - uv.y; +#endif + return texture(uBackdrop, clamp(uv, vec2(0.0), vec2(1.0))).rgb; +} + +void main() { + vec2 fragCoord = FlutterFragCoord().xy; + vec2 halfSize = uRectSize * 0.5; + vec2 center = uRectOrigin + halfSize; + vec2 p = fragCoord - center; + + float radius = min(uRadius, min(halfSize.x, halfSize.y)); + float sd = roundedBoxSdf(p, halfSize, radius); + + if (sd > 0.0) { + fragColor = vec4(sampleBackdrop(fragCoord), 1.0); + return; + } + + vec2 normal = surfaceNormal(p, halfSize, radius); + float reach = max(uSpread * min(halfSize.x, halfSize.y), 1.0); + float shift = displacement(sd, reach); + float lens = shift / max(uRefraction, 1e-6); + + float slope = displacement(sd + 1.0, reach) - displacement(sd - 1.0, reach); + float footprint = clamp(abs(1.0 + slope * 0.5), 1.0, 24.0); + float aberration = uChroma * lens; + + vec3 refracted = vec3(0.0); + for (int i = 0; i < TAPS; i++) { + float offset = (float(i) / float(TAPS - 1) - 0.5) * footprint; + vec2 base = fragCoord + normal * (shift + offset); + if (aberration > 0.0) { + refracted.r += sampleBackdrop(base + normal * shift * aberration).r; + refracted.g += sampleBackdrop(base).g; + refracted.b += sampleBackdrop(base - normal * shift * aberration).b; + } else { + refracted += sampleBackdrop(base); + } + } + refracted /= float(TAPS); + + float veil = smoothstep(0.0, 1.0, clamp(-sd / max(uTintFeather, 1.0), 0.0, 1.0)); + vec3 color = mix(refracted, uTint.rgb, uTint.a * veil); + + vec2 light = normalize(uLight + vec2(1e-6)); + float facing = dot(normal, light); + float rim = 1.0 - clamp(-sd / max(uRimWidth, 1.0), 0.0, 1.0); + rim = rim * rim; + float highlight = pow(max(facing, 0.0), 5.0) * rim * uSpecular; + float shade = pow(max(-facing, 0.0), 4.0) * rim * uSpecular * 0.35; + + color += vec3(highlight); + color = mix(color, color * 0.72, shade); + + fragColor = vec4(clamp(color, vec3(0.0), vec3(1.0)), 1.0); +} 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/app_icon_names_test.dart b/test/app_icon_names_test.dart new file mode 100644 index 0000000..aa682ff --- /dev/null +++ b/test/app_icon_names_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/config/app_icon.dart'; + +Set _iosAlternateIcons() { + final plist = File('ios/Runner/Info.plist').readAsStringSync(); + final anchor = plist.indexOf('CFBundleAlternateIcons'); + expect( + anchor, + isNonNegative, + reason: 'в Info.plist нет альтернативных иконок', + ); + final tokens = RegExp( + r'||([^<]*)', + ).allMatches(plist.substring(anchor)); + + final names = {}; + var depth = 0; + var entered = false; + for (final token in tokens) { + final text = token.group(0)!; + if (text == '') { + depth++; + entered = true; + } else if (text == '') { + depth--; + if (entered && depth == 0) break; + } else if (entered && depth == 1) { + names.add(token.group(1)!); + } + } + return names; +} + +Set _androidComponents() { + final manifest = File( + 'android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); + return RegExp( + r'android:name="ru\.komet\.app\.(\w+)"', + ).allMatches(manifest).map((m) => m.group(1)!).toSet(); +} + +Set _androidIconKeys() { + final source = File( + 'android/app/src/main/kotlin/ru/komet/app/MainActivity.kt', + ).readAsStringSync(); + final start = source.indexOf('iconComponents = mapOf('); + expect(start, isNonNegative, reason: 'в MainActivity нет карты иконок'); + final body = source.substring(start, source.indexOf(')', start)); + return RegExp(r'"(\w+)" to').allMatches(body).map((m) => m.group(1)!).toSet(); +} + +void main() { + test('дефолтная иконка на iOS — это primary, а не альтернативная', () { + expect( + AppIcon.defaultIcon.iosAlternateName, + isNull, + reason: 'setAlternateIconName ждёт nil, любое имя тут — APPLY_FAILED', + ); + }); + + test('каждая альтернативная иконка объявлена в Info.plist', () { + final declared = _iosAlternateIcons(); + for (final icon in AppIcon.values) { + final name = icon.iosAlternateName; + if (name == null) continue; + expect( + declared, + contains(name), + reason: 'иконка ${icon.id} шлёт в iOS имя $name', + ); + } + }); + + test('каждый android alias объявлен в манифесте и в MainActivity', () { + final components = _androidComponents(); + final keys = _androidIconKeys(); + for (final icon in AppIcon.values) { + expect( + components, + contains(icon.androidAlias), + reason: 'иконка ${icon.id} шлёт в Android имя ${icon.androidAlias}', + ); + expect(keys, contains(icon.androidAlias)); + } + }); + + test('имена иконок не пересекаются между платформами по смыслу', () { + final ids = AppIcon.values.map((i) => i.id).toSet(); + expect(ids.length, AppIcon.values.length); + final aliases = AppIcon.values.map((i) => i.androidAlias).toSet(); + expect(aliases.length, AppIcon.values.length); + }); +} diff --git a/test/banners_module_test.dart b/test/banners_module_test.dart new file mode 100644 index 0000000..a059213 --- /dev/null +++ b/test/banners_module_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/api.dart'; +import 'package:komet/backend/modules/banners.dart'; +import 'package:komet/models/informer_banner.dart'; + +void main() { + test('a pinned banner records one presentation', () async { + final api = Api(); + addTearDown(api.dispose); + final module = BannersModule(api); + const banner = InformerBanner( + id: 'synthetic-banner', + title: 'Synthetic title', + repeat: 3, + ); + + await module.markShown(banner); + await module.markShown(banner); + + expect(module.stateOf(banner.id).showCounter, 1); + + await module.close(banner); + await module.markShown(banner); + + expect(module.stateOf(banner.id).showCounter, 2); + }); +} diff --git a/test/bot_start_bubble_test.dart b/test/bot_start_bubble_test.dart new file mode 100644 index 0000000..82bdb4f --- /dev/null +++ b/test/bot_start_bubble_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +const int _me = 1; + +CachedMessage _botStart({String? payload}) => CachedMessage.fromPushPayload( + _me, + 2, + { + 'id': '5005', + 'time': DateTime(2026, 1, 1, 18, 6).millisecondsSinceEpoch, + 'type': 'USER', + 'sender': _me, + 'text': payload ?? '', + 'attaches': [ + {'_type': 'CONTROL', 'event': 'botStarted'}, + ], + }, +); + +Future _pump(WidgetTester tester, CachedMessage message) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: MessageBubble( + message: message, + isMe: true, + myId: _me, + chatType: 'DIALOG', + ), + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('a start with a payload shows a service line', (tester) async { + await _pump(tester, _botStart(payload: 'abc123')); + + expect(find.text('Бот запущен: abc123'), findsOneWidget); + }); + + testWidgets('a start without a payload takes no space', (tester) async { + await _pump(tester, _botStart()); + + expect(find.textContaining('Бот запущен'), findsNothing); + expect(tester.getSize(find.byType(MessageBubble)), Size.zero); + }); +} diff --git a/test/bot_start_control_test.dart b/test/bot_start_control_test.dart new file mode 100644 index 0000000..3265920 --- /dev/null +++ b/test/bot_start_control_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/models/attachment.dart'; +import 'package:komet/models/bot_info.dart'; + +void main() { + group('botStarted control message', () { + CachedMessage parse(Map message) => + CachedMessage.fromPushPayload(1001, 2002, message); + + test('is control and shows the payload the server put into text', () { + final message = parse({ + 'id': '3003', + 'time': 1700000000000, + 'type': 'USER', + 'sender': 1001, + 'text': 'abc123', + 'attaches': [ + {'_type': 'CONTROL', 'event': 'botStarted'}, + ], + }); + + expect(message.isControl, isTrue); + expect(message.botStartPayload, 'abc123'); + expect(message.isSilentBotStart, isFalse); + }); + + test('reads the payload off the attach when it is there', () { + final message = parse({ + 'id': '3006', + 'time': 1700000000000, + 'type': 'USER', + 'sender': 1001, + 'attaches': [ + { + '_type': 'CONTROL', + 'event': 'botStarted', + 'startPayload': 'abc123', + }, + ], + }); + + expect(message.botStartPayload, 'abc123'); + expect(message.isSilentBotStart, isFalse); + }); + + test('a start without a payload stays hidden', () { + final message = parse({ + 'id': '3007', + 'time': 1700000000000, + 'type': 'USER', + 'sender': 1001, + 'text': '', + 'attaches': [ + {'_type': 'CONTROL', 'event': 'botStarted'}, + ], + }); + + expect(message.isControl, isTrue); + expect(message.botStartPayload, isNull); + expect(message.isSilentBotStart, isTrue); + }); + + test('other control events are untouched', () { + final message = parse({ + 'id': '3004', + 'time': 1700000000000, + 'type': 'USER', + 'sender': 1001, + 'attaches': [ + {'_type': 'CONTROL', 'event': 'add', 'userIds': [1002]}, + ], + }); + + expect(message.isControl, isTrue); + expect(message.botStartPayload, isNull); + expect(message.isSilentBotStart, isFalse); + }); + + test('a plain message is not a start at all', () { + final message = parse({ + 'id': '3005', + 'time': 1700000000000, + 'type': 'USER', + 'sender': 1001, + 'text': 'привет', + 'attaches': const [], + }); + + expect(message.isControl, isFalse); + expect(message.botStartPayload, isNull); + expect(message.isSilentBotStart, isFalse); + }); + + test('the event name used on the wire stays stable', () { + expect(ControlAttachment.botStartedEvent, 'botStarted'); + }); + }); + + group('BotInfo', () { + test('parses commands and the bot contact', () { + final info = BotInfo.fromPayload(4004, { + 'commands': [ + {'botId': 4004, 'name': 'start', 'description': 'Главное меню'}, + {'botId': 4004, 'name': 'stats', 'description': ' '}, + {'botId': 4004, 'description': 'без имени'}, + ], + 'contact': { + 'id': 4004, + 'names': [ + {'name': 'Тестовый бот', 'type': 'ONEME'}, + ], + 'options': ['BOT'], + 'description': 'Описание бота', + 'link': 'https://max.ru/id100000000001_bot', + }, + }); + + expect(info.botId, 4004); + expect(info.commands.map((c) => c.name), ['start', 'stats']); + expect(info.commands.first.slash, '/start'); + expect(info.commands.first.description, 'Главное меню'); + expect(info.commands.last.description, isNull); + expect(info.contact?.isBot, isTrue); + expect(info.contact?.displayName, 'Тестовый бот'); + expect(info.description, 'Описание бота'); + expect(info.link, 'https://max.ru/id100000000001_bot'); + }); + + test('tolerates a payload without commands or contact', () { + final info = BotInfo.fromPayload(4005, const {}); + + expect(info.commands, isEmpty); + expect(info.contact, isNull); + expect(info.link, isNull); + }); + }); +} diff --git a/test/call_mic_selection_test.dart b/test/call_mic_selection_test.dart new file mode 100644 index 0000000..6d88633 --- /dev/null +++ b/test/call_mic_selection_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/calls/audio_devices.dart'; +import 'package:komet/core/calls/pulse_audio.dart'; +import 'package:komet/core/config/call_no_mute.dart'; + +const _deviceId = 'mic-test-0'; + +const _sourcesJson = ''' +[ + { + "name": "alsa_input.test-device", + "description": "(null)", + "monitor_source": "", + "properties": {"device.description": "Тестовая звуковая карта"} + }, + { + "name": "alsa_output.test-device.monitor", + "description": "(null)", + "monitor_source": "alsa_output.test-device", + "properties": {"device.description": "Тестовая звуковая карта"} + }, + { + "name": "virtual_sink.monitor", + "description": "Monitor of Virtual Sink", + "monitor_source": "virtual_sink", + "properties": {} + }, + { + "name": "komet_capture_4242", + "description": "komet_capture_4242", + "monitor_source": "", + "properties": {} + } +] +'''; + +void main() { + tearDown(() { + debugDefaultTargetPlatformOverride = null; + CallNoMute.enabled = false; + }); + + test('--no-mute включается только своим флагом', () { + CallNoMute.enabled = false; + CallNoMute.parse(const ['--debug-test']); + expect(CallNoMute.enabled, isFalse); + + CallNoMute.parse(const ['--debug-test', '--no-mute']); + expect(CallNoMute.enabled, isTrue); + }); + + test('desktop выбирает вход через sourceId', () { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + expect(AudioDevices.switchesInsideEngine, isFalse); + expect(AudioDevices.micConstraints(_deviceId), { + 'optional': [ + {'sourceId': _deviceId}, + ], + }); + }); + + test('мобильные платформы переключают вход внутри движка', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(AudioDevices.switchesInsideEngine, isTrue); + expect(AudioDevices.micConstraints(_deviceId), isTrue); + }); + + test('без выбранного устройства ограничений нет', () { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + expect(AudioDevices.micConstraints(null), isTrue); + expect(AudioDevices.micConstraints(''), isTrue); + }); + + test('захват монитора глушит шумодав и АРУ, но оставляет эхоподавление', () { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final constraints = + AudioDevices.micConstraints(_deviceId, monitorCapture: true) + as Map; + expect(constraints['optional'], [ + {'sourceId': _deviceId}, + ]); + expect(constraints['echoCancellation'], isTrue); + expect(constraints['noiseSuppression'], isFalse); + expect(constraints['autoGainControl'], isFalse); + expect(constraints['highpassFilter'], isFalse); + }); + + test('источники pulse разбираются вместе с мониторами', () { + final sources = PulseAudio.parseSources(_sourcesJson); + expect(sources.map((s) => s.name), [ + 'alsa_input.test-device', + 'alsa_output.test-device.monitor', + 'virtual_sink.monitor', + ]); + expect(sources[0].isMonitor, isFalse); + expect(sources[0].label, 'Тестовая звуковая карта'); + expect(sources[1].isMonitor, isTrue); + expect(sources[1].label, 'Monitor of Тестовая звуковая карта'); + expect(sources[2].label, 'Monitor of Virtual Sink'); + }); + + test('битый вывод pactl не роняет разбор', () { + expect(PulseAudio.parseSources('не json'), isEmpty); + }); +} diff --git a/test/chat_bar_alignment_test.dart b/test/chat_bar_alignment_test.dart new file mode 100644 index 0000000..f9bfe62 --- /dev/null +++ b/test/chat_bar_alignment_test.dart @@ -0,0 +1,196 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/core/config/app_chat_chrome.dart'; +import 'package:komet/core/config/app_composer_background.dart'; +import 'package:komet/core/config/app_composer_style.dart'; +import 'package:komet/frontend/screens/chats/chat/upload_status.dart'; +import 'package:komet/frontend/screens/chats/chat/view/chat_header.dart'; +import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart'; +import 'package:komet/frontend/screens/chats/chat/view/composer_input.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/rich_message_controller.dart'; + +void main() { + late RichMessageController messageController; + late FocusNode focusNode; + late AnimationController attachAnim; + late VoiceRecordController voiceRec; + late VideoNoteController note; + late ValueNotifier replyTo; + late ValueNotifier> forwards; + late ValueNotifier hasText; + late ValueNotifier uploadStatus; + + setUp(() { + messageController = RichMessageController(); + focusNode = FocusNode(); + attachAnim = AnimationController( + vsync: const TestVSync(), + duration: const Duration(milliseconds: 200), + ); + voiceRec = VoiceRecordController( + contextOf: () => throw UnimplementedError(), + isMounted: () => true, + myId: () => 1, + onRecorded: (File file, int durationMs, List amps) async {}, + ); + note = VideoNoteController( + contextOf: () => throw UnimplementedError(), + isMounted: () => true, + onRecorded: (File file, int durationMs) async {}, + formatElapsed: (ms) => '0:00', + bottomInset: () => 0, + ); + replyTo = ValueNotifier(null); + forwards = ValueNotifier(const []); + hasText = ValueNotifier(false); + uploadStatus = ValueNotifier(const UploadStatus()); + }); + + tearDown(() { + messageController.dispose(); + focusNode.dispose(); + attachAnim.dispose(); + replyTo.dispose(); + forwards.dispose(); + hasText.dispose(); + uploadStatus.dispose(); + }); + + Future pumpBar(WidgetTester tester, ComposerStyle style) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: ComposerInputBar( + chatType: 'DIALOG', + chrome: ChatChromeStyle.none, + vignette: true, + style: style, + background: ComposerBackground.standard, + attachAnim: attachAnim, + replyTo: replyTo, + forwardMessages: forwards, + myId: 1, + hasText: hasText, + uploadStatus: uploadStatus, + messageController: messageController, + messageFocusNode: focusNode, + voiceRec: voiceRec, + note: note, + onToggleStickerPanel: () {}, + onSendText: () {}, + onScheduleMessage: () {}, + onOpenAttach: () {}, + onOpenAttachScheduled: () {}, + onSendHistory: (entry) async {}, + onCancelReply: () {}, + onCancelForward: () {}, + formatElapsed: (ms) => '0:00', + contextMenuBuilder: (context, state) => const SizedBox.shrink(), + isMuted: false, + onToggleMute: () {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + return tester.getSize(find.byType(ComposerInputBar)).height; + } + + Future pumpHeader(WidgetTester tester) async { + final status = ValueNotifier('online'); + final scheduled = ValueNotifier(0); + final unread = ValueNotifier(0); + addTearDown(status.dispose); + addTearDown(scheduled.dispose); + addTearDown(unread.dispose); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: SizedBox( + height: kToolbarHeight, + child: ChatHeaderRow( + glossy: false, + frosted: false, + cs: Theme.of(context).colorScheme, + embedded: false, + chatId: 0, + heroTag: 'header', + name: 'Chat', + imageUrl: '', + chatType: 'CHAT', + isOfficial: false, + myId: 1, + headerStatus: status, + scheduledCount: scheduled, + otherUnread: unread, + showCall: true, + onClose: null, + onOpenInfo: () {}, + onOpenScheduled: () {}, + onCall: () {}, + onMenu: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('the material composer is exactly as tall as the app bar', ( + tester, + ) async { + expect(await pumpBar(tester, ComposerStyle.materialYou), kToolbarHeight); + }); + + testWidgets('the glossy composer keeps its taller pill layout', ( + tester, + ) async { + expect( + await pumpBar(tester, ComposerStyle.glossy), + greaterThan(kToolbarHeight), + ); + }); + + testWidgets('material actions sit on the app bar icon columns', ( + tester, + ) async { + await pumpHeader(tester); + final headerWidth = tester.getSize(find.byType(ChatHeaderRow)).width; + final back = tester.getCenter(find.byIcon(Symbols.arrow_back)).dx; + final call = headerWidth - tester.getCenter(find.byIcon(Symbols.call)).dx; + final menu = + headerWidth - tester.getCenter(find.byIcon(Symbols.more_vert)).dx; + + await pumpBar(tester, ComposerStyle.materialYou); + final width = tester.getSize(find.byType(ComposerInputBar)).width; + + expect(tester.getCenter(find.byIcon(Symbols.face)).dx, back); + expect(width - tester.getCenter(find.byIcon(Symbols.attachment)).dx, call); + expect(width - tester.getCenter(find.byType(ComposerMorphIcon)).dx, menu); + }); + + testWidgets('glossy action geometry is untouched', (tester) async { + await pumpBar(tester, ComposerStyle.glossy); + final width = tester.getSize(find.byType(ComposerInputBar)).width; + + expect(tester.getCenter(find.byType(ComposerMorphIcon)).dx, width - 39); + expect(tester.getCenter(find.byIcon(Symbols.face)).dx, 38); + expect(tester.getCenter(find.byIcon(Symbols.attachment)).dx, width - 100); + }); +} diff --git a/test/chat_crypto_roundtrip_test.dart b/test/chat_crypto_roundtrip_test.dart new file mode 100644 index 0000000..03c7f3b --- /dev/null +++ b/test/chat_crypto_roundtrip_test.dart @@ -0,0 +1,136 @@ +import 'dart:io'; + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet_crypto/komet_crypto.dart' as kc; + +const _libPath = 'build/linux/x64/debug/bundle/lib/libkomet_crypto.so'; + +void main() { + if (!File(_libPath).existsSync()) { + // ignore: avoid_print + print('skipping: run `flutter build linux --debug` first'); + return; + } + + setUpAll(() async { + await kc.RustLib.init( + externalLibrary: ExternalLibrary.open(_libPath), + ); + }); + + test('round-trips through the native bridge', () async { + final key = await kc.deriveKey(password: 'общий ключ'); + expect(key.length, 32); + + const plaintext = 'встречаемся в 19:00 у метро'; + final encrypted = await kc.encryptMessage(plaintext: plaintext, key: key); + + expect(encrypted, isNot(contains(RegExp(r'[a-zA-Z0-9]')))); + expect(encrypted, contains(' ')); + expect(await kc.decryptMessage(text: encrypted, key: key), plaintext); + }); + + test('derives the same key from the same password', () async { + final a = await kc.deriveKey(password: 'один ключ'); + final b = await kc.deriveKey(password: 'один ключ'); + expect(a, b); + }); + + test('rejects a wrong key', () async { + final key = await kc.deriveKey(password: 'правильный'); + final wrong = await kc.deriveKey(password: 'неправильный'); + final encrypted = await kc.encryptMessage(plaintext: 'секрет', key: key); + + expect( + () => kc.decryptMessage(text: encrypted, key: wrong), + throwsA(predicate((e) => e.toString().contains('wrong_key'))), + ); + }); + + test('reports plain text as not encrypted', () async { + final key = await kc.deriveKey(password: 'ключ'); + expect(await kc.looksEncrypted(text: 'привет как дела'), isFalse); + expect( + () => kc.decryptMessage(text: 'привет как дела', key: key), + throwsA(predicate((e) => e.toString().contains('not_encrypted'))), + ); + }); + + group('images', _imageTests); + + test('survives whitespace mangling', () async { + final key = await kc.deriveKey(password: 'ключ'); + final encrypted = await kc.encryptMessage( + plaintext: 'пробелы декоративные', + key: key, + ); + final mangled = ' ${encrypted.replaceAll(' ', ' ')}\n'; + expect( + await kc.decryptMessage(text: mangled, key: key), + 'пробелы декоративные', + ); + }); +} + +const List _tinyPng = [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 4, 0, + 0, 0, 4, 8, 2, 0, 0, 0, 38, 147, 9, 41, 0, 0, 0, 63, 73, 68, 65, 84, 120, + 156, 1, 52, 0, 203, 255, 0, 0, 40, 80, 120, 160, 200, 240, 24, 64, 104, 144, + 184, 0, 17, 57, 97, 137, 177, 217, 1, 41, 81, 121, 161, 201, 0, 34, 74, 114, + 154, 194, 234, 18, 58, 98, 138, 178, 218, 0, 51, 91, 131, 171, 211, 251, 35, + 75, 115, 155, 195, 235, 36, 246, 23, 9, 123, 15, 58, 142, 0, 0, 0, 0, 73, 69, + 78, 68, 174, 66, 96, 130, +]; + +void _imageTests() { + late Directory tmp; + + setUp(() => tmp = Directory.systemTemp.createTempSync('komet_img')); + tearDown(() => tmp.deleteSync(recursive: true)); + + File writePlain() => + File('${tmp.path}/plain.png')..writeAsBytesSync(_tinyPng); + + test('round-trips a photo through the native bridge', () async { + final key = await kc.deriveKey(password: 'фото-ключ'); + final plain = writePlain(); + final enc = '${tmp.path}/enc.png'; + final out = '${tmp.path}/out.png'; + + await kc.encryptImageFile( + sourcePath: plain.path, + destPath: enc, + key: key, + ); + + final encBytes = File(enc).readAsBytesSync(); + expect(encBytes.sublist(1, 4), 'PNG'.codeUnits); + expect(encBytes, isNot(_tinyPng)); + expect(await kc.looksEncryptedImageFile(path: enc), isTrue); + expect(await kc.looksEncryptedImageFile(path: plain.path), isFalse); + + await kc.decryptImageFile(sourcePath: enc, destPath: out, key: key); + expect(File(out).readAsBytesSync(), _tinyPng); + }); + + test('rejects a photo decrypted with a wrong key', () async { + final key = await kc.deriveKey(password: 'правильный'); + final wrong = await kc.deriveKey(password: 'неправильный'); + final enc = '${tmp.path}/enc.png'; + + await kc.encryptImageFile( + sourcePath: writePlain().path, + destPath: enc, + key: key, + ); + expect( + () => kc.decryptImageFile( + sourcePath: enc, + destPath: '${tmp.path}/out.png', + key: wrong, + ), + throwsA(predicate((e) => e.toString().contains('wrong_key'))), + ); + }); +} 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/chat_membership_test.dart b/test/chat_membership_test.dart new file mode 100644 index 0000000..0b94d31 --- /dev/null +++ b/test/chat_membership_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/storage/app_database.dart'; + +void main() { + test('chat list state distinguishes previews from memberships', () { + expect(AppDatabase.chatRowIsInList({'in_list': 0}), isFalse); + expect(AppDatabase.chatRowIsInList({'in_list': 1}), isTrue); + expect(AppDatabase.chatRowIsInList({'in_list': 2}), isTrue); + expect(AppDatabase.chatRowIsInList({}), isTrue); + }); +} diff --git a/test/chat_mention_badge_test.dart b/test/chat_mention_badge_test.dart new file mode 100644 index 0000000..0cc3611 --- /dev/null +++ b/test/chat_mention_badge_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/chat_parsing.dart'; +import 'package:komet/backend/modules/chats.dart'; + +const int _me = 4242; +const int _peer = 7331; +const int _chatId = -1000000000001; + +const int _readMark = 1700000000000; +const int _mentionTime = 1700000005000; +const int _lastMsgTime = 1700000010000; + +const int _mentionId = 111411200327680777; +const int _lastMsgId = 111411200655360012; +const int _oldMentionId = 111411196067840005; + +CachedChat _chat({ + int? mentionId, + int readMark = _readMark, + int unread = 2, +}) => CachedChat( + id: _chatId, + accountId: _me, + type: 'CHAT', + unreadCount: unread, + lastEventTime: _lastMsgTime, + cachedAt: 0, + dontDisturbUntil: 0, + isOnline: false, + seenTime: 0, + participants: {_me: readMark, _peer: _lastMsgTime}, + lastMentionMsgId: mentionId, +); + +void main() { + group('message id', () { + test('carries the timestamp in its high bits', () { + expect(messageIdToTime(_mentionId), _mentionTime); + expect(messageIdToTime(_lastMsgId), _lastMsgTime); + expect(messageIdToTime(_oldMentionId), _readMark - 60000); + }); + }); + + group('hasUnreadMention', () { + test('is set when the mention is newer than my read mark', () { + expect(_chat(mentionId: _mentionId).hasUnreadMention, isTrue); + }); + + test('stays off for a mention I have already read', () { + expect(_chat(mentionId: _oldMentionId).hasUnreadMention, isFalse); + }); + + test('clears once the read mark passes the mention', () { + final read = _chat( + mentionId: _mentionId, + readMark: _lastMsgTime, + unread: 0, + ); + expect(read.hasUnreadMention, isFalse); + }); + + test('is false without a mention id', () { + expect(_chat().hasUnreadMention, isFalse); + }); + }); + + group('parseChatRow', () { + CachedChat parse(Map chat) => parseChatRow( + chat, + _me, + _me, + const {}, + const {}, + const {}, + const {}, + 0, + )!; + + test('reads lastMentionMessageId from the server chat', () { + final chat = parse({ + 'id': _chatId, + 'type': 'CHAT', + 'title': 'test mention', + 'newMessages': 2, + 'lastEventTime': _lastMsgTime, + 'participants': {'$_me': _readMark}, + 'lastMentionMessageId': '$_mentionId', + }); + + expect(chat.lastMentionMsgId, _mentionId); + expect(chat.hasUnreadMention, isTrue); + }); + + test('a chat without mentions keeps the badge off', () { + final chat = parse({ + 'id': _chatId, + 'type': 'CHAT', + 'title': 'test', + 'lastEventTime': _lastMsgTime, + 'participants': {'$_me': _readMark}, + }); + + expect(chat.lastMentionMsgId, isNull); + expect(chat.hasUnreadMention, isFalse); + }); + }); + + group('messageMentionsUser', () { + test('matches a USER_MENTION addressed to me', () { + expect( + messageMentionsUser(const { + 'elements': [ + {'type': 'USER_MENTION', 'entityId': _me, 'length': 15}, + ], + }, _me), + isTrue, + ); + }); + + test('ignores a mention of somebody else', () { + expect( + messageMentionsUser(const { + 'elements': [ + {'type': 'USER_MENTION', 'entityId': _peer, 'length': 15}, + ], + }, _me), + isFalse, + ); + }); + + test('ignores messages without elements', () { + expect(messageMentionsUser(const {'text': 'privet'}, _me), isFalse); + }); + }); +} diff --git a/test/chat_pin_order_test.dart b/test/chat_pin_order_test.dart new file mode 100644 index 0000000..035c762 --- /dev/null +++ b/test/chat_pin_order_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/chat_parsing.dart'; +import 'package:komet/backend/modules/chats.dart'; + +const int _me = 4242; +const int _chatId = -1000000000001; + +CachedChat _cached({int? favIndex}) => CachedChat( + id: _chatId, + accountId: _me, + type: 'CHAT', + title: 'pinned chat', + unreadCount: 0, + lastEventTime: 1700000000000, + cachedAt: 0, + favIndex: favIndex, + dontDisturbUntil: 0, + isOnline: false, + seenTime: 0, + participants: {_me: 1700000000000}, +); + +CachedChat _parse({ + Map chatsConfig = const {}, + CachedChat? existing, +}) => parseChatRow( + { + 'id': _chatId, + 'type': 'CHAT', + 'title': 'pinned chat', + 'lastEventTime': 1700000000000, + 'participants': {'$_me': 1700000000000}, + }, + _me, + _me, + const {}, + chatsConfig, + const {}, + existing == null ? const {} : {_chatId: existing}, + 0, +)!; + +void main() { + group('login sync keeps pins', () { + test('a zero favIndex in the config does not unpin a cached chat', () { + final parsed = _parse( + chatsConfig: { + '$_chatId': {'favIndex': 0, 'dontDisturbUntil': 0}, + }, + existing: _cached(favIndex: 3), + ); + expect(parsed.favIndex, 3); + }); + + test('a real favIndex from the config wins', () { + final parsed = _parse( + chatsConfig: { + '$_chatId': {'favIndex': 2, 'dontDisturbUntil': 0}, + }, + existing: _cached(favIndex: 3), + ); + expect(parsed.favIndex, 2); + }); + + test('mute settings still come from the config', () { + final parsed = _parse( + chatsConfig: { + '$_chatId': {'favIndex': 0, 'dontDisturbUntil': -1}, + }, + existing: _cached(favIndex: 3), + ); + expect(parsed.dontDisturbUntil, -1); + expect(parsed.isMuted, isTrue); + }); + + test('a chat with no cached pin stays unpinned', () { + final parsed = _parse( + chatsConfig: { + '$_chatId': {'favIndex': 0, 'dontDisturbUntil': 0}, + }, + ); + expect(parsed.favIndex, isNull); + }); + + test('without a config entry the cached pin survives', () { + expect(_parse(existing: _cached(favIndex: 5)).favIndex, 5); + }); + }); +} diff --git a/test/chat_preview_line_test.dart b/test/chat_preview_line_test.dart new file mode 100644 index 0000000..6efc52d --- /dev/null +++ b/test/chat_preview_line_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/screens/chats/chat/view/chat_preview_line.dart'; +import 'package:komet/models/chat_preview_media.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +const String _pixel = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ' + 'AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +const TextStyle _style = TextStyle(fontSize: 14, height: 1.2); + +Widget _host(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), +); + +String _plainText(WidgetTester tester) { + final span = tester.widget(find.byType(Text).first).textSpan!; + final buffer = StringBuffer(); + span.visitChildren((child) { + if (child is TextSpan && child.text != null) buffer.write(child.text); + return true; + }); + return buffer.toString(); +} + +List _spans(WidgetTester tester) { + final span = tester.widget(find.byType(Text).first).textSpan!; + final result = []; + span.visitChildren((child) { + if (child is TextSpan && child.text != null) result.add(child); + return true; + }); + return result; +} + +void main() { + testWidgets('фото без подписи: миниатюра и курсивная подпись', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'Изображение', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.photo, + thumbs: [ChatPreviewThumb(source: _pixel)], + label: 'Изображение', + ), + ), + ), + ); + + expect(find.byType(Image), findsOneWidget); + expect(find.byIcon(Symbols.play_arrow), findsNothing); + expect(_plainText(tester), 'Изображение'); + expect(_spans(tester).single.style?.fontStyle, FontStyle.italic); + }); + + testWidgets('фото с подписью: миниатюра и обычный текст', (tester) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + prefix: 'Кто-то: ', + text: 'смотри', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.photo, + thumbs: [ChatPreviewThumb(source: _pixel)], + ), + ), + ), + ); + + expect(find.byType(Image), findsOneWidget); + expect(_plainText(tester), 'Кто-то: смотри'); + expect(_spans(tester).last.style?.fontStyle, isNot(FontStyle.italic)); + }); + + testWidgets('видео помечается иконкой проигрывания на миниатюре', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'Видео', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.video, + thumbs: [ + ChatPreviewThumb(source: _pixel, video: true), + ChatPreviewThumb(source: _pixel), + ], + label: 'Видео', + ), + ), + ), + ); + + expect(find.byType(Image), findsNWidgets(2)); + expect(find.byIcon(Symbols.play_arrow), findsOneWidget); + }); + + testWidgets('файл: иконка вместо слова и имя после двоеточия', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'Файл: notes.pdf', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.file, + label: 'Файл', + detail: 'notes.pdf', + ), + ), + ), + ); + + expect(find.byIcon(Symbols.description), findsOneWidget); + expect(_plainText(tester), ': notes.pdf'); + }); + + testWidgets('специфичная подпись идёт с иконкой и курсивом', (tester) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'Пропущенный звонок', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.missedCall, + label: 'Пропущенный звонок', + ), + ), + ), + ); + + expect(find.byIcon(Symbols.call_missed), findsOneWidget); + expect(_plainText(tester), 'Пропущенный звонок'); + expect(_spans(tester).single.style?.fontStyle, FontStyle.italic); + }); + + testWidgets('метка пересылки остаётся перед иконкой', (tester) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: '↪ Контакт', + style: _style, + media: ChatPreviewMedia( + kind: ChatPreviewKind.contact, + label: '↪ Контакт', + ), + ), + ), + ); + + expect(find.byIcon(Symbols.person), findsOneWidget); + expect(_plainText(tester), '↪ Контакт'); + }); + + testWidgets('ссылка с текстом сообщения не тащит иконку', (tester) async { + await tester.pumpWidget( + _host( + const ChatPreviewLine( + text: 'глянь komet.ru', + style: _style, + media: ChatPreviewMedia(kind: ChatPreviewKind.share), + ), + ), + ); + + expect(find.byIcon(Symbols.link), findsNothing); + expect(_plainText(tester), 'глянь komet.ru'); + }); + + testWidgets('без описания вложения строка остаётся обычным текстом', ( + tester, + ) async { + await tester.pumpWidget( + _host(const ChatPreviewLine(text: 'привет', style: _style)), + ); + + expect(find.byType(Image), findsNothing); + expect(_plainText(tester), 'привет'); + }); + + test('иконка типа чата зависит от вида чата', () { + expect(chatKindIcon('CHANNEL', isBot: false), Symbols.campaign); + expect(chatKindIcon('CHAT', isBot: false), Symbols.group); + expect(chatKindIcon('GROUP', isBot: false), Symbols.group); + expect(chatKindIcon('DIALOG', isBot: true), Symbols.smart_toy); + expect(chatKindIcon('DIALOG', isBot: false), isNull); + }); +} diff --git a/test/chat_preview_media_test.dart b/test/chat_preview_media_test.dart new file mode 100644 index 0000000..3a4e90b --- /dev/null +++ b/test/chat_preview_media_test.dart @@ -0,0 +1,186 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/chat_preview.dart'; +import 'package:komet/models/chat_preview_media.dart'; + +const String _thumbA = 'data:image/webp;base64,AAAA'; +const String _thumbB = 'data:image/webp;base64,BBBB'; +const String _thumbC = 'data:image/webp;base64,CCCC'; +const String _thumbD = 'data:image/webp;base64,DDDD'; + +Map _photo(String preview) => { + '_type': 'PHOTO', + 'previewData': preview, + 'photoId': 1, +}; + +Map _video(String preview) => { + '_type': 'VIDEO', + 'previewData': preview, + 'videoId': 2, +}; + +ChatPreviewMedia _media(Map msg) { + final encoded = messagePreviewMedia(msg); + expect(encoded, isNotNull); + final decoded = ChatPreviewMedia.decode(encoded); + expect(decoded, isNotNull); + return decoded!; +} + +void main() { + group('превью вложений', () { + test('одиночное фото без подписи даёт миниатюру и словесную подпись', () { + final msg = { + 'text': '', + 'attaches': [_photo(_thumbA)], + }; + final media = _media(msg); + expect(media.kind, ChatPreviewKind.photo); + expect(media.captioned, isFalse); + expect(media.label, 'Изображение'); + expect(media.detail, isNull); + expect(media.thumbs.map((t) => t.source), [_thumbA]); + expect(media.thumbs.single.video, isFalse); + expect(messagePreviewText(msg), 'Изображение'); + }); + + test('фото с подписью оставляет текст сообщения', () { + final msg = { + 'text': 'подпись', + 'attaches': [_photo(_thumbA)], + }; + final media = _media(msg); + expect(media.captioned, isTrue); + expect(media.label, isNull); + expect(media.thumbs, hasLength(1)); + expect(messagePreviewText(msg), 'подпись'); + }); + + test('альбом отдаёт не больше трёх миниатюр и помечает видео', () { + final msg = { + 'text': '', + 'attaches': [ + _photo(_thumbA), + _video(_thumbB), + _photo(_thumbC), + _photo(_thumbD), + ], + }; + final media = _media(msg); + expect(media.label, 'Изображения'); + expect(media.thumbs.map((t) => t.source), [_thumbA, _thumbB, _thumbC]); + expect(media.thumbs.map((t) => t.video), [false, true, false]); + }); + + test('кружок не считается обычным видео', () { + final msg = { + 'text': '', + 'attaches': [ + {'_type': 'VIDEO', 'videoType': 1, 'previewData': _thumbA}, + ], + }; + final media = _media(msg); + expect(media.kind, ChatPreviewKind.videoNote); + expect(media.label, 'Видео-сообщение'); + }); + + test('файл отдаёт имя отдельно от подписи', () { + final msg = { + 'text': '', + 'attaches': [ + {'_type': 'FILE', 'name': 'notes.pdf', 'fileId': 3}, + ], + }; + final media = _media(msg); + expect(media.kind, ChatPreviewKind.file); + expect(media.label, 'Файл'); + expect(media.detail, 'notes.pdf'); + expect(media.thumbs, isEmpty); + expect(messagePreviewText(msg), 'Файл: notes.pdf'); + }); + + test('пропущенный звонок отличается от состоявшегося', () { + final missed = _media({ + 'text': '', + 'attaches': [ + {'_type': 'CALL', 'callType': 'AUDIO', 'duration': 0}, + ], + }); + expect(missed.kind, ChatPreviewKind.missedCall); + expect(missed.label, 'Пропущенный звонок'); + + final answered = _media({ + 'text': '', + 'attaches': [ + {'_type': 'CALL', 'callType': 'VIDEO', 'duration': 42}, + ], + }); + expect(answered.kind, ChatPreviewKind.videoCall); + expect(answered.label, 'Видеозвонок'); + }); + + test('пересланное вложение сохраняет метку пересылки', () { + final msg = { + 'text': '', + 'link': { + 'type': 'FORWARD', + 'message': { + 'text': '', + 'attaches': [_photo(_thumbA)], + }, + }, + }; + final media = _media(msg); + expect(media.kind, ChatPreviewKind.photo); + expect(media.label, '↪ Изображение'); + expect(media.thumbs, hasLength(1)); + expect(messagePreviewText(msg), '↪ Изображение'); + }); + + test('клавиатура бота не считается вложением', () { + final encoded = messagePreviewMedia({ + 'text': 'выбери вариант', + 'attaches': [ + {'_type': 'INLINE_KEYBOARD'}, + ], + }); + expect(encoded, isNull); + }); + + test('без вложений описания нет', () { + expect(messagePreviewMedia({'text': 'привет'}), isNull); + }); + + test('слишком тяжёлая миниатюра не попадает в кеш чатов', () { + final heavy = 'data:image/webp;base64,${'A' * 30000}'; + final media = _media({ + 'text': '', + 'attaches': [_photo(heavy)], + }); + expect(media.thumbs, isEmpty); + }); + + test('описание переживает сериализацию', () { + const media = ChatPreviewMedia( + kind: ChatPreviewKind.video, + thumbs: [ + ChatPreviewThumb(source: _thumbA, video: true), + ChatPreviewThumb(source: _thumbB), + ], + label: 'Видео', + ); + final restored = ChatPreviewMedia.decode(media.encode())!; + expect(restored.kind, ChatPreviewKind.video); + expect(restored.label, 'Видео'); + expect(restored.detail, isNull); + expect(restored.thumbs.map((t) => t.source), [_thumbA, _thumbB]); + expect(restored.thumbs.map((t) => t.video), [true, false]); + }); + + test('битое описание не роняет разбор', () { + expect(ChatPreviewMedia.decode('{'), isNull); + expect(ChatPreviewMedia.decode('{"k":"чтоэто"}'), isNull); + expect(ChatPreviewMedia.decode(null), isNull); + }); + }); +} diff --git a/test/chat_row_last_message_test.dart b/test/chat_row_last_message_test.dart new file mode 100644 index 0000000..764c2d4 --- /dev/null +++ b/test/chat_row_last_message_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/chat_parsing.dart'; +import 'package:komet/backend/modules/chats.dart'; + +const int _me = 501; +const int _peer = 777; +const int _chatId = 4242; +const int _lastMsgId = 9000000; + +CachedChat _cached({int? senderId, String? status, int? lastMsgId}) => + CachedChat( + id: _chatId, + accountId: _me, + type: 'DIALOG', + title: 'Диалог', + lastMsgId: lastMsgId ?? _lastMsgId, + lastMsgTime: 1700000000000, + lastMsgText: 'привет', + lastMsgSenderId: senderId, + lastMsgStatus: status, + unreadCount: 0, + lastEventTime: 1700000000000, + cachedAt: 0, + dontDisturbUntil: ChatsModule.muteOff, + isOnline: false, + seenTime: 0, + participants: {_me: 1700000000000, _peer: 0}, + ); + +Map _serverChat({Object? sender}) => { + 'id': _chatId, + 'type': 'DIALOG', + 'participants': {'$_me': 1700000000000, '$_peer': 0}, + 'lastMessage': { + 'id': _lastMsgId, + 'time': 1700000000000, + 'text': 'привет', + if (sender != null) 'sender': sender, + }, +}; + +CachedChat _parse( + Map chat, { + Map existing = const {}, +}) { + final parsed = parseChatRow( + chat, + _me, + _me, + const {}, + const {}, + const {}, + existing, + 0, + ); + expect(parsed, isNotNull); + return parsed!; +} + +void main() { + group('разбор чата из ответа сервера', () { + test('отправитель последнего сообщения берётся из payload', () { + final parsed = _parse(_serverChat(sender: _me)); + expect(parsed.lastMsgSenderId, _me); + }); + + test('отправитель читается и когда сервер прислал его строкой', () { + final parsed = _parse(_serverChat(sender: '$_me')); + expect(parsed.lastMsgSenderId, _me); + expect(parsed.lastMsgId, _lastMsgId); + }); + + test('без sender в payload отправитель берётся из кэша', () { + final parsed = _parse( + _serverChat(), + existing: {_chatId: _cached(senderId: _me, status: 'read')}, + ); + expect(parsed.lastMsgSenderId, _me); + expect(parsed.lastMsgStatus, 'read'); + }); + + test('на новом последнем сообщении кэш не подмешивается', () { + final parsed = _parse( + _serverChat(sender: _peer), + existing: { + _chatId: _cached( + senderId: _me, + status: 'read', + lastMsgId: _lastMsgId - 100, + ), + }, + ); + expect(parsed.lastMsgSenderId, _peer); + expect(parsed.lastMsgStatus, isNull); + }); + }); +} diff --git a/test/chat_scroll_anchor_test.dart b/test/chat_scroll_anchor_test.dart new file mode 100644 index 0000000..b5cc5a3 --- /dev/null +++ b/test/chat_scroll_anchor_test.dart @@ -0,0 +1,366 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/screens/chats/chat/chat_controller.dart'; +import 'package:komet/frontend/screens/chats/chat/retain_offset_physics.dart'; + +const double _itemHeight = 60; +const double _newestHeight = 84; +const double _viewportHeight = 300; + +double? _offsetInList(GlobalKey listKey, GlobalKey itemKey) { + final listBox = listKey.currentContext?.findRenderObject(); + final box = itemKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox || box is! RenderBox || !box.attached) return null; + return box.localToGlobal(Offset.zero, ancestor: listBox).dy; +} + +class _Harness { + _Harness(this.tester, {required this.physics}); + + final WidgetTester tester; + final ScrollPhysics? physics; + final GlobalKey listKey = GlobalKey(); + final ScrollController controller = ScrollController(); + final List items = [for (var i = 0; i < 200; i++) 'm$i']; + final Map keys = {}; + + GlobalKey keyFor(String id) => keys.putIfAbsent(id, GlobalKey.new); + + Future pump() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + key: listKey, + height: _viewportHeight, + child: CustomScrollView( + controller: controller, + reverse: true, + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) return const SizedBox(height: 0); + final id = items[items.length - index]; + return SizedBox( + key: keyFor(id), + height: id == 'newest' ? _newestHeight : _itemHeight, + child: Text(id), + ); + }, childCount: items.length + 1), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + } + + String anchorId() => items.firstWhere((id) { + final dy = _offsetInList(listKey, keyFor(id)); + return dy != null && dy >= 0 && dy <= _viewportHeight; + }); + + double dyOf(String id) => _offsetInList(listKey, keyFor(id))!; + + double? dyOrNull(String id) => _offsetInList(listKey, keyFor(id)); + + double contentOffsetOf(String id) => dyOf(id) - controller.position.pixels; + + double alignmentOf(String id) => dyOf(id) / _viewportHeight; + + void insertAt(int index, int count) { + items.insertAll(index, [for (var i = 0; i < count; i++) 'gap$index-$i']); + } + + bool restore(String id, double before) { + final dy = dyOrNull(id); + if (dy == null) return false; + final delta = before - (dy - controller.position.pixels); + if (delta.abs() <= 0.5) return true; + final pos = controller.position; + final target = (pos.pixels + delta).clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + if ((target - pos.pixels).abs() <= 0.5) return true; + controller.jumpTo(target); + return true; + } + + int visibleOldestIndex() { + for (var i = 0; i < items.length; i++) { + final dy = dyOrNull(items[i]); + if (dy != null && dy + _itemHeight > 0 && dy < _viewportHeight) return i; + } + return -1; + } + + bool jumpNear(String id) { + final index = items.indexOf(id); + final oldest = visibleOldestIndex(); + if (index == -1 || oldest == -1) return false; + final perScreen = (_viewportHeight / _itemHeight).floor(); + final away = (oldest - index).abs(); + final screens = (away / perScreen).clamp(1.0, 4.0); + final pos = controller.position; + final step = pos.viewportDimension * screens; + final next = index < oldest ? pos.pixels + step : pos.pixels - step; + final clamped = next.clamp(pos.minScrollExtent, pos.maxScrollExtent); + if ((clamped - pos.pixels).abs() < 0.5) return false; + controller.jumpTo(clamped); + return true; + } + + Future align(String id, double alignment) async { + for (var frame = 0; frame < 40; frame++) { + final dy = dyOrNull(id); + if (dy == null) { + if (!jumpNear(id)) return frame; + await tester.pump(); + continue; + } + final delta = alignment * _viewportHeight - dy; + if (delta.abs() <= 0.5) return frame; + final pos = controller.position; + final target = (pos.pixels + delta).clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + if ((target - pos.pixels).abs() <= 0.5) return frame; + controller.jumpTo(target); + await tester.pump(); + } + return -1; + } +} + +void main() { + testWidgets('appending to a reversed list drags the view toward the newest ' + 'message', (tester) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final beforeDy = h.dyOf(anchor); + + h.items.add('newest'); + await h.pump(); + + expect(h.dyOf(anchor), lessThan(beforeDy - 1)); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('RetainOffsetScrollPhysics holds the view in place when a ' + 'message is appended', (tester) async { + var retainOnce = false; + final h = _Harness( + tester, + physics: RetainOffsetScrollPhysics( + retain: () { + if (!retainOnce) return false; + retainOnce = false; + return true; + }, + ), + ); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final beforeDy = h.dyOf(anchor); + + retainOnce = true; + h.items.add('newest'); + await h.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, greaterThan(600)); + }); + + testWidgets('RetainOffsetScrollPhysics stays inert while the flag is unset', ( + tester, + ) async { + final h = _Harness( + tester, + physics: RetainOffsetScrollPhysics(retain: () => false), + ); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final beforeDy = h.dyOf(anchor); + + h.items.add('newest'); + await h.pump(); + + expect(h.dyOf(anchor), lessThan(beforeDy - 1)); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('вставка старее вьюпорта не двигает его вообще', (tester) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final beforeDy = h.dyOf(anchor); + + h.insertAt(10, 60); + await h.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('заполнение дыры не двигает то, что новее её', (tester) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + final beforeDy = h.dyOf(anchor); + + h.insertAt(10, 5); + await h.pump(); + h.restore(anchor, before); + await tester.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('заполнение дыры удерживает то, что старее её', (tester) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + final beforeDy = h.dyOf(anchor); + + h.insertAt(195, 5); + await h.pump(); + expect(h.dyOf(anchor), lessThan(beforeDy - 1)); + + h.restore(anchor, before); + await tester.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, 600 + 5 * _itemHeight); + }); + + testWidgets('скролл пользователя во время дозагрузки не отменяется', ( + tester, + ) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + final beforeDy = h.dyOf(anchor); + + h.insertAt(195, 5); + await h.pump(); + h.controller.jumpTo(h.controller.position.pixels + 120); + await tester.pump(); + + h.restore(anchor, before); + await tester.pump(); + + expect(h.dyOf(anchor), closeTo(beforeDy + 120, 0.5)); + expect(h.controller.position.pixels, 600 + 120 + 5 * _itemHeight); + }); + + testWidgets('большой блок уносит якорь за пределы отрисованного окна', ( + tester, + ) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + + h.insertAt(195, 60); + await h.pump(); + + expect(h.dyOrNull(anchor), isNull); + expect(h.restore(anchor, before), isFalse); + expect(h.controller.position.pixels, 600); + }); + + testWidgets('после большого блока выравнивание возвращает якорь на место', ( + tester, + ) async { + final h = _Harness(tester, physics: null); + addTearDown(h.controller.dispose); + + await h.pump(); + h.controller.jumpTo(600); + await tester.pump(); + + final anchor = h.anchorId(); + final before = h.contentOffsetOf(anchor); + final beforeDy = h.dyOf(anchor); + final alignment = h.alignmentOf(anchor); + + h.insertAt(195, 60); + await h.pump(); + expect(h.restore(anchor, before), isFalse); + + final frames = await h.align(anchor, alignment); + + expect(frames, greaterThanOrEqualTo(0)); + expect(frames, lessThan(10)); + expect(h.dyOf(anchor), closeTo(beforeDy, 0.5)); + expect(h.controller.position.pixels, 600 + 60 * _itemHeight); + }); + + group('дыру можно заполнять только с новой стороны', () { + final gap = HistoryGap(edgeId: 'e', edgeTime: 1100, tailTime: 9000); + + test('пользователь в хвосте — вставка ляжет выше вьюпорта', () { + expect(ChatController.gapFillLeavesViewportInPlace(gap, 9000), isTrue); + expect(ChatController.gapFillLeavesViewportInPlace(gap, 9050), isTrue); + }); + + test('пользователь у закрепа — вставка утащила бы вьюпорт', () { + expect(ChatController.gapFillLeavesViewportInPlace(gap, 1050), isFalse); + expect(ChatController.gapFillLeavesViewportInPlace(gap, 8999), isFalse); + }); + + test('без отрисованных сообщений заполнять нечего', () { + expect(ChatController.gapFillLeavesViewportInPlace(gap, null), isFalse); + }); + }); +} diff --git a/test/composer_forward_preview_test.dart b/test/composer_forward_preview_test.dart new file mode 100644 index 0000000..b671402 --- /dev/null +++ b/test/composer_forward_preview_test.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/core/config/app_chat_chrome.dart'; +import 'package:komet/core/config/app_composer_background.dart'; +import 'package:komet/core/config/app_composer_style.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/view/composer_input.dart'; +import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart'; +import 'package:komet/frontend/widgets/rich_message_controller.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +CachedMessage _message() => const CachedMessage( + id: '101', + accountId: 7, + chatId: 70, + senderId: 7, + text: 'Synthetic forwarded text', + time: 1000, + status: 'sent', +); + +void main() { + testWidgets('forward preview forces send mode and can be cancelled', ( + tester, + ) async { + final forwards = ValueNotifier>([_message()]); + final reply = ValueNotifier(null); + final hasText = ValueNotifier(false); + final uploadStatus = ValueNotifier(const UploadStatus()); + final messageController = RichMessageController(); + final focusNode = FocusNode(); + final attachAnimation = AnimationController( + vsync: const TestVSync(), + duration: const Duration(milliseconds: 1), + ); + late BuildContext composerContext; + var sendCount = 0; + var cancelCount = 0; + final voice = VoiceRecordController( + contextOf: () => composerContext, + isMounted: () => true, + myId: () => 7, + onRecorded: (File file, int durationMs, List amplitudes) async {}, + ); + final note = VideoNoteController( + contextOf: () => composerContext, + isMounted: () => true, + onRecorded: (File file, int durationMs) async {}, + formatElapsed: (milliseconds) => '$milliseconds', + bottomInset: () => 0, + ); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + composerContext = context; + return Scaffold( + bottomNavigationBar: ComposerInputBar( + chatType: 'CHAT', + chrome: ChatChromeStyle.color, + style: ComposerStyle.materialYou, + background: ComposerBackground.standard, + attachAnim: attachAnimation, + replyTo: reply, + forwardMessages: forwards, + myId: 7, + hasText: hasText, + uploadStatus: uploadStatus, + messageController: messageController, + messageFocusNode: focusNode, + voiceRec: voice, + note: note, + onToggleStickerPanel: () {}, + onSendText: () => sendCount++, + onScheduleMessage: () {}, + onOpenAttach: () {}, + onOpenAttachScheduled: () {}, + onSendHistory: (_) async {}, + onCancelReply: () {}, + onCancelForward: () { + cancelCount++; + forwards.value = const []; + }, + formatElapsed: (milliseconds) => '$milliseconds', + contextMenuBuilder: (context, state) => const SizedBox.shrink(), + isMuted: false, + onToggleMute: () {}, + showStickerButton: false, + showAttachButton: false, + ), + ); + }, + ), + ), + ); + + expect(find.text('Пересылка от вас'), findsOneWidget); + expect(find.text('Synthetic forwarded text'), findsOneWidget); + expect(find.byIcon(Symbols.forward), findsOneWidget); + expect(find.byIcon(Symbols.send), findsOneWidget); + expect(find.byIcon(Symbols.mic), findsNothing); + + await tester.tap(find.byIcon(Symbols.send)); + expect(sendCount, 1); + + 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); + expect(find.byIcon(Symbols.send), findsNothing); + expect(find.byIcon(Symbols.mic), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + forwards.dispose(); + reply.dispose(); + hasText.dispose(); + uploadStatus.dispose(); + messageController.dispose(); + focusNode.dispose(); + attachAnimation.dispose(); + voice.dispose(); + note.dispose(); + }); +} 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/test/contact_bubble_test.dart b/test/contact_bubble_test.dart new file mode 100644 index 0000000..a21c329 --- /dev/null +++ b/test/contact_bubble_test.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/core/storage/app_database.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _SyntheticPathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + final String directory; + + _SyntheticPathProvider(this.directory); + + @override + Future getApplicationSupportPath() async => directory; +} + +CachedMessage _contactMessage({ + required String id, + required int contactId, + required String firstName, + required String lastName, +}) { + return CachedMessage( + id: id, + accountId: 1, + chatId: 2, + senderId: 3, + text: '', + time: DateTime(2026, 1, 2, 5, 46).millisecondsSinceEpoch, + attachments: [ + ContactAttachment( + contactId: contactId, + firstName: firstName, + lastName: lastName, + name: '$firstName $lastName', + ), + ], + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('contact bubble shows profile and contextual contact action', ( + tester, + ) async { + final directory = Directory.systemTemp.createTempSync( + 'synthetic_contact_bubble_test', + ); + addTearDown(() async { + await AppDatabase.close(); + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + PathProviderPlatform.instance = _SyntheticPathProvider(directory.path); + + await tester.runAsync(() async { + await AppDatabase.init(); + await AppDatabase.saveProfile( + ProfileData( + id: 1, + firstName: 'Synthetic owner', + phone: 100000, + country: 'ZZ', + accountStatus: 0, + updateTime: 1, + ), + ); + await AppDatabase.saveContacts([ + { + 'id': 77, + 'account_id': 1, + 'first_name': 'Existing', + 'last_name': 'Contact', + 'phone': 100001, + 'photo_id': null, + 'base_url': null, + 'base_raw_url': null, + 'update_time': 1, + 'options': '', + }, + ]); + expect(await ContactsModule.getContact(1, 77), isNotNull); + }); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MessageBubble( + message: _contactMessage( + id: 'synthetic-existing-message', + contactId: 77, + firstName: 'Existing', + lastName: 'Contact', + ), + isMe: false, + myId: 1, + chatType: 'DIALOG', + ), + MessageBubble( + message: _contactMessage( + id: 'synthetic-new-message', + contactId: 88, + firstName: 'New', + lastName: 'Contact', + ), + isMe: false, + myId: 1, + chatType: 'DIALOG', + ), + ], + ), + ), + ), + ); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pump(); + + expect(find.text('Existing Contact'), findsOneWidget); + expect(find.text('Уже твой контакт'), findsOneWidget); + expect(find.text('New Contact'), findsOneWidget); + expect(find.text('Новый контакт'), findsOneWidget); + expect(find.byKey(const ValueKey('contact-add-button')), findsOneWidget); + expect( + find.byKey(const ValueKey('contact-profile-button')), + findsNWidgets(2), + ); + expect(find.text('05:46'), findsNWidgets(2)); + expect( + tester.getSize(find.byKey(const ValueKey('contact-card')).first).width, + 320, + ); + }); +} diff --git a/test/contacts_deleted_accounts_test.dart b/test/contacts_deleted_accounts_test.dart new file mode 100644 index 0000000..5469371 --- /dev/null +++ b/test/contacts_deleted_accounts_test.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/core/storage/app_database.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _SyntheticPathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + final String directory; + + _SyntheticPathProvider(this.directory); + + @override + Future getApplicationSupportPath() async => directory; +} + +Map _serverContact({ + required int id, + required String firstName, + required int phone, + int accountStatus = 0, +}) { + return { + 'id': id, + 'phone': phone, + 'updateTime': 1, + 'accountStatus': accountStatus, + 'names': [ + {'type': 'ONEME', 'firstName': firstName, 'lastName': 'Synthetic'}, + ], + }; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const accountId = 1; + + setUp(() async { + final directory = Directory.systemTemp.createTempSync( + 'synthetic_contacts_deleted_test', + ); + PathProviderPlatform.instance = _SyntheticPathProvider(directory.path); + addTearDown(() async { + await AppDatabase.close(); + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + await AppDatabase.init(); + await AppDatabase.saveProfile( + ProfileData( + id: accountId, + firstName: 'Synthetic owner', + phone: 100000, + country: 'ZZ', + accountStatus: 0, + updateTime: 1, + ), + ); + await ContactsModule.syncFromLoginPayload({ + 'contacts': [ + _serverContact(id: 11, firstName: 'Alive', phone: 700000011), + _serverContact( + id: 12, + firstName: 'Gone', + phone: 700000012, + accountStatus: 2, + ), + ], + }, accountId); + }); + + test('deleted accounts are hidden from the contact list', () async { + final visible = await ContactsModule.getContacts(accountId); + + expect(visible.map((c) => c.id), [11]); + expect(visible.single.isDeleted, isFalse); + }); + + test('deleted accounts stay available when explicitly requested', () async { + final all = await ContactsModule.getContacts( + accountId, + includeDeleted: true, + ); + + expect(all.map((c) => c.id).toSet(), {11, 12}); + expect(all.firstWhere((c) => c.id == 12).isDeleted, isTrue); + }); + + test( + 'a re-synced deleted account does not come back into the list', + () async { + await ContactsModule.syncFromLoginPayload({ + 'contacts': [ + _serverContact( + id: 12, + firstName: 'Gone', + phone: 700000012, + accountStatus: 2, + ), + ], + }, accountId); + + final visible = await ContactsModule.getContacts(accountId); + + expect(visible.map((c) => c.id), [11]); + }, + ); + + test('a contact without accountStatus is treated as alive', () async { + await ContactsModule.syncFromLoginPayload({ + 'contacts': [ + { + 'id': 13, + 'phone': 700000013, + 'updateTime': 1, + 'names': [ + {'type': 'ONEME', 'firstName': 'Legacy', 'lastName': 'Synthetic'}, + ], + }, + ], + }, accountId); + + final visible = await ContactsModule.getContacts(accountId); + + expect(visible.map((c) => c.id).toSet(), {11, 13}); + }); +} diff --git a/test/crop_workspace_test.dart b/test/crop_workspace_test.dart new file mode 100644 index 0000000..6b06cf1 --- /dev/null +++ b/test/crop_workspace_test.dart @@ -0,0 +1,114 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:komet/frontend/widgets/attachment/editor_common.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +Future _solidImage(int width, int height) { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = const Color(0xFF3366AA), + ); + return recorder.endRecording().toImage(width, height); +} + +void main() { + group('CropView', () { + const vp = Size(400, 800); + + test('рамка во весь вьюпорт даёт единичный масштаб', () { + final crop = Rect.fromCenter( + center: const Offset(200, 400), + width: 360, + height: 720, + ); + final view = CropView.fit(crop, vp); + expect(view.scale, closeTo(1, 0.001)); + expect(view.focus, crop.center); + }); + + test('маленькая рамка приближает и центрирует', () { + final crop = Rect.fromLTWH(40, 80, 90, 180); + final view = CropView.fit(crop, vp); + expect(view.scale, closeTo(4, 0.001)); + final display = view.rect(crop, vp); + expect(display.center.dx, closeTo(200, 0.001)); + expect(display.center.dy, closeTo(400, 0.001)); + expect(display.width, closeTo(360, 0.001)); + }); + + test('перевод координат обратим', () { + final view = CropView.fit(Rect.fromLTWH(40, 80, 90, 180), vp); + const point = Offset(123, 456); + final round = view.toLogical(view.toDisplay(point, vp), vp); + expect(round.dx, closeTo(point.dx, 0.001)); + expect(round.dy, closeTo(point.dy, 0.001)); + }); + }); + + testWidgets('после зума жест по рамке двигает её в масштабе', (tester) async { + final image = await _solidImage(400, 400); + addTearDown(image.dispose); + + CropState? applied; + Size? viewport; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: CropWorkspace( + imageSize: const Size(400, 400), + imageBuilder: (context, matrix) => + CustomPaint(painter: MatrixImagePainter(image, matrix)), + onApply: (state, vp, changed, identity) async { + applied = state; + viewport = vp; + return 'ok'; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final area = tester.getRect(find.byType(LayoutBuilder).first); + final geometry = const CropGeometry(source: Size(400, 400)); + final fitted = geometry.fittedRect(area.size); + final corner = area.topLeft + fitted.topLeft; + + Future dragCorner(Offset from, Offset delta) async { + final gesture = await tester.startGesture(from); + await tester.pump(); + await gesture.moveBy(delta); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + } + + await dragCorner(corner, const Offset(40, 40)); + + final zoomed = Rect.fromLTRB( + fitted.left + 40, + fitted.top + 40, + fitted.right, + fitted.bottom, + ); + final view = CropView.fit(zoomed, area.size); + expect(view.scale, greaterThan(1)); + + await dragCorner( + area.topLeft + view.rect(zoomed, area.size).topLeft, + const Offset(40, 40), + ); + + await tester.tap(find.text('ГОТОВО')); + await tester.pumpAndSettle(); + expect(applied, isNotNull); + final left = applied!.cropNorm.left * viewport!.width; + expect(left, closeTo(fitted.left + 40 + 40 / view.scale, 1.5)); + expect(left, lessThan(fitted.left + 80)); + }); +} diff --git a/test/downloads_screen_test.dart b/test/downloads_screen_test.dart new file mode 100644 index 0000000..5c846b3 --- /dev/null +++ b/test/downloads_screen_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/utils/download_history.dart'; +import 'package:komet/core/utils/media_cache.dart'; +import 'package:komet/frontend/screens/downloads_screen.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _SyntheticPathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + final String directory; + + _SyntheticPathProvider(this.directory); + + @override + Future getApplicationSupportPath() async => directory; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('download kinds are inferred from synthetic file names', () { + expect(downloadKindForName('sample.gif'), DownloadKind.gif); + expect(downloadKindForName('clip.mp4'), DownloadKind.video); + expect(downloadKindForName('sound.ogg'), DownloadKind.audio); + expect(downloadKindForName('picture.png'), DownloadKind.photo); + expect(downloadKindForName('package.zip'), DownloadKind.file); + }); + + testWidgets('recent downloads show persisted metadata', (tester) async { + final directory = Directory.systemTemp.createTempSync( + 'synthetic_downloads_test', + ); + addTearDown(() { + DownloadHistory.resetForTesting(); + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + PathProviderPlatform.instance = _SyntheticPathProvider(directory.path); + SharedPreferences.setMockInitialValues({}); + + await tester.runAsync(() async { + DownloadHistory.resetForTesting(); + final file = await MediaCache.fileFor('synthetic-package.zip'); + await file.writeAsBytes(List.filled(2048, 1)); + await DownloadHistory.record( + const DownloadMetadata( + cacheName: 'synthetic-package.zip', + name: 'sample-package.zip', + kind: DownloadKind.file, + sourceName: 'Synthetic channel', + chatId: 77, + messageId: 'synthetic-message', + messageTime: 123456, + ), + file, + ); + DownloadHistory.resetForTesting(); + await DownloadHistory.load(); + }); + + final record = DownloadHistory.records.value.single; + expect(record.chatId, 77); + expect(record.messageId, 'synthetic-message'); + expect(record.messageTime, 123456); + + await tester.pumpWidget( + const MaterialApp( + locale: Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DownloadsScreen(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Недавние загрузки'), findsOneWidget); + expect(find.text('sample-package.zip'), findsOneWidget); + expect(find.text('2.0 КБ · Synthetic channel'), findsOneWidget); + expect(find.byKey(const ValueKey('downloads-settings')), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('download-more-synthetic-package.zip')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Перейти к сообщению'), findsOneWidget); + expect(find.text('Сохранить как…'), findsOneWidget); + }); +} diff --git a/test/floating_call_badge_test.dart b/test/floating_call_badge_test.dart new file mode 100644 index 0000000..62576cd --- /dev/null +++ b/test/floating_call_badge_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/calls/active_call.dart'; +import 'package:komet/core/calls/call_session.dart'; +import 'package:komet/core/calls/ws2_signaling.dart'; +import 'package:komet/frontend/widgets/floating_call_badge.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +const _name = 'Тестовый собеседник'; + +CallSession _session() => CallSession( + ws2Config: Ws2Config(uri: Uri.parse('wss://calls.invalid/ws2'), userId: 42), + role: CallRole.caller, +); + +Widget _host() => MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => Stack( + children: [ + child!, + const Positioned.fill(child: FloatingCallBadgeLayer()), + ], + ), + home: const Scaffold(body: SizedBox.expand()), +); + +final Finder _hangup = find.byIcon(Symbols.call_end); + +double _controlsOpacity(WidgetTester tester) => tester + .widget( + find.ancestor(of: _hangup, matching: find.byType(Opacity)).first, + ) + .opacity; + +Finder get _badge => find.descendant( + of: find.byType(FloatingCallBadgeLayer), + matching: find.byType(ScaleTransition), +); + +Offset _badgeCorner(WidgetTester tester) => tester.getTopLeft(_badge); + +void main() { + tearDown(() { + ActiveCall.instance.detach(); + while (ActiveCall.instance.screenVisible.value) { + ActiveCall.instance.leaveScreen(); + } + }); + + testWidgets('бейджик виден только пока экран звонка закрыт', (tester) async { + await tester.pumpWidget(_host()); + expect(find.text(_name), findsNothing); + + ActiveCall.instance.attach(session: _session(), name: _name); + await tester.pump(); + expect(find.text(_name), findsOneWidget); + + ActiveCall.instance.enterScreen(); + await tester.pump(); + expect(find.text(_name), findsNothing); + + ActiveCall.instance.leaveScreen(); + await tester.pump(); + expect(find.text(_name), findsOneWidget); + + ActiveCall.instance.detach(); + await tester.pump(); + expect(find.text(_name), findsNothing); + }); + + testWidgets('тап раскрывает кнопки и повторный тап их прячет', ( + tester, + ) async { + ActiveCall.instance.attach(session: _session(), name: _name); + await tester.pumpWidget(_host()); + + expect( + _hangup, + findsNothing, + reason: 'свёрнутый бейджик не держит кнопки в дереве', + ); + + await tester.tap(find.text(_name)); + await tester.pumpAndSettle(); + expect(_hangup, findsOneWidget); + expect(_controlsOpacity(tester), 1); + + await tester.tap(find.text(_name)); + await tester.pumpAndSettle(); + expect(_hangup, findsNothing); + }); + + testWidgets('кнопки прячутся сами, если бейджик не трогают', (tester) async { + ActiveCall.instance.attach(session: _session(), name: _name); + await tester.pumpWidget(_host()); + + await tester.tap(find.text(_name)); + await tester.pumpAndSettle(); + expect(_controlsOpacity(tester), 1); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(_hangup, findsNothing); + }); + + testWidgets('перетаскивание липнет к краю экрана', (tester) async { + ActiveCall.instance.attach(session: _session(), name: _name); + await tester.pumpWidget(_host()); + + final screen = tester.getSize(find.byType(FloatingCallBadgeLayer)).width; + final start = _badgeCorner(tester); + final width = tester.getSize(_badge).width; + expect(start.dx + width, moreOrLessEquals(screen - 12)); + + await tester.timedDrag( + find.text(_name), + const Offset(-260, -120), + const Duration(milliseconds: 300), + ); + await tester.pumpAndSettle(); + + expect(_badgeCorner(tester).dx, moreOrLessEquals(12)); + expect(_badgeCorner(tester).dy, lessThan(start.dy)); + }); +} diff --git a/test/floating_dock_geometry_test.dart b/test/floating_dock_geometry_test.dart new file mode 100644 index 0000000..b8be72a --- /dev/null +++ b/test/floating_dock_geometry_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/widgets/draggable_floating_layer.dart'; + +const _screen = Size(400, 800); +const _safe = EdgeInsets.only(top: 48, bottom: 24); + +FloatingDockGeometry _dockOf(Size size) => + FloatingDockGeometry(bounds: _screen, size: size, safeArea: _safe); + +final _badge = _dockOf(const Size(120, 118)); + +void main() { + test('бейджик стартует у правого края над нижней панелью', () { + final resting = _badge.resting; + expect(resting.dx, _screen.width - 120 - 12); + expect(resting.dy, _screen.height - 118 - 24 - 96); + }); + + test('позиция зажата в безопасной зоне', () { + expect(_badge.clamp(const Offset(-500, -500)), Offset(12, 48 + 12)); + expect( + _badge.clamp(const Offset(9999, 9999)), + Offset(_screen.width - 120 - 12, _screen.height - 118 - 24 - 12), + ); + }); + + test('без броска липнет к ближнему краю', () { + final left = _badge.snap(const Offset(40, 300), Offset.zero); + expect(left.dx, 12); + expect( + left.dy, + 300, + reason: 'высота не меняется без вертикальной скорости', + ); + + final right = _badge.snap(const Offset(250, 300), Offset.zero); + expect(right.dx, _screen.width - 120 - 12); + }); + + test('бросок перебивает ближний край', () { + final flungRight = _badge.snap(const Offset(40, 300), const Offset(900, 0)); + expect(flungRight.dx, _screen.width - 120 - 12); + + final flungLeft = _badge.snap( + const Offset(250, 300), + const Offset(-900, 0), + ); + expect(flungLeft.dx, 12); + }); + + test('слабый рывок не считается броском', () { + final weak = _badge.snap(const Offset(40, 300), const Offset(200, 0)); + expect(weak.dx, 12); + }); + + test('вертикальный бросок продолжает движение, но не за экран', () { + final down = _badge.snap(const Offset(250, 300), const Offset(0, 1000)); + expect(down.dy, 300 + 1000 * FloatingDockGeometry.flingSeconds); + + final overshoot = _badge.snap( + const Offset(250, 600), + const Offset(0, 5000), + ); + expect(overshoot.dy, _screen.height - 118 - 24 - 12); + + final up = _badge.snap(const Offset(250, 100), const Offset(0, -5000)); + expect(up.dy, 48 + 12); + }); + + test('раскрытие бейджика удерживает правый край на месте', () { + final collapsed = _badge; + final expanded = _dockOf(const Size(152, 166)); + + final docked = collapsed.resting; + final grown = expanded.clamp(docked); + + expect( + grown.dx + 152, + docked.dx + 120, + reason: 'кнопки должны вырастать влево, а не выезжать за экран', + ); + }); + + test('узкий экран не переворачивает границы', () { + final tiny = FloatingDockGeometry( + bounds: const Size(100, 200), + size: const Size(120, 118), + safeArea: EdgeInsets.zero, + ); + expect(tiny.maxX, tiny.minX); + expect(tiny.clamp(const Offset(50, 50)).dx, 12); + }); +} diff --git a/test/folder_action_sheet_test.dart b/test/folder_action_sheet_test.dart new file mode 100644 index 0000000..3b2f952 --- /dev/null +++ b/test/folder_action_sheet_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/models/chat_folder.dart'; +import 'package:komet/backend/modules/folders.dart'; +import 'package:komet/frontend/screens/chats/folder_action_sheet.dart'; + +Future _openSheet(WidgetTester tester, ChatFolder folder) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showFolderActionSheet(context, folder: folder), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a user folder offers edit, create and delete', (tester) async { + await _openSheet( + tester, + const ChatFolder(id: 'synthetic-folder', title: 'Работа'), + ); + + expect(find.text('Работа'), findsOneWidget); + expect(find.text('Изменить'), findsOneWidget); + expect(find.text('Новая папка'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + }); + + testWidgets('the all-chats folder can only spawn a new folder', ( + tester, + ) async { + await _openSheet( + tester, + const ChatFolder(id: FoldersModule.allChatsFolderId, title: 'Все чаты'), + ); + + expect(find.text('Изменить'), findsNothing); + expect(find.text('Удалить'), findsNothing); + expect(find.text('Новая папка'), findsOneWidget); + }); + + testWidgets('server options hide the forbidden actions', (tester) async { + await _openSheet( + tester, + const ChatFolder( + id: 'synthetic-system-folder', + title: 'Каналы', + options: [ + FolderOption.noDelete, + FolderOption.noTitleEdit, + FolderOption.noFiltersEdit, + ], + ), + ); + + expect(find.text('Изменить'), findsNothing); + expect(find.text('Удалить'), findsNothing); + expect(find.text('Новая папка'), findsOneWidget); + }); +} diff --git a/test/folder_edit_sheet_test.dart b/test/folder_edit_sheet_test.dart new file mode 100644 index 0000000..75aae22 --- /dev/null +++ b/test/folder_edit_sheet_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/models/chat_folder.dart'; +import 'package:komet/frontend/screens/chats/folder_edit_sheet.dart'; +import 'package:komet/frontend/widgets/sheet_helpers.dart'; + +Future _openSheet(WidgetTester tester, {ChatFolder? folder}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showFolderEditSheet(context, folder: folder), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); +} + +Future _scrollToBottom(WidgetTester tester) async { + await tester.drag(find.byType(ListView), const Offset(0, -600)); + await tester.pump(const Duration(milliseconds: 300)); +} + +SheetButton _button(WidgetTester tester, String label) => + tester.widget( + find.byWidgetPredicate((w) => w is SheetButton && w.label == label), + ); + +void main() { + testWidgets('the create layout shows every picker section', (tester) async { + await _openSheet(tester); + + expect(find.text('Новая папка'), findsOneWidget); + expect(find.text('Название папки'), findsOneWidget); + expect(find.text('0/20'), findsOneWidget); + expect(find.text('ТИПЫ ЧАТОВ'), findsOneWidget); + expect(find.text('Контакты'), findsOneWidget); + expect(find.text('Не в контактах'), findsOneWidget); + expect(find.text('Группы'), findsOneWidget); + expect(find.text('Каналы'), findsOneWidget); + expect(find.text('Боты'), findsOneWidget); + + await _scrollToBottom(tester); + expect(find.text('ПОКАЗЫВАТЬ ТОЛЬКО'), findsOneWidget); + expect(find.text('Чаты с уведомлениями'), findsOneWidget); + expect(find.text('Непрочитанные чаты'), findsOneWidget); + expect(find.text('Очистить выбор'), findsOneWidget); + expect(find.text('Создать папку'), findsOneWidget); + }); + + testWidgets('creating needs both a name and a selection', (tester) async { + await _openSheet(tester); + + expect(_button(tester, 'Создать папку').onTap, isNull); + expect(_button(tester, 'Очистить выбор').onTap, isNull); + + await tester.enterText(find.byType(TextField).first, 'Работа'); + await tester.pump(); + expect(_button(tester, 'Создать папку').onTap, isNull); + expect(find.text('6/20'), findsOneWidget); + + await tester.tap(find.text('Каналы')); + await tester.pump(const Duration(milliseconds: 300)); + expect(_button(tester, 'Создать папку').onTap, isNotNull); + expect(_button(tester, 'Очистить выбор').onTap, isNotNull); + + await tester.tap(find.text('Очистить выбор')); + await tester.pump(const Duration(milliseconds: 300)); + expect(_button(tester, 'Создать папку').onTap, isNull); + }); + + testWidgets('the edit layout preloads the folder and offers delete', ( + tester, + ) async { + await _openSheet( + tester, + folder: const ChatFolder( + id: 'synthetic-folder', + title: 'Каналы', + filters: [FolderFilter.channel, FolderFilter.unread], + ), + ); + + expect(find.text('Изменение папки'), findsOneWidget); + expect(find.text('6/20'), findsOneWidget); + expect(find.text('Удалить папку'), findsOneWidget); + expect(find.text('Сохранить'), findsOneWidget); + expect(_button(tester, 'Сохранить').onTap, isNotNull); + expect(_button(tester, 'Удалить папку').onTap, isNotNull); + + await _scrollToBottom(tester); + final unreadSwitch = tester.widget(find.byType(Switch).last); + expect(unreadSwitch.value, isTrue); + }); + + testWidgets('a folder the server locks cannot be deleted', (tester) async { + await _openSheet( + tester, + folder: const ChatFolder( + id: 'synthetic-system-folder', + title: 'Каналы', + options: [FolderOption.noDelete], + ), + ); + + expect(_button(tester, 'Удалить папку').onTap, isNull); + }); +} diff --git a/test/folders_module_test.dart b/test/folders_module_test.dart new file mode 100644 index 0000000..83e3a82 --- /dev/null +++ b/test/folders_module_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/models/chat_folder.dart'; +import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/folders.dart'; + +const int _me = 4242; +const int _contactId = 777; + +CachedChat _chat({ + required int id, + required String type, + int unreadCount = 0, + int dontDisturbUntil = 0, + Set options = const {}, + int? owner, +}) => CachedChat( + id: id, + accountId: _me, + type: type, + title: 'chat $id', + unreadCount: unreadCount, + lastEventTime: 1700000000000, + cachedAt: 0, + dontDisturbUntil: dontDisturbUntil, + isOnline: false, + seenTime: 0, + participants: {_me: 1700000000000}, + options: options, + owner: owner, +); + +bool _matches(CachedChat chat, ChatFolder folder) => + FoldersModule.chatMatchesFolder( + chat, + folder, + myId: _me, + contactIds: const {_contactId}, + ); + +void main() { + group('ChatFolder.fromJson', () { + test('reads the server payload of FOLDERS_UPDATE', () { + final folder = ChatFolder.fromJson(const { + 'id': '6fd177c5-ba2e-4360-9593-3ae798326806', + 'title': 'Каналы', + 'include': [111, -222], + 'filters': [0, 11, 2], + 'favorites': [111], + 'options': [1, 2], + 'updateTime': 1700000000000, + 'sourceId': 1, + }); + + expect(folder.id, '6fd177c5-ba2e-4360-9593-3ae798326806'); + expect(folder.include, [111, -222]); + expect(folder.filters, [ + FolderFilter.unread, + FolderFilter.notMuted, + FolderFilter.channel, + ]); + expect(folder.favorites, [111]); + expect(folder.updateTime, 1700000000000); + expect(folder.sourceId, 1); + expect(folder.canDelete, isFalse); + expect(folder.canEditTitle, isFalse); + expect(folder.canEditFilters, isTrue); + }); + + test('normalizes legacy string filters and the hideEmpty flag', () { + final folder = ChatFolder.fromJson(const { + 'id': 'legacy', + 'title': 'legacy', + 'filters': ['CHANNEL', 'GROUP'], + 'hideEmpty': true, + }); + + expect(folder.filters, [FolderFilter.channel, FolderFilter.chat]); + expect(folder.options, [FolderOption.hideEmpty]); + expect(folder.hideEmpty, isTrue); + }); + }); + + group('chatMatchesFolder', () { + const channelsFolder = ChatFolder( + id: 'f1', + title: 'Каналы', + filters: [FolderFilter.channel], + ); + + test('type filters are combined with OR', () { + const folder = ChatFolder( + id: 'f2', + title: 'Каналы и боты', + filters: [FolderFilter.channel, FolderFilter.bot], + ); + + expect(_matches(_chat(id: 1, type: 'CHANNEL'), folder), isTrue); + expect( + _matches(_chat(id: 2, type: 'DIALOG', options: {'BOT'}), folder), + isTrue, + ); + expect(_matches(_chat(id: 3, type: 'CHAT'), folder), isFalse); + }); + + test('a folder without filters only holds its included chats', () { + const folder = ChatFolder(id: 'f3', title: 'Свои', include: [10]); + + expect(_matches(_chat(id: 10, type: 'CHAT'), folder), isTrue); + expect(_matches(_chat(id: 11, type: 'CHAT'), folder), isFalse); + }); + + test('show-only filters narrow both types and included chats', () { + const folder = ChatFolder( + id: 'f4', + title: 'Непрочитанные каналы', + include: [10], + filters: [FolderFilter.channel, FolderFilter.unread], + ); + + expect( + _matches(_chat(id: 1, type: 'CHANNEL', unreadCount: 3), folder), + isTrue, + ); + expect(_matches(_chat(id: 2, type: 'CHANNEL'), folder), isFalse); + expect( + _matches(_chat(id: 10, type: 'CHAT', unreadCount: 1), folder), + isTrue, + ); + expect(_matches(_chat(id: 10, type: 'CHAT'), folder), isFalse); + }); + + test('show-only filters are combined with AND', () { + const folder = ChatFolder( + id: 'f5', + title: 'Непрочитанные с уведомлениями', + filters: [ + FolderFilter.channel, + FolderFilter.unread, + FolderFilter.notMuted, + ], + ); + + expect( + _matches(_chat(id: 1, type: 'CHANNEL', unreadCount: 1), folder), + isTrue, + ); + expect( + _matches( + _chat(id: 2, type: 'CHANNEL', unreadCount: 1, dontDisturbUntil: -1), + folder, + ), + isFalse, + ); + }); + + test('an expired mute counts as not muted', () { + const folder = ChatFolder( + id: 'f6', + title: 'С уведомлениями', + filters: [FolderFilter.channel, FolderFilter.notMuted], + ); + + expect( + _matches( + _chat(id: 1, type: 'CHANNEL', dontDisturbUntil: 1700000000000), + folder, + ), + isTrue, + ); + }); + + test('unknown filters do not empty a folder', () { + const folder = ChatFolder( + id: 'f7', + title: 'Каналы', + filters: [FolderFilter.channel, FolderFilter.markedUnread], + ); + + expect(_matches(_chat(id: 1, type: 'CHANNEL'), folder), isTrue); + }); + + test('role filters keep only chats with that role', () { + const folder = ChatFolder( + id: 'f8', + title: 'Мои группы', + filters: [FolderFilter.chat, FolderFilter.owner], + ); + + expect(_matches(_chat(id: 1, type: 'CHAT', owner: _me), folder), isTrue); + expect(_matches(_chat(id: 2, type: 'CHAT', owner: 5), folder), isFalse); + }); + + test('channels folder ignores groups and dialogs', () { + expect(_matches(_chat(id: 1, type: 'CHANNEL'), channelsFolder), isTrue); + expect(_matches(_chat(id: 2, type: 'CHAT'), channelsFolder), isFalse); + expect(_matches(_chat(id: 3, type: 'DIALOG'), channelsFolder), isFalse); + }); + }); + + group('newFolderId', () { + test('generates distinct uuid v4 ids', () { + final a = FoldersModule.newFolderId(); + final b = FoldersModule.newFolderId(); + + expect(a, isNot(b)); + expect( + RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ).hasMatch(a), + isTrue, + ); + }); + }); +} diff --git a/test/forwarded_message_attachment_test.dart b/test/forwarded_message_attachment_test.dart new file mode 100644 index 0000000..05380e7 --- /dev/null +++ b/test/forwarded_message_attachment_test.dart @@ -0,0 +1,549 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/core/utils/text_format.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/bubble_context.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/call_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/contact_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/file_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/forwarded_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/location_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/photo_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/share_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/sticker_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/video_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/voice_bubble.dart'; +import 'package:komet/frontend/widgets/formatted_message_text.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +Future _pumpBubble( + WidgetTester tester, + CachedMessage message, { + void Function(ForwardedMessageAttachment forwarded)? onSourceTap, + bool isMe = false, +}) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: MessageBubble( + message: message, + isMe: isMe, + myId: 1, + chatType: 'CHAT', + onForwardedSourceTap: onSourceTap, + ), + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + group('ForwardedMessageAttachment', () { + test('styles a server heading', () { + final style = applyTextFormats(const TextStyle(fontSize: 16), { + TextFormat.heading, + }); + + expect(style.fontWeight, FontWeight.w700); + expect(style.fontSize, greaterThan(16)); + }); + + test('uses channel metadata as the original author', () { + final attachment = ForwardedMessageAttachment.fromMap({ + 'link': { + 'type': 'FORWARD', + 'message': { + 'id': '101', + 'time': 1000, + 'type': 'CHANNEL', + 'text': 'Synthetic channel message', + 'attaches': [ + {'_type': 'PHOTO', 'photoId': 11}, + ], + 'elements': [ + {'type': 'HEADING', 'length': 9}, + ], + }, + 'chatId': -10, + 'chatName': 'Example Channel', + 'chatIconUrl': 'https://example.test/channel.jpg', + }, + }); + + expect(attachment.originalSenderId, 0); + expect(attachment.originalSenderName, 'Example Channel'); + expect(attachment.isChannel, isTrue); + expect(attachment.originalMessageId, '101'); + expect(attachment.originalTime, 1000); + expect( + attachment.originalSenderAvatar, + 'https://example.test/channel.jpg', + ); + expect(attachment.originalChatId, -10); + expect(attachment.originalAttachments, hasLength(1)); + expect(attachment.originalAttachments!.single, isA()); + expect(attachment.originalFormatRanges, hasLength(1)); + expect(attachment.originalFormatRanges.single.format, TextFormat.heading); + }); + + test('keeps the user as the original author', () { + final attachment = ForwardedMessageAttachment.fromMap({ + 'link': { + 'type': 'FORWARD', + 'message': { + 'id': '102', + 'time': 2000, + 'type': 'USER', + 'sender': 42, + 'text': '', + 'attaches': const [], + }, + 'chatId': -20, + 'chatName': 'Example Group', + 'chatIconUrl': 'https://example.test/group.jpg', + }, + }); + + expect(attachment.originalSenderId, 42); + expect(attachment.isChannel, isFalse); + expect(attachment.originalSenderName, isNull); + expect(attachment.originalSenderAvatar, isNull); + }); + + test('keeps channel metadata in an optimistic forward', () { + final forwarded = MessagesModule.buildForwardMessage( + myId: 1, + targetChatId: 2, + sourceChatId: -10, + source: const CachedMessage( + id: '101', + accountId: 1, + chatId: -10, + senderId: 0, + text: 'Synthetic channel message', + time: 1000, + payload: { + 'type': 'CHANNEL', + 'attaches': [], + 'elements': [ + {'type': 'STRONG', 'length': 9}, + ], + }, + ), + tempId: 'temp_1', + time: 3000, + status: 'sending', + sourceChatName: 'Example Channel', + sourceChatIconUrl: 'https://example.test/channel.jpg', + sourceChatType: 'CHANNEL', + ); + + final attachment = + forwarded.attachments!.single as ForwardedMessageAttachment; + expect(attachment.originalSenderName, 'Example Channel'); + expect( + attachment.originalSenderAvatar, + 'https://example.test/channel.jpg', + ); + expect(attachment.originalFormatRanges, hasLength(1)); + }); + + test('keeps the original channel when forwarding a forward', () { + final forwarded = MessagesModule.buildForwardMessage( + myId: 1, + targetChatId: 2, + sourceChatId: 3, + source: const CachedMessage( + id: '201', + accountId: 1, + chatId: 3, + senderId: 42, + time: 3000, + payload: { + 'link': { + 'type': 'FORWARD', + 'message': { + 'id': '101', + 'time': 1000, + 'type': 'CHANNEL', + 'text': 'Synthetic channel message', + 'attaches': [], + }, + 'chatId': -10, + 'chatName': 'Example Channel', + 'chatIconUrl': 'https://example.test/channel.jpg', + }, + }, + ), + tempId: 'temp_2', + time: 4000, + status: 'sending', + sourceChatName: 'Current Chat', + sourceChatIconUrl: 'https://example.test/current-chat.jpg', + sourceChatType: 'CHAT', + ); + + final attachment = + forwarded.attachments!.single as ForwardedMessageAttachment; + expect(attachment.originalSenderName, 'Example Channel'); + expect( + attachment.originalSenderAvatar, + 'https://example.test/channel.jpg', + ); + }); + + testWidgets('renders a formatted caption with a forwarded photo', ( + tester, + ) async { + const caption = + 'Bold synthetic caption that wraps within the synthetic photo width'; + final attachment = ForwardedMessageAttachment.fromMap({ + 'link': { + 'type': 'FORWARD', + 'message': { + 'id': '103', + 'time': 5000, + 'type': 'CHANNEL', + 'text': caption, + 'attaches': [ + {'_type': 'PHOTO', 'photoId': 13, 'width': 200, 'height': 200}, + ], + 'elements': [ + {'type': 'HEADING', 'length': 4}, + ], + }, + 'chatId': -30, + 'chatName': 'Another Example Channel', + }, + }); + final message = CachedMessage( + id: '202', + accountId: 1, + chatId: 2, + senderId: 42, + time: 6000, + attachments: [attachment], + ); + ForwardedMessageAttachment? tappedSource; + + await _pumpBubble( + tester, + message, + onSourceTap: (forwarded) => tappedSource = forwarded, + ); + + expect(find.text('Another Example Channel'), findsOneWidget); + expect(find.text(caption), findsOneWidget); + final formatted = tester.widget( + find.byType(FormattedMessageText), + ); + expect(formatted.ranges.single.format, TextFormat.heading); + expect(find.text('0'), findsNothing); + expect( + tester.getSize(find.byType(ForwardedHeader)).width, + closeTo(200, 0.1), + ); + expect( + tester.getSize(find.byType(ForwardedHeader)).width, + closeTo(tester.getSize(find.byType(PhotoBubble)).width, 0.1), + ); + expect( + tester.getBottomLeft(find.byType(ClipRRect).first).dy, + lessThanOrEqualTo(tester.getTopLeft(find.text(caption)).dy), + ); + + await tester.tap(find.text('Another Example Channel')); + expect(tappedSource, same(attachment)); + expect(tappedSource?.isChannel, isTrue); + }); + + testWidgets('renders a forwarded video with its original metadata', ( + tester, + ) async { + final attachment = ForwardedMessageAttachment.fromMap({ + 'link': { + 'type': 'FORWARD', + 'message': { + 'id': 'synthetic-source-message', + 'time': 9000, + 'type': 'USER', + 'sender': 44, + 'text': 'Synthetic video caption', + 'attaches': [ + { + '_type': 'VIDEO', + 'videoId': 7001, + 'token': 'synthetic-video-token', + 'videoType': 0, + 'duration': 9000, + 'width': 720, + 'height': 1280, + }, + ], + }, + 'chatId': -50, + }, + }); + final video = attachment.originalAttachments!.single as VideoAttachment; + final message = CachedMessage( + id: 'synthetic-forward-message', + accountId: 1, + chatId: 2, + senderId: 43, + time: 10000, + attachments: [attachment], + ); + + expect(video.videoId, 7001); + expect(video.videoToken, 'synthetic-video-token'); + expect(video.videoType, 0); + + await _pumpBubble(tester, message); + + expect(find.byType(ForwardedHeader), findsOneWidget); + expect(find.byType(VideoBubble), findsOneWidget); + expect(find.text('Synthetic video caption'), findsOneWidget); + expect(find.text('0:09'), findsOneWidget); + final forwardedWidth = tester.getSize(find.byType(ForwardedHeader)).width; + final videoWidth = tester.getSize(find.byType(VideoBubble)).width; + expect(forwardedWidth, closeTo(BubbleContext.photoMaxSize, 0.1)); + expect(forwardedWidth, closeTo(videoWidth, 0.1)); + final preview = find.descendant( + of: find.byType(VideoBubble), + matching: find.byType(ClipRRect), + ); + expect( + tester.getBottomLeft(preview.first).dy, + lessThanOrEqualTo( + tester.getTopLeft(find.text('Synthetic video caption')).dy, + ), + ); + final bubble = tester.widget(find.byType(VideoBubble)); + expect(bubble.ctx.sourceMessageId, 'synthetic-source-message'); + expect(bubble.ctx.sourceChatId, -50); + final forwardedVideoSize = tester.getSize(find.byType(VideoBubble)); + final forwardedPreviewSize = tester.getSize(preview.first); + + final regularMessage = CachedMessage( + id: 'synthetic-regular-message', + accountId: 1, + chatId: 2, + senderId: 43, + time: 10000, + text: 'Synthetic video caption', + attachments: [video], + ); + await _pumpBubble(tester, regularMessage); + + final regularPreview = find.descendant( + of: find.byType(VideoBubble), + matching: find.byType(ClipRRect), + ); + expect(tester.getSize(find.byType(VideoBubble)), forwardedVideoSize); + expect(tester.getSize(regularPreview.first), forwardedPreviewSize); + expect(find.byType(ForwardedHeader), findsNothing); + }); + + final nativeAttachmentCases = + <({String name, MessageAttachment attachment, Type bubbleType})>[ + ( + name: 'file', + attachment: const FileAttachment( + fileId: 7101, + name: 'synthetic.txt', + size: 128, + ), + bubbleType: FileBubble, + ), + ( + name: 'sticker', + attachment: const StickerAttachment( + stickerId: 'synthetic-sticker', + width: 128, + height: 128, + ), + bubbleType: StickerBubble, + ), + ( + name: 'location', + attachment: const LocationAttachment( + latitude: 1, + longitude: 2, + title: 'Synthetic location', + ), + bubbleType: LocationBubble, + ), + ( + name: 'call', + attachment: const CallAttachment(isVideo: false, durationMs: 1000), + bubbleType: CallBubble, + ), + ( + name: 'share', + attachment: const ShareAttachment( + shareId: 7102, + title: 'Synthetic preview', + url: 'https://example.test/synthetic', + ), + bubbleType: ShareBubble, + ), + ( + name: 'audio', + attachment: const AudioAttachment( + audioId: 7103, + duration: 1000, + baseUrl: 'https://example.test/synthetic.ogg', + ), + bubbleType: VoiceMessageBubble, + ), + ( + name: 'video note', + attachment: const VideoAttachment( + videoId: 7104, + videoToken: 'synthetic-note-token', + videoType: 1, + duration: 1000, + width: 480, + height: 480, + ), + bubbleType: VideoNoteBubble, + ), + ]; + + for (final item in nativeAttachmentCases) { + testWidgets('decorates forwarded ${item.name} native bubble', ( + tester, + ) async { + final forwarded = ForwardedMessageAttachment( + originalSenderId: 71, + originalMessageId: 'synthetic-source-${item.name}', + originalChatId: 72, + originalText: item.name == 'share' + ? 'Synthetic forwarded caption' + : null, + originalAttachments: [item.attachment], + ); + final message = CachedMessage( + id: 'synthetic-forward-${item.name}', + accountId: 1, + chatId: 2, + senderId: 70, + time: 11000, + text: 'Synthetic outer text', + attachments: [forwarded], + ); + + await _pumpBubble(tester, message, isMe: item.name == 'audio'); + + expect(find.byType(ForwardedHeader), findsOneWidget); + expect(find.byType(item.bubbleType), findsOneWidget); + final usesFloatingHeader = + item.name == 'sticker' || item.name == 'video note'; + if (usesFloatingHeader) { + expect(find.byType(ForwardedHeaderFloating), findsOneWidget); + expect( + tester.getRect(find.byType(ForwardedHeaderFloating)).bottom, + lessThanOrEqualTo(tester.getRect(find.byType(item.bubbleType)).top), + ); + } else { + expect(find.byType(ForwardedHeaderFloating), findsNothing); + } + if (item.name == 'audio') { + final headerRect = tester.getRect(find.byType(ForwardedHeader)); + final voiceRect = tester.getRect(find.byType(VoiceMessageBubble)); + expect(voiceRect.left - headerRect.left, closeTo(14, 0.1)); + expect(headerRect.right - voiceRect.right, closeTo(14, 0.1)); + expect( + find.descendant( + of: find.byType(VoiceMessageBubble), + matching: find.byIcon(Symbols.check), + ), + findsOneWidget, + ); + } + if (item.name == 'share') { + expect(find.text('Synthetic forwarded caption'), findsOneWidget); + expect(find.text('Synthetic outer text'), findsNothing); + } + }); + } + + testWidgets('decorates the native forwarded contact bubble', ( + tester, + ) async { + const forwarded = ForwardedMessageAttachment( + originalSenderId: 73, + originalMessageId: 'synthetic-contact-source', + originalChatId: 74, + originalContact: ContactAttachment( + firstName: 'Synthetic', + lastName: 'Contact', + phoneNumber: '+10000000000', + ), + ); + const message = CachedMessage( + id: 'synthetic-contact-forward', + accountId: 1, + chatId: 2, + senderId: 70, + time: 12000, + attachments: [forwarded], + ); + + await _pumpBubble(tester, message); + + expect(find.byType(ForwardedHeader), findsOneWidget); + expect(find.byType(ContactBubble), findsOneWidget); + }); + + testWidgets('makes the forwarded user name clickable', (tester) async { + const attachment = ForwardedMessageAttachment( + originalSenderId: 42, + originalSenderName: 'Example Person', + originalType: 'USER', + originalMessageId: '104', + originalTime: 7000, + originalText: 'Synthetic user message', + originalChatId: -40, + originalFormatRanges: [ + FormatRange(format: TextFormat.strong, start: 0, length: 9), + ], + ); + const message = CachedMessage( + id: '203', + accountId: 1, + chatId: 2, + senderId: 43, + time: 8000, + attachments: [attachment], + ); + ForwardedMessageAttachment? tappedSource; + + await _pumpBubble( + tester, + message, + onSourceTap: (forwarded) => tappedSource = forwarded, + ); + expect(find.byType(FormattedMessageText), findsOneWidget); + await tester.tap(find.text('Example Person')); + + expect(tappedSource, same(attachment)); + expect(tappedSource?.originalSenderId, 42); + }); + }); +} diff --git a/test/informer_banner_tile_test.dart b/test/informer_banner_tile_test.dart new file mode 100644 index 0000000..19e9ddd --- /dev/null +++ b/test/informer_banner_tile_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/widgets/informer_banner_tile.dart'; +import 'package:komet/models/animoji.dart'; +import 'package:komet/models/informer_banner.dart'; + +const _banner = InformerBanner( + id: 'synthetic-banner', + title: 'Synthetic title', + description: 'Synthetic description', + settings: BannerSettings.textAnimation, + type: BannerType.link, + url: 'https://example.invalid/synthetic', +); + +Widget _app(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + testWidgets('renders the banner content and reports presentation once', ( + tester, + ) async { + var presentations = 0; + + await tester.pumpWidget( + _app( + InformerBannerTile( + banner: _banner, + onPresented: (_) => presentations++, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Synthetic title'), findsOneWidget); + expect(find.text('Synthetic description'), findsOneWidget); + expect( + find.byKey(const ValueKey('informer-banner-fallback-icon')), + findsOneWidget, + ); + expect(presentations, 1); + + await tester.pumpWidget( + _app( + InformerBannerTile( + banner: _banner, + onPresented: (_) => presentations++, + ), + ), + ); + await tester.pump(); + + expect(presentations, 1); + }); + + testWidgets('handles body and close actions independently', (tester) async { + var taps = 0; + var closes = 0; + + await tester.pumpWidget( + _app( + InformerBannerTile( + banner: _banner, + onTap: () => taps++, + onClose: () => closes++, + ), + ), + ); + await tester.pump(); + + await tester.tap( + find.byKey(const ValueKey('informer-banner-synthetic-banner')), + ); + await tester.pump(); + expect(taps, 1); + expect(closes, 0); + + await tester.tap( + find.byKey(const ValueKey('informer-banner-close-synthetic-banner')), + ); + await tester.pump(); + expect(taps, 1); + expect(closes, 1); + }); + + testWidgets('honors close visibility and resolves the configured animoji', ( + tester, + ) async { + int? requestedId; + const banner = InformerBanner( + id: 'synthetic-themed-banner', + title: 'Synthetic themed title', + settings: BannerSettings.hideCloseButton | BannerSettings.iconThemeColor, + animojiId: 42, + ); + + await tester.pumpWidget( + _app( + InformerBannerTile( + banner: banner, + animojiLoader: (id) async { + requestedId = id; + return const Animoji(id: 42, emoji: '🧪'); + }, + ), + ), + ); + await tester.pump(); + + expect(requestedId, 42); + expect( + find.byKey(const ValueKey('informer-banner-animoji')), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('informer-banner-close-synthetic-themed-banner'), + ), + findsNothing, + ); + expect(find.byType(ColorFiltered), findsOneWidget); + }); +} diff --git a/test/inline_keyboard_button_test.dart b/test/inline_keyboard_button_test.dart new file mode 100644 index 0000000..f0eeeca --- /dev/null +++ b/test/inline_keyboard_button_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +const int _me = 1; + +CachedMessage _withButton(Map button) => + CachedMessage.fromPushPayload(_me, 2, { + 'id': '7007', + 'time': DateTime(2026, 1, 1, 12, 30).millisecondsSinceEpoch, + 'type': 'USER', + 'sender': 2, + 'text': 'Ваш код', + 'attaches': [ + { + '_type': 'INLINE_KEYBOARD', + 'keyboard': { + 'buttons': [ + [button], + ], + }, + }, + ], + }); + +Future _pump(WidgetTester tester, CachedMessage message) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: MessageBubble( + message: message, + isMe: false, + myId: _me, + chatType: 'DIALOG', + ), + ), + ), + ), + ); + await tester.pump(); +} + +double _horizontalDrift(WidgetTester tester, String label) { + final button = tester.getRect( + find.ancestor(of: find.text(label), matching: find.byType(InkWell)).first, + ); + final text = tester.getRect(find.text(label)); + return text.center.dx - button.center.dx; +} + +void main() { + testWidgets('a clipboard button keeps its label centred', (tester) async { + await _pump(tester, _withButton({ + 'type': 'CLIPBOARD', + 'text': 'Копировать', + 'payload': '123456', + })); + + expect(find.text('Копировать'), findsOneWidget); + expect(_horizontalDrift(tester, 'Копировать').abs(), lessThan(0.5)); + }); + + testWidgets('a plain callback button keeps its label centred', ( + tester, + ) async { + await _pump(tester, _withButton({ + 'type': 'CALLBACK', + 'text': 'Продолжить', + 'payload': 'go', + })); + + expect(_horizontalDrift(tester, 'Продолжить').abs(), lessThan(0.5)); + }); +} diff --git a/test/login_info_test.dart b/test/login_info_test.dart new file mode 100644 index 0000000..49ee826 --- /dev/null +++ b/test/login_info_test.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/models/login_info.dart'; + +void main() { + group('LoginInfo', () { + test('extracts profile, packet, chat, and config data', () { + final info = LoginInfo.fromPayload({ + 'profile': { + 'contact': { + 'id': 100200300, + 'updateTime': 1700000002000, + 'registrationTime': 1700000001000, + 'baseUrl': 'https://example.invalid/avatar', + 'baseRawUrl': 'https://example.invalid/avatar/raw', + 'photoId': 400500600, + 'phone': 70000000000, + 'names': [ + { + 'name': 'Тест Пользователь', + 'firstName': 'Тест', + 'lastName': 'Пользователь', + 'type': 'SYNTHETIC', + }, + ], + 'options': ['SYNTHETIC'], + 'accountStatus': 2, + 'country': 'ZZ', + }, + 'profileOptions': [7], + }, + 'chats': [ + { + 'id': -1001, + 'type': 'CHAT', + 'status': 'ACTIVE', + 'lastEventTime': 1700000008000, + 'newMessages': 3, + 'messagesCount': 12, + }, + { + 'id': -1002, + 'type': 'CHANNEL', + 'status': 'HIDDEN', + 'lastEventTime': 1700000007000, + }, + { + 'id': 1003, + 'type': 'DIALOG', + 'status': 'ACTIVE', + 'lastEventTime': 1700000006000, + }, + ], + 'chatMarker': 1700000009000, + 'contacts': [ + {'id': 2001}, + {'id': 2002}, + ], + 'presence': {'synthetic-peer': 1700000003000}, + 'messages': {'synthetic-message': {}}, + 'config': { + 'hash': 'synthetic-config-hash', + 'server': { + 'known-flag': true, + 'nested': {'limit': 4}, + }, + 'user': {'SYNTHETIC_SETTING': 'ON'}, + 'chats': { + 'synthetic-chat': {'sound': false}, + }, + 'experiments': { + 'synthetic-experiment': {'enabled': true}, + }, + }, + 'videoChatHistory': true, + 'time': 1700000010000, + 'updates': 4, + }); + + expect(info['id'], 100200300); + expect(info['phone'], 70000000000); + expect(info['photoId'], 400500600); + expect(info['accountStatus'], 2); + expect(info['country'], 'ZZ'); + expect(info['profileOptions'], [7]); + expect(info['contactOptions'], ['SYNTHETIC']); + expect(info['chatMarker'], 1700000009000); + expect(info['contactsCount'], 2); + expect(info['presenceCount'], 1); + expect(info['messagesCount'], 1); + expect(info['configHash'], 'synthetic-config-hash'); + expect(info['chats'], { + 'count': 3, + 'active': 2, + 'hidden': 1, + 'dialogs': 1, + 'groups': 1, + 'channels': 1, + 'unread': 1, + 'newMessages': 3, + 'messages': 12, + }); + expect(info['server'], { + 'known-flag': true, + 'nested': {'limit': 4}, + }); + expect(info['user'], {'SYNTHETIC_SETTING': 'ON'}); + expect(info['chatSettings'], { + 'synthetic-chat': {'sound': false}, + }); + expect(info['experiments'], { + 'synthetic-experiment': {'enabled': true}, + }); + expect(() => jsonEncode(info), returnsNormally); + }); + + test('falls back to latest chat event when marker is absent', () { + final info = LoginInfo.fromPayload({ + 'chats': [ + {'lastEventTime': 1700000004000}, + {'lastEventTime': 1700000006000}, + {'lastEventTime': 1700000005000}, + ], + }); + + expect(info['chatMarker'], 1700000006000); + expect(info['chats'], { + 'count': 3, + 'active': 0, + 'hidden': 0, + 'dialogs': 0, + 'groups': 0, + 'channels': 0, + 'unread': 0, + 'newMessages': 0, + 'messages': 0, + }); + }); + }); +} diff --git a/test/lottie_slash_icon_test.dart b/test/lottie_slash_icon_test.dart new file mode 100644 index 0000000..ca46c35 --- /dev/null +++ b/test/lottie_slash_icon_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/widgets/lottie_slash_icon.dart'; +import 'package:lottie/lottie.dart'; + +const _asset = 'assets/lottie/ic_flash_on_to_off.json'; + +Map _doc() => + jsonDecode(File(_asset).readAsStringSync()) as Map; + +List> _layers(Map doc) => + (doc['layers'] as List).cast>(); + +List> _maskFrames(Map layer) { + final masks = (layer['masksProperties'] as List).cast>(); + return ((masks.single['pt'] as Map)['k'] as List) + .cast>(); +} + +List> _quad(Map frame) => + ((((frame['s'] as List).first as Map)['v']) as List) + .map((point) => (point as List).cast()) + .toList(); + +Widget _host(bool slashed) => MaterialApp( + home: Scaffold( + body: Center( + child: LottieSlashIcon( + asset: _asset, + slashed: slashed, + color: const Color(0xFFFFFFFF), + ), + ), + ), +); + +void main() { + group('Ассет перечёркивания', () { + test('квадратный, из двух слоёв, каждый со своей маской', () { + final doc = _doc(); + expect(doc['w'], doc['h']); + expect(doc['op'], greaterThan(0)); + + final layers = _layers(doc); + expect(layers.length, 2); + for (final layer in layers) { + expect(_maskFrames(layer).length, 2, reason: 'маска должна ехать'); + final group = (layer['shapes'] as List).first as Map; + final paths = (group['it'] as List).cast>().where( + (item) => item['ty'] == 'sh', + ); + expect(paths, isNotEmpty); + for (final path in paths) { + expect( + (path['ks'] as Map)['a'], + 0, + reason: 'глифы статичны — двигается только маска', + ); + } + } + }); + + test('маски слоёв дополняют друг друга', () { + final layers = _layers(_doc()); + final slashed = _maskFrames(layers.first); + final plain = _maskFrames(layers.last); + + for (var frame = 0; frame < 2; frame++) { + final a = _quad(slashed[frame]); + final b = _quad(plain[frame]); + expect( + a.take(2), + b.take(2), + reason: 'обе маски должны делить одну и ту же диагональ', + ); + expect( + a.skip(2), + isNot(b.skip(2)), + reason: 'маски должны смотреть в разные стороны от диагонали', + ); + } + }); + + test('слои двигаются одинаково', () { + final layers = _layers(_doc()); + expect(layers.first['ks'], layers.last['ks']); + }); + + testWidgets('композиция читается lottie без предупреждений', ( + tester, + ) async { + final composition = await AssetLottie(_asset).load(); + expect(composition.warnings, isEmpty); + expect(composition.layers.length, 2); + }); + }); + + group('LottieSlashIcon', () { + testWidgets('рисует lottie в обоих состояниях', (tester) async { + await tester.pumpWidget(_host(false)); + await tester.pump(); + expect(find.byType(Lottie), findsOneWidget); + + await tester.pumpWidget(_host(true)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(Lottie), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(Lottie), findsOneWidget); + }); + + testWidgets('перечёркивание анимируется в обе стороны', (tester) async { + await tester.pumpWidget(_host(false)); + final controller = tester + .widget(find.byType(LottieBuilder)) + .controller!; + expect(controller.value, 0); + + await tester.pumpWidget(_host(true)); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.value, greaterThan(0)); + expect(controller.value, lessThan(1)); + + await tester.pump(const Duration(milliseconds: 400)); + expect(controller.value, 1); + + await tester.pumpWidget(_host(false)); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.value, lessThan(1)); + + await tester.pump(const Duration(milliseconds: 400)); + expect(controller.value, 0); + }); + }); +} diff --git a/test/markup_editor_test.dart b/test/markup_editor_test.dart new file mode 100644 index 0000000..d27c2f7 --- /dev/null +++ b/test/markup_editor_test.dart @@ -0,0 +1,73 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +void main() { + late Directory tmp; + late File source; + + setUpAll(() async { + tmp = Directory.systemTemp.createTempSync('komet_markup_test'); + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 8, 8), + Paint()..color = const Color(0xFF224466), + ); + final image = await recorder.endRecording().toImage(8, 8); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + source = File('${tmp.path}/source.png') + ..writeAsBytesSync(data!.buffer.asUint8List()); + }); + + tearDownAll(() => tmp.deleteSync(recursive: true)); + + testWidgets('пустая разметка закрывается без результата', (tester) async { + var previews = 0; + Object? popped = 'untouched'; + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (ctx) { + context = ctx; + return const Scaffold(); + }, + ), + ), + ); + + unawaited( + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => PhotoDrawEditor( + source: source, + imageWidth: 8, + imageHeight: 8, + onPreview: (_) async => previews++, + ), + ), + ) + .then((value) => popped = value), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Symbols.check)); + await tester.pumpAndSettle(); + + expect(previews, 0); + expect(popped, isNull); + }); +} diff --git a/test/max_link_test.dart b/test/max_link_test.dart new file mode 100644 index 0000000..ee8d5cd --- /dev/null +++ b/test/max_link_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/links/max_link.dart'; +import 'package:komet/frontend/widgets/link_text.dart'; + +void main() { + group('linkPattern', () { + List matches(String text) => + linkPattern.allMatches(text).map((m) => m.group(0)!).toList(); + + test('picks up a bare max.ru link inside plain text', () { + expect( + matches('Ваша ссылка 👇\nmax.ru/id100000000001_bot?start=abc123\n'), + ['max.ru/id100000000001_bot?start=abc123'], + ); + expect(matches('зайди на max.ru и посмотри'), ['max.ru']); + }); + + test('still picks up schemed and www links', () { + expect(matches('https://max.ru/somebot?start=x'), [ + 'https://max.ru/somebot?start=x', + ]); + expect(matches('www.max.ru/somebot'), ['www.max.ru/somebot']); + }); + + test('does not match look-alike hosts or emails', () { + expect(matches('evil.max.ru/phish'), isEmpty); + expect(matches('max.ru.evil.com/phish'), isEmpty); + expect(matches('bot@max.ru'), isEmpty); + expect(matches('max.rules/somebot'), isEmpty); + }); + + test('linkTarget adds a scheme only when missing', () { + expect(linkTarget('max.ru/somebot'), 'https://max.ru/somebot'); + expect(linkTarget('www.max.ru/somebot'), 'https://www.max.ru/somebot'); + expect(linkTarget('http://max.ru/somebot'), 'http://max.ru/somebot'); + expect(linkTarget('https://max.ru/somebot'), 'https://max.ru/somebot'); + }); + }); + + group('MaxLink.parse — не наши ссылки', () { + test('rejects other hosts and non-links', () { + expect(MaxLink.parse('https://example.com/somebot'), isNull); + expect(MaxLink.parse('evil.max.ru/phish'), isNull); + expect(MaxLink.parse('mailto:bot@max.ru'), isNull); + expect(MaxLink.parse(''), isNull); + }); + + test('rejects reserved site pages', () { + expect(MaxLink.parse('https://max.ru/login'), isNull); + expect(MaxLink.parse('https://max.ru/tos'), isNull); + }); + }); + + group('MaxLink.parse — контентные ссылки', () { + test('bare host, http and www all normalize to the root link', () { + expect(MaxLink.parse('max.ru'), isA()); + expect(MaxLink.parse('http://max.ru/'), isA()); + expect(MaxLink.parse('https://www.max.ru'), isA()); + expect(MaxLink.parse('max://max.ru/'), isA()); + }); + + test('public link keeps the canonical https url', () { + final link = MaxLink.parse('max.ru/somebot') as MaxContentLink; + + expect(link.kind, MaxContentKind.public); + expect(link.url, 'https://max.ru/somebot'); + expect(link.baseUrl, 'https://max.ru/somebot'); + expect(link.startPayload, isNull); + expect(link.messageId, isNull); + }); + + test('@nickname is a public link too', () { + final link = MaxLink.parse('https://max.ru/@somebot') as MaxContentLink; + + expect(link.kind, MaxContentKind.public); + expect(link.baseUrl, 'https://max.ru/somebot'); + }); + + test('bot start payload is parsed and the base url drops it', () { + final link = + MaxLink.parse('http://max.ru/id100000000001_bot?start=a%20b') + as MaxContentLink; + + expect(link.startPayload, 'a b'); + expect(link.url, 'https://max.ru/id100000000001_bot?start=a%20b'); + expect(link.baseUrl, 'https://max.ru/id100000000001_bot'); + }); + + test('empty start payload is ignored', () { + final link = MaxLink.parse('max.ru/somebot?start=') as MaxContentLink; + expect(link.startPayload, isNull); + }); + + test('startapp opens a mini app and is cut at the first &', () { + final link = + MaxLink.parse('max.ru/somebot?startapp=deal%2F42&ref=x') + as MaxWebAppLink; + + expect(link.startApp, 'deal/42'); + }); + + test('message links carry the message id', () { + final byName = + MaxLink.parse('max.ru/somechannel/900000000000000001') + as MaxContentLink; + expect(byName.kind, MaxContentKind.public); + expect(byName.messageId, 900000000000000001); + expect(byName.baseUrl, 'https://max.ru/somechannel'); + + final byId = + MaxLink.parse('max.ru/c/424242/900000000000000001') + as MaxContentLink; + expect(byId.kind, MaxContentKind.content); + expect(byId.messageId, 900000000000000001); + expect(byId.baseUrl, 'https://max.ru/c/424242'); + }); + + test('invite, call and sticker links keep their own types', () { + expect( + (MaxLink.parse('max.ru/join/AbCdEf') as MaxContentLink).kind, + MaxContentKind.invite, + ); + expect(MaxLink.parse('max.ru/joincall/AbCdEf'), isA()); + expect( + (MaxLink.parse('max.ru/stickerset/512-abc') as MaxStickerSetLink).path, + 'stickerset/512-abc', + ); + }); + + test('uid and cid open a contact and a chat', () { + expect( + (MaxLink.parse('max://max.ru/?uid=434343') as MaxContactIdLink) + .userId, + 434343, + ); + final chat = MaxLink.parse('max://max.ru/?cid=424242') as MaxChatIdLink; + expect(chat.chatId, 424242); + expect(chat.messageId, isNull); + }); + }); + + group('MaxLink.parse — внутренние маршруты', () { + test('auth keeps the full url with its token', () { + final link = MaxLink.parse('https://max.ru/:auth/tok3n') as MaxAuthLink; + expect(link.url, 'https://max.ru/:auth/tok3n'); + }); + + test('share and share-self-out are separate targets', () { + final share = + MaxLink.parse('https://max.ru/:share?text=%D0%BF%D1%80%D0%B8%D0%B2') + as MaxShareTextLink; + expect(share.text, 'прив'); + expect( + MaxLink.parse('https://max.ru/:share-self-out'), + isA(), + ); + }); + + test('folder needs an id, otherwise it stays a plain route', () { + expect( + (MaxLink.parse('max.ru/:folder?id=42') as MaxFolderLink).folderId, + '42', + ); + expect(MaxLink.parse('max.ru/:folder'), isA()); + }); + + test('current is a no-op target', () { + expect(MaxLink.parse('max.ru/:current'), isA()); + }); + + test('other routes keep their path and query params', () { + final route = + MaxLink.parse('max://max.ru/:profile?id=123&type=CHAT') + as MaxRouteLink; + + expect(route.route, ':profile'); + expect(route.params, {'id': '123', 'type': 'CHAT'}); + + final nested = + MaxLink.parse('https://max.ru/:Settings/Appearance') as MaxRouteLink; + expect(nested.route, ':settings/appearance'); + }); + }); +} diff --git a/test/max_web_protocol_test.dart b/test/max_web_protocol_test.dart new file mode 100644 index 0000000..09c1e5e --- /dev/null +++ b/test/max_web_protocol_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/webpush/max_web_protocol.dart'; + +Uint8List _hex(String value) { + final bytes = Uint8List(value.length ~/ 2); + for (var i = 0; i < bytes.length; i++) { + bytes[i] = int.parse(value.substring(i * 2, i * 2 + 2), radix: 16); + } + return bytes; +} + +const _responseHex = '0a0100000006010000caf0b985ad7765622d7077612d70726f6d6fc3b270686f6e652d617574682d656e61626c6564c3a86c6f636174696f6ea25255a46c616e67c3b07265672d636f756e7472792d636f6465dc002aa2415aa2414da24b5aa24b47a24d44a2544aa2555aa24745a25448a25452a2544da24145a24c41a24d59a24944a24355a24b48a2564ea24146a2424fa24344a24347a2434fa24744a2474da2494ea24951a24b4ea24b57a24c42a24d4da24e49a2504ba25057a25141a25341a25645a2545aa24547a2434ea25a41a24252'; +const _requestHex = '0a00000000060000015482a9757365724167656e748baa64657669636554797065a3574542ae7075736844657669636554797065a757454250555348a66c6f63616c65a27275ac6465766963654c6f63616c65a27275a96f7356657273696f6ea56d61634f53aa6465766963654e616d65a6536166617269af686561646572557365724167656e74d9754d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031305f31355f3729204170706c655765624b69742f3630352e312e313520284b48544d4c2c206c696b65204765636b6f292056657273696f6e2f31382e35205361666172692f3630352e312e3135a56973507761c3aa61707056657273696f6ea732362e362e3230a673637265656eac3935367834343020332e3078a874696d657a6f6e65ad4575726f70652f4d6f73636f77a86465766963654964b06b6f6d65742d636f6465632d74657374'; + +void main() { + test('заголовок кадра совпадает с эталоном сервера', () { + final frame = _hex(_responseHex); + expect(frame[0], MaxWebFraming.protocolVersion); + final decoded = MaxWebFraming.decode(frame); + expect(decoded.cmd, MaxWebCmd.ok); + expect(decoded.opcode, 6); + expect(decoded.isOk, isTrue); + }); + + test('распаковка LZ4 и msgpack на живом ответе сервера', () { + final decoded = MaxWebFraming.decode(_hex(_responseHex)); + final payload = decoded.payload as Map; + expect(payload['location'], 'RU'); + expect(payload['web-pwa-promo'], isTrue); + expect(payload['reg-country-code'], isA>()); + expect(payload.length, 5); + }); + + test('кодирование кадра байт в байт как у веб-клиента', () { + final expected = _hex(_requestHex); + final payload = jsonDecode(_requestPayloadJson) as Map; + final actual = MaxWebFraming.encode(cmd: 0, seq: 0, opcode: 6, payload: payload); + expect(actual.length, expected.length); + expect(actual.sublist(0, MaxWebFraming.headerSize), + expected.sublist(0, MaxWebFraming.headerSize)); + }); + + test('msgpack переживает круговой рейс', () { + final source = { + 'subscribe': true, + 'pushToken': 'https://web.push.apple.com/AAA-bbb_ccc', + 'secretKey': 'z2sMRx0MgXELERMrtCcK_Q', + 'publicKey': 'BOgrn-9cRlyU4jnyxQROVWWrTgpof_3T9UAO6DR6QkNvIbexyAQSEz4J5BBM6VQY6hZklsMq06aSK1oCzF5A644', + 'chatsCount': 40, + 'presenceSync': -1, + 'nested': {'a': null, 'b': 3.5, 'c': [1, 'два', false]}, + }; + final restored = MaxMsgpack.decode(MaxMsgpack.encode(source)) as Map; + expect(restored['subscribe'], true); + expect(restored['pushToken'], source['pushToken']); + expect(restored['chatsCount'], 40); + expect(restored['presenceSync'], -1); + final nested = restored['nested'] as Map; + expect(nested['a'], isNull); + expect(nested['b'], 3.5); + expect((nested['c'] as List)[1], 'два'); + }); + + test('ext(1) разворачивается во вложенное число', () { + final pollingInterval = MaxMsgpack.decode( + Uint8List.fromList([0x81, 0xA8, 0x69, 0x6E, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6C, + 0xC7, 0x03, 0x01, 0xD1, 0x13, 0x88]), + ) as Map; + expect(pollingInterval['interval'], 5000); + + final expiresAt = MaxMsgpack.decode( + Uint8List.fromList([0xC7, 0x09, 0x01, 0xD3, 0x00, 0x00, 0x01, 0xA0, 0x25, + 0x5B, 0xE9, 0xD2]), + ); + expect(expiresAt, 1787333175762); + }); + + test('LZ4 разворачивает перекрывающиеся совпадения', () { + final compressed = Uint8List.fromList([0x6E, 0x6B, 0x6F, 0x6D, 0x65, 0x74, 0x20, 0x06, 0x00, 0x46, 0x70, 0x75, 0x73, 0x68, 0x05, 0x00, 0x50, 0x20, 0x70, 0x75, 0x73, 0x68]); + final result = Lz4Block.decompress(compressed, 256); + expect(utf8.decode(result), 'komet komet komet komet push push push push'); + }); +} + +const _requestPayloadJson = r'''{"userAgent": {"deviceType": "WEB", "pushDeviceType": "WEBPUSH", "locale": "ru", "deviceLocale": "ru", "osVersion": "macOS", "deviceName": "Safari", "headerUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", "isPwa": true, "appVersion": "26.6.20", "screen": "956x440 3.0x", "timezone": "Europe/Moscow"}, "deviceId": "komet-codec-test"}'''; diff --git a/test/media_viewer_video_test.dart b/test/media_viewer_video_test.dart new file mode 100644 index 0000000..eecfb3e --- /dev/null +++ b/test/media_viewer_video_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/liquid_glass.dart'; +import 'package:komet/frontend/widgets/photo_viewer.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +const _video = VideoAttachment( + videoId: 42, + videoToken: 'synthetic-token', + duration: 12000, + width: 1280, + height: 720, +); + +final _message = CachedMessage( + id: 'synthetic-message', + accountId: 1, + chatId: 2, + senderId: 3, + text: 'Синтетическая подпись', + time: DateTime(2026, 1, 2, 12, 34).millisecondsSinceEpoch, + attachments: const [_video], +); + +Future _pumpVideo( + WidgetTester tester, { + PhotoViewerActions? actions, +}) async { + tester.view.physicalSize = const Size(1200, 1800); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: PhotoViewerScreen.video( + attachment: _video, + initialVideoSources: const { + '720p': 'https://media.example.test/video-720.mp4', + '360p': 'https://media.example.test/video-360.mp4', + }, + message: _message, + actions: actions, + sourceName: 'Тестовый чат', + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); +} + +void main() { + testWidgets('video uses the shared media chrome and advanced controls', ( + tester, + ) async { + await _pumpVideo(tester); + + expect(find.text('1 из 1'), findsOneWidget); + expect(find.textContaining('Тестовый чат'), findsOneWidget); + expect(find.text('00:00'), findsOneWidget); + expect(find.text('00:12'), findsOneWidget); + expect(find.byKey(const ValueKey('video-play-toggle')), findsOneWidget); + expect(find.byKey(const ValueKey('video-settings')), findsOneWidget); + expect(find.byKey(const ValueKey('downloads-button')), findsNothing); + expect(find.byIcon(Symbols.rotate_90_degrees_ccw), findsOneWidget); + expect(find.byIcon(Symbols.download), findsNothing); + expect(find.byType(GlassSurface), findsOneWidget); + expect(find.text('Синтетическая подпись'), findsOneWidget); + + final playCenter = tester.getCenter( + find.byKey(const ValueKey('video-play-toggle')), + ); + expect(playCenter.dx, closeTo(tester.view.physicalSize.width / 4, 0.1)); + }); + + testWidgets('video rotates left inside the shared viewer', (tester) async { + await _pumpVideo(tester); + + RotatedBox rotation() => + tester.widget(find.byKey(const ValueKey('video-rotation'))); + + expect(rotation().quarterTurns, 0); + await tester.tap(find.byIcon(Symbols.rotate_90_degrees_ccw)); + await tester.pump(); + expect(rotation().quarterTurns, 3); + }); + + testWidgets('settings contain playback speed and available qualities', ( + tester, + ) async { + await _pumpVideo(tester); + + await tester.tap(find.byKey(const ValueKey('video-settings'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Скорость'), findsOneWidget); + expect(find.text('0.5x'), findsOneWidget); + expect(find.text('1.0x'), findsOneWidget); + expect(find.text('2x'), findsOneWidget); + expect(find.text('Качество'), findsOneWidget); + expect(find.text('720p'), findsOneWidget); + expect(find.text('360p'), findsOneWidget); + }); + + testWidgets('video menu reuses media actions without frame sharing', ( + tester, + ) async { + await _pumpVideo( + tester, + actions: PhotoViewerActions( + goToMessage: (_, _) {}, + forward: (_) {}, + delete: (_, _) {}, + viewAllMedia: () {}, + ), + ); + + await tester.tap(find.byIcon(Symbols.more_vert)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Перейти к сообщению'), findsOneWidget); + expect(find.text('Переслать'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + expect(find.text('Сохранить как…'), findsOneWidget); + expect(find.text('Все медиа чата'), findsOneWidget); + expect(find.textContaining('Share at'), findsNothing); + expect(find.textContaining('Copy Frame'), findsNothing); + }); +} diff --git a/test/mention_test.dart b/test/mention_test.dart new file mode 100644 index 0000000..a586b53 --- /dev/null +++ b/test/mention_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:komet/core/utils/text_format.dart'; +import 'package:komet/models/contact_info.dart'; +import 'package:komet/frontend/screens/chats/chat/mention_panel_controller.dart'; +import 'package:komet/frontend/widgets/rich_message_controller.dart'; + +void main() { + group('mentionQueryAt', () { + test('detects a bare @ at the start', () { + final q = mentionQueryAt('@', 1)!; + expect(q.start, 0); + expect(q.end, 1); + expect(q.text, ''); + }); + + test('detects a query after a space', () { + final q = mentionQueryAt('hi @ал', 6)!; + expect(q.start, 3); + expect(q.end, 6); + expect(q.text, 'ал'); + }); + + test('ignores an @ glued to a preceding word', () { + expect(mentionQueryAt('mail@ya', 7), isNull); + }); + + test('ignores a token that already contains a space', () { + expect(mentionQueryAt('@ал ексей', 9), isNull); + }); + + test('ignores text without an @ before the caret', () { + expect(mentionQueryAt('привет', 6), isNull); + }); + }); + + group('RichMessageController mentions', () { + test('insertMention replaces the token and emits USER_MENTION', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: '@ал', + selection: TextSelection.collapsed(offset: 3), + ); + final query = mentionQueryAt(c.text, 3)!; + c.insertMention( + userId: 555001, + name: 'Пётр Синицын', + start: query.start, + end: query.end, + ); + c.value = TextEditingValue( + text: '${c.text}test', + selection: TextSelection.collapsed(offset: c.text.length + 4), + ); + + final content = c.buildContent(); + expect(content.text, 'Пётр Синицын test'); + expect(content.elements, [ + {'type': 'USER_MENTION', 'from': 0, 'length': 12, 'entityId': 555001}, + ]); + }); + + test('editing inside a mention drops it', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: '@a', + selection: TextSelection.collapsed(offset: 2), + ); + c.insertMention(userId: 42, name: 'Иван', start: 0, end: 2); + expect(c.buildContent().elements, hasLength(1)); + + c.value = const TextEditingValue( + text: 'Ив ', + selection: TextSelection.collapsed(offset: 2), + ); + expect(c.buildContent().elements, isEmpty); + }); + + test('text typed before a mention shifts its offset', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: '@a', + selection: TextSelection.collapsed(offset: 2), + ); + c.insertMention(userId: 42, name: 'Иван', start: 0, end: 2); + c.value = const TextEditingValue( + text: 'эй, Иван ', + selection: TextSelection.collapsed(offset: 4), + ); + + final element = c.buildContent().elements.single; + expect(element['from'], 4); + expect(element['length'], 4); + expect(element['entityId'], 42); + }); + + test('setFormatRanges restores mentions for editing', () { + final c = RichMessageController(text: 'Иван привет'); + c.setFormatRanges(const [ + FormatRange( + format: TextFormat.userMention, + start: 0, + length: 4, + entityId: 42, + ), + ]); + + expect(c.buildContent().elements, [ + {'type': 'USER_MENTION', 'from': 0, 'length': 4, 'entityId': 42}, + ]); + }); + }); + + group('ContactInfo names', () { + ContactInfo info(List> names) => + ContactInfo.fromMap({'id': 1, 'names': names}); + + test('full name joins first and last, not the short name field', () { + final contact = info([ + { + 'name': 'Светлана', + 'firstName': 'Светлана', + 'lastName': 'Михайловна', + 'type': 'CUSTOM', + }, + { + 'name': 'Светлана', + 'firstName': 'Светлана', + 'lastName': '', + 'type': 'ONEME', + }, + ]); + + expect(contact.fullName, 'Светлана Михайловна'); + expect(contact.isSavedContact, isTrue); + }); + + test('a non-contact falls back to the ONEME name', () { + final contact = info([ + { + 'name': 'Пётр', + 'firstName': 'Пётр', + 'lastName': 'Синицын', + 'type': 'ONEME', + }, + ]); + + expect(contact.fullName, 'Пётр Синицын'); + expect(contact.isSavedContact, isFalse); + }); + + test('a custom name wins over the oneme one', () { + final contact = info([ + {'firstName': 'Лёша', 'lastName': 'сосед', 'type': 'CUSTOM'}, + {'firstName': 'Пётр', 'lastName': 'Синицын', 'type': 'ONEME'}, + ]); + + expect(contact.fullName, 'Лёша сосед'); + }); + }); + + group('parseFormatElements', () { + test('reads a server USER_MENTION without an explicit from', () { + final ranges = parseFormatElements([ + {'entityId': 555001, 'type': 'USER_MENTION', 'length': 12}, + ]); + expect(ranges.single.format, TextFormat.userMention); + expect(ranges.single.start, 0); + expect(ranges.single.length, 12); + expect(ranges.single.entityId, 555001); + }); + + test('segmentizeFormats carries the mention id onto its segment', () { + final segments = segmentizeFormats('Пётр Синицын test', const [ + FormatRange( + format: TextFormat.userMention, + start: 0, + length: 12, + entityId: 555001, + ), + ]); + expect(segments.first.mentionId, 555001); + expect(segments.last.mentionId, isNull); + }); + }); +} diff --git a/test/message_bubble_layout_test.dart b/test/message_bubble_layout_test.dart new file mode 100644 index 0000000..0312369 --- /dev/null +++ b/test/message_bubble_layout_test.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; + +const int _me = 1; +const int _peer = 7; +const double _photoWidth = 180; +const double _maxBubbleWidth = 324; + +CachedMessage _message({ + required String text, + bool withReply = false, + int senderId = _peer, + String replyText = 'Пётр Синицын написал очень длинный ответ', +}) => CachedMessage( + id: '1', + accountId: _me, + chatId: 2, + senderId: senderId, + text: text, + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', + payload: withReply + ? { + 'link': { + 'type': 'REPLY', + 'message': { + 'id': '9', + 'sender': _me, + 'text': replyText, + 'time': 0, + 'attaches': [], + }, + }, + } + : null, +); + +CachedMessage _photoReply() => CachedMessage( + id: '1', + accountId: _me, + chatId: 2, + senderId: _peer, + text: 'Вот те раз, не может быть', + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', + attachments: [ + PhotoAttachment( + baseUrl: 'https://example.com/synthetic.jpg', + width: _photoWidth.toInt(), + height: 240, + ), + ], + payload: { + 'link': { + 'type': 'REPLY', + 'message': { + 'id': '9', + 'sender': _me, + 'text': + 'Эта функция, она для «спамеров - скамеров» и «мутных - анонимов»', + 'time': 0, + 'attaches': [], + }, + }, + }, +); + +Future _pumpColumn( + WidgetTester tester, + List messages, { + required String chatType, + double textScale = 1, +}) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < messages.length; i++) + MessageBubble( + key: ValueKey('bubble$i'), + message: messages[i], + prevMessage: i > 0 ? messages[i - 1] : null, + nextMessage: i < messages.length - 1 ? messages[i + 1] : null, + isMe: false, + myId: _me, + chatType: chatType, + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); +} + +Rect _bubbleRect(WidgetTester tester, int index, String text) { + final label = find.descendant( + of: find.byKey(ValueKey('bubble$index')), + matching: find.textContaining(text, findRichText: true), + ); + final box = find.ancestor(of: label, matching: find.byType(Container)).first; + return tester.getTopLeft(box) & tester.getSize(box); +} + +Future _pumpBubble( + WidgetTester tester, + CachedMessage message, { + String chatType = 'CHAT', +}) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: MessageBubble( + message: message, + isMe: false, + myId: _me, + chatType: chatType, + ), + ), + ), + ), + ); + await tester.pump(); +} + +Rect _rectOf(WidgetTester tester, Finder finder) { + final size = tester.getSize(finder); + final topLeft = tester.getTopLeft(finder); + return topLeft & size; +} + +Rect _clockRect(WidgetTester tester) { + var rect = Rect.zero; + for (final element in find.textContaining('05:46').evaluate()) { + final box = element.renderObject! as RenderBox; + final candidate = box.localToGlobal(Offset.zero) & box.size; + if (candidate.right > rect.right) rect = candidate; + } + return rect; +} + +void main() { + setUp(() => ContactCache.put(_peer, 'Пётр Синицын')); + + testWidgets('a long sender name pushes the clock to the bubble edge', ( + tester, + ) async { + await _pumpBubble(tester, _message(text: 'нет')); + + final header = _rectOf(tester, find.text('Пётр Синицын')); + final clock = _clockRect(tester); + final body = _rectOf( + tester, + find.textContaining('нет', findRichText: true), + ); + + expect(header.width, greaterThan(body.width)); + expect(clock.right, closeTo(header.right, 1)); + }); + + testWidgets('a short reply quote fills the width the sender name opened up', ( + tester, + ) async { + await _pumpBubble( + tester, + _message(text: 'нет', withReply: true, replyText: 'ок'), + ); + + final header = _rectOf(tester, find.text('Пётр Синицын')); + final label = _rectOf(tester, find.text('Вы')); + final quote = _rectOf( + tester, + find + .ancestor(of: find.text('Вы'), matching: find.byType(Container)) + .first, + ); + final clock = _clockRect(tester); + + expect(quote.right, greaterThan(label.right)); + expect(quote.right, closeTo(header.right, 1)); + expect(clock.right, closeTo(header.right, 1)); + }); + + testWidgets('a long reply quote widens the bubble past its own text', ( + tester, + ) async { + await _pumpBubble(tester, _message(text: 'т')); + final withoutReply = _clockRect(tester).right; + + await _pumpBubble(tester, _message(text: 'т', withReply: true)); + + final header = _rectOf(tester, find.text('Пётр Синицын')); + final quote = _rectOf( + tester, + find + .ancestor(of: find.text('Вы'), matching: find.byType(Container)) + .first, + ); + final clock = _clockRect(tester); + + expect(clock.right, greaterThan(withoutReply + 40)); + expect(quote.right, greaterThan(header.right)); + expect(clock.right, closeTo(quote.right, 1)); + expect(quote.width, lessThanOrEqualTo(_maxBubbleWidth * 0.75 + 1)); + }); + + testWidgets('grouped bubbles keep the same gap with and without avatars', ( + tester, + ) async { + final stream = [ + for (var i = 0; i < 4; i++) + CachedMessage( + id: '$i', + accountId: _me, + chatId: 2, + senderId: 404, + text: 'm$i', + time: DateTime(2026, 1, 1, 12, 54).millisecondsSinceEpoch + i * 1000, + status: 'sent', + ), + ]; + + double gapAt(WidgetTester tester, int index) => + _bubbleRect(tester, index + 1, 'm${index + 1}').top - + _bubbleRect(tester, index, 'm$index').bottom; + + await _pumpColumn(tester, stream, chatType: 'DIALOG', textScale: 0.35); + final dialogGaps = [for (var i = 0; i < 3; i++) gapAt(tester, i)]; + + await _pumpColumn(tester, stream, chatType: 'CHAT', textScale: 0.35); + final groupGaps = [for (var i = 0; i < 3; i++) gapAt(tester, i)]; + + expect(dialogGaps, everyElement(2.0)); + expect(groupGaps, dialogGaps); + }); + + testWidgets('a reply above a photo stays inside the photo width', ( + tester, + ) async { + await _pumpBubble(tester, _photoReply()); + + final quote = _rectOf( + tester, + find + .ancestor(of: find.text('Вы'), matching: find.byType(Container)) + .first, + ); + final caption = _rectOf( + tester, + find.textContaining('Вот те раз', findRichText: true), + ); + + expect(quote.width, closeTo(_photoWidth - 16, 1)); + expect(quote.left, greaterThan(0)); + expect(quote.right, lessThanOrEqualTo(caption.left + _photoWidth)); + }); + + testWidgets('a bubble without a header or reply still hugs its text', ( + tester, + ) async { + await _pumpBubble(tester, _message(text: 'нет', senderId: 404)); + + final clock = _clockRect(tester); + final body = _rectOf( + tester, + find.textContaining('нет', findRichText: true), + ); + + expect(clock.left, closeTo(body.right + 8, 1)); + expect(clock.center.dy, closeTo(body.center.dy, 4)); + }); + + testWidgets('the clock drops below wrapped text instead of widening it', ( + tester, + ) async { + await _pumpBubble( + tester, + _message(text: '${'ф' * 14}\n${'ф' * 14}', senderId: 404), + chatType: 'DIALOG', + ); + + final clock = _clockRect(tester); + final body = _rectOf(tester, find.textContaining('ф', findRichText: true)); + + expect(clock.top, greaterThanOrEqualTo(body.bottom - 2)); + expect(clock.right, lessThanOrEqualTo(body.right + 1)); + }); +} diff --git a/test/message_reaction_animoji_test.dart b/test/message_reaction_animoji_test.dart new file mode 100644 index 0000000..92263b4 --- /dev/null +++ b/test/message_reaction_animoji_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/lottie_image.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/animoji.dart'; + +const _reaction = '🔥'; +const _messageId = 'synthetic-message'; + +CachedMessage _message() => const CachedMessage( + id: _messageId, + accountId: 1, + chatId: 2, + senderId: 3, + text: 'Synthetic message', + time: 1000, + payload: { + 'reactionInfo': { + 'counters': [ + {'reaction': _reaction, 'count': 1}, + ], + 'yourReaction': _reaction, + 'totalCount': 1, + }, + }, +); + +Future _pumpBubble( + WidgetTester tester, { + required Animoji animoji, + required ValueListenable animation, +}) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MessageBubble( + message: _message(), + isMe: false, + myId: 1, + chatType: 'DIALOG', + reactionAnimation: animation, + reactionAnimojiResolver: (emoji) => + emoji == _reaction ? animoji : null, + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('reaction animoji stays static until its reaction is applied', ( + tester, + ) async { + final animation = ValueNotifier(null); + addTearDown(animation.dispose); + const animoji = Animoji( + id: 1, + emoji: _reaction, + iconUrl: 'https://example.test/reaction.png', + lottieUrl: 'https://example.test/reaction-idle.json', + lottiePlayUrl: 'https://example.test/reaction-play.json', + ); + + await _pumpBubble(tester, animoji: animoji, animation: animation); + + var glyph = tester.widget(find.byType(LottieImage)); + expect(glyph.url, animoji.iconUrl); + expect(glyph.lottieUrl, isNull); + + animation.value = const ReactionAnimationEvent( + messageId: 'different-synthetic-message', + emoji: _reaction, + token: 1, + ); + await tester.pump(); + glyph = tester.widget(find.byType(LottieImage)); + expect(glyph.lottieUrl, isNull); + + animation.value = const ReactionAnimationEvent( + messageId: _messageId, + emoji: _reaction, + token: 2, + ); + await tester.pump(); + final animatedGlyphs = tester + .widgetList(find.byType(LottieImage)) + .toList(); + expect(animatedGlyphs, hasLength(2)); + final body = animatedGlyphs.singleWhere( + (item) => item.lottieUrl == animoji.lottieUrl, + ); + final effect = animatedGlyphs.singleWhere( + (item) => item.lottieUrl == animoji.lottiePlayUrl, + ); + expect(body.size, 18); + expect(body.repeat, isFalse); + expect(effect.size, 36); + expect(effect.repeat, isFalse); + final effectFinder = find.byWidgetPredicate( + (widget) => + widget is LottieImage && + widget.lottieUrl == animoji.lottiePlayUrl, + ); + expect(tester.getSize(effectFinder), const Size.square(36)); + final players = tester + .widgetList(find.byType(LottiePlayer)) + .toList(); + expect(players, hasLength(2)); + expect(players.every((player) => player.animate && !player.repeat), isTrue); + }); + + testWidgets('reaction without an icon holds the first lottie frame', ( + tester, + ) async { + final animation = ValueNotifier(null); + addTearDown(animation.dispose); + const animoji = Animoji( + id: 2, + emoji: _reaction, + lottieUrl: 'https://example.test/reaction-idle.json', + ); + + await _pumpBubble(tester, animoji: animoji, animation: animation); + + final glyph = tester.widget(find.byType(LottieImage)); + expect(glyph.lottieUrl, animoji.lottieUrl); + expect(glyph.animate, isFalse); + expect(glyph.repeat, isFalse); + final player = tester.widget(find.byType(LottiePlayer)); + expect(player.animate, isFalse); + expect(player.repeat, isFalse); + }); +} diff --git a/test/note_ring_geometry_test.dart b/test/note_ring_geometry_test.dart new file mode 100644 index 0000000..39c2952 --- /dev/null +++ b/test/note_ring_geometry_test.dart @@ -0,0 +1,141 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart'; + +const _extent = 210.0; +const _knob = 7.0; +const _geometry = NoteRingGeometry(extent: _extent, knobRadius: _knob); + +void main() { + test('ручка идёт по ободу от 12 часов по часовой', () { + final center = _geometry.center; + final r = _geometry.radius; + + expect(_geometry.knobCenter(0).dx, closeTo(center.dx, 0.001)); + expect(_geometry.knobCenter(0).dy, closeTo(center.dy - r, 0.001)); + + expect(_geometry.knobCenter(0.25).dx, closeTo(center.dx + r, 0.001)); + expect(_geometry.knobCenter(0.25).dy, closeTo(center.dy, 0.001)); + + expect(_geometry.knobCenter(0.5).dy, closeTo(center.dy + r, 0.001)); + expect(_geometry.knobCenter(0.75).dx, closeTo(center.dx - r, 0.001)); + + expect(_geometry.knobCenter(1).dx, closeTo(center.dx, 0.001)); + expect(_geometry.knobCenter(1).dy, closeTo(center.dy - r, 0.001)); + }); + + test('ручка целиком помещается в бокс и не обрезается', () { + for (var i = 0; i <= 100; i++) { + final knob = _geometry.knobCenter(i / 100); + expect(knob.dx - _knob, greaterThanOrEqualTo(0)); + expect(knob.dy - _knob, greaterThanOrEqualTo(0)); + expect(knob.dx + _knob, lessThanOrEqualTo(_extent)); + expect(knob.dy + _knob, lessThanOrEqualTo(_extent)); + } + }); + + test('центр кружка остаётся под тап, обод и ручка ловят драг', () { + expect( + _geometry.grabs(_geometry.center, 0), + isFalse, + reason: 'центр должен переключать воспроизведение, а не мотать', + ); + expect(_geometry.grabs(_geometry.knobCenter(0.4), 0.4), isTrue); + expect( + _geometry.grabs(_geometry.knobCenter(0.4) + const Offset(0, 18), 0.4), + isTrue, + reason: 'промах мимо ручки в пределах допуска всё ещё считается', + ); + + final onBand = _geometry.center + Offset(_geometry.radius, 0); + expect(_geometry.grabs(onBand, 0), isTrue); + + final wellInside = _geometry.center + Offset(_geometry.radius / 2, 0); + expect(_geometry.grabs(wellInside, 0), isFalse); + }); + + test('тап по ободу попадает ровно в свою долю', () { + for (final progress in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 0.999]) { + expect( + _geometry.progressAt(_geometry.knobCenter(progress)), + closeTo(progress, 0.0001), + reason: 'тап по точке $progress', + ); + } + }); + + test('тап работает на любом удалении от обода вдоль того же луча', () { + final center = _geometry.center; + for (final radius in [_geometry.radius - 10, _geometry.radius + 5]) { + final point = center + Offset(radius, 0); + expect(_geometry.progressAt(point), closeTo(0.25, 0.0001)); + } + }); + + test('захват абсолютный: драг ведёт ручку ровно под пальцем', () { + for (final route in [ + [0.1, 0.3], + [0.4, 0.2], + [0.7, 0.95], + [0.0, 0.15], + ]) { + final from = _geometry.knobCenter(route[0]); + final to = _geometry.knobCenter(route[1]); + + final grabbed = _geometry.progressAt(from); + expect( + grabbed, + closeTo(route[0], 1e-4), + reason: 'захват должен встать в точку пальца, а не в текущую позицию', + ); + + final delta = NoteRingGeometry.angleDelta( + _geometry.angleAt(from), + _geometry.angleAt(to), + ); + expect( + _geometry.advance(grabbed, delta), + closeTo(route[1], 1e-4), + reason: 'ручка отстала от пальца на маршруте $route', + ); + } + }); + + test('прокрутка мимо конца упирается, а не заворачивается в начало', () { + final from = _geometry.knobCenter(0.9); + final to = _geometry.knobCenter(0.1); + final delta = NoteRingGeometry.angleDelta( + _geometry.angleAt(from), + _geometry.angleAt(to), + ); + expect(delta, greaterThan(0)); + expect(_geometry.advance(_geometry.progressAt(from), delta), 1.0); + }); + + test('переход через 12 часов не перебрасывает позицию', () { + final before = _geometry.angleAt( + _geometry.center + const Offset(-4, -90), + ); + final after = _geometry.angleAt(_geometry.center + const Offset(4, -90)); + final delta = NoteRingGeometry.angleDelta(before, after); + + expect(delta.abs(), lessThan(0.3), reason: 'скачок вместо плавного шага'); + expect(delta, greaterThan(0)); + expect(_geometry.advance(0.99, delta), 1.0); + expect(_geometry.advance(0.01, -delta), greaterThanOrEqualTo(0.0)); + }); + + test('прогресс зажат в границах при бесконечной прокрутке', () { + expect(_geometry.advance(0.5, 100 * math.pi), 1.0); + expect(_geometry.advance(0.5, -100 * math.pi), 0.0); + }); + + test('увеличенный кружок сохраняет пропорции обода', () { + const big = NoteRingGeometry(extent: 357, knobRadius: 9); + expect(big.radius, closeTo(357 / 2 - 10, 0.001)); + expect(big.knobCenter(0).dy, closeTo(big.center.dy - big.radius, 0.001)); + expect(big.knobCenter(0.5).dy + 9, lessThanOrEqualTo(357)); + }); +} diff --git a/test/opus_ogg_slice_test.dart b/test/opus_ogg_slice_test.dart new file mode 100644 index 0000000..30ce00b --- /dev/null +++ b/test/opus_ogg_slice_test.dart @@ -0,0 +1,253 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/media/ogg_page_writer.dart'; +import 'package:komet/core/media/opus_ogg_index.dart'; + +const int _sampleRate = 48000; +const int _packetSamples = 960; +const int _preSkip = 312; +const int _serial = 0x4b6f6d74; +const int _packetsPerPage = 20; + +Uint8List _le16(int value) => + Uint8List(2)..buffer.asByteData().setUint16(0, value, Endian.little); + +Uint8List _le32(int value) => + Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little); + +Uint8List _opusHead() { + final out = BytesBuilder() + ..add('OpusHead'.codeUnits) + ..addByte(1) + ..addByte(1) + ..add(_le16(_preSkip)) + ..add(_le32(_sampleRate)) + ..add(_le16(0)) + ..addByte(0); + return out.toBytes(); +} + +Uint8List _opusTags() { + const vendor = 'komet-test'; + final out = BytesBuilder() + ..add('OpusTags'.codeUnits) + ..add(_le32(vendor.length)) + ..add(vendor.codeUnits) + ..add(_le32(0)); + return out.toBytes(); +} + +Uint8List _audioPacket(int index) { + final out = Uint8List(40); + out[0] = 0x08; + out[1] = index & 0xff; + for (var i = 2; i < out.length; i++) { + out[i] = (index + i) & 0xff; + } + return out; +} + +Uint8List _buildStream(int packetCount, {int endTrim = 0}) { + final pages = []; + var sequence = 0; + pages.add( + OggPageWriter.page( + headerType: OggPageWriter.beginningOfStream, + granulePos: 0, + serial: _serial, + sequence: sequence++, + packets: [_opusHead()], + ), + ); + pages.add( + OggPageWriter.page( + headerType: 0, + granulePos: 0, + serial: _serial, + sequence: sequence++, + packets: [_opusTags()], + ), + ); + + for (var start = 0; start < packetCount; start += _packetsPerPage) { + final end = (start + _packetsPerPage).clamp(0, packetCount); + final last = end == packetCount; + final packets = [ + for (var i = start; i < end; i++) _audioPacket(i), + ]; + pages.add( + OggPageWriter.page( + headerType: last ? OggPageWriter.endOfStream : 0, + granulePos: end * _packetSamples - (last ? endTrim : 0), + serial: _serial, + sequence: sequence++, + packets: packets, + ), + ); + } + + final builder = BytesBuilder(); + for (final page in pages) { + builder.add(page); + } + return builder.toBytes(); +} + +int _referenceCrc(Uint8List data) { + var crc = 0; + for (final byte in data) { + crc ^= (byte << 24) & 0xffffffff; + for (var bit = 0; bit < 8; bit++) { + if ((crc & 0x80000000) != 0) { + crc = ((crc << 1) ^ 0x04c11db7) & 0xffffffff; + } else { + crc = (crc << 1) & 0xffffffff; + } + } + } + return crc; +} + +class _Page { + _Page({required this.headerType, required this.granulePos}); + + final int headerType; + final int granulePos; +} + +List<_Page> _verifyPages(Uint8List bytes) { + final pages = <_Page>[]; + var offset = 0; + while (offset + 27 <= bytes.length) { + expect( + String.fromCharCodes(bytes, offset, offset + 4), + 'OggS', + reason: 'страница на смещении $offset', + ); + final view = ByteData.sublistView(bytes, offset); + final segmentCount = bytes[offset + 26]; + final tableStart = offset + 27; + var bodyLength = 0; + for (var i = 0; i < segmentCount; i++) { + bodyLength += bytes[tableStart + i]; + } + final pageEnd = tableStart + segmentCount + bodyLength; + expect(pageEnd <= bytes.length, isTrue); + + final stored = view.getUint32(22, Endian.little); + final page = Uint8List.fromList(bytes.sublist(offset, pageEnd)); + ByteData.sublistView(page).setUint32(22, 0, Endian.little); + expect(_referenceCrc(page), stored, reason: 'CRC страницы $offset'); + + pages.add( + _Page( + headerType: bytes[offset + 5], + granulePos: view.getInt64(6, Endian.little), + ), + ); + offset = pageEnd; + } + expect(offset, bytes.length); + return pages; +} + +void main() { + test('crc32 совпадает с побитовой реализацией на любой длине', () { + for (var length = 0; length <= 260; length++) { + final data = Uint8List.fromList( + List.generate(length, (i) => (i * 31 + length) & 0xff), + ); + expect( + OggPageWriter.crc32(data), + _referenceCrc(data), + reason: 'длина $length', + ); + } + }); + + test('crc32 по диапазону не зависит от окружающих байт', () { + final payload = Uint8List.fromList( + List.generate(1021, (i) => (i * 7) & 0xff), + ); + final padded = Uint8List(payload.length + 9) + ..fillRange(0, 5, 0xab) + ..setRange(5, 5 + payload.length, payload) + ..fillRange(5 + payload.length, payload.length + 9, 0xcd); + + expect( + OggPageWriter.crc32(padded, 5, 5 + payload.length), + _referenceCrc(payload), + ); + }); + + test('разбирает длительность из TOC-байтов', () { + final index = OpusOggIndex.parse(_buildStream(250))!; + expect( + index.duration, + closeTo((250 * _packetSamples - _preSkip) / _sampleRate, 1e-9), + ); + }); + + test('не разбирает мусор', () { + expect(OpusOggIndex.parse(Uint8List(0)), isNull); + expect( + OpusOggIndex.parse(Uint8List.fromList(List.filled(512, 7))), + isNull, + ); + }); + + test('срез укорачивает поток ровно на запрошенную позицию', () { + final index = OpusOggIndex.parse(_buildStream(500))!; + final total = index.duration; + + for (final seconds in [0.02, 0.5, 1.0, 3.3, 7.75]) { + final sliced = index.sliceFrom(seconds); + expect(sliced, isNotNull, reason: 'срез с $seconds с'); + final reparsed = OpusOggIndex.parse(sliced!)!; + expect( + reparsed.duration, + closeTo(total - seconds, 1e-6), + reason: 'длительность среза с $seconds с', + ); + } + }); + + test('срез остаётся валидным Ogg с EOS на последней странице', () { + final index = OpusOggIndex.parse(_buildStream(500))!; + final sliced = index.sliceFrom(4.0)!; + final pages = _verifyPages(sliced); + + expect(pages.length, greaterThan(2)); + expect(pages.first.headerType & OggPageWriter.beginningOfStream, isNot(0)); + expect(pages.first.granulePos, 0); + expect(pages[1].granulePos, 0); + expect(pages.last.headerType & OggPageWriter.endOfStream, isNot(0)); + + var previous = -1; + for (final page in pages) { + expect(page.granulePos, greaterThanOrEqualTo(previous)); + previous = page.granulePos; + } + }); + + test('учитывает обрезку хвоста в финальной granule', () { + const trim = 700; + final index = OpusOggIndex.parse(_buildStream(300, endTrim: trim))!; + expect( + index.duration, + closeTo((300 * _packetSamples - _preSkip - trim) / _sampleRate, 1e-9), + ); + + final sliced = index.sliceFrom(2.0); + final reparsed = OpusOggIndex.parse(sliced!)!; + expect(reparsed.duration, closeTo(index.duration - 2.0, 1e-6)); + }); + + test('срез за пределами длительности не строится', () { + final index = OpusOggIndex.parse(_buildStream(100))!; + expect(index.sliceFrom(0), isNull); + expect(index.sliceFrom(-1), isNull); + expect(index.sliceFrom(index.duration + 1), isNull); + }); +} diff --git a/test/photo_album_bubble_test.dart b/test/photo_album_bubble_test.dart new file mode 100644 index 0000000..fe9c7c8 --- /dev/null +++ b/test/photo_album_bubble_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/frontend/widgets/photo_viewer.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; + +CachedMessage _album(List photos) => CachedMessage( + id: '1', + accountId: 1, + chatId: 2, + senderId: 1, + time: DateTime(2026, 1, 1).millisecondsSinceEpoch, + status: 'sent', + attachments: photos, +); + +List _remote(int count) => List.generate( + count, + (i) => PhotoAttachment( + baseUrl: 'https://example.com/$i.jpg', + width: 1200, + height: 1600, + ), +); + +List _local(int count) => List.generate( + count, + (i) => PhotoAttachment(localPath: '/tmp/photo$i.jpg', width: 1200, height: 1600), +); + +Future _pumpBubble(WidgetTester tester, CachedMessage message) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + tester.view.padding = const FakeViewPadding(top: 210, bottom: 120); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: MessageBubble( + message: message, + isMe: true, + myId: 1, + chatType: 'DIALOG', + ), + ), + ), + ), + ); + await tester.pump(); +} + +int _viewerIndex(WidgetTester tester) => + tester.widget(find.byType(PhotoViewerScreen)).initialIndex; + +Size _bubbleSize(WidgetTester tester) => tester.getSize( + find + .ancestor( + of: find.byType(ClipRRect).first, + matching: find.byType(ConstrainedBox), + ) + .first, +); + +void main() { + testWidgets('album grid ignores safe area insets', (tester) async { + await _pumpBubble(tester, _album(_remote(4))); + + final size = _bubbleSize(tester); + expect(size.height, closeTo(size.width, 1)); + }); + + testWidgets('tapping an album photo opens the viewer at its index', ( + tester, + ) async { + await _pumpBubble(tester, _album(_remote(4))); + + await tester.tap(find.byType(GestureDetector).at(2)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + expect(_viewerIndex(tester), 2); + }); + + testWidgets('the +N tile opens the viewer', (tester) async { + await _pumpBubble(tester, _album(_remote(6))); + + expect(find.text('+2'), findsOneWidget); + + await tester.tap(find.byType(GestureDetector).at(3)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + expect(_viewerIndex(tester), 3); + }); + + testWidgets('photos still uploading open from their local file', ( + tester, + ) async { + await _pumpBubble(tester, _album(_local(4))); + + await tester.tap(find.byType(GestureDetector).first); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + }); +} diff --git a/test/photo_hero_test.dart b/test/photo_hero_test.dart new file mode 100644 index 0000000..1dae2b4 --- /dev/null +++ b/test/photo_hero_test.dart @@ -0,0 +1,227 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/widgets/attachment/photo_hero.dart'; + +const Rect _origin = Rect.fromLTWH(50, 500, 100, 100); + +class _TestImageProvider extends ImageProvider<_TestImageProvider> { + _TestImageProvider(this.image); + + final ui.Image image; + + @override + Future<_TestImageProvider> obtainKey(ImageConfiguration configuration) => + SynchronousFuture<_TestImageProvider>(this); + + @override + ImageStreamCompleter loadImage( + _TestImageProvider key, + ImageDecoderCallback decode, + ) => OneFrameImageStreamCompleter( + SynchronousFuture(ImageInfo(image: image.clone())), + ); +} + +Widget _page() => Scaffold( + body: Column( + children: [ + Expanded( + child: PhotoHeroTarget( + child: Center( + child: Container(key: const ValueKey('target'), color: Colors.red), + ), + ), + ), + const SizedBox(height: 100), + ], + ), +); + +Finder get _flying => find.byType(Image); + +double _flyingWidth(WidgetTester tester) => tester.getSize(_flying).width; + +bool _targetHidden(WidgetTester tester) => tester.any( + find.ancestor( + of: find.byKey(const ValueKey('target')), + matching: find.byType(Opacity), + ), +); + +double _pageOpacity(WidgetTester tester) => tester + .widget( + find + .ancestor( + of: find.byKey(const ValueKey('target')), + matching: find.byType(FadeTransition), + ) + .first, + ) + .opacity + .value; + +void main() { + late ui.Image image; + late ui.Image wide; + + setUpAll(() async { + image = await createTestImage(width: 4, height: 3); + wide = await createTestImage(width: 8, height: 2); + }); + + testWidgets('photo flies from the origin rect to the contained target', ( + tester, + ) async { + final hero = PhotoHeroController( + origin: () => _origin, + image: _TestImageProvider(image), + ); + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const Scaffold(); + }, + ), + ), + ); + + Navigator.of( + context, + ).push(PhotoHeroRoute(hero: hero, builder: (_) => _page())); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + expect(_flying, findsOneWidget); + expect(_targetHidden(tester), isTrue); + final early = _flyingWidth(tester); + expect(early, greaterThanOrEqualTo(133)); + + await tester.pump(const Duration(milliseconds: 150)); + final late_ = _flyingWidth(tester); + expect(late_, greaterThan(early)); + expect(late_, lessThan(667)); + + await tester.pumpAndSettle(); + expect(_flying, findsNothing); + expect(_targetHidden(tester), isFalse); + expect(tester.getSize(find.byKey(const ValueKey('target'))).height, 500); + }); + + testWidgets('photo flies back to the origin rect on pop', (tester) async { + final hero = PhotoHeroController( + origin: () => _origin, + image: _TestImageProvider(image), + ); + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const Scaffold(); + }, + ), + ), + ); + + final navigator = Navigator.of(context); + navigator.push(PhotoHeroRoute(hero: hero, builder: (_) => _page())); + await tester.pumpAndSettle(); + + navigator.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(_flying, findsOneWidget); + expect(_targetHidden(tester), isTrue); + expect(_flyingWidth(tester), lessThan(667)); + + await tester.pumpAndSettle(); + expect(_flying, findsNothing); + }); + + testWidgets('disabled hero fades the page instead of flying', (tester) async { + final hero = PhotoHeroController( + origin: () => _origin, + image: _TestImageProvider(image), + )..enabled = false; + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const Scaffold(); + }, + ), + ), + ); + + Navigator.of( + context, + ).push(PhotoHeroRoute(hero: hero, builder: (_) => _page())); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(_flying, findsNothing); + expect(_targetHidden(tester), isFalse); + expect(_pageOpacity(tester), lessThan(1)); + + await tester.pumpAndSettle(); + expect(_pageOpacity(tester), 1); + }); + + testWidgets('target renders untouched without a hero scope', (tester) async { + await tester.pumpWidget(MaterialApp(home: _page())); + await tester.pumpAndSettle(); + + expect(find.byType(Opacity), findsNothing); + expect(tester.getSize(find.byKey(const ValueKey('target'))).height, 500); + }); + + testWidgets('замена кадра меняет пропорции обратного перелёта', ( + tester, + ) async { + final hero = PhotoHeroController( + origin: () => _origin, + image: RawImageProvider(image), + ); + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + context = ctx; + return const Scaffold(); + }, + ), + ), + ); + + final navigator = Navigator.of(context); + navigator.push(PhotoHeroRoute(hero: hero, builder: (_) => _page())); + await tester.pumpAndSettle(); + hero.image.value = RawImageProvider(wide); + await tester.pump(); + + navigator.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + expect(_flying, findsOneWidget); + final size = tester.getSize(_flying); + expect(size.width / size.height, closeTo(4, 0.2)); + + await tester.pump(const Duration(milliseconds: 400)); + }); +} diff --git a/test/photo_viewer_test.dart b/test/photo_viewer_test.dart new file mode 100644 index 0000000..df360be --- /dev/null +++ b/test/photo_viewer_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/photo_viewer.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +CachedMessage _message({String? text}) => CachedMessage( + id: '77', + accountId: 1, + chatId: 2, + senderId: 5, + text: text, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sent', + attachments: const [], +); + +Future _pumpViewer( + WidgetTester tester, { + CachedMessage? message, + PhotoViewerActions? actions, +}) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: PhotoViewerScreen( + photos: const [ + PhotoAttachment(baseUrl: 'https://example.com/a.jpg'), + PhotoAttachment(baseUrl: 'https://example.com/b.jpg'), + ], + message: message, + actions: actions, + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('shows who sent the photo and when', (tester) async { + await _pumpViewer(tester, message: _message()); + + expect(find.textContaining('сегодня в'), findsOneWidget); + }); + + testWidgets('rotate button turns the photo by 90 degrees', (tester) async { + await _pumpViewer(tester, message: _message()); + + expect( + tester.widget(find.byType(RotatedBox).first).quarterTurns, + 0, + ); + + await tester.tap(find.byIcon(Symbols.rotate_90_degrees_ccw)); + await tester.pump(); + + expect( + tester.widget(find.byType(RotatedBox).first).quarterTurns, + 1, + ); + }); + + testWidgets('shows the photo caption when there is one', (tester) async { + await _pumpViewer(tester, message: _message(text: 'Делу время')); + + expect(find.text('Делу время'), findsOneWidget); + }); + + testWidgets('arrows step between photos', (tester) async { + await _pumpViewer(tester, message: _message()); + + expect(find.byIcon(Symbols.chevron_left), findsNothing); + expect(find.byIcon(Symbols.chevron_right), findsOneWidget); + + await tester.tap(find.byIcon(Symbols.chevron_right)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(Symbols.chevron_left), findsOneWidget); + expect(find.byIcon(Symbols.chevron_right), findsNothing); + + await tester.tap(find.byIcon(Symbols.chevron_left)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(Symbols.chevron_right), findsOneWidget); + }); + + testWidgets('arrow keys step between photos', (tester) async { + await _pumpViewer(tester, message: _message()); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(Symbols.chevron_left), findsOneWidget); + expect(find.byIcon(Symbols.chevron_right), findsNothing); + }); + + testWidgets('single tap hides the chrome, another tap brings it back', ( + tester, + ) async { + await _pumpViewer(tester, message: _message()); + + double chromeOpacity() => + tester.widget(find.byType(AnimatedOpacity)).opacity; + + expect(chromeOpacity(), 1); + + await tester.tapAt(tester.getCenter(find.byType(PageView))); + await tester.pump(); + expect(chromeOpacity(), 0); + + await tester.tapAt(tester.getCenter(find.byType(PageView))); + await tester.pump(); + expect(chromeOpacity(), 1); + }); + + testWidgets('three-dot menu appears only with actions', (tester) async { + await _pumpViewer(tester, message: _message()); + expect(find.byIcon(Symbols.more_vert), findsNothing); + + await _pumpViewer( + tester, + message: _message(), + actions: PhotoViewerActions( + goToMessage: (_, _) {}, + delete: (_, _) {}, + ), + ); + expect(find.byIcon(Symbols.more_vert), findsOneWidget); + + await tester.tap(find.byIcon(Symbols.more_vert)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Перейти к сообщению'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + expect(find.text('Сохранить как…'), findsOneWidget); + expect(find.text('Переслать'), findsNothing); + }); + + testWidgets('menu action closes the viewer before running', (tester) async { + var ran = false; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PhotoViewerScreen( + photos: const [ + PhotoAttachment(baseUrl: 'https://example.com/a.jpg'), + ], + message: _message(), + actions: PhotoViewerActions( + goToMessage: (_, _) => ran = true, + ), + ), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(PhotoViewerScreen), findsOneWidget); + + await tester.tap(find.byIcon(Symbols.more_vert)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Перейти к сообщению')); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + + expect(ran, isTrue); + expect(find.byType(PhotoViewerScreen), findsNothing); + }); +} diff --git a/test/profile_hero_test.dart b/test/profile_hero_test.dart new file mode 100644 index 0000000..d2a53e3 --- /dev/null +++ b/test/profile_hero_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/widgets/profile_hero.dart'; + +const _headerStyle = TextStyle(fontSize: 17, fontWeight: FontWeight.w600); +const _profileStyle = TextStyle(fontSize: 22, fontWeight: FontWeight.w700); + +final _tag = UniqueKey(); + +Widget _header({Object? tag}) => Scaffold( + body: Row( + children: [ + ProfileHeroAvatar( + tag: tag, + size: 44, + child: Container(width: 44, height: 44, color: Colors.red), + ), + ProfileHeroName( + tag: tag, + text: 'Ann', + style: _headerStyle, + child: const Text('Ann', style: _headerStyle), + ), + ], + ), +); + +Widget _profile({Object? tag, bool loaded = false}) => Scaffold( + body: Column( + children: [ + ProfileHeroAvatar( + tag: tag, + size: 96, + child: Container(width: 96, height: 96, color: Colors.red), + ), + ProfileHeroName( + tag: tag, + text: 'Ann', + style: _profileStyle, + child: const Text('Ann', style: _profileStyle), + ), + if (loaded) const Text('details'), + ], + ), +); + +Iterable _fontSizes(WidgetTester tester) => tester + .widgetList(find.byType(Text)) + .map( + (t) => + t.style?.fontSize ?? + DefaultTextStyle.of(tester.element(find.byWidget(t))).style.fontSize, + ) + .whereType(); + +double _flyingAvatarWidth(WidgetTester tester) => + tester.getSize(find.byType(FittedBox).first).width; + +TextStyle _flyingNameStyle(WidgetTester tester) { + final transition = find.byType(DefaultTextStyleTransition); + expect(transition, findsOneWidget); + return DefaultTextStyle.of( + tester.element( + find.descendant(of: transition, matching: find.byType(Text)), + ), + ).style; +} + +void main() { + testWidgets('avatar and name fly between header and profile', (tester) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: _header(tag: _tag), + ), + ); + + navigator.currentState!.push( + MaterialPageRoute(builder: (_) => _profile(tag: _tag)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(_fontSizes(tester).any((s) => s > 17 && s < 22), isTrue); + final pushWidth = _flyingAvatarWidth(tester); + expect(pushWidth, greaterThan(44)); + expect(pushWidth, lessThan(96)); + + await tester.pumpAndSettle(); + expect(find.byType(FittedBox), findsNothing); + + navigator.currentState!.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(_fontSizes(tester).any((s) => s > 17 && s < 22), isTrue); + final popWidth = _flyingAvatarWidth(tester); + expect(popWidth, greaterThan(44)); + expect(popWidth, lessThan(96)); + + await tester.pumpAndSettle(); + expect(find.byType(FittedBox), findsNothing); + }); + + testWidgets( + 'flying name inherits no decoration from the app fallback style', + (tester) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: _header(tag: _tag), + ), + ); + + navigator.currentState!.push( + MaterialPageRoute(builder: (_) => _profile(tag: _tag)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + final style = _flyingNameStyle(tester); + expect(style.decoration, isNull); + expect(style.fontFamily, isNull); + expect(style.fontSize, greaterThan(17)); + expect(style.fontSize, lessThan(22)); + + await tester.pumpAndSettle(); + }, + ); + + testWidgets('flight stays opaque when the destination finishes loading', ( + tester, + ) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: _header(tag: _tag), + ), + ); + + var loaded = false; + late StateSetter setProfileState; + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => StatefulBuilder( + builder: (_, setState) { + setProfileState = setState; + return _profile(tag: _tag, loaded: loaded); + }, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + setProfileState(() => loaded = true); + + var minOpacity = 1.0; + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 10)); + final flying = find.byType(FittedBox); + if (flying.evaluate().isEmpty) break; + final fade = tester.widget( + find.ancestor(of: flying, matching: find.byType(FadeTransition)), + ); + minOpacity = fade.opacity.value < minOpacity + ? fade.opacity.value + : minOpacity; + } + expect(minOpacity, 1.0); + + await tester.pumpAndSettle(); + expect(find.byType(FittedBox), findsNothing); + }); + + testWidgets('a null tag opts out of the flight entirely', (tester) async { + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: _header(tag: _tag), + ), + ); + + navigator.currentState!.push( + MaterialPageRoute(builder: (_) => _profile(tag: null)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(find.byType(FittedBox), findsNothing); + expect(find.byType(DefaultTextStyleTransition), findsNothing); + + await tester.pumpAndSettle(); + }); +} diff --git a/test/reactions_inside_bubble_test.dart b/test/reactions_inside_bubble_test.dart new file mode 100644 index 0000000..7f5c053 --- /dev/null +++ b/test/reactions_inside_bubble_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/animoji.dart'; +import 'package:komet/models/attachment.dart'; + +const int _me = 1; +const int _peer = 7; + +Map get _reactions => { + 'totalCount': 2, + 'counters': [ + {'reaction': '🔥', 'count': 2}, + ], + 'yourReaction': '🔥', +}; + +CachedMessage _photo({String? caption}) => CachedMessage( + id: '1', + accountId: _me, + chatId: 2, + senderId: _peer, + text: caption, + time: DateTime(2026, 1, 1, 12, 0).millisecondsSinceEpoch, + status: 'sent', + attachments: [ + PhotoAttachment( + baseUrl: 'https://example.com/synthetic.jpg', + width: 180, + height: 240, + ), + ], + payload: {'reactionInfo': _reactions}, +); + +CachedMessage _text() => CachedMessage( + id: '2', + accountId: _me, + chatId: 2, + senderId: _peer, + text: 'привет', + time: DateTime(2026, 1, 1, 12, 0).millisecondsSinceEpoch, + status: 'sent', + payload: {'reactionInfo': _reactions}, +); + +Future _pump(WidgetTester tester, CachedMessage message) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MessageBubble( + key: const ValueKey('bubble'), + message: message, + isMe: false, + myId: _me, + chatType: 'DIALOG', + reactionAnimojiResolver: (emoji) => + Animoji(id: 1, emoji: emoji, iconUrl: 'https://example.com/a.png'), + ), + ), + ), + ); + await tester.pump(); +} + +/// Прямоугольник контейнера-бабла (самый крупный Container внутри пузыря). +Rect _bubbleRect(WidgetTester tester) { + final containers = find.descendant( + of: find.byKey(const ValueKey('bubble')), + matching: find.byType(Container), + ); + Rect? best; + for (final element in tester.elementList(containers)) { + final box = element.renderObject as RenderBox?; + if (box == null || !box.hasSize) continue; + final rect = box.localToGlobal(Offset.zero) & box.size; + final current = best; + if (current == null || + rect.height * rect.width > current.height * current.width) { + best = rect; + } + } + return best!; +} + +/// Чип реакции ищем по счётчику: сам глиф может быть анимодзи, а не текстом. +Rect _reactionRect(WidgetTester tester) { + final counter = find.descendant( + of: find.byKey(const ValueKey('bubble')), + matching: find.text('2'), + ); + expect(counter, findsOneWidget); + final chip = find + .ancestor(of: counter, matching: find.byType(Container)) + .first; + return tester.getTopLeft(chip) & tester.getSize(chip); +} + +void main() { + testWidgets('реакция под фото лежит внутри бабла', (tester) async { + await _pump(tester, _photo()); + final bubble = _bubbleRect(tester); + final chip = _reactionRect(tester); + expect( + bubble.contains(chip.topLeft) && bubble.contains(chip.bottomRight), + isTrue, + reason: 'чип реакции должен быть внутри бабла: $chip vs $bubble', + ); + }); + + testWidgets('реакция у фото с подписью тоже внутри бабла', (tester) async { + await _pump(tester, _photo(caption: 'подпись')); + final bubble = _bubbleRect(tester); + final chip = _reactionRect(tester); + expect(bubble.contains(chip.topLeft), isTrue); + expect(bubble.contains(chip.bottomRight), isTrue); + }); + + testWidgets('реакция в текстовом сообщении внутри бабла', (tester) async { + await _pump(tester, _text()); + final bubble = _bubbleRect(tester); + final chip = _reactionRect(tester); + expect(bubble.contains(chip.topLeft), isTrue); + expect(bubble.contains(chip.bottomRight), isTrue); + }); +} diff --git a/test/route_settle_test.dart b/test/route_settle_test.dart new file mode 100644 index 0000000..105f6af --- /dev/null +++ b/test/route_settle_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/utils/route_settle.dart'; + +class _GatedPage extends StatefulWidget { + const _GatedPage({required this.onSettled}); + + final VoidCallback onSettled; + + @override + State<_GatedPage> createState() => _GatedPageState(); +} + +class _GatedPageState extends State<_GatedPage> { + late final RouteSettle _settle = RouteSettle(isMounted: () => mounted); + bool _queued = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _settle.bind(context); + if (!_queued) { + _queued = true; + _settle.run(widget.onSettled); + } + } + + @override + void dispose() { + _settle.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + testWidgets('queued work waits for the push transition to finish', ( + tester, + ) async { + final navigator = GlobalKey(); + var ran = 0; + + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: const SizedBox.shrink()), + ); + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => _GatedPage(onSettled: () => ran++), + ), + ); + + await tester.pump(); + expect(ran, 0); + + await tester.pump(const Duration(milliseconds: 100)); + expect(ran, 0); + + await tester.pumpAndSettle(); + expect(ran, 1); + }); + + testWidgets('a route that is already in place does not hold work back', ( + tester, + ) async { + var ran = 0; + await tester.pumpWidget( + MaterialApp(home: _GatedPage(onSettled: () => ran++)), + ); + expect(ran, 1); + }); + + testWidgets('work runs immediately once the gate is open', (tester) async { + final settle = RouteSettle(isMounted: () => true); + settle.settleNow(); + + var ran = 0; + settle.run(() => ran++); + expect(ran, 1); + + settle.dispose(); + }); + + testWidgets('queued work is dropped when the owner is gone', (tester) async { + var alive = true; + final settle = RouteSettle(isMounted: () => alive); + + var ran = 0; + settle.run(() => ran++); + expect(ran, 0); + + alive = false; + settle.settleNow(); + expect(ran, 0); + + settle.dispose(); + }); +} diff --git a/test/selection_copy_test.dart b/test/selection_copy_test.dart new file mode 100644 index 0000000..3439cc9 --- /dev/null +++ b/test/selection_copy_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/screens/chats/chat/view/selection_bar.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +const int _me = 1; +const int _chatId = 2; + +CachedMessage _text(String id, String? text) => CachedMessage( + id: id, + accountId: _me, + chatId: _chatId, + senderId: _me, + text: text, + time: DateTime(2026, 1, 1, 12).millisecondsSinceEpoch, +); + +CachedMessage _forwarded(String id, String originalText) => + CachedMessage.fromPushPayload(_me, _chatId, { + 'id': id, + 'time': DateTime(2026, 1, 1, 12).millisecondsSinceEpoch, + 'type': 'USER', + 'sender': _me, + 'link': { + 'type': 'FORWARD', + 'message': { + 'id': '900', + 'time': 1000, + 'type': 'USER', + 'sender': 5, + 'text': originalText, + 'attaches': const [], + }, + 'chatId': -30, + 'chatName': 'Synthetic Channel', + }, + }); + +Future?> _tapCopy( + WidgetTester tester, + List copyMsgs, { + required bool glossy, +}) async { + List? copied; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SelectionTopBar( + cs: ThemeData.light().colorScheme, + selected: copyMsgs.map((m) => m.id).toSet(), + glossy: glossy, + copyMsgs: copyMsgs, + editMsg: null, + onClear: () {}, + onCopy: (msgs) => copied = msgs, + onEdit: (_) {}, + onDelete: () {}, + ), + ), + ), + ); + final copyButton = find.widgetWithIcon(IconButton, Symbols.content_copy); + if (copyButton.evaluate().isEmpty) return null; + await tester.tap(copyButton); + await tester.pump(); + return copied; +} + +void main() { + testWidgets('copy stays available for several selected messages', ( + tester, + ) async { + final msgs = [_text('1', 'первое'), _text('2', 'второе')]; + for (final glossy in [false, true]) { + expect(await _tapCopy(tester, msgs, glossy: glossy), msgs); + } + }); + + testWidgets('copy is hidden when nothing carries text', (tester) async { + expect(await _tapCopy(tester, const [], glossy: false), isNull); + }); + + testWidgets('a forwarded message exposes its original text', (tester) async { + final forwarded = _forwarded('3', 'исходный текст'); + expect(forwarded.text, isNull); + expect(forwarded.selectableText, 'исходный текст'); + expect(await _tapCopy(tester, [forwarded], glossy: false), [forwarded]); + }); +} diff --git a/test/share_composer_bar_test.dart b/test/share_composer_bar_test.dart new file mode 100644 index 0000000..aa77c81 --- /dev/null +++ b/test/share_composer_bar_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/backend/modules/share_sender.dart'; +import 'package:komet/frontend/screens/chats/share_composer_bar.dart'; +import 'package:komet/frontend/widgets/rich_message_controller.dart'; +import 'package:komet/models/shared_payload.dart'; + +PreparedShareFile _file(String name, String mime) => PreparedShareFile( + source: SharedFile(path: '/synthetic/$name', name: name, mime: mime, size: 8), +); + +Future _pump( + WidgetTester tester, { + required PreparedShare share, + required List recipients, + bool sending = false, + Future Function(String)? onSend, +}) async { + final controller = RichMessageController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: ShareComposerBar( + share: share, + controller: controller, + recipientNames: recipients, + sending: sending, + onSend: onSend ?? (_) async {}, + ), + ), + ), + ), + ); + await tester.pump(); + return controller; +} + +void main() { + testWidgets('a single photo names itself and lists the recipient', ( + tester, + ) async { + await _pump( + tester, + share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]), + recipients: const ['ЛУКА'], + ); + + expect(find.text('Отправить фотографию'), findsOneWidget); + expect(find.text('В чат ЛУКА'), findsOneWidget); + expect(find.text('Добавить подпись...'), findsOneWidget); + }); + + testWidgets('three chats collapse into a count and a badge', (tester) async { + await _pump( + tester, + share: PreparedShare( + files: [ + _file('a.jpg', 'image/jpeg'), + _file('b.jpg', 'image/jpeg'), + _file('c.jpg', 'image/jpeg'), + ], + ), + recipients: const ['a', 'b', 'c'], + ); + + expect(find.text('Отправить 3 фотографии'), findsOneWidget); + expect(find.text('В 3 чата'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('a text share drops the preview row', (tester) async { + final controller = await _pump( + tester, + share: const PreparedShare(files: [], text: 'https://komet.pw'), + recipients: const ['ЛУКА'], + ); + + expect(find.text('Отправить сообщение'), findsNothing); + expect(find.text('Сообщение'), findsOneWidget); + expect(controller.text, ''); + }); + + testWidgets('a file share still shows a title but no photo wording', ( + tester, + ) async { + await _pump( + tester, + share: PreparedShare(files: [_file('doc.pdf', 'application/pdf')]), + recipients: const ['ЛУКА', 'Zarub'], + ); + + expect(find.text('Отправить файл'), findsOneWidget); + expect(find.text('В чат ЛУКА, Zarub'), findsOneWidget); + }); + + testWidgets('the caption reaches onSend', (tester) async { + String? captured; + final controller = await _pump( + tester, + share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]), + recipients: const ['ЛУКА'], + onSend: (caption) async => captured = caption, + ); + + controller.text = ' привет '; + await tester.pump(); + await tester.tap(find.byIcon(Symbols.send)); + await tester.pump(); + + expect(captured, 'привет'); + }); + + testWidgets('with no recipients the send button is inert', (tester) async { + var sent = false; + await _pump( + tester, + share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]), + recipients: const [], + onSend: (_) async => sent = true, + ); + + expect(find.text('Выберите чат'), findsOneWidget); + await tester.tap(find.byIcon(Symbols.send)); + await tester.pump(); + expect(sent, isFalse); + }); +} diff --git a/test/share_intent_test.dart b/test/share_intent_test.dart new file mode 100644 index 0000000..b9d7d0a --- /dev/null +++ b/test/share_intent_test.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/share/share_labels.dart'; +import 'package:komet/models/shared_payload.dart'; + +late Directory _dir; + +String _makeFile(String name, {int bytes = 8}) { + final file = File('${_dir.path}${Platform.pathSeparator}$name') + ..writeAsBytesSync(List.filled(bytes, 0x41)); + return file.path; +} + +Map _entry(String path, String mime, {int size = 8}) => { + 'path': path, + 'name': path.split(Platform.pathSeparator).last, + 'mime': mime, + 'size': size, +}; + +void main() { + setUp(() { + _dir = Directory.systemTemp.createTempSync('synthetic_share_test'); + addTearDown(() { + if (_dir.existsSync()) _dir.deleteSync(recursive: true); + }); + }); + + group('SharedPayload.fromMap', () { + test('classifies photos, videos and documents by mime', () { + final payload = SharedPayload.fromMap({ + 'files': [ + _entry(_makeFile('a.jpg'), 'image/jpeg'), + _entry(_makeFile('b.mp4'), 'video/mp4'), + _entry(_makeFile('c.pdf'), 'application/pdf'), + ], + 'text': null, + }); + + expect(payload, isNotNull); + expect(payload!.photos.map((f) => f.name), ['a.jpg']); + expect(payload.videos.map((f) => f.name), ['b.mp4']); + expect(payload.documents.map((f) => f.name), ['c.pdf']); + expect(payload.dominantKind, SharedFileKind.file); + }); + + test('an svg is a document, not a photo', () { + final payload = SharedPayload.fromMap({ + 'files': [_entry(_makeFile('d.svg'), 'image/svg+xml')], + }); + + expect(payload!.files.single.kind, SharedFileKind.file); + }); + + test('drops entries whose file is gone', () { + final payload = SharedPayload.fromMap({ + 'files': [ + _entry(_makeFile('present.jpg'), 'image/jpeg'), + _entry('${_dir.path}/missing.jpg', 'image/jpeg'), + ], + }); + + expect(payload!.files.map((f) => f.name), ['present.jpg']); + }); + + test('a text-only share survives with no files', () { + final payload = SharedPayload.fromMap({ + 'files': const [], + 'text': ' https://komet.pw ', + }); + + expect(payload!.isTextOnly, isTrue); + expect(payload.text, 'https://komet.pw'); + }); + + test('an empty share is rejected', () { + expect(SharedPayload.fromMap({'files': const [], 'text': ' '}), isNull); + expect(SharedPayload.fromMap(null), isNull); + expect(SharedPayload.fromMap('nonsense'), isNull); + }); + + test('a missing mime falls back to a document', () { + final payload = SharedPayload.fromMap({ + 'files': [ + {'path': _makeFile('e.bin'), 'name': 'e.bin', 'size': 8}, + ], + }); + + expect(payload!.files.single.mime, 'application/octet-stream'); + expect(payload.files.single.kind, SharedFileKind.file); + }); + }); + + group('shareTitleFor', () { + test('photos use Russian plural forms', () { + expect( + shareTitleFor(photos: 1, videos: 0, documents: 0), + 'Отправить фотографию', + ); + expect( + shareTitleFor(photos: 3, videos: 0, documents: 0), + 'Отправить 3 фотографии', + ); + expect( + shareTitleFor(photos: 5, videos: 0, documents: 0), + 'Отправить 5 фотографий', + ); + expect( + shareTitleFor(photos: 11, videos: 0, documents: 0), + 'Отправить 11 фотографий', + ); + }); + + test('videos stay uninflected', () { + expect( + shareTitleFor(photos: 0, videos: 1, documents: 0), + 'Отправить видео', + ); + expect( + shareTitleFor(photos: 0, videos: 2, documents: 0), + 'Отправить 2 видео', + ); + }); + + test('documents and mixed sets fall back to file wording', () { + expect( + shareTitleFor(photos: 0, videos: 0, documents: 1), + 'Отправить файл', + ); + expect( + shareTitleFor(photos: 0, videos: 0, documents: 4), + 'Отправить 4 файла', + ); + expect( + shareTitleFor(photos: 1, videos: 1, documents: 0), + 'Отправить 2 файла', + ); + }); + + test('a text share has no media wording', () { + expect( + shareTitleFor(photos: 0, videos: 0, documents: 0, textOnly: true), + 'Отправить сообщение', + ); + }); + }); + + group('shareSubtitleFor', () { + test('names are listed up to two recipients', () { + expect(shareSubtitleFor(const ['ЛУКА']), 'В чат ЛУКА'); + expect(shareSubtitleFor(const ['ЛУКА', 'Zarub']), 'В чат ЛУКА, Zarub'); + }); + + test('three or more recipients collapse to a count', () { + expect(shareSubtitleFor(const ['a', 'b', 'c']), 'В 3 чата'); + expect(shareSubtitleFor(List.filled(5, 'x')), 'В 5 чатов'); + }); + + test('an empty selection asks for one', () { + expect(shareSubtitleFor(const []), 'Выберите чат'); + }); + }); +} diff --git a/test/spectrum_background_test.dart b/test/spectrum_background_test.dart new file mode 100644 index 0000000..73afb06 --- /dev/null +++ b/test/spectrum_background_test.dart @@ -0,0 +1,199 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:komet/core/config/app_spectrum_background.dart'; +import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/frontend/widgets/spectrum_background.dart'; + +class _CountingCanvas implements Canvas { + final List rects = []; + final List colors = []; + + @override + void drawRect(Rect rect, Paint paint) { + rects.add(rect); + colors.add(paint.color); + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +const Size _viewport = Size(800, 600); + +Widget _host({Widget? overlay, required Brightness brightness, Color? seed}) { + return MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: seed ?? const Color(0xFF6750A4), + brightness: brightness, + ), + ), + home: Scaffold( + body: Stack( + children: [ + const Positioned.fill(child: SpectrumBackground()), + ?overlay, + ], + ), + ), + ); +} + +_CountingCanvas _paintOnce(WidgetTester tester) { + final paint = tester.widget( + find.descendant( + of: find.byType(SpectrumBackground), + matching: find.byType(CustomPaint), + ), + ); + final canvas = _CountingCanvas(); + paint.painter!.paint(canvas, _viewport); + return canvas; +} + +Future _renderedPixels(WidgetTester tester) async { + final paint = tester.widget( + find.descendant( + of: find.byType(SpectrumBackground), + matching: find.byType(CustomPaint), + ), + ); + final recorder = ui.PictureRecorder(); + paint.painter!.paint(Canvas(recorder), _viewport); + final picture = recorder.endRecording(); + + ByteData? data; + await tester.runAsync(() async { + final image = await picture.toImage( + _viewport.width.round(), + _viewport.height.round(), + ); + data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + }); + return data!; +} + +double _averageRed(ByteData pixels, int row, int fromX, int toX) { + var total = 0.0; + var counted = 0; + for (var x = fromX; x < toX; x++) { + final offset = (row * _viewport.width.round() + x) * 4; + if (pixels.getUint8(offset + 3) == 0) continue; + total += pixels.getUint8(offset); + counted++; + } + return counted == 0 ? 0 : total / counted; +} + +Future _settleBars(WidgetTester tester) async { + for (var i = 0; i < 12; i++) { + await tester.pump(const Duration(milliseconds: 60)); + } +} + +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({ + AppSpectrumBackground.prefKey: true, + }); + await AppSpectrumBackground.load(); + }); + + testWidgets('bars grow from the bottom and stay inside the lower zone', ( + tester, + ) async { + await tester.pumpWidget(_host(brightness: Brightness.dark)); + await _settleBars(tester); + + final canvas = _paintOnce(tester); + expect(canvas.rects, isNotEmpty); + + final zoneTop = _viewport.height * (1 - SpectrumTuning.heightFraction); + for (final rect in canvas.rects) { + expect(rect.bottom, _viewport.height); + expect(rect.top, greaterThanOrEqualTo(zoneTop - 0.01)); + expect(rect.width, SpectrumTuning.barWidth); + } + + final heights = canvas.rects.map((r) => r.height).toSet(); + expect(heights.length, greaterThan(1)); + }); + + testWidgets('bar color is a neutral lift of the surface, not the accent', ( + tester, + ) async { + await tester.pumpWidget( + _host(brightness: Brightness.dark, seed: const Color(0xFFFF4FA3)), + ); + await _settleBars(tester); + + final context = tester.element(find.byType(SpectrumBackground)); + final cs = Theme.of(context).colorScheme; + final bar = _paintOnce(tester).colors.first; + + expect(bar.computeLuminance(), greaterThan(cs.surface.computeLuminance())); + expect( + bar.computeLuminance(), + lessThan(cs.surfaceContainerHighest.computeLuminance()), + ); + + final barHsl = HSLColor.fromColor(bar); + final surfaceHsl = HSLColor.fromColor(cs.surface); + expect(barHsl.saturation, lessThanOrEqualTo(surfaceHsl.saturation + 0.01)); + expect((barHsl.hue - surfaceHsl.hue).abs(), lessThan(1)); + expect(barHsl.lightness, greaterThan(surfaceHsl.lightness)); + }); + + testWidgets('bar count scales to thin lines across the width', ( + tester, + ) async { + await tester.pumpWidget(_host(brightness: Brightness.dark)); + await _settleBars(tester); + + final expected = + (_viewport.width + SpectrumTuning.barGap) / + (SpectrumTuning.barWidth + SpectrumTuning.barGap); + expect(expected, greaterThan(400)); + expect(_paintOnce(tester).rects.length, greaterThan(200)); + }); + + testWidgets('a nearby avatar tints the bars closest to it', (tester) async { + await tester.pumpWidget( + _host( + brightness: Brightness.dark, + overlay: const Align( + alignment: Alignment.bottomLeft, + child: KometAvatar( + name: 'Nova', + size: 48, + backgroundColor: Color(0xFFFF0000), + fadeIn: false, + ), + ), + ), + ); + await _settleBars(tester); + + final pixels = await _renderedPixels(tester); + final row = _viewport.height.round() - 2; + final nearAvatar = _averageRed(pixels, row, 0, 200); + final farSide = _averageRed(pixels, row, 600, 800); + + expect(nearAvatar, greaterThan(farSide)); + }); + + testWidgets('no tint sources leave every bar on the base color', ( + tester, + ) async { + await tester.pumpWidget(_host(brightness: Brightness.dark)); + await _settleBars(tester); + + expect(_paintOnce(tester).colors.toSet().length, 1); + }); +} diff --git a/test/text_entities_test.dart b/test/text_entities_test.dart new file mode 100644 index 0000000..8aa4eb2 --- /dev/null +++ b/test/text_entities_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/utils/text_entities.dart'; + +void main() { + group('detectTextEntities', () { + test('finds a phone and a card in one message', () { + final found = detectTextEntities('+70001234567 тест 2200123456789019'); + + expect(found, hasLength(2)); + expect(found.first.kind, TextEntityKind.phone); + expect(found.first.value, '+70001234567'); + expect(found.last.kind, TextEntityKind.card); + expect(found.last.value, '2200123456789019'); + }); + + test('finds a bare russian phone and a spaced card', () { + final found = detectTextEntities('80001234567 и 2200 1234 5678 9019'); + expect(found.map((e) => e.kind), [ + TextEntityKind.phone, + TextEntityKind.card, + ]); + expect(found.first.value, '+80001234567'); + expect(found.last.value, '2200123456789019'); + }); + + test('ignores digits that are not a valid card', () { + expect(detectTextEntities('111411200327680777'), isEmpty); + expect(detectTextEntities('2200123456789018'), isEmpty); + expect(detectTextEntities('1234567890123456'), isEmpty); + }); + + test('ignores timestamps and short numbers', () { + expect(detectTextEntities('05:46:16 1785041009832'), isEmpty); + }); + + test('finds a nickname but not an email', () { + final found = detectTextEntities('привет @ExampleBot и mail@ya.ru'); + expect(found, hasLength(1)); + expect(found.single.kind, TextEntityKind.mention); + expect(found.single.value, 'ExampleBot'); + expect(found.single.start, 7); + expect(found.single.end, 18); + }); + + test('finds a formatted profile phone', () { + final found = detectTextEntities('+7 (000) 123-45-67'); + expect(found, hasLength(1)); + expect(found.single.kind, TextEntityKind.phone); + expect(found.single.value, '+70001234567'); + }); + + test('skips ranges that are already claimed', () { + const text = 'https://max.ru/ExampleBot'; + expect( + detectTextEntities(text, skip: [(start: 0, end: text.length)]), + isEmpty, + ); + }); + }); + + group('card metadata', () { + test('recognises payment systems by BIN', () { + expect(cardBrand('2200123456789019'), 'MIR'); + expect(cardBrand('4111111111111111'), 'VISA'); + expect(cardBrand('5500000000000004'), 'MASTERCARD'); + expect(cardBrand('340000000000009'), 'AMEX'); + expect(cardBrand('6200000000000005'), 'UNIONPAY'); + expect(cardBrand('1234567890123456'), isNull); + }); + + test('builds the mask shown in the action menu', () { + expect(cardMask('2200123456789019'), 'MIR*9019'); + expect(cardBrandTitle('2200123456789019'), 'МИР'); + }); + + test('formats a card number in groups of four', () { + expect(formatCardNumber('2200123456789019'), '2200 1234 5678 9019'); + }); + + test('luhn rejects a corrupted number', () { + expect(isLuhnValid('2200123456789019'), isTrue); + expect(isLuhnValid('2200123456789018'), isFalse); + }); + }); +} diff --git a/test/text_entity_render_test.dart b/test/text_entity_render_test.dart new file mode 100644 index 0000000..092bd1f --- /dev/null +++ b/test/text_entity_render_test.dart @@ -0,0 +1,204 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/formatted_message_text.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/frontend/widgets/text_entity_actions.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +const String _sample = '+70001234567 тест 2200123456789019 @ExampleBot'; + +CachedMessage _message(String text) => CachedMessage( + id: '1', + accountId: 1, + chatId: 2, + senderId: 1, + text: text, + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', +); + +Future _pump(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align(alignment: Alignment.topLeft, child: child), + ), + ), + ); + await tester.pump(); +} + +TextSpan? _spanWithText(WidgetTester tester, String text) { + TextSpan? found; + for (final widget in tester.widgetList(find.byType(RichText))) { + widget.text.visitChildren((span) { + if (span is TextSpan && span.text == text) { + found = span; + return false; + } + return true; + }); + if (found != null) break; + } + return found; +} + +void main() { + testWidgets('a bubble highlights the phone, the card and the nickname', ( + tester, + ) async { + await _pump( + tester, + MessageBubble( + message: _message(_sample), + isMe: false, + myId: 1, + chatType: 'DIALOG', + ), + ); + + final accent = ThemeData().colorScheme.primary; + final phone = _spanWithText(tester, '+70001234567'); + final card = _spanWithText(tester, '2200123456789019'); + final mention = _spanWithText(tester, '@ExampleBot'); + final plain = _spanWithText(tester, ' тест '); + + expect(phone?.style?.color, accent); + expect(card?.style?.color, accent); + expect(mention?.style?.color, accent); + expect(plain?.style?.color, isNot(accent)); + + expect(phone?.recognizer, isA()); + expect(card?.recognizer, isA()); + expect(mention?.recognizer, isA()); + }); + + testWidgets('a server USER_MENTION by name opens the profile on tap', ( + tester, + ) async { + final message = CachedMessage( + id: '2', + accountId: 1, + chatId: 2, + senderId: 1, + text: '@ExampleBot test', + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', + payload: const { + 'elements': [ + {'entityName': 'ExampleBot', 'type': 'USER_MENTION', 'length': 11}, + ], + }, + ); + + await _pump( + tester, + MessageBubble(message: message, isMe: false, myId: 1, chatType: 'DIALOG'), + ); + + final mention = _spanWithText(tester, '@ExampleBot'); + expect(mention?.style?.color, ThemeData().colorScheme.primary); + expect(mention?.recognizer, isA()); + }); + + testWidgets('copy mode taps instead of opening a menu', (tester) async { + await _pump( + tester, + FormattedMessageText( + text: _sample, + ranges: const [], + entityMode: TextEntityMode.copy, + style: const TextStyle(fontSize: 16), + ), + ); + + expect( + _spanWithText(tester, '+70001234567')?.recognizer, + isA(), + ); + expect( + _spanWithText(tester, '2200123456789019')?.recognizer, + isA(), + ); + }); + + Future openMenuAt(WidgetTester tester, Offset at) async { + await _pump( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () => + showCardEntityMenu(context, '2200123456789019', at: at), + child: const Text('open'), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + Rect menuRect(WidgetTester tester) => tester.getRect( + find + .ancestor( + of: find.text('Скопировать номер карты'), + matching: find.byType(SingleChildScrollView), + ) + .first, + ); + + testWidgets('a menu opened near the bottom flips above the anchor', ( + tester, + ) async { + await openMenuAt(tester, const Offset(200, 940)); + + final screen = tester.view.physicalSize / tester.view.devicePixelRatio; + final rect = menuRect(tester); + + expect(rect.bottom, lessThanOrEqualTo(screen.height - 8)); + expect(rect.bottom, lessThan(940)); + expect(rect.top, greaterThanOrEqualTo(8)); + }); + + testWidgets('a menu opened near the top stays below the anchor', ( + tester, + ) async { + await openMenuAt(tester, const Offset(200, 100)); + + final rect = menuRect(tester); + expect(rect.top, greaterThanOrEqualTo(100)); + }); + + testWidgets('the card menu shows the copy action and the card mask', ( + tester, + ) async { + await _pump( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () => showCardEntityMenu( + context, + '2200123456789019', + at: const Offset(200, 300), + ), + child: const Text('open'), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Скопировать номер карты'), findsOneWidget); + expect(find.text('MIR*9019'), findsOneWidget); + expect(find.text('МИР'), findsOneWidget); + }); +} diff --git a/test/video_editor_test.dart b/test/video_editor_test.dart new file mode 100644 index 0000000..3965766 --- /dev/null +++ b/test/video_editor_test.dart @@ -0,0 +1,318 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'package:komet/core/media/video_transcoder.dart'; +import 'package:komet/frontend/widgets/attachment/editor_common.dart'; +import 'package:komet/frontend/widgets/attachment/video_edit.dart'; + +class _FakePathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + _FakePathProvider(this.dir); + final String dir; + + @override + Future getTemporaryPath() async => dir; +} + +VideoCropEdit _cropEdit({ + required Size viewport, + required Rect crop, + int quarterTurns = 0, + bool flipH = false, + double straightenDeg = 0, +}) => VideoCropEdit( + viewport: viewport, + state: CropState( + quarterTurns: quarterTurns, + flipH: flipH, + straightenDeg: straightenDeg, + cropNorm: Rect.fromLTRB( + crop.left / viewport.width, + crop.top / viewport.height, + crop.right / viewport.width, + crop.bottom / viewport.height, + ), + ), +); + +Future _hasFfmpeg() async { + try { + final probe = await Process.run('ffprobe', const ['-version']); + return probe.exitCode == 0; + } catch (_) { + return false; + } +} + +Future<(int, int, double, bool)> _describe(String path) async { + final out = await Process.run('ffprobe', [ + '-v', + 'error', + '-show_entries', + 'stream=codec_type,width,height:format=duration', + '-of', + 'default=noprint_wrappers=1', + path, + ]); + var width = 0; + var height = 0; + var duration = 0.0; + var hasAudio = false; + for (final line in '${out.stdout}'.split('\n')) { + final parts = line.trim().split('='); + if (parts.length != 2) continue; + switch (parts[0]) { + case 'width': + width = int.tryParse(parts[1]) ?? width; + case 'height': + height = int.tryParse(parts[1]) ?? height; + case 'duration': + duration = double.tryParse(parts[1]) ?? duration; + case 'codec_type': + if (parts[1] == 'audio') hasAudio = true; + } + } + return (width, height, duration, hasAudio); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('VideoGeometry', () { + const source = Size(720, 1280); + + test('без правок кадр остаётся исходным', () { + final geometry = VideoGeometry.resolve(null, source); + expect(geometry.rotationDegrees, 0); + expect(geometry.cropNorm, const Rect.fromLTRB(0, 0, 1, 1)); + expect(geometry.naturalOutput, source); + }); + + test('рамка во весь кадр даёт полные доли', () { + const viewport = Size(400, 800); + final fitted = const CropGeometry(source: source).fittedRect(viewport); + final geometry = VideoGeometry.resolve( + _cropEdit(viewport: viewport, crop: fitted), + source, + ); + expect(geometry.cropNorm.left, closeTo(0, 0.001)); + expect(geometry.cropNorm.top, closeTo(0, 0.001)); + expect(geometry.cropNorm.right, closeTo(1, 0.001)); + expect(geometry.cropNorm.bottom, closeTo(1, 0.001)); + expect(geometry.naturalOutput.width, closeTo(720, 0.5)); + expect(geometry.naturalOutput.height, closeTo(1280, 0.5)); + }); + + test('половина рамки кадрирует ровно половину', () { + const viewport = Size(400, 800); + final fitted = const CropGeometry(source: source).fittedRect(viewport); + final half = Rect.fromLTRB( + fitted.left, + fitted.top, + fitted.center.dx, + fitted.bottom, + ); + final geometry = VideoGeometry.resolve( + _cropEdit(viewport: viewport, crop: half), + source, + ); + expect(geometry.cropNorm.right, closeTo(0.5, 0.001)); + expect(geometry.naturalOutput.width, closeTo(360, 1)); + expect(geometry.naturalOutput.height, closeTo(1280, 1)); + }); + + test('поворот на четверть меняет стороны местами', () { + const viewport = Size(400, 800); + const geometryBase = CropGeometry(source: source, quarterTurns: 1); + final fitted = geometryBase.fittedRect(viewport); + final geometry = VideoGeometry.resolve( + _cropEdit(viewport: viewport, crop: fitted, quarterTurns: 1), + source, + ); + expect(geometry.rotationDegrees, closeTo(90, 0.001)); + expect(geometry.rotatedSize.width, closeTo(1280, 0.5)); + expect(geometry.rotatedSize.height, closeTo(720, 0.5)); + }); + + test('отражение переворачивает знак поворота', () { + const viewport = Size(400, 800); + const geometryBase = CropGeometry( + source: source, + quarterTurns: 1, + flipH: true, + ); + final fitted = geometryBase.fittedRect(viewport); + final geometry = VideoGeometry.resolve( + _cropEdit( + viewport: viewport, + crop: fitted, + quarterTurns: 1, + flipH: true, + ), + source, + ); + expect(geometry.flipH, isTrue); + expect(geometry.rotationDegrees, closeTo(-90, 0.001)); + }); + + test('качество ограничивает короткую сторону и держит её чётной', () { + final geometry = VideoGeometry.resolve(null, source); + expect(geometry.outputSize(480), const Size(480, 854)); + expect(geometry.outputSize(null), source); + expect(geometry.outputSize(2000), source); + }); + + test('матрица цвета переносит сдвиги в четвёртый столбец', () { + final adjust = ColorAdjust(warmth: 0.5); + final gl = glColorMatrix(adjust)!; + final base = adjust.matrix(); + expect(gl.length, 16); + expect(gl[0], closeTo(base[0], 1e-9)); + expect(gl[12], closeTo(base[4] / 255, 1e-9)); + expect(gl[14], closeTo(base[14] / 255, 1e-9)); + expect(gl[15], 1); + expect(glColorMatrix(ColorAdjust()), isNull); + }); + }); + + group('экспорт видео', () { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('komet_video_test'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + }); + + tearDown(() => tmp.deleteSync(recursive: true)); + + test('обрезает, кадрирует, масштабирует и убирает звук', () async { + if (!await _hasFfmpeg()) { + markTestSkipped('ffmpeg недоступен'); + return; + } + final input = File('${tmp.path}/source.mp4'); + final make = await Process.run('ffmpeg', [ + '-y', + '-v', + 'error', + '-f', + 'lavfi', + '-i', + 'testsrc2=size=640x480:rate=30:duration=4', + '-f', + 'lavfi', + '-i', + 'sine=frequency=440:duration=4', + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-c:a', + 'aac', + input.path, + ]); + expect(make.exitCode, 0, reason: '${make.stderr}'); + + final info = await VideoTranscoder.probe(input.path); + expect(info, isNotNull); + expect(info!.width, 640); + expect(info.height, 480); + expect(info.hasAudio, isTrue); + expect(info.durationMs, greaterThan(3500)); + + final source = Size(info.width.toDouble(), info.height.toDouble()); + final edit = VideoEditState() + ..sourceDuration = Duration(milliseconds: info.durationMs) + ..start = const Duration(milliseconds: 500) + ..end = const Duration(milliseconds: 2500) + ..muted = true + ..maxShortSide = 240 + ..adjust = ColorAdjust(contrast: 0.3, vignette: 0.4); + final viewport = const Size(400, 300); + final fitted = CropGeometry(source: source).fittedRect(viewport); + edit.crop = _cropEdit( + viewport: viewport, + crop: Rect.fromLTRB( + fitted.left, + fitted.top, + fitted.center.dx, + fitted.bottom, + ), + ); + + final spec = await buildVideoExportSpec( + edit, + input.path, + source, + info.fps, + ); + expect(spec, isNotNull); + expect(spec!.overlayPath, isNotNull); + expect(File(spec.overlayPath!).existsSync(), isTrue); + + final ok = await VideoTranscoder.export(spec); + expect(ok, isTrue); + + final (width, height, duration, hasAudio) = await _describe(spec.output); + expect(width, spec.outWidth); + expect(height, spec.outHeight); + expect(width, 240); + expect(hasAudio, isFalse); + expect(duration, closeTo(2.0, 0.35)); + }, timeout: const Timeout(Duration(minutes: 3))); + + test('поворачивает и отражает кадр', () async { + if (!await _hasFfmpeg()) { + markTestSkipped('ffmpeg недоступен'); + return; + } + final input = File('${tmp.path}/rotate.mp4'); + final make = await Process.run('ffmpeg', [ + '-y', + '-v', + 'error', + '-f', + 'lavfi', + '-i', + 'testsrc2=size=640x480:rate=30:duration=2', + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + input.path, + ]); + expect(make.exitCode, 0, reason: '${make.stderr}'); + + const source = Size(640, 480); + const viewport = Size(400, 800); + const base = CropGeometry(source: source, quarterTurns: 1, flipH: true); + final edit = VideoEditState() + ..sourceDuration = const Duration(seconds: 2) + ..end = const Duration(seconds: 2) + ..crop = _cropEdit( + viewport: viewport, + crop: base.fittedRect(viewport), + quarterTurns: 1, + flipH: true, + ); + + final spec = await buildVideoExportSpec(edit, input.path, source, 30); + expect(spec, isNotNull); + expect(spec!.rotationDegrees, closeTo(-90, 0.001)); + expect(spec.flipH, isTrue); + expect(spec.outWidth, 480); + expect(spec.outHeight, 640); + + final ok = await VideoTranscoder.export(spec); + expect(ok, isTrue); + + final (width, height, _, _) = await _describe(spec.output); + expect(width, 480); + expect(height, 640); + }, timeout: const Timeout(Duration(minutes: 3))); + }); +} diff --git a/test/video_note_frame_test.dart b/test/video_note_frame_test.dart new file mode 100644 index 0000000..419343e --- /dev/null +++ b/test/video_note_frame_test.dart @@ -0,0 +1,45 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/media/video_note_frame.dart'; + +void main() { + const fallback = 220.0; + + test('a reported frame is used as is', () { + expect( + videoNoteFrameSize(const Size(480, 480), fallback), + const Size(480, 480), + ); + expect( + videoNoteFrameSize(const Size(640, 360), fallback), + const Size(640, 360), + ); + }); + + test('an unreported frame falls back to a square of the circle size', () { + expect(videoNoteFrameSize(Size.zero, fallback), const Size(220, 220)); + }); + + test('a half-reported frame keeps the dimension it does have', () { + expect( + videoNoteFrameSize(const Size(480, 0), fallback), + const Size(480, 220), + ); + expect( + videoNoteFrameSize(const Size(0, 480), fallback), + const Size(220, 480), + ); + }); + + test('negative and non-finite dimensions fall back', () { + expect( + videoNoteFrameSize(const Size(-1, -1), fallback), + const Size(220, 220), + ); + expect( + videoNoteFrameSize(const Size(double.nan, double.infinity), fallback), + const Size(220, 220), + ); + }); +} diff --git a/test/video_note_layout_test.dart b/test/video_note_layout_test.dart new file mode 100644 index 0000000..74ff904 --- /dev/null +++ b/test/video_note_layout_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; + +const int _me = 1; +const int _peer = 7; +const int _longNoteMs = 45000; + +CachedMessage _note({required int durationMs}) => CachedMessage( + id: '1', + accountId: _me, + chatId: 2, + senderId: _peer, + time: DateTime(2026, 1, 1, 5, 46).millisecondsSinceEpoch, + status: 'sent', + attachments: [ + VideoAttachment( + videoId: 4242, + videoToken: 'synthetic-token', + videoType: 1, + width: 400, + height: 400, + duration: durationMs, + ), + ], +); + +Future _pumpNote(WidgetTester tester, {required double screenWidth}) async { + tester.view.physicalSize = Size(screenWidth * 2, 2400); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: MessageBubble( + message: _note(durationMs: _longNoteMs), + isMe: false, + myId: _me, + chatType: 'DIALOG', + ), + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('у кружка есть длительность слева на одном Y с временем', ( + tester, + ) async { + await _pumpNote(tester, screenWidth: 390); + + final duration = find.text('0:45'); + final clock = find.text('05:46'); + expect(duration, findsOneWidget); + expect(clock, findsOneWidget); + + final durationBox = tester.getRect(duration); + final clockBox = tester.getRect(clock); + + expect( + durationBox.center.dy, + closeTo(clockBox.center.dy, 1.0), + reason: 'длительность и время должны быть на одном Y', + ); + expect( + durationBox.right, + lessThan(clockBox.left), + reason: 'длительность должна быть слева от времени', + ); + + final circle = tester.getRect(find.byType(VideoNoteBubble)); + expect(durationBox.left, closeTo(circle.left, 8.0)); + expect(clockBox.right, closeTo(circle.right, 12.0)); + }); + + testWidgets('свёрнутый кружок сохраняет базовый размер', (tester) async { + await _pumpNote(tester, screenWidth: 390); + + final size = tester.getSize(find.byType(VideoNoteBubble)); + expect(size.width, closeTo(210, 0.5)); + }); + + testWidgets('кружку хватает ширины под увеличение', (tester) async { + const expanded = 210 * 1.7; + final reached = {}; + + for (final screenWidth in [360.0, 390.0, 412.0, 800.0]) { + await _pumpNote(tester, screenWidth: screenWidth); + expect( + tester.takeException(), + isNull, + reason: 'переполнение раскладки при ширине $screenWidth', + ); + + final box = + tester.renderObject(find.byType(VideoNoteBubble)) as RenderBox; + final available = box.constraints.maxWidth; + reached[screenWidth] = (available / 210).clamp(1.0, 1.7); + + expect( + available, + lessThanOrEqualTo(screenWidth), + reason: 'кружку дали больше ширины, чем есть на экране', + ); + } + + expect(reached[390.0], closeTo(1.7, 0.001)); + expect(reached[412.0], closeTo(1.7, 0.001)); + expect(reached[800.0], closeTo(1.7, 0.001)); + expect(reached[360.0], closeTo((360 - 24) / 210, 0.001)); + expect(reached[360.0], greaterThan(1.55)); + expect(expanded, 357); + }); +} diff --git a/test/webapp_bridge_test.dart b/test/webapp_bridge_test.dart new file mode 100644 index 0000000..cc13c81 --- /dev/null +++ b/test/webapp_bridge_test.dart @@ -0,0 +1,191 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/frontend/screens/webapp/web_app_bridge.dart'; + +void main() { + late List<(String, Map, bool)> sent; + late int closeCalls; + + WebAppBridge buildBridge({ + bool privateChannel = false, + String entryPoint = WebAppEntryPoint.webApp, + }) { + return WebAppBridge( + botId: 777, + entryPoint: entryPoint, + privateChannel: privateChannel, + contextResolver: () => null, + viewportResolver: () => const Size(420, 800), + onClose: () => closeCalls++, + emitter: (method, payload, private) => sent.add(( + method, + jsonDecode(payload) as Map, + private, + )), + ); + } + + setUp(() { + sent = []; + closeCalls = 0; + }); + + test('reports the launch context it was created with', () async { + final bridge = buildBridge(entryPoint: WebAppEntryPoint.inlineButton); + + await bridge.handleEvent( + 'WebAppGetLaunchContext', + '{"requestId":"r1"}', + false, + ); + + expect(sent, hasLength(1)); + expect(sent.first.$1, 'WebAppGetLaunchContext'); + expect(sent.first.$2, { + 'requestId': 'r1', + 'entryPoint': 'inline_button', + }); + }); + + test('answers viewport requests with the current webview size', () async { + final bridge = buildBridge(); + + await bridge.handleEvent( + 'WebAppGetViewportSize', + '{"requestId":"r2"}', + false, + ); + + expect(sent.first.$2['width'], 420); + expect(sent.first.$2['height'], 800); + expect(sent.first.$2['isStateStable'], isTrue); + }); + + test('rejects an unknown method with the client error code', () async { + final bridge = buildBridge(); + + await bridge.handleEvent('WebAppSomethingElse', '{"requestId":"r3"}', false); + + expect(sent.first.$2['error'], { + 'code': 'client.unsupported_method.unsupported_method', + }); + }); + + test('stays silent for methods that never get an answer', () async { + final bridge = buildBridge(); + + await bridge.handleEvent('WebAppReady', '{}', false); + await bridge.handleEvent('WebAppStat', '{}', false); + + expect(sent, isEmpty); + }); + + test('drops gesture-gated methods until the user touches the page', () async { + final bridge = buildBridge(); + + await bridge.handleEvent( + 'WebAppShare', + '{"requestId":"r4","text":"hi"}', + false, + ); + expect(sent, isEmpty); + + bridge.registerGesture(); + await bridge.handleEvent( + 'WebAppShare', + '{"requestId":"r5"}', + false, + ); + + expect(sent.single.$2['error'], {'code': 'client.web_app_share.invalid_request'}); + }); + + test('ignores private-channel events when the channel is off', () async { + final bridge = buildBridge(); + + await bridge.handleEvent( + 'WebAppVerifyMobileId', + '{"requestId":"r6","url":"https://example.test/verify"}', + true, + ); + + expect(sent, isEmpty); + }); + + test('reports malformed payloads as a decode error', () async { + final bridge = buildBridge(); + + await bridge.handleEvent('WebAppGetViewportSize', 'not-json', false); + + expect(sent, isEmpty); + }); + + test('tracks the back button and closing behaviour the app asked for', () async { + final bridge = buildBridge(); + + expect(bridge.handlesBackButton, isFalse); + expect(bridge.needsCloseConfirmation, isFalse); + + await bridge.handleEvent( + 'WebAppSetupBackButton', + '{"isVisible":true}', + false, + ); + await bridge.handleEvent( + 'WebAppSetupClosingBehavior', + '{"needConfirmation":true}', + false, + ); + + expect(bridge.handlesBackButton, isTrue); + expect(bridge.needsCloseConfirmation, isTrue); + + bridge.notifyBackPressed(); + expect(sent.single.$1, 'WebAppBackButtonPressed'); + }); + + test('closes the screen when the app asks to', () async { + final bridge = buildBridge(); + + await bridge.handleEvent('WebAppClose', '{}', false); + + expect(closeCalls, 1); + }); + + test('echoes the screen capture behaviour back', () async { + final bridge = buildBridge(); + + await bridge.handleEvent( + 'WebAppSetupScreenCaptureBehavior', + '{"requestId":"r7","isScreenCaptureEnabled":true}', + false, + ); + + expect(sent.first.$2, { + 'requestId': 'r7', + 'isScreenCaptureEnabled': true, + }); + }); + + test('answers NFC availability without pretending to support it', () async { + final bridge = buildBridge(); + + await bridge.handleEvent('WebAppNfcGetInfo', '{"requestId":"r8"}', false); + await bridge.handleEvent( + 'WebAppNfcEmulateNfcTag', + '{"requestId":"r9"}', + false, + ); + + expect(sent[0].$2, { + 'requestId': 'r8', + 'available': false, + 'enabled': false, + }); + expect(sent[1].$2['error'], { + 'code': 'client.nfc_emulate_nfc_tag.not_supported', + }); + }); +} diff --git a/test/webapp_module_test.dart b/test/webapp_module_test.dart new file mode 100644 index 0000000..38fcd4a --- /dev/null +++ b/test/webapp_module_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/webapp.dart'; + +void main() { + group('ExternalCallbackResult', () { + test('parses callback response fields', () { + final result = ExternalCallbackResult.fromPayload({ + 'botId': '123456', + 'startParam': 'esia-complete', + }); + + expect(result?.botId, 123456); + expect(result?.startParam, 'esia-complete'); + }); + + test('parses a nested protocol response', () { + final result = ExternalCallbackResult.fromPayload({ + 'data': {'bot_id': 42, 'start_param': 'done'}, + }); + + expect(result?.botId, 42); + expect(result?.startParam, 'done'); + }); + + test('rejects a response without a bot id', () { + expect( + ExternalCallbackResult.fromPayload({ + 'data': {'startParam': 'done'}, + }), + isNull, + ); + }); + }); +} diff --git a/test/webview_scheme_test.dart b/test/webview_scheme_test.dart new file mode 100644 index 0000000..0c23bda --- /dev/null +++ b/test/webview_scheme_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/links/max_link.dart'; +import 'package:komet/core/utils/link_opener.dart'; + +void main() { + group('leavesWebView', () { + test('keeps page navigation inside the web view', () { + for (final scheme in [ + 'http', + 'https', + 'HTTPS', + 'about', + 'data', + 'blob', + ]) { + expect(leavesWebView(scheme), isFalse, reason: scheme); + } + }); + + test('hands app schemes over to the app', () { + for (final scheme in ['max', 'MAX', 'komet', 'tel', 'mailto', 'intent']) { + expect(leavesWebView(scheme), isTrue, reason: scheme); + } + }); + + test('treats a missing scheme as in-page', () { + expect(leavesWebView(null), isFalse); + expect(leavesWebView(''), isFalse); + }); + }); + + test('a max deep link from a web view resolves to in-app content', () { + expect(MaxLink.parse('max://max.ru/somechannel'), isA()); + expect(MaxLink.isMaxLink('max://max.ru/?cid=424242'), isTrue); + }); +} diff --git a/third_party/rlottie.podspec b/third_party/rlottie.podspec index 4d80705..f9c74cc 100644 --- a/third_party/rlottie.podspec +++ b/third_party/rlottie.podspec @@ -24,10 +24,24 @@ Pod::Spec.new do |s| 'rlottie/src/vector/stb/*.{cpp,h}', 'rlottie/src/binding/c/*.cpp', ] - s.exclude_files = ['rlottie/src/vector/pixman/*.S'] + # rapidjson's msinttypes/ are MSVC-only shims (guarded by _MSC_VER in + # rapidjson.h); on Apple clang they must not be compiled — the module build + # would otherwise hit their `#error "Use this header only with MSVC"`. + s.exclude_files = [ + 'rlottie/src/vector/pixman/*.S', + 'rlottie/src/lottie/rapidjson/msinttypes/*.h', + ] s.public_header_files = 'rlottie/inc/*.h' s.pod_target_xcconfig = { + # On Apple arm64 the compiler predefines __ARM_NEON__, which pulls in + # vdrawhelper_neon.cpp's hand-asm blitter calling pixman_composite_*_asm_neon + # — defined only in pixman-arm-neon-asm.S, which we can't assemble in the pod + # (excluded above) → undefined symbols at link. Drop the hand-asm path (like + # the CMake build does for 32-bit ARM) and let the C blitter compile; clang + # still auto-vectorizes it to NEON. No-op on x86_64 (macro undefined there). + 'OTHER_CFLAGS' => '$(inherited) -U__ARM_NEON__', + 'OTHER_CPLUSPLUSFLAGS' => '$(inherited) -U__ARM_NEON__', 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++14', 'CLANG_CXX_LIBRARY' => 'libc++', 'GCC_ENABLE_CPP_EXCEPTIONS' => 'NO', diff --git a/tool/make_morph_icons.py b/tool/make_morph_icons.py new file mode 100644 index 0000000..ae06090 --- /dev/null +++ b/tool/make_morph_icons.py @@ -0,0 +1,1030 @@ +"""Собирает lottie-морфы иконок прямо из шрифта Material Symbols. + +Контуры глифов берутся из MaterialSymbolsOutlined.ttf (инстанс по умолчанию — +FILL 0, GRAD 0, opsz 24, wght 400, то есть ровно то, что рисует Icon в приложении), +разбиваются на равное число безье-сегментов и попарно сопоставляются, чтобы +lottie мог интерполировать один глиф в другой. Спекам с fill=1 контуры считаются +по FILL=1 — для кнопок, которые рисуют Icon(..., fill: 1). + +SPECS — морфы композера (ComposerMorphIcon), проигрываются вперёд. +SLASH_SPECS — переключатели «обычная/перечёркнутая» (LottieSlashIcon): оба глифа +лежат статикой, а перечёркивание рисуется бегущей по диагонали маской, поэтому +одного ассета хватает на оба направления. + + 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() + self._read_fvar() + self._read_gvar() + + 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 _read_fvar(self): + off, _ = self.tables['fvar'] + axes_off, _, axis_count, axis_size = struct.unpack( + '>HHHH', self.data[off + 4:off + 12]) + self.axes = [] + for i in range(axis_count): + p = off + axes_off + i * axis_size + self.axes.append(self.data[p:p + 4].decode('latin1')) + + def _read_gvar(self): + off, _ = self.tables['gvar'] + axis_count, shared_count, shared_off, glyph_count, flags, data_off = ( + struct.unpack('>HHIHHI', self.data[off + 4:off + 20])) + base = off + 20 + if flags & 1: + raw = struct.unpack( + '>%dI' % (glyph_count + 1), self.data[base:base + 4 * (glyph_count + 1)]) + offsets = list(raw) + else: + raw = struct.unpack( + '>%dH' % (glyph_count + 1), self.data[base:base + 2 * (glyph_count + 1)]) + offsets = [v * 2 for v in raw] + shared = [] + p = off + shared_off + for i in range(shared_count): + step = 2 * axis_count + shared.append(struct.unpack('>%dh' % axis_count, + self.data[p + i * step:p + (i + 1) * step])) + self.gvar = { + 'axis_count': axis_count, + 'shared': shared, + 'offsets': offsets, + 'data': off + data_off, + } + + def _axis_deltas(self, gid, axis, contours): + """Deltas that move the glyph to the `axis`=1 instance. + + Only tuples peaking on `axis` alone contribute: every other tuple is + multiplied by an axis coordinate that stays at its default zero. + """ + gvar = self.gvar + start = gvar['data'] + gvar['offsets'][gid] + end = gvar['data'] + gvar['offsets'][gid + 1] + if end <= start: + return None + + d = self.data[start:end] + axis_count = gvar['axis_count'] + index = self.axes.index(axis) + n_points = sum(len(c) for c in contours) + 4 + + tuple_count, cursor = struct.unpack('>HH', d[0:4]) + shared_points = None + if tuple_count & 0x8000: + shared_points, cursor = _packed_points(d, cursor) + + total = [(0.0, 0.0)] * n_points + applied = False + p = 4 + for _ in range(tuple_count & 0x0FFF): + var_size, tuple_index = struct.unpack('>HH', d[p:p + 4]) + p += 4 + if tuple_index & 0x8000: + peak = struct.unpack('>%dh' % axis_count, d[p:p + 2 * axis_count]) + p += 2 * axis_count + else: + peak = gvar['shared'][tuple_index & 0x0FFF] + if tuple_index & 0x4000: + p += 4 * axis_count + block, cursor = cursor, cursor + var_size + + if peak[index] <= 0 or any( + v for i, v in enumerate(peak) if i != index): + continue + + q = block + points = shared_points + if tuple_index & 0x2000: + points, q = _packed_points(d, q) + size = n_points if points is None else len(points) + xs, q = _packed_deltas(d, q, size) + ys, _ = _packed_deltas(d, q, size) + + scale = 16384.0 / peak[index] + sparse = [None] * n_points + for k, point in enumerate(range(size) if points is None else points): + if point < n_points: + sparse[point] = (xs[k] * scale, ys[k] * scale) + _infer_deltas(contours, sparse) + total = [(a[0] + b[0], a[1] + b[1]) for a, b in zip(total, sparse)] + applied = True + + return total if applied else None + + def contours(self, codepoint, fill=0.0): + gid = self.cmap[codepoint] + contours = self._glyph_contours(gid) + if fill <= 0: + return contours + if self._is_composite(gid): + raise SystemExit('fill=1 не поддержан для составного глифа %04X' + % codepoint) + + deltas = self._axis_deltas(gid, 'FILL', contours) + if deltas is None: + return contours + + out = [] + index = 0 + for contour in contours: + shifted = [] + for x, y, on in contour: + dx, dy = deltas[index] + index += 1 + shifted.append((x + dx * fill, y + dy * fill, on)) + out.append(shifted) + return out + + def _is_composite(self, gid): + goff, _ = self.tables['glyf'] + start, end = self.loca[gid], self.loca[gid + 1] + if start == end: + return False + return struct.unpack('>h', self.data[goff + start:goff + start + 2])[0] < 0 + + 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 _packed_points(d, p): + """gvar packed point numbers; None means «все точки глифа».""" + count = d[p] + p += 1 + if count == 0: + return None, p + if count & 0x80: + count = ((count & 0x7F) << 8) | d[p] + p += 1 + points, value = [], 0 + while len(points) < count: + control = d[p] + p += 1 + run = (control & 0x7F) + 1 + for _ in range(run): + if control & 0x80: + value += struct.unpack('>H', d[p:p + 2])[0] + p += 2 + else: + value += d[p] + p += 1 + points.append(value) + return points[:count], p + + +def _packed_deltas(d, p, count): + out = [] + while len(out) < count: + control = d[p] + p += 1 + run = (control & 0x3F) + 1 + if control & 0x80: + out.extend([0] * run) + elif control & 0x40: + for _ in range(run): + out.append(struct.unpack('>h', d[p:p + 2])[0]) + p += 2 + else: + for _ in range(run): + out.append(struct.unpack('>b', d[p:p + 1])[0]) + p += 1 + return out[:count], p + + +def _interpolate(v, v1, d1, v2, d2): + if v1 > v2: + v1, d1, v2, d2 = v2, d2, v1, d1 + if v1 == v2: + return d1 if d1 == d2 else 0.0 + if v <= v1: + return d1 + if v >= v2: + return d2 + return d1 + (d2 - d1) * (v - v1) / (v2 - v1) + + +def _infer_deltas(contours, deltas): + """IUP: точки, которых нет в тапле, тянутся за соседними опорными.""" + first = 0 + for contour in contours: + last = first + len(contour) - 1 + refs = [i for i in range(first, last + 1) if deltas[i] is not None] + if not refs: + for i in range(first, last + 1): + deltas[i] = (0.0, 0.0) + elif len(refs) == 1: + for i in range(first, last + 1): + deltas[i] = deltas[refs[0]] + else: + for k, a in enumerate(refs): + b = refs[(k + 1) % len(refs)] + i = first if a == last else a + 1 + while i != b: + deltas[i] = ( + _interpolate(contour[i - first][0], + contour[a - first][0], deltas[a][0], + contour[b - first][0], deltas[b][0]), + _interpolate(contour[i - first][1], + contour[a - first][1], deltas[a][1], + contour[b - first][1], deltas[b][1]), + ) + i = first if i == last else i + 1 + first = last + 1 + for i, value in enumerate(deltas): + if value is None: + deltas[i] = (0.0, 0.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, fill=0.0): + out = [] + for contour in _font.contours(codepoint, fill): + 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, fill=0.0): + """[(path_from, path_to), ...] with matching vertex counts and winding.""" + src = glyph_paths(from_cp, count, fill) + dst = glyph_paths(to_cp, count, fill) + 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 +MIC_OFF = 0xE02B +CAM = 0xE04B +CAM_OFF = 0xE04C +SEND = 0xE163 +VOLUME_UP = 0xE050 +VOLUME_OFF = 0xE04F +FLASH_ON = 0xE3E7 +FLASH_OFF = 0xE3E6 + +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 _group(items, name): + items = list(items) + 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 { + 'ty': 'gr', + 'it': items, + 'nm': name, + 'np': len(items), + 'cix': 2, + 'bm': 0, + 'ix': 1, + 'mn': 'ADBE Vector Group', + 'hd': False, + } + + +def static_shape(index, path): + return { + 'ind': index, + 'ty': 'sh', + 'ix': index + 1, + 'ks': {'a': 0, 'k': path_value(path), '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, + fill=0.0): + pairs = pair_glyphs(from_cp, to_cp, POINTS, fill) + items = [shape_item(i, a, b) for i, (a, b) in enumerate(pairs)] + + 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': [_group(items, 'Group 1')], + 'ip': 0, + 'op': DUR, + 'st': 0, + 'bm': 0, + }], + 'markers': [], + } + + +def _wipe_quad(cut, ahead): + """Половина плоскости по обе стороны от диагонали x + y = cut.""" + reach = CANVAS * 1.5 + mid = (cut / 2, cut / 2) + along = (reach / math.sqrt(2), -reach / math.sqrt(2)) + depth = reach * math.sqrt(2) * (1 if ahead else -1) + corners = [ + (mid[0] + along[0], mid[1] + along[1]), + (mid[0] - along[0], mid[1] - along[1]), + (mid[0] - along[0] + depth, mid[1] - along[1] + depth), + (mid[0] + along[0] + depth, mid[1] + along[1] + depth), + ] + return { + 'i': [[0, 0]] * 4, + 'o': [[0, 0]] * 4, + 'v': [[r2(x), r2(y)] for x, y in corners], + 'c': True, + } + + +def wipe_mask(span, ahead): + start, end = span + return [{ + 'inv': False, + 'mode': 'a', + 'pt': { + 'a': 1, + 'k': [ + {'i': EASE_IN, 'o': EASE_OUT, 't': 0, + 's': [_wipe_quad(start, ahead)]}, + {'t': DUR, 's': [_wipe_quad(end, ahead)]}, + ], + 'ix': 1, + }, + 'o': {'a': 0, 'k': 100, 'ix': 3}, + 'x': {'a': 0, 'k': 0, 'ix': 4}, + 'nm': 'Wipe', + }] + + +def _diagonal_span(*glyphs): + values = [v[0][0] + v[0][1] for paths in glyphs for _, path in paths + for v in path] + margin = CANVAS * 0.04 + return min(values) - margin, max(values) + margin + + +def build_slash(name, plain_cp, slashed_cp, fill=0.0, scale=None): + """Кадр 0 — обычный глиф, последний — перечёркнутый. + + Оба глифа лежат статичными слоями, а по диагонали (перпендикулярно самой + перечёркивающей линии) едет маска: перечёркнутый слой открывается ровно там, + где обычный закрывается, поэтому линия выглядит нарисованной поверх иконки. + """ + plain = glyph_paths(plain_cp, POINTS, fill) + slashed = glyph_paths(slashed_cp, POINTS, fill) + span = _diagonal_span(plain, slashed) + + def layer(index, paths, ahead, title): + items = [static_shape(i, path) for i, (_, path) in enumerate(paths)] + return { + 'ddd': 0, + 'ind': index, + 'ty': 4, + 'nm': title, + 'sr': 1, + 'ks': transform(scale=scale), + 'ao': 0, + 'hasMask': True, + 'masksProperties': wipe_mask(span, ahead), + 'shapes': [_group(items, title)], + 'ip': 0, + 'op': DUR, + 'st': 0, + 'bm': 0, + } + + return { + 'v': '5.12.1', + 'fr': FPS, + 'ip': 0, + 'op': DUR, + 'w': int(CANVAS), + 'h': int(CANVAS), + 'nm': name, + 'ddd': 0, + 'assets': [], + 'layers': [ + layer(1, slashed, False, 'slashed'), + layer(2, plain, True, 'plain'), + ], + '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)], + ), +] + + +SLASH_SPECS = [ + dict( + name='ic_flash_on_to_off', + plain_cp=FLASH_ON, slashed_cp=FLASH_OFF, + fill=1.0, + scale=[(0, 100), (11, 92), (DUR, 100)], + ), + dict( + name='ic_volume_on_to_off', + plain_cp=VOLUME_UP, slashed_cp=VOLUME_OFF, + fill=1.0, + scale=[(0, 100), (11, 92), (DUR, 100)], + ), + dict( + name='ic_mic_on_to_off', + plain_cp=MIC, slashed_cp=MIC_OFF, + fill=1.0, + scale=[(0, 100), (11, 92), (DUR, 100)], + ), + dict( + name='ic_videocam_on_to_off', + plain_cp=CAM, slashed_cp=CAM_OFF, + fill=1.0, + scale=[(0, 100), (11, 92), (DUR, 100)], + ), +] + + +def _write(name, data): + path = os.path.join(OUT_DIR, name + '.json') + with open(path, 'w') as fh: + json.dump(data, fh, separators=(',', ':')) + print(f'{name:24s} {os.path.getsize(path) // 1024:3d} KB ' + f'layers={len(data["layers"])}') + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + for spec in SPECS: + _write(spec['name'], build(**spec)) + for spec in SLASH_SPECS: + _write(spec['name'], build_slash(**spec)) + + +if __name__ == '__main__': + main()