diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 0000000..140dc22 --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,22 @@ +name: Setup Rust +description: > + Install the Rust toolchain with platform targets and a build cache, so + cargokit can compile the kolibri native library during the Flutter build. + +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: third_party/kolibri/kolibri-dart/rust diff --git a/.github/workflows/build-android-fcm.yml b/.github/workflows/build-android-fcm.yml index 015969d..3fef1cb 100644 --- a/.github/workflows/build-android-fcm.yml +++ b/.github/workflows/build-android-fcm.yml @@ -25,6 +25,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -39,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 50cef79..390a215 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -25,6 +25,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -39,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 3f1b8ce..8ee23ef 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -35,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 diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 650c4b5..d47216f 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -40,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 62d1888..0c07ea5 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -35,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 cad04ce..ede96ba 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -35,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 eff518a..c3a09fa 100644 --- a/.github/workflows/flutter-dev.yml +++ b/.github/workflows/flutter-dev.yml @@ -12,6 +12,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -27,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 diff --git a/.github/workflows/flutter-main.yml b/.github/workflows/flutter-main.yml index b6f240a..3e065f9 100644 --- a/.github/workflows/flutter-main.yml +++ b/.github/workflows/flutter-main.yml @@ -11,6 +11,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -26,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 @@ -64,11 +71,13 @@ jobs: - name: Build Android APK run: flutter build apk --release --flavor komet - web-linux: + linux: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Set up Flutter uses: subosito/flutter-action@v2 @@ -81,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 @@ -95,6 +106,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Set up Flutter uses: subosito/flutter-action@v2 @@ -102,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 @@ -113,6 +131,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 + with: + submodules: recursive - name: Set up Flutter uses: subosito/flutter-action@v2 @@ -120,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 ee4f5ca..6471534 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -26,6 +26,8 @@ jobs: flavor: [komet, oneme] steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -40,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 @@ -101,11 +108,17 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 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 @@ -127,6 +140,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -136,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 @@ -156,11 +175,17 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 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 @@ -184,11 +209,17 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 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) diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index 18e45cc..7827214 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -21,6 +21,8 @@ jobs: flavor: [komet, oneme] steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 @@ -35,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 @@ -96,11 +103,17 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 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 @@ -122,6 +135,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -131,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 @@ -151,11 +170,17 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 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 @@ -177,11 +202,17 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup Flutter uses: subosito/flutter-action@v2 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) @@ -238,6 +269,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: recursive - name: Download all artifacts uses: actions/download-artifact@v4 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4032a51 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "third_party/rlottie"] + path = third_party/rlottie + url = https://github.com/Samsung/rlottie.git +[submodule "third_party/kolibri"] + path = third_party/kolibri + url = https://github.com/KometTeam/kolibri.git diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b424e3e..169f484 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -37,6 +37,18 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + externalNativeBuild { + cmake { + arguments += listOf("-DBUILD_SHARED_LIBS=ON") + } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } } flavorDimensions += "distribution" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e93a31e..6c621f0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,7 +33,7 @@ android:enableOnBackInvokedCallback="true" android:icon="@mipmap/ic_launcher"> + android:targetActivity="ru.komet.app.MainActivity"> diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..fd0b9b7 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.10) + +project(komet_rlottie LANGUAGES C CXX ASM) + +add_subdirectory( + "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third_party/rlottie_build" + "${CMAKE_BINARY_DIR}/rlottie") 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 1257145..30f6d86 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -52,7 +52,11 @@ import java.util.concurrent.atomic.AtomicBoolean class MainActivity : FlutterActivity() { private val channelName = "ru.komet.app/vpn_bypass" - private val iconComponents = listOf("MainActivity", "MinimalIcon") + private val iconPackage = MainActivity::class.java.name.substringBeforeLast('.') + private val iconComponents = mapOf( + "MainActivity" to "$iconPackage.MainActivity", + "MinimalIcon" to "$iconPackage.MinimalIcon", + ) private var nfcAdapter: NfcAdapter? = null private var nfcEvents: EventChannel.EventSink? = null @@ -86,8 +90,8 @@ class MainActivity : FlutterActivity() { private fun applyIcon(name: String) { val pm = packageManager - for (alias in iconComponents) { - val component = ComponentName(packageName, "$packageName.$alias") + for ((alias, className) in iconComponents) { + val component = ComponentName(packageName, className) val state = if (alias == name) { PackageManager.COMPONENT_ENABLED_STATE_ENABLED } else { @@ -165,7 +169,7 @@ class MainActivity : FlutterActivity() { when (call.method) { "setAppIcon" -> { val name = call.argument("name") - if (name == null || !iconComponents.contains(name)) { + if (name == null || !iconComponents.containsKey(name)) { result.error("INVALID_ICON", "Unknown icon: $name", null) return@setMethodCallHandler } diff --git a/assets/wallpapers/patterns/bubbles.svg b/assets/wallpapers/patterns/bubbles.svg new file mode 100644 index 0000000..590fc10 --- /dev/null +++ b/assets/wallpapers/patterns/bubbles.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/hearts.svg b/assets/wallpapers/patterns/hearts.svg new file mode 100644 index 0000000..ac5f0d8 --- /dev/null +++ b/assets/wallpapers/patterns/hearts.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/planes.svg b/assets/wallpapers/patterns/planes.svg new file mode 100644 index 0000000..525b9dd --- /dev/null +++ b/assets/wallpapers/patterns/planes.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/plus.svg b/assets/wallpapers/patterns/plus.svg new file mode 100644 index 0000000..fb558b6 --- /dev/null +++ b/assets/wallpapers/patterns/plus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/rings.svg b/assets/wallpapers/patterns/rings.svg new file mode 100644 index 0000000..cbd62a4 --- /dev/null +++ b/assets/wallpapers/patterns/rings.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/wallpapers/patterns/stars.svg b/assets/wallpapers/patterns/stars.svg new file mode 100644 index 0000000..88c305a --- /dev/null +++ b/assets/wallpapers/patterns/stars.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ios/Podfile b/ios/Podfile index 57209b3..fc81fe8 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -62,6 +62,7 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! + pod 'rlottie', :path => '../third_party' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 0be7e66..fa39b16 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -1,46 +1,52 @@ 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; @@ -49,6 +55,9 @@ class Api { int? get callsSeed => _callsSeed; String? get deviceId => _deviceId; + /// Сырой доступ к сессии ядра — для медиа-загрузок (data-plane). + KolibriSession? get session => _session; + String? spoofScope; static bool _tzInitialized = false; @@ -63,28 +72,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,31 +106,17 @@ 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; try { endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout); @@ -137,80 +129,68 @@ class Api { } if (gen != _connectGen) return; + 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 +224,6 @@ class Api { _connectGen++; _cancelConnectWatchdog(); _cleanup(); - try { - await _connection.disconnect(); - } catch (_) {} _setSessionState(SessionState.disconnected); if (_autoReconnect) _scheduleReconnect(); } @@ -254,37 +231,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,7 +265,101 @@ class Api { } } - Future sendHandshake() async { + /// Отправляет запрос и ждёт ответ от сервера. + Future sendRequest(int opcode, Map payload) 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)} таймаут'), + ); + + 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) _errorController.add(text); + final err = PacketError( + messageFromErrorPayload(packet.payload), + errorKey: resp.errorKey, + ); + throw err; + } + return packet; + } + + Future?> sendRequestMap( + int opcode, + Map payload, + ) async { + final response = await sendRequest(opcode, payload); + if (!response.isOk || response.payload is! Map) return null; + return response.payload as Map; + } + + Future sendRequestOk(int opcode, Map payload) async { + final response = await sendRequest(opcode, payload); + return response.isOk; + } + + Future sendRequestOrThrow( + int opcode, + Map payload, + ) async { + final response = await sendRequest(opcode, payload); + throwIfPacketError(response); + return response; + } + + /// Вешает обработчик на пуши с указанным опкодом. + void registerPushHandler(int opcode, void Function(Packet) handler) { + _dispatcher.registerHandler(opcode, handler); + } + + /// Снимает обработчик пушей с указанного опкода. + void unregisterPushHandler(int opcode) { + _dispatcher.unregisterHandler(opcode); + } + + /// Стрим всех входящих пушей от сервера. + Stream get pushStream => _dispatcher.pushStream; + + Future dispose() async { + _autoReconnect = false; + _reconnectTimer?.cancel(); + _cleanup(); + _dispatcher.dispose(); + await _stateController.close(); + await _sessionExpiredController.close(); + await _handshakeSuccessController.close(); + await _errorController.close(); + } + + // Внутрянка + + /// Строит устройство-поля и создаёт сессию ядра. Заодно заполняет + /// [_userAgent] и [_deviceId] для геттеров. + Future<(KolibriSession, Stream)> _buildSessionOptions( + ({String host, int port}) endpoint, + ) async { final deviceInfo = DeviceInfoPlugin(); String deviceType = 'ANDROID'; @@ -404,95 +460,100 @@ class Api { 'deviceName': deviceName, 'deviceLocale': deviceLocale, }; - _deviceId = deviceId; - final payload = { - 'mt_instanceid': instanceId, - 'userAgent': _userAgent, - 'clientSessionId': clientSessionId, - 'deviceId': deviceId, - }; + final insecureTls = await TlsConfig.isInsecureAllowed(); + final proxy = await _buildProxyUrl(); - return sendRequest(Opcode.sessionInit, payload); + 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, + ); } - /// Отправляет запрос и ждёт ответ от сервера. - Future sendRequest(int opcode, Map payload) { - final seq = _sender.send(_connection, opcode, payload); - DebugSessionLog.instance.recordRequest(opcode, seq, payload); - return _dispatcher - .registerPending(seq) - .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); - }, - ); + 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}'; } - Future?> sendRequestMap( - int opcode, - Map payload, - ) async { - final response = await sendRequest(opcode, payload); - if (!response.isOk || response.payload is! Map) return null; - return response.payload as Map; + void _onPush((int, Map) event) { + final packet = Packet( + cmd: CmdType.push, + opcode: event.$1, + payload: event.$2, + ); + // Учёт трафика пушей ведётся из wire-лога ядра (_onWireLog). + _dispatcher.dispatch(packet); } - Future sendRequestOk(int opcode, Map payload) async { - final response = await sendRequest(opcode, payload); - return response.isOk; + /// Единый источник лога трафика: ядро отдаёт сюда каждый пакет обеих сторон — + /// включая 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, + ); } - Future sendRequestOrThrow( - int opcode, - Map payload, - ) async { - final response = await sendRequest(opcode, payload); - throwIfPacketError(response); - return response; + 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' + } } - /// Вешает обработчик на пуши с указанным опкодом. - void registerPushHandler(int opcode, void Function(Packet) handler) { - _dispatcher.registerHandler(opcode, handler); + static dynamic _decodeWireJson(String json) { + try { + return jsonDecode(json); + } catch (_) { + return json; + } } - /// Снимает обработчик пушей с указанного опкода. - void unregisterPushHandler(int opcode) { - _dispatcher.unregisterHandler(opcode); - } - - /// Стрим всех входящих пушей от сервера. - Stream get pushStream => _dispatcher.pushStream; - - Future dispose() async { - _autoReconnect = false; - _reconnectTimer?.cancel(); - _cleanup(); - _dispatcher.dispose(); - await _connection.dispose(); - await _stateController.close(); - await _sessionExpiredController.close(); - await _handshakeSuccessController.close(); - } - - // Внутрянка - void _setSessionState(SessionState state) { if (_sessionState == state) return; _sessionState = state; @@ -500,35 +561,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 +568,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 +592,6 @@ class Api { Future _forceReconnect() async { _connectGen++; _cleanup(); - await _connection.disconnect(); _reconnectAttempts = 0; _reconnectTimer?.cancel(); _setSessionState(SessionState.disconnected); @@ -564,12 +600,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,16 +628,44 @@ class Api { _onReconnectCallback = callback; } - void _startPinging() { - _pingTimer?.cancel(); - _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 { @@ -607,6 +679,15 @@ class Api { 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) { if (payload is! Map) return null; final raw = payload['reg-country-code']; diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 395e0c3..a322a93 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -177,9 +177,13 @@ class AccountModule { final result = VerifyCodeResult(payload: data.cast()); final sessionToken = result.loginToken ?? result.registerToken; - final accountId = result.accountId; + final verifiedProfile = _profileFromVerifyPayload(result.payload); + final accountId = result.accountId ?? verifiedProfile?.id; if (sessionToken != null && accountId != null) { + if (result.loginToken != null && verifiedProfile != null) { + await AppDatabase.saveProfile(verifiedProfile, isActive: true); + } await TokenStorage.saveToken(sessionToken, accountId); await TokenStorage.setActiveAccount(accountId); await SpoofingService.commitPendingSpoof(accountId); @@ -188,6 +192,12 @@ class AccountModule { return result; } + ProfileData? _profileFromVerifyPayload(Map payload) { + final profileMap = payload['profile']; + if (profileMap is! Map) return null; + return ProfileData.fromServerProfile(profileMap.cast()); + } + Future completeRegistration({ required String token, required String firstName, @@ -396,10 +406,16 @@ class AccountModule { } Future logout() async { + final accountId = await TokenStorage.getActiveAccountId(); + try { + await _logoutOnServer(accountId); + } on SessionExpiredException catch (e) { + logger.w('logout: сервер отклонил сессию: ${e.message}'); + } + _loggedIn = false; try { await _api.disconnect(); } catch (_) {} - final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { await removeAccount(accountId); } @@ -409,6 +425,33 @@ class AccountModule { chats.resetForAccountSwitch(); } + Future _logoutOnServer(int? accountId) async { + await _ensureLogoutSession(accountId); + await _api.sendRequestOrThrow(Opcode.logout, {}); + } + + Future _ensureLogoutSession(int? accountId) async { + if (_api.state == SessionState.disconnected) { + await _api.connect(); + } + if (_api.state != SessionState.online) { + await _api.stateStream + .firstWhere((state) => state == SessionState.online) + .timeout(const Duration(seconds: 20)); + } + if (_loggedIn) return; + if (accountId == null) return; + final token = await TokenStorage.readToken(accountId); + if (token == null || token.isEmpty) { + throw StateError('logout: нет токена для серверного выхода'); + } + await _api.sendRequestOrThrow( + Opcode.login, + buildLoginPayload(token, interactive: false), + ); + _loggedIn = true; + } + Future checkPassword({ required String password, required String trackId, @@ -501,7 +544,8 @@ class AccountModule { payload['lastLogin'] = sync.lastLogin; if (sync.configHash != null) payload['configHash'] = sync.configHash; } else { - payload['presenceSync'] = 0; + payload['presenceSync'] = -1; + payload['chatsSync'] = -1; } return payload; @@ -542,6 +586,13 @@ class AccountModule { await _saveSyncState(data, serverTime, profile.id); await ContactsModule.syncFromLoginPayload(data, profile.id); await chats.syncFromLoginPayload(data, profile.id, profile.id); + unawaited(chats.paginateChats(_api, profile.id, profile.id, data)); + + try { + await ContactsModule.syncFromServer(_api, profile.id); + } catch (e) { + logger.w('Контакты: $e'); + } final config = data['config']; if (config is Map) { diff --git a/lib/backend/modules/account/privacy_module.dart b/lib/backend/modules/account/privacy_module.dart index 37803e3..af9e543 100644 --- a/lib/backend/modules/account/privacy_module.dart +++ b/lib/backend/modules/account/privacy_module.dart @@ -91,15 +91,9 @@ class PrivacyModule extends AccountApiBase { } } - Future unregisterPushToken(String pushToken) async { + Future unregisterPushToken(String _) async { if (api.state != SessionState.online) return; - final accountId = await TokenStorage.getActiveAccountId(); - if (accountId == null) return; - final authToken = await TokenStorage.readToken(accountId); - if (authToken == null) return; - await api.sendRequest(Opcode.logout, { - 'token': authToken, - 'pushToken': pushToken, - }); + final packet = await api.sendRequest(Opcode.logout, {}); + throwIfPacketError(packet); } } diff --git a/lib/backend/modules/animoji.dart b/lib/backend/modules/animoji.dart new file mode 100644 index 0000000..ba541ab --- /dev/null +++ b/lib/backend/modules/animoji.dart @@ -0,0 +1,165 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; +import '../../models/animoji.dart'; + +class AnimojiModule { + final Api _api; + + AnimojiModule(this._api); + + static const List fallbackReactions = [ + '👍', + '❤️', + '🔥', + '🤣', + '😭', + '😍', + ]; + + static const String _recentsKey = 'komet_recent_animoji'; + static const int _maxRecents = 24; + + final Map _byId = {}; + List _orderedIds = []; + List _recentIds = []; + bool _recentsLoaded = false; + Future? _loading; + + bool get isLoaded => _orderedIds.isNotEmpty; + + List get animojis => + _orderedIds.map((id) => _byId[id]).whereType().toList(); + + List get recentAnimojis => + _recentIds.map((id) => _byId[id]).whereType().toList(); + + List get emojis => animojis.map((a) => a.emoji).toList(); + + Future ensureRecentsLoaded() async { + if (_recentsLoaded) return; + _recentsLoaded = true; + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_recentsKey) ?? const []; + _recentIds = raw.map(int.tryParse).whereType().toList(); + } catch (_) {} + } + + Future noteUsed(Animoji animoji) async { + await ensureRecentsLoaded(); + _byId[animoji.id] = animoji; + _recentIds.remove(animoji.id); + _recentIds.insert(0, animoji.id); + if (_recentIds.length > _maxRecents) { + _recentIds = _recentIds.sublist(0, _maxRecents); + } + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _recentsKey, + _recentIds.map((e) => e.toString()).toList(), + ); + } catch (_) {} + } + + List get quickAnimojis { + final list = animojis; + return list.length <= 6 ? list : list.sublist(0, 6); + } + + Future ensureLoaded() { + return _loading ??= _load().catchError((Object e) { + _loading = null; + throw e; + }); + } + + Future _load() async { + final setIds = []; + final fallbackIds = []; + + final sync = await _api.sendRequestMap(Opcode.assetsUpdate, { + 'type': 'ANIMOJI_SET', + 'sync': 0, + }); + if (sync != null) { + final sections = sync['sections']; + if (sections is List) { + for (final s in sections) { + if (s is Map) _appendIntList(setIds, s['animojiSetIds']); + } + } + final updates = sync['animojiUpdates']; + if (updates is Map) { + for (final key in updates.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null) fallbackIds.add(id); + } + } + } + + final orderedIds = []; + if (setIds.isNotEmpty) { + final setMap = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI_SET', + 'ids': setIds, + }); + if (setMap != null) { + final sets = setMap['animojiSets']; + if (sets is List) { + for (final set in sets) { + if (set is! Map) continue; + _appendIntList(orderedIds, set['animojis']); + _appendIntList(orderedIds, set['animojiIds']); + } + } + } + } + + final ids = _dedup(orderedIds.isNotEmpty ? orderedIds : fallbackIds); + if (ids.isEmpty) return; + + for (final batch in _chunk(ids, 100)) { + final map = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': 'ANIMOJI', + 'ids': batch, + }); + if (map == null) continue; + final list = map['animojis']; + if (list is! List) continue; + for (final e in list) { + if (e is! Map) continue; + final animoji = Animoji.fromMap(e); + if (animoji != null) _byId[animoji.id] = animoji; + } + } + + _orderedIds = ids.where(_byId.containsKey).toList(); + logger.i('Анимодзи: ${_orderedIds.length} доступно для реакций'); + } + + List _dedup(List ids) { + final seen = {}; + final result = []; + for (final id in ids) { + if (seen.add(id)) result.add(id); + } + return result; + } + + void _appendIntList(List target, dynamic raw) { + if (raw is! List) return; + for (final e in raw) { + if (e is int) target.add(e); + } + } + + Iterable> _chunk(List list, int size) sync* { + for (var i = 0; i < list.length; i += size) { + yield list.sublist(i, i + size > list.length ? list.length : i + size); + } + } +} diff --git a/lib/backend/modules/chat_parsing.dart b/lib/backend/modules/chat_parsing.dart index e92089f..06c0ee7 100644 --- a/lib/backend/modules/chat_parsing.dart +++ b/lib/backend/modules/chat_parsing.dart @@ -43,6 +43,7 @@ CachedChat? parseChatRow( final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing); final presence = _resolvePresence(type, otherId, presenceMap); final adminsOwner = _resolveAdmins(chat); + final pinned = _resolvePinnedMessage(chat['pinnedMessage']); return CachedChat( id: id, @@ -66,6 +67,10 @@ CachedChat? parseChatRow( options: titleIcon.options, owner: adminsOwner.owner, admins: adminsOwner.admins, + pinnedMsgId: pinned.id, + pinnedMsgText: pinned.text, + pinnedMsgTime: pinned.time, + pinnedMsgIsPreview: pinned.isPreview, ); } catch (e) { logger.e("Ошибка при парсинге чата: $e"); @@ -130,6 +135,24 @@ _resolveLastMessage(dynamic lastMsg) { ); } +({int? id, String? text, int? time, bool isPreview}) _resolvePinnedMessage( + dynamic pinned, +) { + if (pinned is! Map) { + return (id: null, text: null, time: null, isPreview: false); + } + final rawId = pinned['id']; + final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? ''); + if (id == null) return (id: null, text: null, time: null, isPreview: false); + final preview = pinnedMessagePreview(pinned); + return ( + id: id, + text: preview.text, + time: pinned['time'] as int?, + isPreview: preview.isPreview, + ); +} + ({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite( Map chatsConfig, int id, @@ -270,6 +293,10 @@ bool sameChatContent(CachedChat a, CachedChat b) { if (a.title != b.title) return false; if (a.iconUrl != b.iconUrl) return false; if (a.owner != b.owner) return false; + if (a.pinnedMsgId != b.pinnedMsgId) return false; + if (a.pinnedMsgText != b.pinnedMsgText) return false; + if (a.pinnedMsgTime != b.pinnedMsgTime) return false; + if (a.pinnedMsgIsPreview != b.pinnedMsgIsPreview) return false; if (a.dontDisturbUntil != b.dontDisturbUntil) return false; if (a.favIndex != b.favIndex) return false; if (a.lastMsgId != b.lastMsgId) return false; diff --git a/lib/backend/modules/chat_preview.dart b/lib/backend/modules/chat_preview.dart index 38b0497..a3e3d2b 100644 --- a/lib/backend/modules/chat_preview.dart +++ b/lib/backend/modules/chat_preview.dart @@ -1,15 +1,14 @@ import 'dart:convert'; String? attachPreviewLabel(dynamic attaches) { - if (attaches is! List || attaches.isEmpty) return null; - final first = attaches.first; - if (first is! Map) return null; + final first = _firstPreviewAttach(attaches); + if (first == null) return null; final type = (first['_type'] as String? ?? '').toUpperCase(); switch (type) { case 'PHOTO': return 'Фото'; case 'VIDEO': - return 'Видео'; + return _isVideoNote(first) ? 'Видео-сообщение' : 'Видео'; case 'AUDIO': return 'Голосовое сообщение'; case 'FILE': @@ -52,6 +51,23 @@ String? attachPreviewLabel(dynamic attaches) { } } +Map? _firstPreviewAttach(dynamic attaches) { + if (attaches is! List || attaches.isEmpty) return null; + for (final attach in attaches) { + if (attach is! Map) continue; + final type = (attach['_type'] as String? ?? '').toUpperCase(); + if (type == 'INLINE_KEYBOARD') continue; + return attach; + } + return null; +} + +bool _isVideoNote(Map attach) { + final raw = attach['videoType']; + if (raw is int) return raw == 1; + return raw?.toString() == '1'; +} + String? _controlPreviewLabel(Map c) { final title = c['title']?.toString(); if (title != null && title.isNotEmpty) return title; @@ -90,6 +106,60 @@ String? messagePreviewText(Map msg) { return _bodyPreviewText(msg); } +({String? text, bool isPreview}) pinnedMessagePreview(Map msg) { + final link = msg['link']; + if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { + final original = link['message']; + if (original is Map) { + final inner = pinnedMessagePreview(original); + return inner.text != null && inner.text!.isNotEmpty + ? (text: '↪ ${inner.text}', isPreview: inner.isPreview) + : (text: '↪ пересланное сообщение', isPreview: true); + } + return (text: '↪ пересланное сообщение', isPreview: true); + } + return _pinnedBodyPreview(msg); +} + +({String? text, bool isPreview}) _pinnedBodyPreview(Map msg) { + final text = msg['text']?.toString(); + if (text != null && text.isNotEmpty) return (text: text, isPreview: false); + final label = _pinnedAttachPreviewLabel(msg['attaches']); + return (text: label, isPreview: label != null); +} + +String? _pinnedAttachPreviewLabel(dynamic attaches) { + final first = _firstPreviewAttach(attaches); + if (first == null) return null; + final type = (first['_type'] as String? ?? '').toUpperCase(); + switch (type) { + case 'PHOTO': + return 'фото'; + case 'VIDEO': + return _isVideoNote(first) ? 'кружок' : 'видео'; + case 'AUDIO': + return 'голосовое сообщение'; + case 'FILE': + return 'файл'; + case 'STICKER': + return 'стикер'; + case 'SHARE': + return 'ссылка'; + case 'POLL': + return 'голосование'; + case 'LOCATION': + return 'геопозиция'; + case 'CONTACT': + return 'контакт'; + case 'CALL': + return 'звонок'; + case 'CONTROL': + return _controlPreviewLabel(first)?.toLowerCase(); + default: + return 'вложение'; + } +} + String? _bodyPreviewText(Map msg) { final text = msg['text']?.toString(); if (text != null && text.isNotEmpty) return text; diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index f2fdc14..1d32c05 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -8,6 +8,7 @@ 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/token_storage.dart'; import '../../core/utils/logger.dart'; @@ -59,6 +60,10 @@ class CachedChat { final Set options; final int? owner; final Set admins; + final int? pinnedMsgId; + final String? pinnedMsgText; + final int? pinnedMsgTime; + final bool pinnedMsgIsPreview; CachedChat({ required this.id, @@ -83,6 +88,10 @@ class CachedChat { this.options = const {}, this.owner, this.admins = const {}, + this.pinnedMsgId, + this.pinnedMsgText, + this.pinnedMsgTime, + this.pinnedMsgIsPreview = false, }) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n') ? lastMsgText.replaceAll('\n', ' ') : lastMsgText; @@ -110,6 +119,15 @@ class CachedChat { bool iAmAdmin(int myId) => owner == myId || admins.contains(myId); + bool get hasPinnedMessage => pinnedMsgId != null; + + bool get isGroupChat => type == 'CHAT' || type == 'GROUP'; + + bool canPinMessages(int myId) { + if (!isGroupChat) return false; + return iAmAdmin(myId) || options.contains('ALL_CAN_PIN_MESSAGE'); + } + bool get isMuted { if (dontDisturbUntil == ChatsModule.muteOff) return false; if (dontDisturbUntil < 0) return true; @@ -141,6 +159,10 @@ class CachedChat { options: _decodeOptions(row['options']), owner: row['owner'] as int?, admins: _decodeAdmins(row['admins']), + pinnedMsgId: row['pinned_msg_id'] as int?, + pinnedMsgText: row['pinned_msg_text'] as String?, + pinnedMsgTime: row['pinned_msg_time'] as int?, + pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1, ); static Set _decodeOptions(dynamic raw) { @@ -182,6 +204,10 @@ class CachedChat { 'options': options.isEmpty ? null : options.join(','), 'owner': owner, 'admins': admins.isEmpty ? null : admins.join(','), + 'pinned_msg_id': pinnedMsgId, + 'pinned_msg_text': pinnedMsgText, + 'pinned_msg_time': pinnedMsgTime, + 'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0, }; static const Object _keep = Object(); @@ -207,6 +233,10 @@ class CachedChat { Set? options, Object? owner = _keep, Set? admins, + Object? pinnedMsgId = _keep, + Object? pinnedMsgText = _keep, + Object? pinnedMsgTime = _keep, + bool? pinnedMsgIsPreview, }) { return CachedChat( id: id, @@ -243,6 +273,16 @@ class CachedChat { options: options ?? this.options, owner: identical(owner, _keep) ? this.owner : owner as int?, admins: admins ?? this.admins, + pinnedMsgId: identical(pinnedMsgId, _keep) + ? this.pinnedMsgId + : pinnedMsgId as int?, + pinnedMsgText: identical(pinnedMsgText, _keep) + ? this.pinnedMsgText + : pinnedMsgText as String?, + pinnedMsgTime: identical(pinnedMsgTime, _keep) + ? this.pinnedMsgTime + : pinnedMsgTime as int?, + pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview, ); } } @@ -333,6 +373,8 @@ class ChatsModule { final _messageEventsController = StreamController.broadcast(); Stream get messageEvents => _messageEventsController.stream; + int? _paginatedAccountId; + void emitMessageSent(int chatId, String tempId, CachedMessage message) { _messageEventsController.add(MessageSentEvent(chatId, tempId, message)); } @@ -366,6 +408,40 @@ class ChatsModule { _bump(); } + Future markReadUpTo( + Api api, + int accountId, + int chatId, + String messageId, + int mark, { + required int remaining, + }) async { + final msgIdNum = int.tryParse(messageId); + if (msgIdNum != null && !KometSettings.antiRead.value) { + try { + await api.sendRequest(Opcode.chatMark, { + 'type': 'READ_MESSAGE', + 'chatId': chatId, + 'messageId': msgIdNum, + 'mark': mark, + }); + } catch (_) {} + } + + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final cached = CachedChat.fromDbRow(rows.first); + final next = remaining < 0 ? 0 : remaining; + final currentMark = cached.participants[accountId] ?? 0; + final nextMark = mark > currentMark ? mark : currentMark; + if (cached.unreadCount == next && nextMark == currentMark) return; + final participants = Map.from(cached.participants) + ..[accountId] = nextMark; + final updated = cached.copyWith(unreadCount: next, participants: participants); + await AppDatabase.saveChats([updated.toDbRow()]); + _bump(); + } + Future markUnread(Api api, int accountId, int chatId, int mark) async { int? unread; try { @@ -381,11 +457,11 @@ class ChatsModule { } if (unread == null) return null; - await _updateChat( - accountId, - chatId, - (chat) => chat.copyWith(unreadCount: unread), - ); + await _updateChat(accountId, chatId, (chat) { + final participants = Map.from(chat.participants) + ..[accountId] = mark - 1; + return chat.copyWith(unreadCount: unread, participants: participants); + }); return unread; } @@ -485,6 +561,7 @@ class ChatsModule { ContactInfoFetch.clear(); PresenceFetch.clear(); ChatInfoFetch.clear(); + SharedContentModule.clearPhotoIndex(); } void _enqueueGlobalPush(Packet packet) { @@ -700,10 +777,45 @@ class ChatsModule { } if (unread != null) newRow['unread_count'] = unread; + final pinned = _extractPinnedMessage(msg); + if (pinned != null) { + newRow['pinned_msg_id'] = pinned.id; + newRow['pinned_msg_text'] = pinned.text; + newRow['pinned_msg_time'] = pinned.time; + newRow['pinned_msg_is_preview'] = pinned.isPreview ? 1 : 0; + } + await AppDatabase.saveChats([newRow]); _bump(); } + ({int? id, String? text, int? time, bool isPreview})? _extractPinnedMessage( + Map msg, + ) { + final attaches = msg['attaches']; + if (attaches is! List) return null; + for (final a in attaches.whereType()) { + if ((a['_type'] as String?) != 'CONTROL') continue; + final event = a['event']?.toString(); + if (event != 'pin' && event != 'unpin') continue; + final pinned = a['pinnedMessage']; + if (event == 'unpin' || pinned is! Map) { + return (id: null, text: null, time: null, isPreview: false); + } + final rawId = pinned['id']; + final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? ''); + if (id == null) return null; + final preview = pinnedMessagePreview(pinned.cast()); + return ( + id: id, + text: preview.text, + time: pinned['time'] as int?, + isPreview: preview.isPreview, + ); + } + return null; + } + Future _reconcileLastMessage( int accountId, int chatId, @@ -996,7 +1108,7 @@ class ChatsModule { return parsed; } final row = parsed.toDbRow(); - row['in_list'] = inList ? 1 : 0; + row['in_list'] = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1); await AppDatabase.saveChats([row]); _bump(); return parsed; @@ -1028,44 +1140,109 @@ class ChatsModule { ? data['presence'] as Map : {}; PresenceFetch.primeAll(presenceMap); - final cachedAt = DateTime.now().millisecondsSinceEpoch; - final existingRows = await AppDatabase.loadChats(accountId); - final existing = { - for (final row in existingRows) - row['id'] as int: CachedChat.fromDbRow(row), - }; - - final rows = chats - .whereType() - .map( - (c) => parseChatRow( - c.cast(), - accountId, - currentUserId, - contactsMap, - chatsConfig, - presenceMap, - existing, - cachedAt, - ), - ) - .whereType() - .map((c) => c.toDbRow()..['in_list'] = 1) - .toList(); - - if (rows.isNotEmpty) { - await AppDatabase.saveChats(rows); - _bump(); - } + await _persistChatMaps( + chats, + accountId, + currentUserId, + contactsMap: contactsMap, + chatsConfig: chatsConfig, + presenceMap: presenceMap, + ); } catch (e) { logger.e("Ошибка при синке: $e"); } } - Future> getChats(int accountId) async { + Future _persistChatMaps( + List chats, + int accountId, + int currentUserId, { + Map> contactsMap = const {}, + Map chatsConfig = const {}, + Map presenceMap = const {}, + }) async { + final cachedAt = DateTime.now().millisecondsSinceEpoch; + final existingRows = await AppDatabase.loadChats( + accountId, + includeHidden: true, + ); + final existing = { + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), + }; + + final rows = >[]; + for (final c in chats.whereType()) { + final map = c.cast(); + final parsed = parseChatRow( + map, + accountId, + currentUserId, + contactsMap, + chatsConfig, + presenceMap, + existing, + cachedAt, + ); + if (parsed == null) continue; + final row = parsed.toDbRow(); + row['in_list'] = map['status'] == 'HIDDEN' ? 2 : 1; + rows.add(row); + } + + if (rows.isNotEmpty) { + await AppDatabase.saveChats(rows); + _bump(); + } + return rows.length; + } + + Future paginateChats( + Api api, + int accountId, + int currentUserId, + Map loginData, + ) async { + if (_paginatedAccountId == accountId) return; + _paginatedAccountId = accountId; try { - final rows = await AppDatabase.loadChats(accountId); + var marker = loginData['chatMarker']; + if (marker is! int || marker <= 0) return; + + const count = 50; + var page = 0; + while (page < 200) { + page++; + final resp = await api.sendRequestMap(Opcode.chatsList, { + 'marker': marker, + 'count': count, + }); + if (resp == null) break; + final chats = resp['chats']; + if (chats is! List || chats.isEmpty) break; + + await _persistChatMaps(chats, accountId, currentUserId); + + final next = resp['marker']; + if (next is! int || next == marker || chats.length < count) break; + marker = next; + } + } catch (e) { + _paginatedAccountId = null; + logger.w('Пагинация чатов: $e'); + } + } + + Future> getChats( + int accountId, { + bool includeHidden = false, + }) async { + try { + final rows = await AppDatabase.loadChats( + accountId, + includeHidden: includeHidden, + ); final chats = rows.map(CachedChat.fromDbRow).toList(); return chats; } catch (e) { @@ -1100,6 +1277,18 @@ class ChatsModule { return Map.from(chats.first as Map); } + 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 { final packet = await api.sendRequest(Opcode.publicSearch, { 'query': userId.toString(), @@ -1269,6 +1458,39 @@ class ChatsModule { return true; } + Future setPinnedMessage( + Api api, { + required int chatId, + required int? messageId, + bool notify = true, + }) async { + try { + final packet = await api.sendRequest(Opcode.chatUpdate, { + 'chatId': chatId, + 'notifyPin': notify, + 'pinMessageId': messageId ?? 0, + }); + if (!packet.isOk) { + return messageFromErrorPayload(packet.payload); + } + 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); + } + } + return null; + } on PacketError catch (e) { + logger.w('setPinnedMessage $chatId: ${e.message}'); + return e.message; + } catch (e) { + logger.w('setPinnedMessage $chatId: $e'); + return 'Не удалось изменить закрепление'; + } + } + Future togglePin( Api api, { required List chatIds, diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index d59f5e2..29b3c71 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -1,7 +1,9 @@ import 'package:flutter/foundation.dart'; +import '../../core/config/debug_test.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; import '../api.dart'; import 'messages.dart'; @@ -174,7 +176,19 @@ class ContactsModule { if (rows.isNotEmpty) { await AppDatabase.saveContacts(rows); + revision.value++; } + logger.i( + 'Контакты: получено ${contacts.length}, сохранено ${rows.length} (акк $accountId)', + ); + } + + static Future syncFromServer(Api api, int accountId) async { + final map = await api.sendRequestMap(Opcode.contactsGet, { + 'contactsSync': 0, + }); + if (map == null) return; + await syncFromLoginPayload(map.cast(), accountId); } static void _primeContactCache(Map contact) { @@ -228,6 +242,41 @@ class ContactsModule { return rows.map(CachedContact.fromDbRow).toList(); } + static const List _debugFirstNames = [ + 'Алиса', 'Борис', 'Вера', 'Глеб', 'Дарья', 'Егор', 'Жанна', 'Захар', + 'Ирина', 'Кирилл', 'Лия', 'Максим', 'Нина', 'Олег', 'Полина', 'Роман', + 'София', 'Тимур', 'Ульяна', 'Фёдор', 'Ханна', 'Цветана', 'Чеслав', 'Шура', + ]; + + static const List _debugLastNames = [ + 'Иванов', 'Петров', 'Сидоров', 'Кузнецов', 'Смирнов', 'Попов', 'Волков', + 'Соколов', 'Морозов', 'Новиков', 'Фёдоров', 'Козлов', + ]; + + static List debugContacts() { + final count = DebugTest.contactCount; + final out = []; + for (var i = 0; i < count; i++) { + final first = _debugFirstNames[i % _debugFirstNames.length]; + final last = + _debugLastNames[(i ~/ _debugFirstNames.length) % + _debugLastNames.length]; + out.add( + CachedContact( + id: 900000000 + i, + accountId: DebugTest.debugAccountId, + firstName: '$first ${i + 1}', + lastName: last, + phone: 79000000000 + i, + baseUrl: 'https://i.pravatar.cc/150?u=komet_debug_$i', + updateTime: 1, + options: i % 6 == 0 ? const {'OFFICIAL'} : const {}, + ), + ); + } + return out; + } + /// Прогревает in-memory ContactCache из локальных контактов. /// Нужно вызывать на cold start: иначе кэш пуст до следующего логина. static Future primeCacheFromDb(int accountId) async { diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 6cb81de..3967ebe 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'; @@ -41,10 +40,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 +61,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 +77,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,27 +92,50 @@ 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; } @@ -137,9 +163,6 @@ class FileUploader { } catch (e) { if (!cancelled) ctrl.add(UploadError(e.toString())); } finally { - try { - socket?.destroy(); - } catch (_) {} await ctrl.close(); } } @@ -155,133 +178,53 @@ 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; + 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 +238,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 +268,55 @@ 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( - 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, + /// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог. + Future<({int status, Uint8List body, String? error})> _consume( + Stream stream, { 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); - 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, - ); - return (status, ''); - } - 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 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()); - } - } - 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)', - ); - } - finishWith(parsed); - }, - ); - timer = Timer(timeout, () { - logger.w('uploadImage: response timeout after ${bytes.length} bytes'); - finishWith(tryParse(atClose: true)); - }); - return completer.future; - } - - 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; + 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 +338,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 74eb3ee..c179c75 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -55,31 +55,69 @@ class FoldersModule { }); } - static bool chatMatchesFolder(CachedChat chat, ChatFolder folder) { - if (folder.include != null && folder.include!.isNotEmpty) { - return folder.include!.contains(chat.id); + 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 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; + } + } + return null; + } + + static bool chatMatchesFolder( + CachedChat chat, + ChatFolder folder, { + required int myId, + required Set contactIds, + }) { + if (folder.include != null && folder.include!.contains(chat.id)) { + return true; } if (folder.filters.isEmpty) return false; - final hasContact = folder.filters.any( - (f) => f == 9 || f == '9' || f == 'CONTACT', - ); - final hasNotContact = folder.filters.any( - (f) => f == 8 || f == '8' || f == 'NOT_CONTACT', - ); + 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); - if (hasContact && hasNotContact) { - if (chat.type != 'DIALOG') return false; - return true; - } - - for (final filter in folder.filters) { - if (filter == 0 || filter == '0' || filter == 'UNREAD') { - if (chat.unreadCount > 0) return true; - } else if (filter == 9 || filter == '9' || filter == 'CONTACT') { - if (chat.type == 'DIALOG') return true; - } else if (filter == 8 || filter == '8' || filter == 'NOT_CONTACT') { - if (chat.type == 'CHAT' || chat.type == 'CHANNEL') return true; + 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; diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 0ba12fd..3be215c 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -7,6 +7,7 @@ import '../../core/config/komet_settings.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 '../../core/utils/logger.dart'; import '../../core/utils/text_format.dart'; import '../../models/attachment.dart'; @@ -393,6 +394,7 @@ class CachedMessage { bool? deleted, List? attachments, List>? editHistory, + Map? payload, }) => CachedMessage( id: id, accountId: accountId, @@ -401,7 +403,7 @@ class CachedMessage { text: text, time: time, status: status ?? this.status, - payload: payload, + payload: payload ?? this.payload, attachments: attachments ?? this.attachments, isControl: isControl, deleted: deleted ?? this.deleted, @@ -1055,6 +1057,143 @@ class MessagesModule { return _api.sendRequestOk(Opcode.msgDelete, payload); } + Future<({bool ok, Map? info})> setReaction( + int chatId, + String messageId, + String emoji, + ) async { + final id = int.tryParse(messageId); + if (id == null) return (ok: false, info: null); + final response = await _api.sendRequest(Opcode.msgReaction, { + 'chatId': chatId, + 'messageId': id, + 'reaction': {'reactionType': 'EMOJI', 'id': emoji}, + }); + return _applyReactionResponse(chatId, messageId, response); + } + + Future<({bool ok, Map? info})> cancelReaction( + int chatId, + String messageId, + ) async { + final id = int.tryParse(messageId); + if (id == null) return (ok: false, info: null); + final response = await _api.sendRequest(Opcode.msgCancelReaction, { + 'chatId': chatId, + 'messageId': id, + }); + 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, + Packet response, + ) async { + if (!response.isOk) return (ok: false, info: null); + final payload = response.payload; + final info = payload is Map + ? _normalizeReactionInfo(payload['reactionInfo']) + : null; + try { + await _persistReaction(chatId, messageId, info); + } catch (_) {} + return (ok: true, info: info); + } + + static Map? _normalizeReactionInfo(dynamic raw) { + if (raw is! Map) return null; + final rawCounters = raw['counters']; + if (rawCounters is! List) return null; + final counters = >[]; + for (final c in rawCounters) { + if (c is! Map) continue; + final reaction = c['reaction']?.toString(); + if (reaction == null || reaction.isEmpty) continue; + final count = c['count']; + counters.add({'reaction': reaction, 'count': count is int ? count : 0}); + } + if (counters.isEmpty) return null; + final your = raw['yourReaction']?.toString(); + final total = raw['totalCount']; + return { + 'counters': counters, + if (your != null && your.isNotEmpty) 'yourReaction': your, + 'totalCount': total is int + ? total + : counters.fold(0, (a, b) => a + (b['count'] as int)), + }; + } + + Future _persistReaction( + int chatId, + String messageId, + Map? info, + ) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final existing = await AppDatabase.loadMessage(accountId, chatId, messageId); + if (existing == null) return; + + Map payloadMap; + final raw = existing['payload']; + if (raw is String && raw.isNotEmpty) { + try { + payloadMap = Map.from(jsonDecode(raw) as Map); + } catch (_) { + payloadMap = {}; + } + } else { + payloadMap = {}; + } + + if (info == null) { + payloadMap.remove('reactionInfo'); + } else { + payloadMap['reactionInfo'] = info; + } + + final newRow = Map.from(existing); + newRow['payload'] = jsonEncode(payloadMap); + await AppDatabase.saveMessages([newRow]); + } + Future?> sendButtonCallback({ required int chatId, required String messageId, diff --git a/lib/backend/modules/shared_content.dart b/lib/backend/modules/shared_content.dart new file mode 100644 index 0000000..af1545e --- /dev/null +++ b/lib/backend/modules/shared_content.dart @@ -0,0 +1,324 @@ +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; +import '../../models/attachment.dart'; +import '../api.dart'; + +const Map _attachTypeByName = { + 'PHOTO': AttachmentType.photo, + 'VIDEO': AttachmentType.video, + 'AUDIO': AttachmentType.audio, + 'FILE': AttachmentType.file, + 'SHARE': AttachmentType.share, +}; + +class SharedMediaItem { + final String messageId; + final int chatId; + final int senderId; + final int time; + final MessageAttachment attachment; + final String? text; + + const SharedMediaItem({ + required this.messageId, + required this.chatId, + required this.senderId, + required this.time, + required this.attachment, + this.text, + }); + + String get dedupKey { + final a = attachment; + final String tail; + if (a is PhotoAttachment) { + tail = 'p${a.photoId ?? a.baseUrl}'; + } else if (a is VideoAttachment) { + tail = 'v${a.videoId ?? a.baseUrl}'; + } else if (a is FileAttachment) { + tail = 'f${a.fileId ?? a.name}'; + } else if (a is AudioAttachment) { + tail = 'a${a.audioId ?? a.fileUrl}'; + } else if (a is ShareAttachment) { + tail = 's${a.shareId ?? a.url}'; + } else { + tail = a.hashCode.toString(); + } + return '$messageId:$tail'; + } +} + +class SharedMediaPage { + final List items; + final int total; + + const SharedMediaPage({required this.items, required this.total}); + + static const empty = SharedMediaPage(items: [], total: 0); +} + +class CommonChatEntry { + final int id; + final String type; + final String title; + final String? iconUrl; + final int participantsCount; + final List participantIds; + + const CommonChatEntry({ + required this.id, + required this.type, + required this.title, + required this.iconUrl, + required this.participantsCount, + required this.participantIds, + }); + + factory CommonChatEntry.fromMap(Map map) { + final participants = map['participants']; + final ids = []; + if (participants is Map) { + for (final key in participants.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null) ids.add(id); + } + } + return CommonChatEntry( + id: (map['id'] as num?)?.toInt() ?? 0, + type: map['type']?.toString() ?? 'CHAT', + title: map['title']?.toString() ?? '', + iconUrl: map['baseIconUrl'] as String?, + participantsCount: + (map['participantsCount'] as num?)?.toInt() ?? ids.length, + participantIds: ids, + ); + } +} + +class ChatPhotoFeed { + final List items; + final int total; + final bool reachedEnd; + + const ChatPhotoFeed({ + required this.items, + required this.total, + required this.reachedEnd, + }); +} + +class _ChatPhotoIndex { + final List items = []; + final Set seen = {}; + int total = 0; + bool reachedEnd = false; + bool started = false; + Future? inFlight; +} + +String photoDedupKey(String messageId, PhotoAttachment photo) => + '$messageId:p${photo.photoId ?? photo.baseUrl}'; + +class SharedContentModule { + static const int _photoIndexPageSize = 60; + static const int _photoIndexMaxPages = 40; + + static final Map _photoIndexes = {}; + + final Api _api; + + SharedContentModule(this._api); + + static void clearPhotoIndex() => _photoIndexes.clear(); + + Future photoFeedFor({ + required int chatId, + required String photoKey, + required Future Function() resolveAnchor, + }) async { + final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new); + + for (var page = 0; page < _photoIndexMaxPages; page++) { + if (index.seen.contains(photoKey)) return _snapshot(index); + if (index.reachedEnd) return null; + await _nextPhotoPage(chatId, index, resolveAnchor); + } + return null; + } + + Future loadMorePhotos({ + required int chatId, + required Future Function() resolveAnchor, + }) async { + final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new); + if (!index.reachedEnd) { + await _nextPhotoPage(chatId, index, resolveAnchor); + } + return _snapshot(index); + } + + ChatPhotoFeed _snapshot(_ChatPhotoIndex index) { + final counted = index.items.length; + final total = index.reachedEnd + ? counted + : (index.total > counted ? index.total : counted); + return ChatPhotoFeed( + items: List.unmodifiable(index.items), + total: total, + reachedEnd: index.reachedEnd, + ); + } + + Future _nextPhotoPage( + int chatId, + _ChatPhotoIndex index, + Future Function() resolveAnchor, + ) async { + final pending = index.inFlight; + if (pending != null) { + await pending; + return; + } + final task = _loadPhotoPage(chatId, index, resolveAnchor); + index.inFlight = task; + try { + await task; + } finally { + index.inFlight = null; + } + } + + Future _loadPhotoPage( + int chatId, + _ChatPhotoIndex 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'], + forward: initial ? _photoIndexPageSize : 0, + backward: _photoIndexPageSize, + ); + 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, + required List attachTypes, + int forward = 0, + int backward = 60, + }) async { + try { + final response = await _api.sendRequest(Opcode.chatMedia, { + 'chatId': chatId, + 'messageId': int.tryParse(anchorMessageId) ?? 0, + 'attachTypes': attachTypes, + 'forward': forward, + 'backward': backward, + }); + if (!response.isOk) return SharedMediaPage.empty; + + final data = response.payload; + if (data is! Map) return SharedMediaPage.empty; + + final messages = data['messages']; + if (messages is! List) return SharedMediaPage.empty; + + final wanted = attachTypes + .map((t) => _attachTypeByName[t]) + .whereType() + .toSet(); + + final out = []; + for (final m in messages) { + if (m is! Map) continue; + final map = Map.from(m); + final id = map['id']?.toString(); + 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) { + if (a is! Map) continue; + final att = MessageAttachment.fromMap(Map.from(a)); + if (!wanted.contains(att.type)) continue; + out.add( + SharedMediaItem( + messageId: id, + chatId: chatId, + senderId: sender, + time: time, + attachment: att, + text: text, + ), + ); + } + } + + out.sort((a, b) => b.time.compareTo(a.time)); + final total = (data['total'] as num?)?.toInt() ?? out.length; + return SharedMediaPage(items: out, total: total); + } catch (e) { + logger.w('SharedContent.fetchMedia failed: $e'); + return SharedMediaPage.empty; + } + } + + Future> fetchCommonChats(int userId) async { + try { + final response = await _api.sendRequest( + Opcode.chatSearchCommonParticipants, + { + 'userIds': [userId], + }, + ); + if (!response.isOk) return const []; + + final data = response.payload; + if (data is! Map) return const []; + + final chats = data['commonChats']; + if (chats is! List) return const []; + + final out = []; + for (final c in chats) { + if (c is Map) { + out.add(CommonChatEntry.fromMap(Map.from(c))); + } + } + return out; + } catch (e) { + logger.w('SharedContent.fetchCommonChats failed: $e'); + return const []; + } + } +} diff --git a/lib/backend/modules/stories.dart b/lib/backend/modules/stories.dart new file mode 100644 index 0000000..9e21706 --- /dev/null +++ b/lib/backend/modules/stories.dart @@ -0,0 +1,383 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.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 '../../core/utils/logger.dart'; +import '../../models/story.dart'; +import '../api.dart'; + +/// Работа с «Историями»: лента-кольца, полные истории владельца, отметка +/// просмотра и реакции. Кэшируется в SQLite (превью, полные истории и позиция +/// просмотра) — переживает перезапуск; истёкшие кольца отсеиваются при загрузке. +class StoriesModule { + StoriesModule(this._api); + + static const _previewsKey = 'stories_previews'; + static const _peersKey = 'stories_peers'; + static const _progressKey = 'stories_progress'; + + final Api _api; + + final Map _previews = {}; + final Map> _peerStories = {}; + + /// ownerId → storyId, на котором пользователь остановил просмотр. + final Map _lastViewed = {}; + + int? _accountId; + + StreamSubscription? _pushSub; + + /// Бампается при любом изменении лент/историй — UI слушает и перечитывает. + final ValueNotifier storiesChanged = ValueNotifier(0); + + void _bump() => storiesChanged.value++; + + Future _acc() async { + _accountId ??= await TokenStorage.getActiveAccountId(); + return _accountId; + } + + int _nowMs() => DateTime.now().millisecondsSinceEpoch; + + int _normMs(int t) => t <= 0 + ? 0 + : (t < 1000000000000 ? t * 1000 : t); + + // ── Кэш (SQLite) ─────────────────────────────────────────────────────── + + /// Загружает кэш из БД (превью/истории/позиции) и показывает мгновенно, + /// до сетевого ответа. Истёкшие кольца отбрасываются. + Future loadCache() async { + final acc = await _acc(); + if (acc == null) return; + try { + final rawPreviews = await AppDatabase.getSyncValue(acc, _previewsKey); + if (rawPreviews != null && rawPreviews.isNotEmpty) { + final list = jsonDecode(rawPreviews); + final now = _nowMs(); + if (list is List) { + for (final raw in list) { + final preview = StoryPreview.fromMap(raw); + if (preview == null || preview.isEmpty) continue; + final exp = _normMs(preview.lastStoryExpirationTime); + if (exp != 0 && exp < now) continue; + // Не затираем уже загруженные из сети (более свежие) кольца. + _previews.putIfAbsent(preview.owner.ownerId, () => preview); + } + } + } + + final rawPeers = await AppDatabase.getSyncValue(acc, _peersKey); + if (rawPeers != null && rawPeers.isNotEmpty) { + final map = jsonDecode(rawPeers); + if (map is Map) { + map.forEach((key, value) { + final ownerId = int.tryParse(key.toString()); + if (ownerId == null || value is! List) return; + if (!_previews.containsKey(ownerId)) return; + if (_peerStories.containsKey(ownerId)) return; + final stories = []; + for (final s in value) { + final story = Story.fromMap(s); + if (story != null) stories.add(story); + } + if (stories.isNotEmpty) _peerStories[ownerId] = stories; + }); + } + } + + final rawProgress = await AppDatabase.getSyncValue(acc, _progressKey); + if (rawProgress != null && rawProgress.isNotEmpty) { + final map = jsonDecode(rawProgress); + if (map is Map) { + map.forEach((key, value) { + final ownerId = int.tryParse(key.toString()); + final storyId = value is int ? value : int.tryParse('$value'); + if (ownerId != null && storyId != null) { + _lastViewed.putIfAbsent(ownerId, () => storyId); + } + }); + } + } + _bump(); + } catch (e) { + logger.w('StoriesModule.loadCache: $e'); + } + } + + Future _persistPreviews() async { + final acc = await _acc(); + if (acc == null) return; + final list = _previews.values.map((p) => p.toJson()).toList(); + await AppDatabase.setSyncValue(acc, _previewsKey, jsonEncode(list)); + } + + Future _persistPeers() async { + final acc = await _acc(); + if (acc == null) return; + final map = {}; + _peerStories.forEach((ownerId, stories) { + map['$ownerId'] = stories.map((s) => s.toJson()).toList(); + }); + await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map)); + } + + Future _persistProgress() async { + final acc = await _acc(); + if (acc == null) return; + final map = {}; + _lastViewed.forEach((ownerId, storyId) => map['$ownerId'] = storyId); + await AppDatabase.setSyncValue(acc, _progressKey, jsonEncode(map)); + } + + // ── Позиция просмотра ────────────────────────────────────────────────── + + /// Запоминает, что у [ownerId] пользователь остановился на [storyId]. + void setLastViewed(int ownerId, int storyId) { + if (storyId == 0 || _lastViewed[ownerId] == storyId) return; + _lastViewed[ownerId] = storyId; + unawaited(_persistProgress()); + } + + int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId]; + + /// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени. + List get previews { + final list = _previews.values.where((p) => !p.isEmpty).toList(); + list.sort((a, b) { + if (a.hasUnread != b.hasUnread) return a.hasUnread ? -1 : 1; + return b.updateTime.compareTo(a.updateTime); + }); + return list; + } + + bool get hasAny => previews.isNotEmpty; + + StoryPreview? previewFor(int ownerId) => _previews[ownerId]; + + List? cachedStories(int ownerId) => _peerStories[ownerId]; + + /// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE). + void attach() { + _pushSub ??= _api.pushStream + .where((p) => p.opcode == Opcode.notifStoriesUpdate) + .listen(_onPush); + } + + void _onPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final preview = StoryPreview.fromMap(payload['storiesPreview']); + if (preview == null) return; + _applyPreview(preview); + _bump(); + unawaited(_persistPreviews()); + } + + void _applyPreview(StoryPreview preview) { + if (preview.isEmpty) { + _previews.remove(preview.owner.ownerId); + _peerStories.remove(preview.owner.ownerId); + } else { + _previews[preview.owner.ownerId] = preview; + } + } + + /// Первая страница ленты историй. Возвращает false при ошибке/оффлайне. + Future loadFeed({int count = 20}) async { + if (_api.state != SessionState.online) return false; + try { + final packet = await _api.sendRequest(Opcode.storiesList, { + 'cursor': '', + 'count': count, + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return false; + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + _previews.clear(); + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview != null) _applyPreview(preview); + } + } + _bump(); + unawaited(_persistPreviews()); + return true; + } catch (e) { + logger.w('StoriesModule.loadFeed: $e'); + return false; + } + } + + /// Полные истории владельца. Обновляет кэш и кольцо, возвращает список. + Future> getByOwner(StoryOwner owner) async { + if (_api.state != SessionState.online) { + return _peerStories[owner.ownerId] ?? const []; + } + try { + final packet = await _api.sendRequest(Opcode.storiesGetByOwner, { + 'owners': [owner.toMap()], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is! Map) return _peerStories[owner.ownerId] ?? const []; + + final rawPreviews = data['storiesPreviews']; + if (rawPreviews is List) { + for (final raw in rawPreviews) { + final preview = StoryPreview.fromMap(raw); + if (preview != null) _applyPreview(preview); + } + } + + final rawPeers = data['peerStories']; + List result = const []; + if (rawPeers is List) { + for (final raw in rawPeers) { + final peer = PeerStories.fromMap(raw); + if (peer == null) continue; + _peerStories[peer.owner.ownerId] = peer.stories; + if (peer.owner.ownerId == owner.ownerId) result = peer.stories; + } + } + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + return result; + } catch (e) { + logger.w('StoriesModule.getByOwner: $e'); + return _peerStories[owner.ownerId] ?? const []; + } + } + + /// Отметить историю просмотренной. Оптимистично поднимает readCount кольца. + Future mark(StoryOwner owner, int storyId) async { + if (_api.state != SessionState.online) return false; + try { + final ok = await _api.sendRequestOk(Opcode.storiesMark, { + 'owner': owner.toMap(), + 'storyId': storyId, + }); + if (ok) _markReadLocally(owner.ownerId); + return ok; + } catch (e) { + logger.w('StoriesModule.mark: $e'); + return false; + } + } + + void _markReadLocally(int ownerId) { + final preview = _previews[ownerId]; + if (preview == null) return; + if (preview.readCount >= preview.totalCount) return; + _previews[ownerId] = preview.copyWith(readCount: preview.readCount + 1); + _bump(); + 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, сек. + /// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI + /// показал реальную причину, а не общее «не удалось». + Future publishPhoto({ + required String photoToken, + int settings = 1, + int expiration = 86400, + }) async { + if (_api.state != SessionState.online) { + throw const PacketError('Нет соединения с сервером'); + } + final cid = DateTime.now().millisecondsSinceEpoch; + final packet = await _api.sendRequest(Opcode.storiesSend, { + 'stories': [ + { + 'cid': cid, + 'settings': settings, + 'media': {'_type': 'PHOTO', 'photoToken': photoToken}, + 'expiration': expiration, + }, + ], + }); + throwIfPacketError(packet); + final data = packet.payload; + if (data is Map) { + final preview = StoryPreview.fromMap(data['storiesPreview']); + if (preview != null) _applyPreview(preview); + final rawStories = data['stories']; + if (rawStories is List) { + for (final raw in rawStories) { + final story = Story.fromMap(raw); + if (story == null) continue; + final list = _peerStories.putIfAbsent( + story.owner.ownerId, + () => [], + ); + list.add(story); + } + } + _bump(); + unawaited(_persistPreviews()); + unawaited(_persistPeers()); + } + } + + void clear() { + _previews.clear(); + _peerStories.clear(); + _lastViewed.clear(); + _accountId = null; + _bump(); + } + + void dispose() { + _pushSub?.cancel(); + _pushSub = null; + storiesChanged.dispose(); + } +} 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/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart index 5f0f242..d3d952b 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. @@ -84,17 +84,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 +106,60 @@ 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) другому участнику. @@ -329,7 +285,9 @@ class Ws2Signaling { extra: {'mediaSource': mediaSource, 'layers': layers}); Future close() async { - await _socket?.close(); - _socket = null; + await _notifSub?.cancel(); + _notifSub = null; + _call?.close(); + _call = null; } } diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart index 661ee95..137602d 100644 --- a/lib/core/config/app_chat_chrome.dart +++ b/lib/core/config/app_chat_chrome.dart @@ -1,15 +1,21 @@ import 'package:flutter/foundation.dart'; +import '../../frontend/widgets/liquid_glass.dart'; import 'persisted_setting.dart'; -enum ChatChromeStyle { color, blur, none } +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'; static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: ChatChromeStyle.none, + defaultValue: ChatChromeStyle.transparent, encode: _encode, decode: _parse, ); @@ -17,7 +23,7 @@ class AppChatChrome { static ValueNotifier get current => _setting.current; static ChatChromeStyle _parse(String? value) => - enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none); + enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.transparent); static String _encode(ChatChromeStyle value) => value.name; diff --git a/lib/core/config/app_composer_background.dart b/lib/core/config/app_composer_background.dart new file mode 100644 index 0000000..e37b115 --- /dev/null +++ b/lib/core/config/app_composer_background.dart @@ -0,0 +1,35 @@ +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..69d3872 --- /dev/null +++ b/lib/core/config/app_composer_style.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +enum ComposerStyle { glossy, materialYou } + +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_frost.dart b/lib/core/config/app_frost.dart new file mode 100644 index 0000000..aca7c83 --- /dev/null +++ b/lib/core/config/app_frost.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class AppFrost { + static const double sigma = 34; + static const double panelSigma = 24; + + static Color panelTint(ColorScheme cs) => cs.surface.withValues(alpha: 0.38); + + static Color blurPanelTint(ColorScheme cs) => + cs.surfaceContainerHigh.withValues(alpha: 0.55); + + static Color pillTint(ColorScheme cs) => + cs.surfaceContainerHigh.withValues(alpha: 0.45); + + static Color navPillTint(ColorScheme cs) => + cs.surfaceContainerHigh.withValues(alpha: 0.28); + + static Color fabTint(ColorScheme cs) => navPillTint(cs); + + static Color inputTint(ColorScheme cs) => + cs.surfaceContainerHighest.withValues(alpha: 0.45); + + 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 80d5454..8c503fa 100644 --- a/lib/core/config/app_icon.dart +++ b/lib/core/config/app_icon.dart @@ -36,12 +36,12 @@ class AppIconConfig { static Future apply(AppIcon icon) async { if (!isSupported) return; if (current.value == icon) return; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(prefKey, icon.id); - current.value = icon; await _channel.invokeMethod('setAppIcon', { 'name': icon.platformName, }); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, icon.id); + current.value = icon; } static AppIcon _parse(String? val) { 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_nav_pill_style.dart b/lib/core/config/app_nav_pill_style.dart new file mode 100644 index 0000000..57eb9cc --- /dev/null +++ b/lib/core/config/app_nav_pill_style.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; + +import '../../frontend/widgets/liquid_glass.dart'; +import 'persisted_setting.dart'; + +enum NavPillStyle { glossy, frostBlur, liquidGlass } + +class NavPillMaterial { + static bool isLiquid(NavPillStyle style) => + style == NavPillStyle.liquidGlass && LiquidGlass.isSupported; + + static bool isFrost(NavPillStyle style) => + style == NavPillStyle.frostBlur || + (style == 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_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_visual_style.dart b/lib/core/config/app_visual_style.dart index ece7066..e102b35 100644 --- a/lib/core/config/app_visual_style.dart +++ b/lib/core/config/app_visual_style.dart @@ -2,14 +2,18 @@ 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'; static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: VisualStyle.materialYou, + defaultValue: VisualStyle.glossy, encode: _encode, decode: _parse, ); @@ -23,5 +27,5 @@ class AppVisualStyle { static String _encode(VisualStyle value) => value.name; static VisualStyle _parse(String? val) => - enumFromName(VisualStyle.values, val, VisualStyle.materialYou); + enumFromName(VisualStyle.values, val, VisualStyle.glossy); } diff --git a/lib/core/config/app_wallpaper_tint.dart b/lib/core/config/app_wallpaper_tint.dart new file mode 100644 index 0000000..23c5ed5 --- /dev/null +++ b/lib/core/config/app_wallpaper_tint.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppWallpaperTint { + static const prefKey = 'app_wallpaper_tint'; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: false, + 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/chat_wallpaper_themes.dart b/lib/core/config/chat_wallpaper_themes.dart index 3a4dcca..ea637f7 100644 --- a/lib/core/config/chat_wallpaper_themes.dart +++ b/lib/core/config/chat_wallpaper_themes.dart @@ -1,31 +1,164 @@ import 'package:flutter/material.dart'; +import '../utils/tiled_svg.dart'; + @immutable class ChatWallpaperTheme { final String id; final String name; - final Gradient gradient; - final Color bubbleTint; + final List colors; + final AlignmentGeometry begin; + final AlignmentGeometry end; + final String? pattern; + final Color patternColor; + final double patternOpacity; + final double tileSize; + final bool dark; const ChatWallpaperTheme({ required this.id, required this.name, - required this.gradient, - this.bubbleTint = Colors.transparent, + required this.colors, + this.begin = Alignment.topLeft, + this.end = Alignment.bottomRight, + this.pattern, + this.patternColor = Colors.white, + this.patternOpacity = 0.1, + this.tileSize = 130, + this.dark = true, }); - Widget buildBackground() => DecoratedBox( - decoration: BoxDecoration(gradient: gradient), - child: const SizedBox.expand(), - ); + Gradient get gradient => + LinearGradient(colors: colors, begin: begin, end: end); - Widget buildPreview() => DecoratedBox( - decoration: BoxDecoration(gradient: gradient), - child: const SizedBox.expand(), - ); + Color get bubbleTint => colors.first; + + Widget buildBackground() => _ChatWallpaperThemeView(theme: this); + + Widget buildPreview() => + _ChatWallpaperThemeView(theme: this, tileScale: 0.42); } -const List kChatWallpaperThemes = []; +class _ChatWallpaperThemeView extends StatelessWidget { + final ChatWallpaperTheme theme; + final double tileScale; + + const _ChatWallpaperThemeView({required this.theme, this.tileScale = 1}); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + DecoratedBox(decoration: BoxDecoration(gradient: theme.gradient)), + if (theme.pattern != null) + TiledSvgPattern( + asset: theme.pattern!, + color: theme.patternColor, + opacity: theme.patternOpacity, + tileSize: theme.tileSize * tileScale, + ), + ], + ); + } +} + +const String _kPatternDir = 'assets/wallpapers/patterns'; + +const List kChatWallpaperThemes = [ + ChatWallpaperTheme( + id: 'ocean', + name: 'Океан', + colors: [Color(0xFF2A7B9B), Color(0xFF57C1EB), Color(0xFF246FA8)], + pattern: '$_kPatternDir/bubbles.svg', + patternOpacity: 0.1, + ), + ChatWallpaperTheme( + id: 'sunset', + name: 'Закат', + colors: [Color(0xFFFF7E5F), Color(0xFFFEB47B)], + pattern: '$_kPatternDir/hearts.svg', + patternOpacity: 0.12, + ), + ChatWallpaperTheme( + id: 'lavender', + name: 'Лаванда', + colors: [Color(0xFF9D50BB), Color(0xFF6E48AA)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'mint', + name: 'Мята', + colors: [Color(0xFF43E97B), Color(0xFF38F9D7)], + pattern: '$_kPatternDir/plus.svg', + patternColor: Colors.black, + patternOpacity: 0.06, + tileSize: 66, + dark: false, + ), + ChatWallpaperTheme( + id: 'graphite', + name: 'Графит', + colors: [Color(0xFF232526), Color(0xFF414345)], + pattern: '$_kPatternDir/plus.svg', + patternOpacity: 0.06, + tileSize: 66, + ), + ChatWallpaperTheme( + id: 'sky', + name: 'Небо', + colors: [Color(0xFF2193B0), Color(0xFF6DD5ED)], + pattern: '$_kPatternDir/planes.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'peach', + name: 'Персик', + colors: [Color(0xFFFFD3A5), Color(0xFFFD6585)], + pattern: '$_kPatternDir/rings.svg', + patternColor: Colors.black, + patternOpacity: 0.05, + dark: false, + ), + ChatWallpaperTheme( + id: 'forest', + name: 'Лес', + colors: [Color(0xFF134E5E), Color(0xFF71B280)], + pattern: '$_kPatternDir/rings.svg', + patternOpacity: 0.09, + ), + ChatWallpaperTheme( + id: 'grape', + name: 'Виноград', + colors: [Color(0xFF4776E6), Color(0xFF8E54E9)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.11, + ), + ChatWallpaperTheme( + id: 'night', + name: 'Ночь', + colors: [Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364)], + pattern: '$_kPatternDir/stars.svg', + patternOpacity: 0.08, + ), + ChatWallpaperTheme( + id: 'rose', + name: 'Роза', + colors: [Color(0xFFF4C4F3), Color(0xFFFC67FA)], + pattern: '$_kPatternDir/hearts.svg', + patternOpacity: 0.14, + ), + ChatWallpaperTheme( + id: 'amber', + name: 'Янтарь', + colors: [Color(0xFFF7971E), Color(0xFFFFD200)], + pattern: '$_kPatternDir/bubbles.svg', + patternColor: Colors.black, + patternOpacity: 0.05, + dark: false, + ), +]; ChatWallpaperTheme? chatWallpaperThemeById(String? id) { if (id == null) return null; diff --git a/lib/core/config/debug_test.dart b/lib/core/config/debug_test.dart new file mode 100644 index 0000000..e0176b9 --- /dev/null +++ b/lib/core/config/debug_test.dart @@ -0,0 +1,39 @@ +class DebugTest { + static bool enabled = false; + static int contactCount = 0; + + static const int debugAccountId = -424242; + + static const bool _envEnabled = bool.fromEnvironment('DEBUG_TEST'); + static const int _envContacts = int.fromEnvironment( + 'DEBUG_CONTACTS', + defaultValue: -1, + ); + + static const String _flag = '--debug-test'; + static const String _contactsFlag = '--contacts'; + + static void parse(List args) { + if (_envEnabled) enabled = true; + if (_envContacts >= 0) { + enabled = true; + contactCount = _envContacts; + } + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == _flag) { + enabled = true; + } else if (arg.startsWith('$_contactsFlag=')) { + enabled = true; + contactCount = + int.tryParse(arg.substring(_contactsFlag.length + 1)) ?? + contactCount; + } else if (arg == _contactsFlag && i + 1 < args.length) { + enabled = true; + contactCount = int.tryParse(args[i + 1]) ?? contactCount; + i++; + } + } + } +} diff --git a/lib/core/config/komet_settings.dart b/lib/core/config/komet_settings.dart index d257dee..ce875f3 100644 --- a/lib/core/config/komet_settings.dart +++ b/lib/core/config/komet_settings.dart @@ -8,6 +8,8 @@ class KometSettings { static const _kGhostMode = 'komet_ghost_mode'; static const _kAntiRead = 'komet_anti_read'; static const _kSelfOnlineCheck = 'komet_self_online_check'; + static const _kHideAllChatsFolder = 'komet_hide_all_chats_folder'; + static const _kShowHiddenChats = 'komet_show_hidden_chats'; static final ValueNotifier viewDeleted = ValueNotifier(false); static final ValueNotifier viewRedacted = ValueNotifier(false); @@ -15,6 +17,8 @@ class KometSettings { static final ValueNotifier ghostMode = ValueNotifier(false); static final ValueNotifier antiRead = ValueNotifier(false); static final ValueNotifier selfOnlineCheck = ValueNotifier(true); + static final ValueNotifier hideAllChatsFolder = ValueNotifier(false); + static final ValueNotifier showHiddenChats = ValueNotifier(false); static Future load() async { final prefs = await SharedPreferences.getInstance(); @@ -24,6 +28,8 @@ class KometSettings { ghostMode.value = prefs.getBool(_kGhostMode) ?? false; antiRead.value = prefs.getBool(_kAntiRead) ?? false; selfOnlineCheck.value = prefs.getBool(_kSelfOnlineCheck) ?? true; + hideAllChatsFolder.value = prefs.getBool(_kHideAllChatsFolder) ?? false; + showHiddenChats.value = prefs.getBool(_kShowHiddenChats) ?? false; } static Future setViewDeleted(bool value) async { @@ -61,4 +67,16 @@ class KometSettings { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_kSelfOnlineCheck, value); } + + static Future setHideAllChatsFolder(bool value) async { + hideAllChatsFolder.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kHideAllChatsFolder, value); + } + + static Future setShowHiddenChats(bool value) async { + showHiddenChats.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kShowHiddenChats, value); + } } diff --git a/lib/core/media/rlottie/rlottie.dart b/lib/core/media/rlottie/rlottie.dart new file mode 100644 index 0000000..e7104bd --- /dev/null +++ b/lib/core/media/rlottie/rlottie.dart @@ -0,0 +1,2 @@ +export 'rlottie_engine_stub.dart' + if (dart.library.io) 'rlottie_engine.dart'; diff --git a/lib/core/media/rlottie/rlottie_disk_cache.dart b/lib/core/media/rlottie/rlottie_disk_cache.dart new file mode 100644 index 0000000..3022c11 --- /dev/null +++ b/lib/core/media/rlottie/rlottie_disk_cache.dart @@ -0,0 +1,198 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../utils/logger.dart'; + +class DiskClip { + DiskClip({ + required this.px, + required this.frameCount, + required this.frameRate, + required this.durationMs, + required this.frames, + }); + + final int px; + final int frameCount; + final double frameRate; + final int durationMs; + final List frames; +} + +class RlottieDiskCache { + RlottieDiskCache._(); + static final RlottieDiskCache instance = RlottieDiskCache._(); + + static const _magic = 0x4b524c46; + static const _version = 2; + static const int _maxBytes = 256 * 1024 * 1024; + + Directory? _dir; + Future? _dirFuture; + + Future _directory() { + return _dirFuture ??= () async { + final base = await getApplicationSupportDirectory(); + final dir = Directory('${base.path}/rlottie_frames'); + if (!await dir.exists()) await dir.create(recursive: true); + _dir = dir; + return dir; + }(); + } + + String _key(String url, int px) { + final digest = sha1.convert(url.codeUnits).toString().substring(0, 20); + return '${digest}_$px.krlf'; + } + + Future _file(String url, int px) async { + final dir = await _directory(); + return File('${dir.path}/${_key(url, px)}'); + } + + Future load(String url, int px) async { + try { + final file = await _file(url, px); + if (!await file.exists()) return null; + final bytes = await file.readAsBytes(); + final clip = await _decode(bytes); + if (clip != null) { + unawaited(file.setLastModified(DateTime.now()).catchError((_) {})); + } + return clip; + } catch (e) { + logger.w('RlottieDiskCache.load failed: $e'); + return null; + } + } + + Future store({ + required String url, + required int px, + required int frameCount, + required double frameRate, + required int durationMs, + required List frames, + }) async { + if (frames.isEmpty || frames.length != frameCount) return; + try { + final bytes = await _encode(px, frameCount, frameRate, durationMs, frames); + final file = await _file(url, px); + await file.writeAsBytes(bytes, flush: false); + unawaited(_evict()); + } catch (e) { + logger.w('RlottieDiskCache.store failed: $e'); + } + } + + static Future _encode( + int px, + int frameCount, + double frameRate, + int durationMs, + List frames, + ) { + return Isolate.run(() { + final frameBytes = px * px * 4; + final payload = Uint8List(frameBytes * frameCount); + for (var i = 0; i < frameCount; i++) { + payload.setRange(i * frameBytes, (i + 1) * frameBytes, frames[i]); + } + for (var i = frameCount - 1; i >= 1; i--) { + final cur = i * frameBytes; + final prev = (i - 1) * frameBytes; + for (var b = 0; b < frameBytes; b++) { + payload[cur + b] ^= payload[prev + b]; + } + } + final compressed = gzip.encode(payload); + final header = ByteData(28); + header.setUint32(0, _magic); + header.setUint8(4, _version); + header.setUint32(8, px); + header.setUint32(12, frameCount); + header.setFloat64(16, frameRate); + header.setUint32(24, durationMs); + final out = BytesBuilder(); + out.add(header.buffer.asUint8List()); + out.add(compressed); + return out.toBytes(); + }); + } + + static Future _decode(Uint8List bytes) { + return Isolate.run(() { + if (bytes.length < 28) return null; + final header = ByteData.sublistView(bytes, 0, 28); + if (header.getUint32(0) != _magic) return null; + if (header.getUint8(4) != _version) return null; + final px = header.getUint32(8); + final frameCount = header.getUint32(12); + final frameRate = header.getFloat64(16); + final durationMs = header.getUint32(24); + final frameBytes = px * px * 4; + final payload = Uint8List.fromList(gzip.decode(bytes.sublist(28))); + if (payload.length != frameBytes * frameCount) return null; + for (var i = 1; i < frameCount; i++) { + final cur = i * frameBytes; + final prev = (i - 1) * frameBytes; + for (var b = 0; b < frameBytes; b++) { + payload[cur + b] ^= payload[prev + b]; + } + } + final frames = []; + for (var i = 0; i < frameCount; i++) { + frames.add(Uint8List.sublistView( + payload, i * frameBytes, (i + 1) * frameBytes)); + } + return DiskClip( + px: px, + frameCount: frameCount, + frameRate: frameRate, + durationMs: durationMs, + frames: frames, + ); + }); + } + + Future _evict() async { + try { + final dir = _dir ?? await _directory(); + final files = await dir + .list() + .where((e) => e is File && e.path.endsWith('.krlf')) + .cast() + .toList(); + var total = 0; + final stats = <(File, FileStat)>[]; + for (final f in files) { + final st = await f.stat(); + total += st.size; + stats.add((f, st)); + } + if (total <= _maxBytes) return; + stats.sort((a, b) => a.$2.modified.compareTo(b.$2.modified)); + for (final (file, st) in stats) { + if (total <= _maxBytes) break; + total -= st.size; + await file.delete().catchError((_) => file); + } + } catch (e) { + logger.w('RlottieDiskCache.evict failed: $e'); + } + } + + Future clear() async { + try { + final dir = await _directory(); + if (await dir.exists()) await dir.delete(recursive: true); + _dir = null; + _dirFuture = null; + } catch (_) {} + } +} diff --git a/lib/core/media/rlottie/rlottie_engine.dart b/lib/core/media/rlottie/rlottie_engine.dart new file mode 100644 index 0000000..151a8cb --- /dev/null +++ b/lib/core/media/rlottie/rlottie_engine.dart @@ -0,0 +1,328 @@ +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:isolate'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +import '../../utils/logger.dart'; +import 'rlottie_disk_cache.dart'; +import 'rlottie_ffi.dart'; +import 'rlottie_worker.dart'; + +class RlottieClip { + RlottieClip({required this.key, required this.px}); + + final String key; + final int px; + + int frameCount = 0; + int durationMs = 1000; + double frameRate = 60; + + List _images = const []; + ui.Image? _lastImage; + + final ValueNotifier ready = ValueNotifier(0); + + bool complete = false; + int bytes = 0; + int lastUsed = 0; + int active = 0; + + bool get playable => frameCount > 0; + + ui.Image? frameAt(int index) { + if (index < 0 || index >= _images.length) return _lastImage; + return _images[index] ?? _lastImage; + } + + void _allocate(int count) { + _images = List.filled(count, null); + } + + void _setFrame(int index, ui.Image image) { + if (index < 0 || index >= _images.length) { + image.dispose(); + return; + } + _images[index] = image; + _lastImage = image; + bytes += px * px * 4; + var r = ready.value; + while (r < _images.length && _images[r] != null) { + r++; + } + if (r != ready.value) ready.value = r; + } + + void dispose() { + for (final img in _images) { + img?.dispose(); + } + _images = const []; + _lastImage = null; + bytes = 0; + ready.dispose(); + } +} + +class _Job { + _Job(this.clip, this.url, this.completer); + final RlottieClip clip; + final String url; + final Completer completer; + List rawFrames = const []; +} + +class RlottieEngine { + RlottieEngine._(); + static final RlottieEngine instance = RlottieEngine._(); + + static String? debugLibraryPath; + + static const int _maxBytes = 192 * 1024 * 1024; + static const int _modelCacheBytes = 20 * 1024 * 1024; + + final Map _clips = {}; + final Map> _loading = {}; + final Map _jobs = {}; + + int _totalBytes = 0; + int _clock = 0; + int _nextJobId = 1; + int _rrIndex = 0; + + bool? _available; + Future>? _poolFuture; + + int _tick() => ++_clock; + String _keyFor(String url, int px) => '$url@$px'; + + bool get available { + return _available ??= () { + final bindings = RlottieBindings.open(path: debugLibraryPath); + if (bindings == null) return false; + bindings.configureModelCache(_modelCacheBytes); + return true; + }(); + } + + Future _worker() async { + final pool = await (_poolFuture ??= _spawnPool()); + return pool[_rrIndex++ % pool.length]; + } + + Future> _spawnPool() async { + final count = (Platform.numberOfProcessors - 1).clamp(1, 3); + final ports = []; + for (var i = 0; i < count; i++) { + final receive = ReceivePort(); + await Isolate.spawn(rlottieWorkerMain, receive.sendPort); + final broadcast = receive.asBroadcastStream(); + final port = await broadcast.first as SendPort; + broadcast.listen(_onWorkerMessage); + ports.add(port); + } + return ports; + } + + void _onWorkerMessage(dynamic message) { + if (message is ClipMeta) { + _onMeta(message); + } else if (message is RenderedFrame) { + _onFrame(message); + } else if (message is RenderDone) { + _onDone(message); + } else if (message is RenderError) { + _onError(message); + } + } + + void _onMeta(ClipMeta meta) { + final job = _jobs[meta.jobId]; + if (job == null) return; + final clip = job.clip + ..frameCount = meta.totalFrame + ..frameRate = meta.frameRate + ..durationMs = meta.durationMs; + clip._allocate(meta.totalFrame); + job.rawFrames = List.filled(meta.totalFrame, null); + if (!job.completer.isCompleted) { + job.completer.complete(clip); + } + } + + void _onFrame(RenderedFrame frame) { + final job = _jobs[frame.jobId]; + if (job == null) return; + final bytes = frame.data.materialize().asUint8List(); + if (frame.index < job.rawFrames.length) { + job.rawFrames[frame.index] = bytes; + } + ui.decodeImageFromPixels( + bytes, + frame.px, + frame.px, + ui.PixelFormat.bgra8888, + (image) { + job.clip._setFrame(frame.index, image); + _totalBytes += frame.px * frame.px * 4; + _evictIfNeeded(); + }, + ); + } + + void _onDone(RenderDone done) { + final job = _jobs.remove(done.jobId); + if (job == null) return; + final clip = job.clip..complete = true; + final raw = job.rawFrames; + if (raw.length == clip.frameCount && !raw.contains(null)) { + unawaited(RlottieDiskCache.instance.store( + url: job.url, + px: clip.px, + frameCount: clip.frameCount, + frameRate: clip.frameRate, + durationMs: clip.durationMs, + frames: raw.cast(), + )); + } + job.rawFrames = const []; + } + + void _onError(RenderError error) { + final job = _jobs.remove(error.jobId); + if (job == null) return; + logger.w('rlottie render failed (${job.url}): ${error.message}'); + if (!job.completer.isCompleted) job.completer.complete(null); + if (job.clip.frameCount == 0) { + _clips.remove(job.clip.key); + job.clip.dispose(); + } + } + + Future acquire(String url, int px, {String? inlineJson}) async { + if (!available) return null; + final key = _keyFor(url, px); + + final cached = _clips[key]; + if (cached != null) { + cached.lastUsed = _tick(); + cached.active++; + return cached; + } + final pending = _loading[key]; + if (pending != null) { + final clip = await pending; + if (clip != null) { + clip.lastUsed = _tick(); + clip.active++; + } + return clip; + } + + final future = _load(url, px, key, inlineJson); + _loading[key] = future; + final clip = await future; + _loading.remove(key); + if (clip != null) { + clip.lastUsed = _tick(); + clip.active++; + } + return clip; + } + + Future _load( + String url, int px, String key, String? inlineJson) async { + if (inlineJson == null) { + final disk = await RlottieDiskCache.instance.load(url, px); + if (disk != null) { + final clip = RlottieClip(key: key, px: px) + ..frameCount = disk.frameCount + ..frameRate = disk.frameRate + ..durationMs = disk.durationMs; + clip._allocate(disk.frameCount); + _clips[key] = clip; + unawaited(_decodeDiskProgressive(clip, disk)); + return clip; + } + } + + final json = inlineJson ?? await _fetchJson(url); + if (json == null) return null; + + final clip = RlottieClip(key: key, px: px); + _clips[key] = clip; + final jobId = _nextJobId++; + final completer = Completer(); + _jobs[jobId] = _Job(clip, url, completer); + + final port = await _worker(); + port.send(RenderJob( + jobId: jobId, + json: json, + cacheKey: url, + px: px, + libPath: debugLibraryPath, + )); + return completer.future; + } + + Future _decodeDiskProgressive(RlottieClip clip, DiskClip disk) async { + for (var i = 0; i < disk.frameCount; i++) { + if (!identical(_clips[clip.key], clip)) return; + final image = await _decode(disk.frames[i], clip.px); + if (!identical(_clips[clip.key], clip)) { + image.dispose(); + return; + } + clip._setFrame(i, image); + _totalBytes += clip.px * clip.px * 4; + } + clip.complete = true; + _evictIfNeeded(); + } + + Future _decode(Uint8List bgra, int px) { + final completer = Completer(); + ui.decodeImageFromPixels( + bgra, px, px, ui.PixelFormat.bgra8888, completer.complete); + return completer.future; + } + + Future _fetchJson(String url) async { + try { + final file = await DefaultCacheManager().getSingleFile(url); + return await file.readAsString(); + } catch (e) { + logger.w('rlottie fetch failed ($url): $e'); + return null; + } + } + + Future prewarm(String url, int px) async { + if (!available) return; + final clip = await acquire(url, px); + if (clip != null) release(clip); + } + + void release(RlottieClip clip) { + if (clip.active > 0) clip.active--; + clip.lastUsed = _tick(); + _evictIfNeeded(); + } + + void _evictIfNeeded() { + if (_totalBytes <= _maxBytes) return; + final candidates = _clips.values.where((c) => c.active <= 0).toList() + ..sort((a, b) => a.lastUsed.compareTo(b.lastUsed)); + for (final clip in candidates) { + if (_totalBytes <= _maxBytes) break; + _totalBytes -= clip.bytes; + _clips.remove(clip.key); + clip.dispose(); + } + } +} diff --git a/lib/core/media/rlottie/rlottie_engine_stub.dart b/lib/core/media/rlottie/rlottie_engine_stub.dart new file mode 100644 index 0000000..94a66a4 --- /dev/null +++ b/lib/core/media/rlottie/rlottie_engine_stub.dart @@ -0,0 +1,31 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; + +class RlottieClip { + RlottieClip({this.px = 0}); + + final int px; + int frameCount = 0; + int durationMs = 1000; + double frameRate = 60; + final ValueNotifier ready = ValueNotifier(0); + + ui.Image? frameAt(int index) => null; +} + +class RlottieEngine { + RlottieEngine._(); + static final RlottieEngine instance = RlottieEngine._(); + + static String? debugLibraryPath; + + bool get available => false; + + Future acquire(String url, int px, {String? inlineJson}) async => + null; + + Future prewarm(String url, int px) async {} + + void release(RlottieClip clip) {} +} diff --git a/lib/core/media/rlottie/rlottie_ffi.dart b/lib/core/media/rlottie/rlottie_ffi.dart new file mode 100644 index 0000000..ae2fd7e --- /dev/null +++ b/lib/core/media/rlottie/rlottie_ffi.dart @@ -0,0 +1,112 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; + +typedef _InitNative = Void Function(); +typedef _VoidFn = void Function(); + +typedef _FromDataNative = Pointer Function( + Pointer, Pointer, Pointer); + +typedef _SizeGetterNative = Size Function(Pointer); +typedef _SizeGetter = int Function(Pointer); + +typedef _DoubleGetterNative = Double Function(Pointer); +typedef _DoubleGetter = double Function(Pointer); + +typedef _RenderNative = Void Function( + Pointer, Size, Pointer, Size, Size, Size); +typedef _Render = void Function( + Pointer, int, Pointer, int, int, int); + +typedef _DestroyNative = Void Function(Pointer); +typedef _Destroy = void Function(Pointer); + +typedef _CacheSizeNative = Void Function(Size); +typedef _CacheSize = void Function(int); + +class RlottieBindings { + RlottieBindings._(this._lib) { + _init = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_init'); + _shutdown = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_shutdown'); + _fromData = _lib.lookupFunction<_FromDataNative, _FromDataNative>( + 'lottie_animation_from_data'); + _totalFrame = _lib.lookupFunction<_SizeGetterNative, _SizeGetter>( + 'lottie_animation_get_totalframe'); + _frameRate = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>( + 'lottie_animation_get_framerate'); + _duration = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>( + 'lottie_animation_get_duration'); + _render = + _lib.lookupFunction<_RenderNative, _Render>('lottie_animation_render'); + _destroy = _lib + .lookupFunction<_DestroyNative, _Destroy>('lottie_animation_destroy'); + _cacheSize = _lib.lookupFunction<_CacheSizeNative, _CacheSize>( + 'lottie_configure_model_cache_size'); + _init(); + } + + final DynamicLibrary _lib; + late final _VoidFn _init; + late final _VoidFn _shutdown; + late final _FromDataNative _fromData; + late final _SizeGetter _totalFrame; + late final _DoubleGetter _frameRate; + late final _DoubleGetter _duration; + late final _Render _render; + late final _Destroy _destroy; + late final _CacheSize _cacheSize; + + static RlottieBindings? open({String? path}) { + try { + final lib = _openLibrary(path); + return lib == null ? null : RlottieBindings._(lib); + } catch (_) { + return null; + } + } + + static DynamicLibrary? _openLibrary(String? path) { + if (path != null) return DynamicLibrary.open(path); + if (Platform.isMacOS || Platform.isIOS) return DynamicLibrary.process(); + try { + return DynamicLibrary.open(rlottieLibraryName); + } catch (_) { + return null; + } + } + + Pointer? loadFromData(String data, String key) { + final dataC = data.toNativeUtf8(); + final keyC = key.toNativeUtf8(); + final resC = ''.toNativeUtf8(); + try { + final anim = _fromData(dataC, keyC, resC); + return anim == nullptr ? null : anim; + } finally { + calloc.free(dataC); + calloc.free(keyC); + calloc.free(resC); + } + } + + int totalFrame(Pointer anim) => _totalFrame(anim); + double frameRate(Pointer anim) => _frameRate(anim); + double duration(Pointer anim) => _duration(anim); + + void render(Pointer anim, int frameNo, Pointer buffer, int px) { + _render(anim, frameNo, buffer, px, px, px * 4); + } + + void destroy(Pointer anim) => _destroy(anim); + + void configureModelCache(int bytes) => _cacheSize(bytes); + + void shutdown() => _shutdown(); +} + +String get rlottieLibraryName { + if (Platform.isWindows) return 'rlottie.dll'; + return 'librlottie.so'; +} diff --git a/lib/core/media/rlottie/rlottie_worker.dart b/lib/core/media/rlottie/rlottie_worker.dart new file mode 100644 index 0000000..b498096 --- /dev/null +++ b/lib/core/media/rlottie/rlottie_worker.dart @@ -0,0 +1,151 @@ +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'rlottie_ffi.dart'; + +class RenderJob { + const RenderJob({ + required this.jobId, + required this.json, + required this.cacheKey, + required this.px, + this.libPath, + }); + + final int jobId; + final String json; + final String cacheKey; + final int px; + final String? libPath; +} + +class ClipMeta { + const ClipMeta({ + required this.jobId, + required this.totalFrame, + required this.frameRate, + required this.durationMs, + }); + + final int jobId; + final int totalFrame; + final double frameRate; + final int durationMs; +} + +class RenderedFrame { + const RenderedFrame({ + required this.jobId, + required this.index, + required this.data, + required this.px, + }); + + final int jobId; + final int index; + final TransferableTypedData data; + final int px; +} + +class RenderDone { + const RenderDone(this.jobId); + final int jobId; +} + +class RenderError { + const RenderError(this.jobId, this.message); + final int jobId; + final String message; +} + +class CancelJob { + const CancelJob(this.jobId); + final int jobId; +} + +const double _maxCacheFps = 30.0; + +void rlottieWorkerMain(SendPort toMain) { + final port = ReceivePort(); + toMain.send(port.sendPort); + + final cancelled = {}; + RlottieBindings? bindings; + String? boundLibPath; + + port.listen((message) { + if (message is CancelJob) { + cancelled.add(message.jobId); + return; + } + if (message is! RenderJob) return; + + final job = message; + cancelled.remove(job.jobId); + + if (bindings == null || boundLibPath != job.libPath) { + bindings = RlottieBindings.open(path: job.libPath); + boundLibPath = job.libPath; + } + final rl = bindings; + if (rl == null) { + toMain.send(RenderError(job.jobId, 'rlottie unavailable')); + return; + } + + final anim = rl.loadFromData(job.json, job.cacheKey); + if (anim == null) { + toMain.send(RenderError(job.jobId, 'parse failed')); + return; + } + + try { + final total = rl.totalFrame(anim); + final fps = rl.frameRate(anim); + final durationMs = fps <= 0 ? 1000 : (total / fps * 1000).round(); + + var outCount = total; + if (fps > _maxCacheFps && total > 1) { + outCount = (durationMs / 1000.0 * _maxCacheFps).round().clamp(2, total); + } + final outFps = durationMs <= 0 ? fps : outCount * 1000.0 / durationMs; + toMain.send(ClipMeta( + jobId: job.jobId, + totalFrame: outCount, + frameRate: outFps, + durationMs: durationMs, + )); + + final px = job.px; + final buffer = calloc(px * px); + final byteView = buffer.cast().asTypedList(px * px * 4); + try { + for (var i = 0; i < outCount; i++) { + if (cancelled.contains(job.jobId)) break; + final src = outCount == total + ? i + : (i * (total - 1) / (outCount - 1)).round().clamp(0, total - 1); + rl.render(anim, src, buffer, px); + toMain.send(RenderedFrame( + jobId: job.jobId, + index: i, + data: TransferableTypedData.fromList([Uint8List.fromList(byteView)]), + px: px, + )); + } + } finally { + calloc.free(buffer); + } + toMain.send(RenderDone(job.jobId)); + } catch (e) { + toMain.send(RenderError(job.jobId, e.toString())); + } finally { + rl.destroy(anim); + cancelled.remove(job.jobId); + } + }); +} + 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 cfee478..193752f 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -11,6 +11,7 @@ abstract class Opcode { static const int reconnect = 3; // Реконнект static const int log = 5; // Аналитика / события static const int sessionInit = 6; // Инициализация сессии (хэндшейк) + static const int contactsGet = 8; // Синхронизация списка контактов // ── Profile ──────────────────────────────────────────────────────── static const int profile = 16; // Обновление профиля @@ -202,6 +203,20 @@ abstract class Opcode { static const int foldersReorder = 275; // Сортировка папок static const int foldersDelete = 276; // Удаление папки + // ── Stories ──────────────────────────────────────────────────────── + static const int storiesList = 208; // Лента историй (кольца-превью) + static const int storiesListByOwner = 209; // Превью по списку владельцев + static const int storiesGetByOwner = 210; // Полные истории владельцев + static const int storiesGetStats = 211; // Агрегированная статистика + static const int storiesGetDetailedStats = 212; // Детальная статистика + static const int storiesReact = 213; // Реакция на историю + static const int storiesMark = 214; // Отметка просмотренной + static const int storiesSend = 215; // Публикация истории + static const int notifStoriesUpdate = 216; // Обновление кольца (push) + static const int storiesEdit = 217; // Изменение настроек истории + static const int storiesDelete = 218; // Удаление историй + static const int storiesGetByStoryId = 220; // Истории по ID + // ── Human-readable names ─────────────────────────────────────────── static String name(int opcode) => _names[opcode] ?? 'UNKNOWN($opcode)'; @@ -212,6 +227,7 @@ abstract class Opcode { reconnect: 'RECONNECT', log: 'LOG', sessionInit: 'SESSION_INIT', + contactsGet: 'CONTACTS_GET', profile: 'PROFILE', authRequest: 'AUTH_REQUEST', auth: 'AUTH', @@ -362,5 +378,17 @@ abstract class Opcode { foldersUpdate: 'FOLDERS_UPDATE', foldersReorder: 'FOLDERS_REORDER', foldersDelete: 'FOLDERS_DELETE', + storiesList: 'STORIES_LIST', + storiesListByOwner: 'STORIES_LIST_BY_OWNER_ID', + storiesGetByOwner: 'STORIES_GET_BY_OWNER_ID', + storiesGetStats: 'STORIES_GET_STATS', + storiesGetDetailedStats: 'STORIES_GET_DETAILED_STATS', + storiesReact: 'STORIES_REACT', + storiesMark: 'STORIES_MARK', + storiesSend: 'STORIES_SEND', + notifStoriesUpdate: 'NOTIF_STORIES_UPDATE', + storiesEdit: 'STORIES_EDIT', + storiesDelete: 'STORIES_DELETE', + storiesGetByStoryId: 'STORIES_GET_BY_STORY_ID', }; } diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index c99c138..c69dab4 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,14 +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; -const int _maxDecompressedSize = 1048576; // 1 MB - /// Типы команд в протоколе abstract class CmdType { static const int request = @@ -20,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; @@ -107,132 +90,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/push_service.dart b/lib/core/push/push_service.dart index 4ecb356..3a21860 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'; @@ -53,6 +54,9 @@ Future _handleCallDecline(String payloadJson) async { } if (vcp.isEmpty || conversationId.isEmpty) return; + // Фоновый изолят: инициализируем ядро перед vcp-декодом/сигналингом. + await initKolibri(); + final params = ConversationParams.decode(vcp); if (params == null) return; @@ -83,6 +87,7 @@ 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}.'); diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 52731c2..9c03f5c 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -212,7 +212,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 17, + version: 19, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -296,6 +296,34 @@ class AppDatabase { await _createChatParticipantsIndex(db); await _backfillChatParticipants(db); } + if (oldVersion < 18) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_id', + 'INTEGER', + ); + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_text', + 'TEXT', + ); + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_time', + 'INTEGER', + ); + } + if (oldVersion < 19) { + await _addColumnIfMissing( + db, + 'chats_cache', + 'pinned_msg_is_preview', + 'INTEGER NOT NULL DEFAULT 0', + ); + } }, ); } @@ -444,6 +472,10 @@ class AppDatabase { owner INTEGER, admins TEXT, in_list INTEGER NOT NULL DEFAULT 1, + pinned_msg_id INTEGER, + pinned_msg_text TEXT, + pinned_msg_time INTEGER, + pinned_msg_is_preview INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (id, account_id) ) '''; @@ -677,27 +709,41 @@ class AppDatabase { ); } - static Future>> loadChats(int accountId) async { + static Future>> loadChats( + int accountId, { + bool includeHidden = false, + }) async { final db = await _instance; return db.query( 'chats_cache', - where: 'account_id = ? AND in_list = 1', + where: includeHidden + ? 'account_id = ? AND in_list IN (1, 2)' + : 'account_id = ? AND in_list = 1', whereArgs: [accountId], orderBy: 'last_event_time DESC', ); } - static Future sumUnread(int accountId, {int? excludeChatId}) async { + static Future sumUnread( + int accountId, { + int? excludeChatId, + Set? excludeChatIds, + }) async { final db = await _instance; - final where = excludeChatId != null - ? 'account_id = ? AND in_list = 1 AND id != ?' - : 'account_id = ? AND in_list = 1'; - final args = excludeChatId != null - ? [accountId, excludeChatId] - : [accountId]; + final buffer = StringBuffer('account_id = ? AND in_list = 1'); + final args = [accountId]; + if (excludeChatId != null) { + buffer.write(' AND id != ?'); + args.add(excludeChatId); + } + if (excludeChatIds != null && excludeChatIds.isNotEmpty) { + final placeholders = List.filled(excludeChatIds.length, '?').join(', '); + buffer.write(' AND id NOT IN ($placeholders)'); + args.addAll(excludeChatIds); + } final result = await db.rawQuery( 'SELECT COALESCE(SUM(unread_count), 0) AS total ' - 'FROM chats_cache WHERE $where', + 'FROM chats_cache WHERE $buffer', args, ); return (result.first['total'] as int?) ?? 0; @@ -882,6 +928,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/archived_chats_store.dart b/lib/core/storage/archived_chats_store.dart new file mode 100644 index 0000000..36a47a8 --- /dev/null +++ b/lib/core/storage/archived_chats_store.dart @@ -0,0 +1,31 @@ +import 'per_chat_json_store.dart'; + +class ArchivedChatsStore extends PerChatJsonStore { + ArchivedChatsStore._() + : super( + prefsKey: 'archived_chats', + fromJson: (raw) => raw == true ? true : null, + toJson: (value) => value, + ); + + static final ArchivedChatsStore instance = ArchivedChatsStore._(); + + bool isArchived(int accountId, int chatId) => + read(accountId, chatId) == true; + + Future setArchived(int accountId, int chatId, bool archived) => + write(accountId, chatId, archived ? true : null); + + Set archivedChatIds(int accountId) { + if (accountId == 0) return const {}; + final prefix = '$accountId/'; + final ids = {}; + for (final entry in allEntries) { + if (entry.value != true) continue; + if (!entry.key.startsWith(prefix)) continue; + final id = int.tryParse(entry.key.substring(prefix.length)); + if (id != null) ids.add(id); + } + return ids; + } +} diff --git a/lib/core/storage/chat_wallpaper_store.dart b/lib/core/storage/chat_wallpaper_store.dart index d3aaabc..ed6730e 100644 --- a/lib/core/storage/chat_wallpaper_store.dart +++ b/lib/core/storage/chat_wallpaper_store.dart @@ -7,6 +7,8 @@ import 'package:path_provider/path_provider.dart'; import '../utils/logger.dart'; import 'per_chat_json_store.dart'; +const int kGlobalWallpaperChatId = 0; + enum ChatWallpaperKind { image, theme } @immutable diff --git a/lib/core/storage/per_chat_json_store.dart b/lib/core/storage/per_chat_json_store.dart index c53c1dd..7f375fc 100644 --- a/lib/core/storage/per_chat_json_store.dart +++ b/lib/core/storage/per_chat_json_store.dart @@ -22,6 +22,9 @@ abstract class PerChatJsonStore { String _buildKey(int accountId, int chatId) => '$accountId/$chatId'; + @protected + Iterable> get allEntries => _values.entries; + @protected void onBeforeWrite(String key, T? previous, T? next) {} 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 1fcd198..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 = 2 * 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/utils/emoji_keyword_index.dart b/lib/core/utils/emoji_keyword_index.dart index 0172b03..431b0b0 100644 --- a/lib/core/utils/emoji_keyword_index.dart +++ b/lib/core/utils/emoji_keyword_index.dart @@ -32,6 +32,18 @@ class EmojiKeywordIndex { }); } + List get all => + List.unmodifiable(_entries.map((e) => e.emoji)); + + List search(String query) { + final targets = resolve(query); + if (targets.isEmpty) return const []; + return [ + for (final entry in _entries) + if (targets.contains(entry.emoji)) entry.emoji, + ]; + } + static String normalize(String emoji) => emoji.replaceAll(_variationSelector, ''); diff --git a/lib/core/utils/media_saver.dart b/lib/core/utils/media_saver.dart index 39968a2..9907a30 100644 --- a/lib/core/utils/media_saver.dart +++ b/lib/core/utils/media_saver.dart @@ -30,27 +30,84 @@ 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()); } } +enum SaveMediaKind { image, video, file } + +Future saveMediaFile({ + required String cacheName, + required Future Function() resolveUrl, + required String saveName, + required SaveMediaKind kind, +}) async { + try { + var file = await MediaCache.existing(cacheName); + if (file == null) { + final url = await resolveUrl(); + if (url == null || url.isEmpty) { + return const MediaSaveResult(ok: false, error: 'нет ссылки'); + } + file = await MediaCache.getOrDownload(cacheName, url); + } + if (file == null) { + return const MediaSaveResult(ok: false, error: 'не удалось загрузить'); + } + return _persist(file, saveName: saveName, kind: kind); + } 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/text_format.dart b/lib/core/utils/text_format.dart index 79b94ca..4100ad9 100644 --- a/lib/core/utils/text_format.dart +++ b/lib/core/utils/text_format.dart @@ -8,6 +8,7 @@ enum TextFormat { monospaced, quote, link, + animoji, } const Map _formatToServer = { @@ -18,6 +19,7 @@ const Map _formatToServer = { TextFormat.monospaced: 'MONOSPACED', TextFormat.quote: 'QUOTE', TextFormat.link: 'LINK', + TextFormat.animoji: 'ANIMOJI', }; final Map _serverToFormat = { @@ -49,6 +51,11 @@ class FormatRange { return value is String ? value : null; } + String? get animojiUrl { + final value = attributes?['animojiLottieUrl']; + return value is String && value.isNotEmpty ? value : null; + } + Map toServer() => { 'type': textFormatToServer(format), 'from': start, @@ -87,6 +94,34 @@ List> serializeFormatElements( Iterable ranges, ) => [for (final range in ranges) range.toServer()]; +List? animojiOnlyLottieUrls( + String? text, + List ranges, { + int limit = 4, +}) { + if (text == null || text.isEmpty) return null; + final len = text.length; + final animoji = + ranges + .where((r) => r.format == TextFormat.animoji && r.animojiUrl != null) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + if (animoji.isEmpty || animoji.length > limit) return null; + + var cursor = 0; + for (final r in animoji) { + final start = r.start.clamp(0, len).toInt(); + if (text.substring(cursor.clamp(0, len).toInt(), start).trim().isNotEmpty) { + return null; + } + cursor = r.end.clamp(0, len).toInt(); + } + if (text.substring(cursor.clamp(0, len).toInt()).trim().isNotEmpty) { + return null; + } + return [for (final r in animoji) r.animojiUrl!]; +} + int _asInt(dynamic value) { if (value is int) return value; if (value is String) return int.tryParse(value) ?? 0; @@ -98,12 +133,14 @@ class FormatSegment { final int end; final Set formats; final String? url; + final String? animojiUrl; const FormatSegment({ required this.start, required this.end, required this.formats, this.url, + this.animojiUrl, }); } @@ -142,14 +179,22 @@ List segmentizeFormats(String text, List ranges) { if (end <= start) continue; final formats = {}; String? url; + String? animojiUrl; 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; } } segments.add( - FormatSegment(start: start, end: end, formats: formats, url: url), + FormatSegment( + start: start, + end: end, + formats: formats, + url: url, + animojiUrl: animojiUrl, + ), ); } return segments; diff --git a/lib/core/utils/tiled_svg.dart b/lib/core/utils/tiled_svg.dart new file mode 100644 index 0000000..674e02e --- /dev/null +++ b/lib/core/utils/tiled_svg.dart @@ -0,0 +1,134 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class TiledSvgPattern extends StatefulWidget { + final String asset; + final Color color; + final double opacity; + final double tileSize; + + const TiledSvgPattern({ + super.key, + required this.asset, + required this.color, + this.opacity = 0.12, + this.tileSize = 120, + }); + + @override + State createState() => _TiledSvgPatternState(); +} + +class _TiledSvgPatternState extends State { + static final Map _cache = {}; + static final Map> _pending = {}; + + ui.Image? _image; + double _dpr = 1; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final dpr = MediaQuery.maybeOf(context)?.devicePixelRatio ?? 1; + if (dpr != _dpr || _image == null) { + _dpr = dpr; + _resolve(); + } + } + + @override + void didUpdateWidget(TiledSvgPattern old) { + super.didUpdateWidget(old); + if (old.asset != widget.asset || old.tileSize != widget.tileSize) { + _resolve(); + } + } + + Future _resolve() async { + final px = (widget.tileSize * _dpr).clamp(1, 4096).round(); + final key = '${widget.asset}@$px'; + final cached = _cache[key]; + if (cached != null) { + if (_image != cached) setState(() => _image = cached); + return; + } + final future = _pending.putIfAbsent(key, () => _rasterize(widget.asset, px)); + try { + final image = await future; + _cache[key] = image; + _pending.remove(key); + if (mounted) setState(() => _image = image); + } catch (_) { + _pending.remove(key); + } + } + + static Future _rasterize(String asset, int px) async { + final info = await vg.loadPicture(SvgAssetLoader(asset), null); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final size = info.size; + if (size.width > 0 && size.height > 0) { + canvas.scale(px / size.width, px / size.height); + } + canvas.drawPicture(info.picture); + final picture = recorder.endRecording(); + final image = await picture.toImage(px, px); + info.picture.dispose(); + picture.dispose(); + return image; + } + + @override + Widget build(BuildContext context) { + final image = _image; + if (image == null) return const SizedBox.expand(); + return CustomPaint( + size: Size.infinite, + painter: _PatternPainter( + image: image, + color: widget.color.withValues(alpha: widget.opacity), + tileSize: widget.tileSize, + dpr: _dpr, + ), + ); + } +} + +class _PatternPainter extends CustomPainter { + final ui.Image image; + final Color color; + final double tileSize; + final double dpr; + + const _PatternPainter({ + required this.image, + required this.color, + required this.tileSize, + required this.dpr, + }); + + @override + void paint(Canvas canvas, Size size) { + final s = 1 / dpr; + final matrix = Matrix4.identity()..scaleByDouble(s, s, 1, 1); + final paint = Paint() + ..shader = ImageShader( + image, + TileMode.repeated, + TileMode.repeated, + matrix.storage, + ) + ..colorFilter = ColorFilter.mode(color, BlendMode.srcIn); + canvas.drawRect(Offset.zero & size, paint); + } + + @override + bool shouldRepaint(_PatternPainter old) => + old.image != image || + old.color != color || + old.tileSize != tileSize || + old.dpr != dpr; +} diff --git a/lib/core/utils/wallpaper_seed.dart b/lib/core/utils/wallpaper_seed.dart new file mode 100644 index 0000000..b55c8b5 --- /dev/null +++ b/lib/core/utils/wallpaper_seed.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; + +import '../config/chat_wallpaper_themes.dart'; +import '../storage/chat_wallpaper_store.dart'; + +Future computeWallpaperSeed(ChatWallpaper? wallpaper) async { + if (wallpaper == null) return null; + if (!wallpaper.isImage) { + final theme = chatWallpaperThemeById(wallpaper.themeId); + if (theme == null) return null; + return _mostVivid(theme.colors); + } + final path = wallpaper.imagePath; + if (path == null) return null; + try { + final bytes = await File(path).readAsBytes(); + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final small = img.copyResize(decoded, width: 8, height: 8); + var r = 0, g = 0, b = 0, n = 0; + for (final pixel in small) { + r += pixel.r.toInt(); + g += pixel.g.toInt(); + b += pixel.b.toInt(); + n++; + } + if (n == 0) return null; + return Color.fromARGB(255, r ~/ n, g ~/ n, b ~/ n); + } catch (_) { + return null; + } +} + +Color _mostVivid(List colors) { + var best = colors.first; + var bestScore = -1.0; + for (final color in colors) { + final hsl = HSLColor.fromColor(color); + final score = hsl.saturation * (1 - (hsl.lightness - 0.5).abs()); + if (score > bestScore) { + bestScore = score; + best = color; + } + } + return best; +} 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/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/sync_probe_section.dart b/lib/frontend/debug/sync_probe_section.dart index 48c696b..197f5d6 100644 --- a/lib/frontend/debug/sync_probe_section.dart +++ b/lib/frontend/debug/sync_probe_section.dart @@ -6,6 +6,7 @@ import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../main.dart'; import '../widgets/glossy_pill.dart'; +import '../widgets/small_spinner.dart'; class DebugSyncProbeSection extends StatefulWidget { const DebugSyncProbeSection({super.key}); @@ -147,11 +148,7 @@ class _DebugSyncProbeSectionState extends State { ), ), 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..0ef603f 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -11,6 +11,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; @@ -525,14 +526,7 @@ 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, color: _codeController.text.length == 6 diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 398b8de..ec3bb9c 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -18,6 +18,7 @@ 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'; @@ -998,13 +999,9 @@ 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, diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index dae30dd..2a6fd9e 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -3,6 +3,7 @@ import '../../../core/protocol/packet.dart'; import '../../../main.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 { @@ -199,14 +200,7 @@ 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, color: _passwordController.text.isNotEmpty diff --git a/lib/frontend/screens/auth/registration_screen.dart b/lib/frontend/screens/auth/registration_screen.dart index 2a6b1da..86b72d1 100644 --- a/lib/frontend/screens/auth/registration_screen.dart +++ b/lib/frontend/screens/auth/registration_screen.dart @@ -6,6 +6,7 @@ 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; @@ -110,14 +111,7 @@ 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, color: _canSubmit ? cs.onPrimaryContainer : cs.onSurfaceVariant, diff --git a/lib/frontend/screens/auth/token_login_screen.dart b/lib/frontend/screens/auth/token_login_screen.dart index 382df99..58bb761 100644 --- a/lib/frontend/screens/auth/token_login_screen.dart +++ b/lib/frontend/screens/auth/token_login_screen.dart @@ -8,6 +8,7 @@ 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'; class TokenLoginScreen extends StatefulWidget { final int? returnToAccountId; @@ -143,11 +144,7 @@ class _TokenLoginScreenState extends State { shape: const StadiumBorder(), ), child: _isLoading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 22) : Text(l10n.tokenLoginButton), ), ), diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 42ac11f..c11295e 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -24,6 +24,7 @@ import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import 'komet_hub.dart'; const Color _kEndRed = Color(0xFFE5484D); @@ -1261,14 +1262,7 @@ class _CallButton extends StatelessWidget { depth: 9, child: Center( child: busy - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - valueColor: AlwaysStoppedAnimation(foreground), - ), - ) + ? SmallSpinner(size: 22, color: foreground) : Icon(icon, color: foreground, size: 26, fill: 1), ), ), diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 8dd0c37..5068710 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -12,6 +12,7 @@ import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/chat_menu_overlay.dart'; +import '../../widgets/small_spinner.dart'; import 'call_screen.dart'; class CallsTab extends StatefulWidget { @@ -421,7 +422,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/chats/chat/chat_controller.dart b/lib/frontend/screens/chats/chat/chat_controller.dart index e91bc49..6b495bc 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,11 @@ class ChatController extends ChangeNotifier { bool hasMoreHistory = true; bool isLoadingMore = false; bool historyKickedOff = false; + bool loadingGap = false; + + final List gaps = []; + + bool get hasGap => gaps.isNotEmpty; bool Function() isMounted = () => true; @@ -93,20 +114,179 @@ 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) 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) mergeMessages(refreshed); + _closeGap(gap); + return refreshed.length; + } + slice = refreshed; + } + + if (slice.isEmpty) { + _closeGap(gap); + return 0; + } + + 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 +299,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 +332,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'); diff --git a/lib/frontend/screens/chats/chat/sticker_panel_controller.dart b/lib/frontend/screens/chats/chat/sticker_panel_controller.dart index 2667a09..4f403a6 100644 --- a/lib/frontend/screens/chats/chat/sticker_panel_controller.dart +++ b/lib/frontend/screens/chats/chat/sticker_panel_controller.dart @@ -14,6 +14,7 @@ class StickerPanelController { duration: const Duration(milliseconds: 240), reverseDuration: const Duration(milliseconds: 200), ); + anim.addStatusListener(_onAnimStatus); showPanel.addListener(_onToggle); } @@ -21,11 +22,17 @@ class StickerPanelController { late final AnimationController anim; final ValueNotifier showPanel = ValueNotifier(false); + final ValueNotifier panelHold = ValueNotifier(true); double panelHeight = 300; Timer? _typingTimer; void hide() => showPanel.value = false; + void _onAnimStatus(AnimationStatus status) { + final held = status != AnimationStatus.completed; + if (panelHold.value != held) panelHold.value = held; + } + void _onToggle() { if (showPanel.value) { anim.forward(); @@ -50,7 +57,9 @@ class StickerPanelController { void dispose() { _typingTimer?.cancel(); showPanel.removeListener(_onToggle); + anim.removeStatusListener(_onAnimStatus); anim.dispose(); showPanel.dispose(); + panelHold.dispose(); } } diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart index 4fb57f3..7e23e24 100644 --- a/lib/frontend/screens/chats/chat/view/chat_header.dart +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -2,11 +2,15 @@ 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/frontend/widgets/glossy_pill.dart'; import 'package:komet/frontend/widgets/online_dot.dart'; class ChatHeaderRow extends StatelessWidget { final bool glossy; + final bool frosted; + final bool liquid; + final BackdropKey? backdropKey; final ColorScheme cs; final bool embedded; final int chatId; @@ -28,6 +32,9 @@ class ChatHeaderRow extends StatelessWidget { const ChatHeaderRow({ super.key, required this.glossy, + required this.frosted, + this.liquid = false, + this.backdropKey, required this.cs, required this.embedded, required this.chatId, @@ -51,6 +58,10 @@ class ChatHeaderRow extends StatelessWidget { Widget build(BuildContext context) => glossy ? _glossyRow(context) : _materialRow(context); + Color? get _pillColor => frosted || liquid ? AppFrost.pillTint(cs) : null; + + double? get _pillBlur => frosted && !liquid ? AppFrost.sigma : null; + Widget _glossyRow(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), @@ -62,6 +73,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,6 +98,10 @@ 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( @@ -168,6 +187,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, diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index b2e3d68..e3ae06a 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -6,17 +6,26 @@ import 'package:flutter/services.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_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/glossy_pill.dart'; +import 'package:komet/frontend/widgets/liquid_glass.dart'; import 'package:komet/frontend/widgets/rich_message_controller.dart'; class ComposerInputBar extends StatelessWidget { const ComposerInputBar({ super.key, required this.chatType, + required this.chrome, + required this.style, + required this.background, + this.backdropKey, required this.attachAnim, required this.replyTo, required this.myId, @@ -40,6 +49,10 @@ class ComposerInputBar extends StatelessWidget { }); final String chatType; + final ChatChromeStyle chrome; + final ComposerStyle style; + final ComposerBackground background; + final BackdropKey? backdropKey; final Animation attachAnim; final ValueListenable replyTo; final int myId; @@ -101,7 +114,7 @@ class ComposerInputBar extends StatelessWidget { ); } - return SafeArea( + final bar = SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -121,18 +134,9 @@ class ComposerInputBar extends StatelessWidget { minHeight: 54, 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( @@ -314,14 +318,16 @@ class ComposerInputBar extends StatelessWidget { builder: (context, videoMode, _) { final sendMode = hasText || locked; - final pill = GlossyPill( - color: sendMode + final pill = _actionSurface( + color: _flat + ? Colors.transparent + : sendMode ? cs.primary : recording ? cs.error + : _frost + ? AppFrost.inputTint(cs) : cs.surfaceContainerHighest, - borderRadius: - BorderRadius.circular(27), onTap: hasText ? onSendText : locked @@ -332,7 +338,6 @@ class ComposerInputBar extends StatelessWidget { onLongPress: hasText ? onScheduleMessage : null, - depth: 8, child: SizedBox( width: 54, height: 54, @@ -343,7 +348,13 @@ class ComposerInputBar extends StatelessWidget { : videoMode ? Symbols.videocam : Symbols.mic, - color: sendMode + color: _flat + ? (sendMode + ? cs.primary + : recording + ? cs.error + : cs.onSurfaceVariant) + : sendMode ? cs.onPrimary : recording ? cs.onError @@ -402,6 +413,78 @@ class ComposerInputBar extends StatelessWidget { ], ), ); + + return _barSurface(cs, bar); + } + + bool get _flat => style == ComposerStyle.materialYou; + + bool get _frost => ComposerMaterial.isFrost(background); + + bool get _liquid => ComposerMaterial.isLiquid(background); + + bool get _translucent => _frost || _liquid; + + Widget _barSurface(ColorScheme cs, Widget child) { + if (!_flat || _translucent) return child; + return DecoratedBox( + decoration: BoxDecoration( + color: cs.surface, + border: Border(top: AppFrost.hairline(cs)), + ), + child: child, + ); + } + + Widget _fieldSurface(ColorScheme cs, Widget child) { + if (_flat) return child; + return GlossyPill( + color: _translucent + ? AppFrost.inputTint(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, + }) { + if (_flat) { + return Material( + color: color, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: onTap == null && onLongPress == null + ? child + : InkWell(onTap: onTap, onLongPress: onLongPress, child: child), + ); + } + return GlossyPill( + color: color, + blurSigma: _frost ? AppFrost.sigma : null, + liquid: _liquid, + backdropKey: backdropKey, + borderRadius: BorderRadius.circular(27), + onTap: onTap, + onLongPress: onLongPress, + depth: 8, + child: child, + ); } Widget _replyPreview(ColorScheme cs) { @@ -418,7 +501,7 @@ class ComposerInputBar extends StatelessWidget { attachments: reply.attachments, ); final preview = info.previewText(); - return Padding( + final row = Padding( padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), child: Row( children: [ @@ -462,6 +545,15 @@ class ComposerInputBar extends StatelessWidget { ], ), ); + if (_flat && _translucent) return row; + if (!_translucent && chrome != ChatChromeStyle.transparent) return row; + return GlassSurface( + liquid: _liquid, + frostTint: AppFrost.panelTint(cs), + border: Border(top: AppFrost.hairline(cs)), + backdropKey: backdropKey, + child: row, + ); }, ); } diff --git a/lib/frontend/screens/chats/chat/view/search_view.dart b/lib/frontend/screens/chats/chat/view/search_view.dart index 2c7c683..06c5c0d 100644 --- a/lib/frontend/screens/chats/chat/view/search_view.dart +++ b/lib/frontend/screens/chats/chat/view/search_view.dart @@ -9,6 +9,7 @@ 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'; @@ -168,14 +169,7 @@ class SearchOverlay extends StatelessWidget { } 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( 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 226c977..374857f 100644 --- a/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart +++ b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:komet/frontend/screens/chats/chat/sticker_panel_controller.dart'; +import 'package:komet/frontend/widgets/lottie_image.dart'; import 'package:komet/frontend/widgets/sticker_panel.dart'; +import 'package:komet/models/animoji.dart'; import 'package:komet/models/sticker.dart'; class StickerPanelView extends StatelessWidget { @@ -9,18 +11,24 @@ class StickerPanelView extends StatelessWidget { super.key, required this.stickers, required this.onStickerTap, + this.onEmojiTap, }); final StickerPanelController stickers; final void Function(StickerItem sticker) onStickerTap; + final void Function(Animoji animoji)? onEmojiTap; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: stickers.anim, - child: StickerPanel( - height: stickers.panelHeight, - onStickerTap: onStickerTap, + child: LottieHoldScope( + isHeld: stickers.panelHold, + child: StickerPanel( + height: stickers.panelHeight, + onStickerTap: onStickerTap, + onEmojiTap: onEmojiTap, + ), ), builder: (context, child) { final t = Curves.easeOutCubic.transform( diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 94eaea4..c264390 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/cache/info_cache.dart'; @@ -11,6 +12,7 @@ import '../../../l10n/app_localizations.dart'; import '../../../models/chat_info.dart'; import '../../../models/contact_info.dart'; import '../../widgets/avatar_history_screen.dart'; +import '../../widgets/chat_info/shared_content_tabs.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; @@ -35,6 +37,8 @@ class _MemberInfo { }); } +enum ChatInfoTab { media } + class ChatInfoScreen extends StatefulWidget { final int chatId; final String name; @@ -42,6 +46,9 @@ class ChatInfoScreen extends StatefulWidget { final String chatType; final int? dialogPeerId; + final ChatInfoTab? initialTab; + + final void Function(String messageId, int time)? onJumpToMessage; const ChatInfoScreen({ super.key, @@ -50,6 +57,8 @@ class ChatInfoScreen extends StatefulWidget { required this.imageUrl, required this.chatType, this.dialogPeerId, + this.initialTab, + this.onJumpToMessage, }); @override @@ -58,6 +67,7 @@ class ChatInfoScreen extends StatefulWidget { class _ChatInfoScreenState extends State { final _tabScrollController = ScrollController(); + final _bodyScrollController = ScrollController(); int _myId = 0; bool _isLoading = true; @@ -76,6 +86,9 @@ class _ChatInfoScreenState extends State { List<_MemberInfo> _members = []; int _onlineCount = 0; + int _mediaChatId = 0; + String? _anchorMsgId; + @override void initState() { super.initState(); @@ -85,6 +98,7 @@ class _ChatInfoScreenState extends State { @override void dispose() { _tabScrollController.dispose(); + _bodyScrollController.dispose(); super.dispose(); } @@ -140,6 +154,23 @@ class _ChatInfoScreenState extends State { if (!mounted) return; _chatInfo = info; + _mediaChatId = (info?.raw['id'] as int?) ?? widget.chatId; + final lastMessage = info?.raw['lastMessage']; + if (lastMessage is Map) { + _anchorMsgId = lastMessage['id']?.toString(); + } + if (_anchorMsgId == null && info != null) { + try { + final recent = await messagesModule.fetchHistory( + _myId, + _mediaChatId, + count: 1, + ); + if (recent.isNotEmpty) _anchorMsgId = recent.first.id; + } catch (_) {} + if (!mounted) return; + } + if (widget.chatType == 'DIALOG') { _otherId = widget.dialogPeerId; if (_otherId == null && info != null) { @@ -204,12 +235,18 @@ class _ChatInfoScreenState extends State { setState(() { _isLoading = false; if (_selectedTab.isEmpty && _tabs.isNotEmpty) { - _selectedTab = _tabs.first; + _selectedTab = _initialTabLabel() ?? _tabs.first; } }); } } + String? _initialTabLabel() { + if (widget.initialTab != ChatInfoTab.media) return null; + final media = AppLocalizations.of(context)!.chatInfoTabMedia; + return _tabs.contains(media) ? media : null; + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -226,6 +263,7 @@ class _ChatInfoScreenState extends State { Widget _buildScrollBody(ColorScheme cs) { return CustomScrollView( + controller: _bodyScrollController, slivers: [ SliverAppBar( backgroundColor: Colors.transparent, @@ -290,7 +328,7 @@ class _ChatInfoScreenState extends State { case 'DIALOG': if (_isBot) return l10n.contactProfileBot; if (_isOnline) return l10n.contactProfileOnline; - if (_presenceStatus == 3) return l10n.contactProfileRecentlyActive; + if (_presenceStatus == 2 || _presenceStatus == 3) return l10n.contactProfileRecentlyActive; if (_seenTime != null && _seenTime! > 0) { return formatLastSeen(_seenTime!); } @@ -671,27 +709,95 @@ class _ChatInfoScreenState extends State { return _buildMembersTabContent(cs); } if (_selectedTab == l10n.chatInfoTabGeneralChats) { - return _buildPlaceholder(cs, l10n.chatInfoEmptyGeneralChats, Icons.group); + final peerId = _otherId; + if (peerId == null) { + return _buildPlaceholder( + cs, + l10n.chatInfoEmptyGeneralChats, + Icons.group, + ); + } + return CommonChatsTab( + key: const ValueKey('tab-common-chats'), + userId: peerId, + emptyLabel: l10n.chatInfoEmptyGeneralChats, + ); } if (_selectedTab == l10n.chatInfoTabMedia) { - return _buildPlaceholder( + return _sharedTab( cs, + SharedContentKind.media, l10n.chatInfoEmptyMedia, Icons.photo_library, ); } if (_selectedTab == l10n.chatInfoTabFiles) { - return _buildPlaceholder(cs, l10n.chatInfoEmptyFiles, Icons.description); + return _sharedTab( + cs, + SharedContentKind.files, + l10n.chatInfoEmptyFiles, + Icons.description, + ); } if (_selectedTab == l10n.chatInfoTabVoice) { - return _buildPlaceholder(cs, l10n.chatInfoEmptyVoice, Icons.mic); + return _sharedTab( + cs, + SharedContentKind.voice, + l10n.chatInfoEmptyVoice, + Icons.mic, + ); } if (_selectedTab == l10n.chatInfoTabLinks) { - return _buildPlaceholder(cs, l10n.chatInfoEmptyLinks, Icons.link); + return _sharedTab( + cs, + SharedContentKind.links, + l10n.chatInfoEmptyLinks, + Icons.link, + ); } return const SizedBox.shrink(); } + Widget _sharedTab( + ColorScheme cs, + SharedContentKind kind, + String emptyLabel, + IconData emptyIcon, + ) { + final anchor = _anchorMsgId; + if (anchor == null) return _buildPlaceholder(cs, emptyLabel, emptyIcon); + return SharedMediaTab( + key: ValueKey('tab-shared-$kind'), + chatId: _mediaChatId, + anchorMessageId: anchor, + myId: _myId, + kind: kind, + emptyLabel: emptyLabel, + emptyIcon: emptyIcon, + onGoToMessage: _goToMessage, + scrollController: _bodyScrollController, + ); + } + + void _goToMessage(String messageId, int time) { + final jumpInParent = widget.onJumpToMessage; + if (jumpInParent != null && _mediaChatId == widget.chatId) { + jumpInParent(messageId, time); + return; + } + pushSwipeable( + context, + (_) => ChatScreen( + chatId: _mediaChatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, + initialMessageId: messageId, + initialMessageTime: time, + ), + ); + } + Widget _buildPlaceholder(ColorScheme cs, String label, IconData icon) { return Padding( padding: const EdgeInsets.symmetric(vertical: 48), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 8113a5d..083b7ca 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -16,6 +16,7 @@ import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; +import '../../widgets/springy_tap.dart'; import '../../widgets/formatted_message_text.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/text_format.dart'; @@ -34,19 +35,30 @@ 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_nav_pill_style.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'; import '../../../backend/models/chat_folder.dart'; import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/cloud_storage.dart'; +import '../../../backend/modules/contacts.dart'; 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/token_storage.dart'; import '../../../core/storage/chat_activity_store.dart'; import '../../../main.dart' - show accountModule, api, messagesModule, appRouteObserver; + show accountModule, api, messagesModule, storiesModule, appRouteObserver; +import '../../widgets/attachment/attachment_sheet.dart'; +import '../stories/story_composer_screen.dart'; +import '../stories/story_owner_info.dart'; +import '../stories/story_ring.dart'; +import '../stories/story_viewer_screen.dart'; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -109,12 +121,14 @@ class ChatListScreen extends StatefulWidget { final ValueChanged? onChatSelected; final bool forwardMode; final int forwardMessageCount; + final bool archiveMode; const ChatListScreen({ super.key, this.onChatSelected, this.forwardMode = false, this.forwardMessageCount = 1, + this.archiveMode = false, }); @override @@ -128,6 +142,7 @@ class _ChatListScreenState extends State String? _selectedFolderId; List _folders = []; + Set _contactIds = {}; int _currentNavIndex = 0; @@ -175,6 +190,7 @@ class _ChatListScreenState extends State late AnimationController _navPageAnimController; late AnimationController _fabController; + final BackdropKey _frostBackdrop = BackdropKey(); late PageController _folderPageController; late AnimationController _storiesRevealController; @@ -188,6 +204,9 @@ class _ChatListScreenState extends State ProfileData? _profile; List _chats = []; + int _archivedCount = 0; + int _archivedUnread = 0; + bool _archiveHadChats = false; int _chatListRevision = 0; final Set _knownChatIds = {}; @@ -315,6 +334,26 @@ class _ChatListScreenState extends State _clearSelection(); } + Future _onArchiveTap() async { + final selected = _selectedChatObjects(); + if (selected.isEmpty) return; + final p = _profile; + if (p == null) return; + final archive = !widget.archiveMode; + for (final c in selected) { + await ArchivedChatsStore.instance.setArchived(p.id, c.id, archive); + } + if (!mounted) return; + _clearSelection(); + final count = selected.length; + showCustomNotification( + context, + archive + ? (count == 1 ? 'Чат в архиве' : 'Чаты в архиве ($count)') + : (count == 1 ? 'Чат возвращён' : 'Чаты возвращены ($count)'), + ); + } + Future _onDeleteTap() async { final selectedBefore = _selectedChatObjects(); if (selectedBefore.isEmpty) return; @@ -525,6 +564,7 @@ class _ChatListScreenState extends State }); if (state == SessionState.online) { _requestReload(); + _maybeLoadStories(); } } }); @@ -532,11 +572,17 @@ class _ChatListScreenState extends State _loginSub = accountModule.loginStatusStream.listen((status) { if (status == LoginStatus.success) { _requestReload(); + _maybeLoadStories(); } }); chats.chatsChanged.addListener(_onChatsChanged); + ArchivedChatsStore.instance.revision.addListener(_onArchivedChanged); DraftStore.instance.revision.addListener(_onDraftsChanged); AppStories.current.addListener(_onStoriesEnabledChanged); + storiesModule.storiesChanged.addListener(_onStoriesDataChanged); + KometSettings.hideAllChatsFolder.addListener(_requestReload); + KometSettings.showHiddenChats.addListener(_requestReload); + _maybeLoadStories(); _typingSub = api.pushStream .where((p) => p.opcode == Opcode.notifTyping) .listen(_onTypingPush); @@ -571,6 +617,10 @@ class _ChatListScreenState extends State if (mounted) _requestReload(); } + void _onArchivedChanged() { + if (mounted) _requestReload(); + } + void _onStoriesEnabledChanged() { if (!mounted) return; if (!AppStories.current.value) { @@ -579,10 +629,56 @@ class _ChatListScreenState extends State _storiesDockedOpen = false; _storiesAnimClosing = false; _storiesOverscrollRevealArmed = false; + } else { + _maybeLoadStories(); } setState(() {}); } + void _onStoriesDataChanged() { + if (mounted) setState(() {}); + } + + void _maybeLoadStories() { + if (!AppStories.current.value) return; + if (api.state != SessionState.online) return; + unawaited(storiesModule.loadFeed()); + } + + StoryOwnerInfo? _selfOwnerInfo() { + final p = _profile; + if (p == null) return null; + final name = [p.firstName, p.lastName] + .where((s) => s != null && s.trim().isNotEmpty) + .map((s) => s!.trim()) + .join(' '); + return StoryOwnerInfo( + name: name.isEmpty ? 'Вы' : name, + avatarUrl: p.baseUrl, + ); + } + + Map _storyOwnerOverrides() { + final me = _profile?.id; + final self = _selfOwnerInfo(); + if (me == null || self == null) return const {}; + return { + me: StoryOwnerInfo(name: 'Ваша история', avatarUrl: self.avatarUrl), + }; + } + + void _openStories(int index, [Offset? origin]) { + final previews = storiesModule.previews; + if (previews.isEmpty) return; + openStoryViewer( + context, + previews: previews, + initialIndex: index.clamp(0, previews.length - 1), + ownerOverrides: _storyOwnerOverrides(), + origin: origin, + ); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -652,9 +748,25 @@ class _ChatListScreenState extends State } try { - final loadedChats = await chats.getChats(p.id); + final loadedChats = await chats.getChats( + p.id, + includeHidden: + widget.archiveMode || KometSettings.showHiddenChats.value, + ); + final archivedIds = ArchivedChatsStore.instance.archivedChatIds(p.id); + var archivedCount = 0; + var archivedUnread = 0; + for (final c in loadedChats) { + if (!archivedIds.contains(c.id)) continue; + if (CloudStorageModule.isCloudStorageGroup(c)) continue; + archivedCount++; + archivedUnread += c.unreadCount; + } 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 allChatsFolder = ChatFolder( id: 'all.chat.folder', @@ -664,8 +776,19 @@ class _ChatListScreenState extends State widgets: [], ); - if (!folders.any((f) => FoldersModule.isAllChatsFolder(f))) { - folders = [allChatsFolder, ...folders]; + if (widget.archiveMode) { + folders = const []; + } else { + final hasRealFolders = folders.any( + (f) => !FoldersModule.isAllChatsFolder(f), + ); + if (KometSettings.hideAllChatsFolder.value && hasRealFolders) { + folders = folders + .where((f) => !FoldersModule.isAllChatsFolder(f)) + .toList(); + } else if (!folders.any((f) => FoldersModule.isAllChatsFolder(f))) { + folders = [allChatsFolder, ...folders]; + } } final pageCount = folders.isEmpty ? 1 : folders.length; @@ -673,7 +796,13 @@ class _ChatListScreenState extends State final filteredChats = loadedChats .where((c) => !CloudStorageModule.isCloudStorageGroup(c)) + .where( + (c) => widget.archiveMode + ? archivedIds.contains(c.id) + : !archivedIds.contains(c.id), + ) .toList(); + final newIds = filteredChats.map((c) => c.id.toString()).toSet(); final entering = _didInitialChatLoad ? newIds.difference(_knownChatIds) @@ -687,6 +816,9 @@ class _ChatListScreenState extends State setState(() { _profile = p; _chats = filteredChats; + _archivedCount = archivedCount; + _archivedUnread = archivedUnread; + _contactIds = contactIds; _enteringChatIds = entering; _chatListRevision++; _folders = folders; @@ -707,6 +839,15 @@ class _ChatListScreenState extends State _isInitialLoading = false; }); _prefetchContactsForChats(loadedChats); + if (widget.archiveMode) { + if (filteredChats.isNotEmpty) { + _archiveHadChats = true; + } else if (_archiveHadChats) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.of(context).maybePop(); + }); + } + } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _jumpFolderPageToSelection(); @@ -805,6 +946,7 @@ class _ChatListScreenState extends State final baseKey = Object.hash( identityHashCode(_chats), identityHashCode(_folders), + identityHashCode(_contactIds), ); if (_pageChatsBaseKey != baseKey) { _pageChatsBaseKey = baseKey; @@ -820,10 +962,18 @@ class _ChatListScreenState extends State base = _chats; } else { final folder = _folders[pageIndex]; + final myId = _profile?.id ?? 0; base = FoldersModule.isAllChatsFolder(folder) ? _chats : _chats - .where((c) => FoldersModule.chatMatchesFolder(c, folder)) + .where( + (c) => FoldersModule.chatMatchesFolder( + c, + folder, + myId: myId, + contactIds: _contactIds, + ), + ) .toList(); } final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() @@ -1071,8 +1221,12 @@ class _ChatListScreenState extends State appRouteObserver.unsubscribe(this); _settleTimer?.cancel(); chats.chatsChanged.removeListener(_onChatsChanged); + ArchivedChatsStore.instance.revision.removeListener(_onArchivedChanged); DraftStore.instance.revision.removeListener(_onDraftsChanged); AppStories.current.removeListener(_onStoriesEnabledChanged); + storiesModule.storiesChanged.removeListener(_onStoriesDataChanged); + KometSettings.hideAllChatsFolder.removeListener(_requestReload); + KometSettings.showHiddenChats.removeListener(_requestReload); _loginSub?.cancel(); _stateSub?.cancel(); _typingSub?.cancel(); @@ -1175,33 +1329,23 @@ class _ChatListScreenState extends State Row( children: [ if (AppStories.current.value && - _pullRatio < 0.8) + _pullRatio < 0.8 && + storiesModule.hasAny) Opacity( opacity: 1.0 - _pullRatio, - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only( - right: 8, - ), - child: Stack( - children: [ - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openStories(0), + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only( + right: 8, + ), + child: FoldedStoryStack( + previews: storiesModule.previews, + opacity: 1.0 - _pullRatio, + ), ), ), ), @@ -1229,26 +1373,17 @@ class _ChatListScreenState extends State shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), + onSelected: _onOverflowMenuSelected, itemBuilder: (context) => [ _buildPopupMenuItem( 1, - 'Кнопка 1', - Symbols.settings, + 'Избранное', + Symbols.bookmark, ), _buildPopupMenuItem( 2, - 'Кнопка 2', - Symbols.notifications, - ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, - ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, + 'Прочитать всё', + Symbols.done_all, ), ], ), @@ -1260,24 +1395,7 @@ class _ChatListScreenState extends State height: 96 * _pullRatio, child: Opacity( opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), + child: _buildStoriesRow(), ), ), Padding( @@ -1441,6 +1559,8 @@ class _ChatListScreenState extends State ), slivers: [ const SliverToBoxAdapter(child: SizedBox(height: 8)), + if (_shouldShowArchiveEntry(pageIndex)) + SliverToBoxAdapter(child: _buildArchiveEntry(cs)), if (chats.isEmpty && !_isInitialLoading) SliverFillRemaining( child: Center( @@ -1746,6 +1866,7 @@ class _ChatListScreenState extends State geometry: geometry, iconSize: 20, labelGap: 4, + backdropKey: _frostBackdrop, onTap: _onNavTabSelected, onItemLongPress: (index, pos) { if (index == 3) _openAccountSwitcher(pos); @@ -1761,6 +1882,9 @@ class _ChatListScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + if (widget.archiveMode) { + return _buildArchiveScaffold(cs); + } return Scaffold( backgroundColor: cs.surface, body: SafeArea( @@ -1904,12 +2028,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.fabTint(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, @@ -1932,85 +2080,195 @@ class _ChatListScreenState extends State ); }, ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - top: _isSelectionMode ? 0 : -80, - left: 0, - right: 0, - child: Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: cs.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Builder( - builder: (_) { - final selected = _selectedChatObjects(); - final deleteCategory = _selectionDeleteCategoryFor( - selected, - ); - final anyMuted = selected.any((c) => c.isMuted); - final anyPinned = selected.any( - (c) => (c.favIndex ?? 0) > 0, - ); - return Row( - children: [ - IconButton( - icon: Icon( - Symbols.arrow_back, - color: cs.onSurface, - ), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - if (deleteCategory != null) - IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: _onDeleteTap, - ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon( - anyPinned ? Symbols.keep_off : Symbols.keep, - color: cs.onSurface, - ), - onPressed: selected.isEmpty ? null : _onPinTap, - ), - IconButton( - icon: Icon( - anyMuted - ? Symbols.volume_up - : Symbols.volume_off, - color: cs.onSurface, - ), - onPressed: selected.isEmpty ? null : _onMuteTap, - ), - ], - ); - }, - ), + _buildSelectionActionBar(cs), + ], + ); + }, + ), + ), + ); + } + + Widget _buildArchiveScaffold(ColorScheme cs) { + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Stack( + children: [ + Column( + children: [ + _buildArchiveAppBar(cs), + Expanded(child: _buildFolderChatPage(0)), + ], + ), + _buildSelectionActionBar(cs), + ], + ), + ), + ); + } + + Widget _buildArchiveAppBar(ColorScheme cs) { + return SizedBox( + height: 52, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.of(context).maybePop(), + ), + const SizedBox(width: 4), + Text( + 'Архив', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + ), + ); + } + + bool _shouldShowArchiveEntry(int pageIndex) { + if (widget.archiveMode || widget.forwardMode) return false; + if (_isInitialLoading) return false; + if (_archivedCount <= 0) return false; + if (_folders.isEmpty) return pageIndex == 0; + final allIdx = _folders.indexWhere( + (f) => FoldersModule.isAllChatsFolder(f), + ); + return pageIndex == (allIdx >= 0 ? allIdx : 0); + } + + Widget _buildArchiveEntry(ColorScheme cs) { + return InkWell( + onTap: () { + if (_isSelectionMode) return; + pushSwipeable(context, (_) => const ChatListScreen(archiveMode: true)); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: cs.surfaceContainerHighest, + child: Icon( + Symbols.archive, + color: cs.onSurfaceVariant, + weight: 500, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Архив', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + if (_archivedUnread > 0) + Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _archivedUnread > 99 ? '99+' : '$_archivedUnread', + style: TextStyle( + color: cs.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, ), ), + ), + Icon(Symbols.chevron_right, color: cs.outline), + ], + ), + ), + ); + } + + Widget _buildSelectionActionBar(ColorScheme cs) { + return AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + top: _isSelectionMode ? 0 : -80, + left: 0, + right: 0, + child: Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: cs.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Builder( + builder: (_) { + final selected = _selectedChatObjects(); + final deleteCategory = _selectionDeleteCategoryFor(selected); + final anyMuted = selected.any((c) => c.isMuted); + final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0); + return Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, + ), + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (deleteCategory != null) + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: _onDeleteTap, + ), + IconButton( + icon: Icon( + widget.archiveMode ? Symbols.unarchive : Symbols.archive, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onArchiveTap, + ), + IconButton( + icon: Icon( + anyPinned ? Symbols.keep_off : Symbols.keep, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onPinTap, + ), + IconButton( + icon: Icon( + anyMuted ? Symbols.volume_up : Symbols.volume_off, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onMuteTap, + ), ], ); }, @@ -2019,48 +2277,73 @@ class _ChatListScreenState extends State ); } - Widget _buildStoryItem(String name, String imageUrl, bool hasUpdate) { - final cs = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.only(right: 16), - child: SizedBox( - width: 68, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(2.5), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: hasUpdate - ? Border.all(color: cs.primary, width: 2) - : Border.all(color: cs.outlineVariant), - ), - child: CircleAvatar( - radius: 26, - backgroundImage: CachedNetworkImageProvider( - imageUrl, - maxWidth: kAvatarThumbSize, - maxHeight: kAvatarThumbSize, + Widget _buildStoriesRow() { + final previews = storiesModule.previews; + final me = _profile?.id; + final selfInfo = _selfOwnerInfo(); + final myIndex = me == null + ? -1 + : previews.indexWhere((p) => p.owner.ownerId == me); + final otherIndices = [ + for (var i = 0; i < previews.length; i++) + if (i != myIndex) i, + ]; + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: otherIndices.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return StorySelfTile( + preview: myIndex >= 0 ? previews[myIndex] : null, + selfInfo: selfInfo == null + ? null + : StoryOwnerInfo( + name: 'Ваша история', + avatarUrl: selfInfo.avatarUrl, ), - ), - ), - const SizedBox(height: 6), - Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), + onOpen: (center) => _openStories(myIndex < 0 ? 0 : myIndex, center), + onAdd: _composeStory, + ); + } + final gi = otherIndices[index - 1]; + return StoryRing( + preview: previews[gi], + onTap: (center) => _openStories(gi, center), + ); + }, + ); + } + + Future _composeStory() async { + await showAttachmentSheet( + context, + title: 'Новая история', + onSend: (photos, caption) async { + if (photos.isEmpty) return; + final picked = photos.first; + if (picked.item.isVideo) { + if (mounted) { + showCustomNotification( + context, + 'Видео в историях пока не поддерживается', + ); + } + return; + } + final file = + picked.editedFile ?? + picked.item.localFile ?? + await picked.item.originFile(); + if (file == null) { + if (mounted) { + showCustomNotification(context, 'Не удалось открыть фото'); + } + return; + } + if (!mounted) return; + pushSwipeable(context, (_) => StoryComposerScreen(file: file)); + }, ); } @@ -2268,219 +2551,226 @@ class _ChatListScreenState extends State ) : null, ); - return InkWell( + 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 (_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 ? 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, + ), + ), + ], + ), + 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, + ), + 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, + 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( - unreadCount.toString(), - style: TextStyle( - color: isMuted ? cs.outline : cs.onPrimary, - fontSize: 11, - fontWeight: FontWeight.w600, - height: 1.1, + ?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( + unreadCount.toString(), + style: TextStyle( + color: isMuted + ? cs.outline + : cs.onPrimary, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ) + 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, - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ), ), ), @@ -2578,6 +2868,64 @@ class _ChatListScreenState extends State ); } + void _onOverflowMenuSelected(int value) { + switch (value) { + case 1: + _openSavedMessages(); + case 2: + unawaited(_markAllChatsRead()); + } + } + + void _openSavedMessages() { + CachedChat? self; + for (final c in _chats) { + if (c.id == 0) { + self = c; + break; + } + } + pushSwipeable( + context, + (_) => ChatScreen( + chatId: 0, + name: 'Избранное', + imageUrl: self?.iconUrl ?? '', + chatType: self?.type ?? 'DIALOG', + ), + ); + } + + Future _markAllChatsRead() async { + final p = _profile ?? await AppDatabase.loadActiveProfile(); + if (p == null) return; + final all = await chats.getChats( + p.id, + includeHidden: KometSettings.showHiddenChats.value, + ); + final targets = all + .where((c) => c.unreadCount > 0) + .where((c) => c.lastMsgId != null) + .where((c) => !CloudStorageModule.isCloudStorageGroup(c)) + .toList(); + if (targets.isEmpty) { + if (mounted) showCustomNotification(context, 'Непрочитанных чатов нет'); + return; + } + for (final c in targets) { + await chats.markRead( + api, + p.id, + c.id, + c.lastMsgId!.toString(), + c.lastMsgTime ?? 0, + ); + } + if (mounted) { + showCustomNotification(context, 'Все чаты отмечены прочитанными'); + } + } + PopupMenuItem _buildPopupMenuItem( int value, String title, @@ -2602,26 +2950,6 @@ class _ChatListScreenState extends State ), ); } - - Widget _buildFoldedStory(ColorScheme cs, String imageUrl, int index) { - return Positioned( - left: index * 12.0, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - child: CircleAvatar( - radius: 12, - backgroundImage: CachedNetworkImageProvider( - imageUrl, - maxWidth: kAvatarThumbSize, - maxHeight: kAvatarThumbSize, - ), - ), - ), - ); - } } class _StoriesUi extends ChangeNotifier { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 31630cc..a2440d1 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -8,23 +8,29 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; 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/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'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/chat_menu_overlay.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; +import '../../../backend/modules/animoji.dart'; +import '../../../models/animoji.dart'; import '../../../backend/modules/complaints.dart'; import '../../../core/calls/call_controller.dart'; +import '../../../core/media/rlottie/rlottie.dart'; import '../calls/call_screen.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; @@ -33,9 +39,11 @@ import '../../../core/storage/app_database.dart'; import '../../../core/storage/chat_activity_store.dart'; import '../../../core/storage/chat_wallpaper_store.dart'; import '../../../core/storage/draft_store.dart'; +import '../../../core/storage/archived_chats_store.dart'; import '../../../core/cache/info_cache.dart'; 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/config/app_cache_extent.dart'; import '../../../core/config/app_colors.dart'; @@ -60,6 +68,9 @@ 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/sticker.dart'; @@ -70,15 +81,20 @@ 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/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_wallpaper_preview_screen.dart'; @@ -94,23 +110,33 @@ class _MessageItem { const _MessageItem(this.message, this.index); } +class _UnreadSeparatorItem { + const _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 GlassSurface( + frostTint: tint, + frostSigma: sigma, + border: border, + backdropKey: backdropKey, + child: child, ); } } @@ -173,6 +199,8 @@ class ChatScreen extends StatefulWidget { final bool embedded; final VoidCallback? onClose; final ForwardRequest? forwardRequest; + final String? initialMessageId; + final int? initialMessageTime; const ChatScreen({ super.key, @@ -183,6 +211,8 @@ class ChatScreen extends StatefulWidget { this.embedded = false, this.onClose, this.forwardRequest, + this.initialMessageId, + this.initialMessageTime, }); @override @@ -195,7 +225,21 @@ class _ChatScreenState extends State final FocusNode _messageFocusNode = FocusNode(); double _keyboardReserve = 0; bool _keyboardWasOpen = false; + bool _keyboardBeforeStickers = false; final ScrollController _scrollController = ScrollController(); + bool _userDidScroll = false; + String? _pinnedMessageId; + double _pinnedAlignment = 0; + int? _unreadAnchorTime; + bool _awaitingPosition = false; + bool _navigatingToTarget = false; + bool _initialPositionDone = false; + bool _positioningInFlight = false; + bool _initialTargetHandled = false; + int _historyAutoloadSuppressCount = 0; + bool get _historyAutoloadSuppressed => _historyAutoloadSuppressCount > 0; + int _readMarkTime = 0; + Timer? _readMarkTimer; final GlobalKey _listKey = GlobalKey(); final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; @@ -241,6 +285,111 @@ class _ChatScreenState extends State return notifier; } + void _reactToMessage(CachedMessage message, String emoji) { + if (message.isControl || message.id.startsWith('temp_')) return; + final notifier = _reactionNotifierFor(message); + final previous = notifier.value; + final applied = _applyLocalReaction(previous, emoji); + notifier.value = applied; + final isToggleOff = applied == null || applied['yourReaction'] == null; + unawaited(_sendReaction(message, emoji, isToggleOff, previous)); + } + + Future _sendReaction( + CachedMessage message, + String emoji, + bool isToggleOff, + Map? previous, + ) async { + ({bool ok, Map? info}) result; + try { + result = isToggleOff + ? await messagesModule.cancelReaction(widget.chatId, message.id) + : await messagesModule.setReaction(widget.chatId, message.id, emoji); + } catch (_) { + result = (ok: false, info: null); + } + if (!mounted) return; + final notifier = _reactionNotifiers[message.id]; + if (notifier == null) return; + if (!result.ok) { + notifier.value = previous; + Haptics.error(); + showCustomNotification(context, 'Не удалось обновить реакцию'); + return; + } + notifier.value = result.info; + _applyReactionInfoToMessage(message.id, result.info); + } + + void _applyReactionInfoToMessage( + String messageId, + Map? info, + ) { + final idx = _messages.indexWhere((m) => m.id == messageId); + if (idx == -1) return; + final payload = {...?_messages[idx].payload}; + if (info == null) { + payload.remove('reactionInfo'); + } else { + payload['reactionInfo'] = info; + } + _messages[idx] = _messages[idx].copyWith(payload: payload); + } + + Map? _applyLocalReaction( + Map? current, + String emoji, + ) { + final counters = {}; + final order = []; + final rawCounters = current?['counters']; + if (rawCounters is List) { + for (final c in rawCounters) { + if (c is! Map) continue; + final r = c['reaction']?.toString(); + if (r == null || r.isEmpty) continue; + final n = c['count']; + counters[r] = n is int ? n : 0; + order.add(r); + } + } + + void decrement(String key) { + final next = (counters[key] ?? 1) - 1; + if (next <= 0) { + counters.remove(key); + order.remove(key); + } else { + counters[key] = next; + } + } + + final prev = current?['yourReaction']?.toString(); + String? your; + if (prev != null && + EmojiKeywordIndex.normalize(prev) == + EmojiKeywordIndex.normalize(emoji)) { + decrement(prev); + your = null; + } else { + if (prev != null && prev.isNotEmpty) decrement(prev); + if (!counters.containsKey(emoji)) order.add(emoji); + counters[emoji] = (counters[emoji] ?? 0) + 1; + your = emoji; + } + + if (counters.isEmpty) return null; + final total = counters.values.fold(0, (a, b) => a + b); + return { + 'counters': [ + for (final key in order) {'reaction': key, 'count': counters[key]}, + ], + 'yourReaction': ?your, + 'totalCount': total, + }; + } + void _pruneReactionNotifiers() { final liveIds = _messages.map((m) => m.id).toSet(); final dead = _reactionNotifiers.keys @@ -259,6 +408,9 @@ class _ChatScreenState extends State final ValueNotifier _replyTo = ValueNotifier(null); final ValueNotifier _highlightMessageId = ValueNotifier(null); Timer? _highlightTimer; + final ValueNotifier _jumpCacheExtent = ValueNotifier(null); + Timer? _goToMessageSettleTimer; + static const double _jumpCacheExtentPx = 800.0; late final ChatSearchController _search; late final AnimationController _searchAnim; @@ -299,8 +451,14 @@ 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 _scrollDownTeleportFactor = 2.0; static const double _glossyHeaderHeight = 76.0; static const double _glossySearchHeight = 58.0; + static const double _pinnedBannerLift = 6.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; @@ -313,36 +471,91 @@ class _ChatScreenState extends State CachedChat? chat; bool _peerIsBot = false; ChatWallpaper? _wallpaper; + + bool get _composerFrosted => + AppComposerBackground.current.value != ComposerBackground.standard; + + bool get _composerUnderlap => + AppChatChrome.current.value != ChatChromeStyle.color || _composerFrosted; + + bool get _liquidChrome => + AppVisualStyle.current.value.glossyChrome && + ChatChromeMaterial.isLiquid(AppChatChrome.current.value); + + ChatChromeStyle get _effectiveChrome { + final chrome = AppChatChrome.current.value; + if (chrome == ChatChromeStyle.liquidGlass) return ChatChromeStyle.transparent; + if (_wallpaper != null && chrome == ChatChromeStyle.none) { + return ChatChromeStyle.blur; + } + return chrome; + } + final ValueNotifier _composerHeight = ValueNotifier(96); + final ValueNotifier _pinnedBannerHeight = ValueNotifier(0); final ValueNotifier _floatingDate = ValueNotifier(null); Timer? _floatingDateTimer; late final AnimationController _floatingDateAnimController; late final CurvedAnimation _floatingDateCurved; + late final AnimationController _scrollDownAnimController; + late final CurvedAnimation _scrollDownCurved; + bool _scrollDownVisible = false; + int _listEpoch = 0; + final List<({String id, double pixels, double alignment})> _returnStack = []; + bool _returningToAnchor = false; final Map _separatorKeys = {}; String? _lastSentId; - String? _lastMarkedId; final ValueNotifier _otherUnread = ValueNotifier(0); + final ValueNotifier _animojiHold = ValueNotifier(true); final ValueNotifier> _selectedIds = ValueNotifier(const {}); + 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 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)); + } + } + } + @override void initState() { super.initState(); _chatController.chatId = widget.chatId; _chatController.isMounted = () => mounted; unawaited(PushService.clearChatNotification(widget.chatId)); + unawaited( + animojiModule + .ensureLoaded() + .then((_) => _prewarmQuickReactions()) + .catchError((_) {}), + ); WidgetsBinding.instance.addObserver(this); chats.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); - _messageFocusNode.addListener(_onComposerFocusChanged); _scrollController.addListener(_onScrollForDate); _scrollController.addListener(_maybeLoadMoreHistory); + _scrollController.addListener(_recordScrollPixels); + _scrollController.addListener(_scheduleReadMarker); + _scrollController.addListener(_exitTextSelectionOnScroll); + _scrollController.addListener(_updateScrollDownVisible); 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), @@ -405,6 +618,16 @@ class _ChatScreenState extends State curve: Curves.easeOut, reverseCurve: Curves.easeIn, ); + _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().then((_) { @@ -445,25 +668,34 @@ class _ChatScreenState extends State final p = await AppDatabase.loadActiveProfile(); if (!mounted) return; _myId = p?.id ?? 0; + if (p != null && p.id != 0) { + final myName = [ + p.firstName, + p.lastName, + ].whereType().where((s) => s.isNotEmpty).join(' '); + if (myName.isNotEmpty) ContactCache.put(p.id, myName); + ContactCache.putAvatar(p.id, p.baseUrl); + } _restoreDraft(); unawaited(_loadPeerKind()); unawaited(_loadWallpaper()); unawaited(_refreshBadge()); - chats - .getChat(_myId, widget.chatId) - .then((value) { - if (mounted && value.isNotEmpty) { - setState(() { - chat = value.first; - }); - _bumpMessages(); - _seedPresenceFromChat(); - _recomputeHeaderStatus(); - _syncOtherReadTime(); - } - }) - .catchError((_) {}); + try { + final chatRows = await chats.getChat(_myId, widget.chatId); + if (!mounted) return; + if (chatRows.isNotEmpty) { + setState(() { + chat = chatRows.first; + }); + _bumpMessages(); + _seedPresenceFromChat(); + _recomputeHeaderStatus(); + _syncOtherReadTime(); + } + } catch (_) {} + + _resolveUnreadAnchor(); final cached = MessageSessionCache.get(_myId, widget.chatId); if (cached != null && cached.messages.isNotEmpty) { @@ -471,10 +703,9 @@ class _ChatScreenState extends State _messages = List.of(cached.messages); _hasMoreHistory = !cached.reachedStart; _messagesRev.value++; - _isLoading = false; - _onLoadingFinished(); }); _syncReactionNotifiersFromMessages(); + _revealOrHoldInitial(); return; } @@ -492,12 +723,57 @@ class _ChatScreenState extends State setState(() { _messages = first; _messagesRev.value++; - _isLoading = false; - _onLoadingFinished(); }); + _revealOrHoldInitial(); } } + void _resolveUnreadAnchor() { + final c = chat; + _readMarkTime = c?.participants[_myId] ?? 0; + if (c == null || c.unreadCount <= 0) { + _unreadAnchorTime = null; + } else { + final myMark = c.participants[_myId] ?? 0; + _unreadAnchorTime = myMark > 0 ? myMark : null; + } + _awaitingPosition = c != null && c.unreadCount > 0; + } + + void _resolveCountBasedAnchor() { + final c = chat; + if (c == null || c.unreadCount <= 0 || _messages.isEmpty) return; + final unread = c.unreadCount; + if (_messages.length > unread) { + _unreadAnchorTime = _messages[_messages.length - unread - 1].time; + } else if (!_hasMoreHistory) { + _unreadAnchorTime = _messages.first.time - 1; + } + } + + void _revealOrHoldInitial() { + if (_awaitingPosition && !_canPositionNow()) return; + setState(() { + _isLoading = false; + _onLoadingFinished(); + }); + } + + bool _canPositionNow() { + if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); + final ua = _unreadAnchorTime; + if (ua == null) return false; + final firstUnread = _messages.indexWhere((m) => m.time > ua); + if (firstUnread == -1) return _newestMessageLoaded(); + return firstUnread > 0 || !_hasMoreHistory; + } + + bool _newestMessageLoaded() { + if (_messages.isEmpty) return false; + final serverLast = chat?.lastMsgTime ?? 0; + return _messages.last.time >= serverLast; + } + void _onFirstFrameRendered(Duration _) { if (!mounted) return; if (widget.embedded) { @@ -527,6 +803,7 @@ class _ChatScreenState extends State } void _kickoffHistory() { + _animojiHold.value = false; if (_historyKickedOff) return; _historyKickedOff = true; _shimmerStartTimer = Timer(const Duration(milliseconds: 150), () { @@ -539,20 +816,282 @@ class _ChatScreenState extends State void _onLoadingFinished() { _shimmerStartTimer?.cancel(); _shimmerStartTimer = null; - if (_shimmerController.isAnimating) _shimmerController.stop(); - _markRead(); + _applyInitialPositioning(); } - void _markRead() { - if (_myId == 0 || _messages.isEmpty) return; - final newest = _messages.last; - if (newest.id == _lastMarkedId) return; - _lastMarkedId = newest.id; - unawaited( - chats.markRead(api, _myId, widget.chatId, newest.id, newest.time), + void _recordScrollPixels() { + if (!_scrollController.hasClients) return; + if (_initialPositionDone && + _scrollController.position.userScrollDirection != + ScrollDirection.idle) { + _userDidScroll = true; + _pinnedMessageId = null; + } + } + + void _positionToMessage(String messageId, double alignment) { + _pinnedMessageId = messageId; + _pinnedAlignment = alignment.clamp(0.0, 1.0); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _scrollToLoadedMessage( + messageId, + alignment: _pinnedAlignment, + highlight: false, + notifyIfMissing: false, + onSettled: () { + if (mounted) setState(_markPositioned); + }, + ); + }); + } + + void _reapplyPinIfNeeded() { + final id = _pinnedMessageId; + if (id == null || _userDidScroll || !_scrollController.hasClients) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _pinnedMessageId != id || _userDidScroll) return; + _alignLoadedMessage(id, _pinnedAlignment, 0); + }); + } + + void _applyInitialPositioning() { + if (_initialPositionDone) { + if (_shimmerController.isAnimating) _shimmerController.stop(); + _scheduleReadMarker(); + return; + } + if (_positioningInFlight) return; + if (_messages.isEmpty) { + if (!_hasMoreHistory) _markPositioned(); + return; + } + + final c = chat; + if (c != null && c.unreadCount > 0) { + if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); + final ua = _unreadAnchorTime; + if (ua == null) { + if (_hasMoreHistory) { + _positioningInFlight = true; + unawaited(_loadUntilUnreadReady()); + } else { + _markPositioned(); + } + return; + } + final firstUnread = _messages.indexWhere((m) => m.time > ua); + if (firstUnread == -1) { + _markPositioned(); + return; + } + if (firstUnread > 0 || !_hasMoreHistory) { + _initialPositionDone = true; + _positionToMessage(_messages[firstUnread].id, 0.15); + } else { + _positioningInFlight = true; + unawaited(_loadUntilUnreadReady()); + } + return; + } + + _markPositioned(); + } + + void _markPositioned() { + _positioningInFlight = false; + _initialPositionDone = true; + _awaitingPosition = false; + _isLoading = false; + if (_shimmerController.isAnimating) _shimmerController.stop(); + _scheduleReadMarker(); + _maybeRunInitialTarget(); + } + + void _maybeRunInitialTarget() { + if (_initialTargetHandled || widget.initialMessageId == null) return; + _initialTargetHandled = true; + _beginTargetNavigation(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_navigateToInitialMessage()); + }); + } + + void _beginTargetNavigation() { + _navigatingToTarget = true; + _jumpCacheExtent.value = _jumpCacheExtentPx; + _goToMessageSettleTimer?.cancel(); + if (!_shimmerController.isAnimating) _shimmerController.repeat(); + } + + void _finishTargetNavigation() { + _goToMessageSettleTimer?.cancel(); + if (!mounted) { + _navigatingToTarget = false; + return; + } + if (_navigatingToTarget) { + setState(() => _navigatingToTarget = false); + } + if (_shimmerController.isAnimating) _shimmerController.stop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _jumpCacheExtent.value = null; + }); + } + + void _openChatInfo({ChatInfoTab? initialTab}) { + final navigator = Navigator.of(context); + final chatRoute = ModalRoute.of(context); + navigator.push( + MaterialPageRoute( + builder: (_) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, + initialTab: initialTab, + 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), + viewAllPhotos: () => _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); + _goToMessageSettleTimer = Timer(const Duration(milliseconds: 340), () { + if (mounted) unawaited(_runGoToMessage(id, time)); + }); + } + + Future _loadUntilUnreadReady() async { + 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; + final idx = ua == null ? -1 : _messages.indexWhere((m) => m.time > ua); + _positioningInFlight = false; + _initialPositionDone = true; + if (idx >= 0) { + _positionToMessage(_messages[idx].id, 0.15); + } else { + setState(_markPositioned); + } + } + + void _scheduleReadMarker() { + _readMarkTimer?.cancel(); + _readMarkTimer = Timer( + const Duration(milliseconds: 350), + _updateReadMarker, + ); + } + + void _updateReadMarker() { + if (!mounted || _myId == 0 || _messages.isEmpty) return; + if (_awaitingPosition || !_initialPositionDone) return; + if (!_scrollController.hasClients) return; + final listBox = _listKey.currentContext?.findRenderObject(); + if (listBox is! RenderBox) return; + final viewportBottom = listBox.size.height; + if (viewportBottom <= 0) return; + + CachedMessage? candidate; + int topIndex = -1; + for (int i = _messages.length - 1; i >= 0; i--) { + final m = _messages[i]; + final ctx = _messageKeys[m.id]?.currentContext; + if (ctx == null) continue; + final box = ctx.findRenderObject(); + if (box is! RenderBox || !box.attached) continue; + final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy; + final bottom = top + box.size.height; + if (bottom <= 0 || top >= viewportBottom) continue; + candidate ??= m; + topIndex = i; + } + if (candidate == null) return; + + final atBottom = candidate.id == _messages.last.id; + + if (_unreadAnchorTime != null && + _unreadSeparatorScrolledPast( + atBottom, + topIndex, + listBox, + viewportBottom, + )) { + _unreadAnchorTime = null; + _bumpMessages(); + } + + if (candidate.time <= _readMarkTime) return; + _readMarkTime = candidate.time; + final remaining = _messages + .where((m) => m.time > _readMarkTime && m.senderId != _myId) + .length; + unawaited( + chats.markReadUpTo( + api, + _myId, + widget.chatId, + candidate.id, + candidate.time, + remaining: remaining, + ), + ); + } + + bool _unreadSeparatorScrolledPast( + bool atBottom, + int topIndex, + RenderBox listBox, + double viewportBottom, + ) { + if (atBottom) return true; + final ua = _unreadAnchorTime; + if (ua == null) return false; + final firstUnread = _messages.indexWhere((m) => m.time > ua); + if (firstUnread == -1) return true; + if (topIndex >= 0 && topIndex > firstUnread) return true; + final box = _messageKeys[_messages[firstUnread].id]?.currentContext + ?.findRenderObject(); + if (box is RenderBox && box.attached) { + final top = box.localToGlobal(Offset.zero, ancestor: listBox).dy; + if (top <= 0) return true; + } + return false; + } + Future _markMessageUnread(CachedMessage message) async { final unread = await chats.markUnread( api, @@ -568,10 +1107,170 @@ 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; + return chat?.canPinMessages(_myId) ?? false; + } + + Future _togglePinMessage(CachedMessage message) async { + final messageId = int.tryParse(message.id); + if (messageId == null) return; + final previousChat = chat; + final willUnpin = chat?.pinnedMsgId == messageId; + if (willUnpin) { + _applyPinnedMessageLocally(); + } else { + final preview = _pinnedPreviewFor(message); + _applyPinnedMessageLocally( + messageId: messageId, + text: preview.text, + time: message.time, + isPreview: preview.isPreview, + ); + } + final error = await chats.setPinnedMessage( + api, + chatId: widget.chatId, + messageId: willUnpin ? null : messageId, + notify: !willUnpin, + ); + if (!mounted) return; + if (error != null) { + if (previousChat != null) setState(() => chat = previousChat); + showCustomNotification(context, error); + return; + } + showCustomNotification( + context, + willUnpin ? 'Сообщение откреплено' : 'Сообщение закреплено', + ); + } + + Future _unpinCurrentMessage() async { + final previousChat = chat; + _applyPinnedMessageLocally(); + final error = await chats.setPinnedMessage( + api, + chatId: widget.chatId, + messageId: null, + notify: false, + ); + if (!mounted) return; + if (error != null) { + if (previousChat != null) setState(() => chat = previousChat); + showCustomNotification(context, error); + return; + } + showCustomNotification(context, 'Сообщение откреплено'); + } + + ({String? text, bool isPreview}) _pinnedPreviewFor(CachedMessage message) { + final payload = message.payload; + if (payload != null) return pinnedMessagePreview(payload); + return pinnedMessagePreview({ + 'text': message.text, + 'attaches': + message.attachments?.map((a) => a.toMap()).toList() ?? const [], + }); + } + + void _applyPinnedMessageLocally({ + int? messageId, + String? text, + int? time, + bool isPreview = false, + }) { + final current = chat; + if (current == null) return; + setState(() { + chat = current.copyWith( + pinnedMsgId: messageId, + pinnedMsgText: text, + pinnedMsgTime: time, + pinnedMsgIsPreview: isPreview, + ); + }); + } + + void _jumpToPinnedMessage() { + final id = chat?.pinnedMsgId; + if (id == null) return; + final messageId = id.toString(); + if (_messages.any((m) => m.id == messageId)) { + _scrollToLoadedMessage(messageId); + return; + } + setState(_beginTargetNavigation); + unawaited(_runGoToMessage(messageId, chat?.pinnedMsgTime ?? 0)); + } + bool _badgeRefreshing = false; bool _badgeRefreshQueued = false; void _onChatsBump() { + unawaited(_reloadChatMeta()); if (_badgeRefreshing) { _badgeRefreshQueued = true; return; @@ -579,6 +1278,27 @@ class _ChatScreenState extends State unawaited(_runBadgeRefresh()); } + Future _reloadChatMeta() async { + if (_myId == 0) return; + final rows = await chats.getChat(_myId, widget.chatId); + if (!mounted || rows.isEmpty) return; + final fresh = rows.first; + final current = chat; + if (current != null && + current.pinnedMsgId == fresh.pinnedMsgId && + current.pinnedMsgText == fresh.pinnedMsgText && + current.pinnedMsgTime == fresh.pinnedMsgTime && + current.pinnedMsgIsPreview == fresh.pinnedMsgIsPreview && + current.owner == fresh.owner && + current.options.length == fresh.options.length && + current.options.containsAll(fresh.options) && + current.admins.length == fresh.admins.length && + current.admins.containsAll(fresh.admins)) { + return; + } + setState(() => chat = fresh); + } + Future _runBadgeRefresh() async { _badgeRefreshing = true; try { @@ -597,6 +1317,7 @@ class _ChatScreenState extends State final total = await AppDatabase.sumUnread( _myId, excludeChatId: widget.chatId, + excludeChatIds: ArchivedChatsStore.instance.archivedChatIds(_myId), ); if (mounted) _otherUnread.value = total; } @@ -629,7 +1350,10 @@ class _ChatScreenState extends State void _maybeLoadMoreHistory() { if (!_scrollController.hasClients) return; - if (_isLoading || _isLoadingMore || !_hasMoreHistory) return; + if (_historyAutoloadSuppressed) return; + if (_isLoading) return; + _maybeFillGap(); + if (_isLoadingMore || !_hasMoreHistory) return; if (_messages.isEmpty) return; final pos = _scrollController.position; if (pos.maxScrollExtent <= 0) return; @@ -638,14 +1362,127 @@ class _ChatScreenState extends State } } - Future _loadMoreHistory() async { + void _maybeFillGap() { + final controller = _chatController; + if (!controller.hasGap || controller.loadingGap) return; + for (final gap in controller.gaps) { + final box = _keyForMessage(gap.edgeId).currentContext?.findRenderObject(); + if (box is RenderBox && box.attached) { + unawaited(_fillGapForward(gap)); + return; + } + } + } + + Future _fillGapForward(HistoryGap gap) async { + final edgeId = gap.edgeId; + final beforeDy = _messageOffsetInList(edgeId); + final added = await _chatController.fillGapForward(gap); + if (!mounted || added == 0) return; + + _syncReactionNotifiersFromMessages(); + _bumpMessages(); + await WidgetsBinding.instance.endOfFrame; + if (!mounted || !_scrollController.hasClients) return; + + final afterDy = _messageOffsetInList(edgeId); + if (beforeDy != null && afterDy != null) { + final delta = beforeDy - afterDy; + if (delta.abs() > 0.5) { + final pos = _scrollController.position; + _scrollController.jumpTo( + (pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent), + ); + } + } + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + } + + 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; + } + + 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) { @@ -674,32 +1511,20 @@ class _ChatScreenState extends State _syncReactionNotifiersFromMessages(); _pruneReactionNotifiers(); _chatController.persistSessionCache(); + _reapplyPinIfNeeded(); } } void _syncReactionNotifiersFromMessages() { for (final m in _messages) { + if (_reactionNotifiers.containsKey(m.id)) continue; final info = m.payload?['reactionInfo']; - final value = info is Map ? Map.from(info) : null; - final existing = _reactionNotifiers[m.id]; - if (existing == null) { - _reactionNotifiers[m.id] = ValueNotifier(value); - } else if (!_reactionsEqual(existing.value, value)) { - existing.value = value; - } + _reactionNotifiers[m.id] = ValueNotifier( + info is Map ? Map.from(info) : null, + ); } } - bool _reactionsEqual(Map? a, Map? b) { - if (identical(a, b)) return true; - if (a == null || b == null) return false; - if (a.length != b.length) return false; - for (final k in a.keys) { - if (a[k].toString() != b[k].toString()) return false; - } - return true; - } - @override void deactivate() { _saveDraft(); @@ -741,17 +1566,28 @@ class _ChatScreenState extends State WidgetsBinding.instance.removeObserver(this); chats.chatsChanged.removeListener(_onChatsBump); _otherUnread.dispose(); + _animojiHold.dispose(); _saveDraft(); _messageController.removeListener(_onTextChanged); _scrollController.removeListener(_onScrollForDate); _scrollController.removeListener(_maybeLoadMoreHistory); + _scrollController.removeListener(_recordScrollPixels); + _scrollController.removeListener(_scheduleReadMarker); + _scrollController.removeListener(_exitTextSelectionOnScroll); + _scrollController.removeListener(_updateScrollDownVisible); + _readMarkTimer?.cancel(); AppVisualStyle.current.removeListener(_onVisualStyleChanged); 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(); _hasText.dispose(); _scheduledCount.dispose(); _showAttachmentPanel.removeListener(_onAttachPanelToggle); @@ -775,6 +1611,11 @@ class _ChatScreenState extends State .listenable(widget.chatId) .removeListener(_recomputeHeaderStatus); PresenceFetch.revision.removeListener(_onPresenceChanged); + if (_wallpaperListening) { + ChatWallpaperStore.instance.revision.removeListener( + _applyEffectiveWallpaper, + ); + } _headerStatusNotifier.dispose(); _otherReadTime.dispose(); _chatController.dispose(); @@ -787,8 +1628,8 @@ class _ChatScreenState extends State _searchFocusNode.dispose(); _search.dispose(); _selectedIds.dispose(); + _textSelection.dispose(); _messageController.dispose(); - _messageFocusNode.removeListener(_onComposerFocusChanged); _messageFocusNode.dispose(); _stickers.dispose(); _scrollController.dispose(); @@ -797,6 +1638,8 @@ class _ChatScreenState extends State _replyTo.dispose(); _highlightTimer?.cancel(); _highlightMessageId.dispose(); + _goToMessageSettleTimer?.cancel(); + _jumpCacheExtent.dispose(); _messageKeys.clear(); super.dispose(); } @@ -831,7 +1674,11 @@ class _ChatScreenState extends State void _saveDraft() { if (_myId == 0) return; unawaited( - DraftStore.instance.set(_myId, widget.chatId, _messageController.text), + DraftStore.instance.set( + _myId, + widget.chatId, + _messageController.buildContent().text, + ), ); } @@ -924,15 +1771,38 @@ 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.text?.isEmpty ?? true)) return; + _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 _exitTextSelectionOnScroll() { + if (_textSelection.value == null) return; + if (!_scrollController.hasClients) return; + if (_scrollController.position.userScrollDirection != + ScrollDirection.idle) { + _exitTextSelection(); + } + } + void _syncSelectionAnim() { if (_selectedIds.value.isEmpty) { _selectionAnim.reverse(); @@ -1271,6 +2141,10 @@ class _ChatScreenState extends State ), ComposerInputBar( chatType: widget.chatType, + chrome: _effectiveChrome, + style: AppComposerStyle.current.value, + background: AppComposerBackground.current.value, + backdropKey: _pillBackdrop, attachAnim: _attachAnim, replyTo: _replyTo, myId: _myId, @@ -1293,7 +2167,11 @@ class _ChatScreenState extends State isMuted: chat?.isMuted ?? false, onToggleMute: _toggleChatMute, ), - StickerPanelView(stickers: _stickers, onStickerTap: _sendSticker), + StickerPanelView( + stickers: _stickers, + onStickerTap: _sendSticker, + onEmojiTap: _insertAnimoji, + ), ], ), ), @@ -1325,15 +2203,23 @@ class _ChatScreenState extends State ], ); Widget wrapChrome(Widget child) { - if (AppChatChrome.current.value != ChatChromeStyle.blur) return child; + if (_composerFrosted) { + if (AppComposerStyle.current.value != ComposerStyle.materialYou) { + return child; + } + return _FrostedPanel( + sigma: AppFrost.sigma, + tint: AppFrost.panelTint(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, ); } @@ -1434,9 +2320,10 @@ class _ChatScreenState extends State return; } - final rawText = controller.text; + final content = controller.buildContent(); + final rawText = content.text; final newText = rawText.trim(); - final elements = _trimmedElements(controller, rawText, newText); + final elements = _trimmedElements(content.elements, rawText, newText); controller.dispose(); final oldElements = serializeFormatElements( @@ -1490,12 +2377,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; } @@ -1503,7 +2390,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) { @@ -1511,7 +2398,7 @@ class _ChatScreenState extends State showCustomNotification(context, 'Не удалось удалить сообщение'); return; } - _startDeleteAnimation(message.id); + _startDeleteAnimation(messageId); } void _startDeleteAnimation(String messageId) { @@ -1610,14 +2497,19 @@ class _ChatScreenState extends State case MessageAddedEvent(:final message): if (message.senderId == _myId) return; if (_messages.any((m) => m.id == message.id)) return; + final nearBottom = _isNearBottom(); _lastSentId = message.id; _messages.add(message); _bumpMessages(); _clearTyping(message.senderId); Haptics.tap(); - _scrollToBottom(); + if (nearBottom) { + _scrollToBottom(); + _scheduleReadMarker(); + } else { + _reapplyPinIfNeeded(); + } _prank.checkTrigger(message); - _markRead(); case MessageEditedEvent(:final message): final idx = _messages.indexWhere((m) => m.id == message.id); if (idx == -1) return; @@ -1677,25 +2569,21 @@ class _ChatScreenState extends State } 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 = AppChatChrome.current.value; + final chrome = _effectiveChrome; 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) @@ -1716,6 +2604,14 @@ class _ChatScreenState extends State child: const SizedBox.expand(), ), ) + : (chrome == ChatChromeStyle.transparent && !glossy) + ? _FrostedPanel( + sigma: AppFrost.sigma, + tint: AppFrost.panelTint(cs), + border: Border(bottom: AppFrost.hairline(cs)), + backdropKey: _barBackdrop, + child: const SizedBox.expand(), + ) : null, foregroundColor: cs.onSurface, surfaceTintColor: Colors.transparent, @@ -1750,6 +2646,10 @@ class _ChatScreenState extends State offset: Offset(0, -height * 0.4 * t), child: ChatHeaderRow( glossy: glossy, + frosted: + glossy && chrome == ChatChromeStyle.transparent, + liquid: _liquidChrome, + backdropKey: _pillBackdrop, cs: cs, embedded: widget.embedded, chatId: widget.chatId, @@ -1764,17 +2664,7 @@ class _ChatScreenState extends State showCall: widget.chatType == 'DIALOG' && !_peerIsBot, onClose: widget.onClose, - onOpenInfo: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType, - ), - ), - ), + onOpenInfo: _openChatInfo, onOpenScheduled: _openScheduledMessages, onCall: _startCall, onMenu: _openChatMenu, @@ -1886,11 +2776,27 @@ class _ChatScreenState extends State ); } + bool _wallpaperListening = false; + Future _loadWallpaper() async { await ChatWallpaperStore.instance.load(); if (!mounted) return; - final wp = ChatWallpaperStore.instance.get(_myId, widget.chatId); - if (wp != _wallpaper) setState(() => _wallpaper = wp); + if (!_wallpaperListening) { + _wallpaperListening = true; + ChatWallpaperStore.instance.revision.addListener( + _applyEffectiveWallpaper, + ); + } + _applyEffectiveWallpaper(); + } + + void _applyEffectiveWallpaper() { + if (!mounted) return; + final store = ChatWallpaperStore.instance; + final wp = + store.get(_myId, widget.chatId) ?? + store.get(_myId, kGlobalWallpaperChatId); + if (!identical(wp, _wallpaper)) setState(() => _wallpaper = wp); } Future _openWallpaperSheet() async { @@ -1901,13 +2807,13 @@ class _ChatScreenState extends State switch (pick.type) { case WallpaperPickType.none: await store.clear(_myId, widget.chatId); - if (mounted) setState(() => _wallpaper = null); + _applyEffectiveWallpaper(); break; case WallpaperPickType.theme: final theme = pick.theme; if (theme == null) break; - final wp = await store.setTheme(_myId, widget.chatId, theme.id); - if (mounted) setState(() => _wallpaper = wp); + await store.setTheme(_myId, widget.chatId, theme.id); + _applyEffectiveWallpaper(); break; case WallpaperPickType.gallery: await _pickWallpaperFromGallery(); @@ -1944,7 +2850,7 @@ class _ChatScreenState extends State showCustomNotification(context, 'Не удалось сохранить обои'); return; } - setState(() => _wallpaper = wp); + _applyEffectiveWallpaper(); } Future _clearHistory() async { @@ -2158,6 +3064,8 @@ class _ChatScreenState extends State return 'Цитата'; case TextFormat.link: return 'Ссылка'; + case TextFormat.animoji: + return 'Animoji'; } } @@ -2209,11 +3117,10 @@ class _ChatScreenState extends State } List> _trimmedElements( - RichMessageController controller, + List> raw, String rawText, String text, ) { - final raw = controller.elementsForSend(); if (raw.isEmpty) return const []; final leading = rawText.length - rawText.trimLeft().length; final result = >[]; @@ -2233,7 +3140,8 @@ class _ChatScreenState extends State } Future _sendMessage() async { - final rawText = _messageController.text; + final content = _messageController.buildContent(); + final rawText = content.text; final text = rawText.trim(); if (text.isEmpty || _myId == 0) return; @@ -2278,7 +3186,7 @@ class _ChatScreenState extends State } _replyTo.value = null; - final elements = _trimmedElements(_messageController, rawText, text); + final elements = _trimmedElements(content.elements, rawText, text); final Map? composedPayload = (replyPayload == null && elements.isEmpty) ? null @@ -2698,17 +3606,143 @@ class _ChatScreenState extends State } void _scrollToBottom() { + _returnStack.clear(); 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) return; + final pos = _scrollController.position; + if (_returnStack.isNotEmpty && + _isNearBottom() && + pos.userScrollDirection != ScrollDirection.idle) { + _returnStack.clear(); + } + final show = + pos.pixels >= _scrollDownRevealExtent || _returnStack.isNotEmpty; + if (show == _scrollDownVisible) return; + _scrollDownVisible = show; + if (show) { + _scrollDownAnimController.forward(); + } else { + _scrollDownAnimController.reverse(); + } + } + + 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; + }); + } + + bool _isNearBottom() { + if (!_scrollController.hasClients) return true; + return _scrollController.position.pixels <= 120; + } + void _startReply(CachedMessage message) { _replyTo.value = message; _messageFocusNode.requestFocus(); @@ -2720,14 +3754,12 @@ class _ChatScreenState extends State void _openSenderProfile(int senderId) { if (senderId == 0 || senderId == _myId) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ContactProfileScreen( - contactId: senderId, - initialName: ContactCache.get(senderId), - initialAvatarUrl: ContactCache.getAvatar(senderId), - ), + unawaited( + openContactDialogProfile( + context, + contactId: senderId, + name: ContactCache.get(senderId) ?? 'User #$senderId', + avatarUrl: ContactCache.getAvatar(senderId), ), ); } @@ -2745,13 +3777,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) { @@ -2761,6 +3795,8 @@ class _ChatScreenState extends State curve: Curves.easeOut, alignment: 0.4, ); + } else { + unawaited(_scrollToMessagePrecise(messageId, alignment: 0.4)); } _highlightTimer?.cancel(); @@ -2794,80 +3830,244 @@ class _ChatScreenState extends State _search.reset(); } - Future _openSearchResult(MessageSearchResult result) async { - _closeSearch(); + Future _navigateToInitialMessage() async { + final id = widget.initialMessageId; + if (id == null) { + _finishTargetNavigation(); + return; + } + await _runGoToMessage(id, widget.initialMessageTime ?? 0); + } + + Future _runGoToMessage(String id, int targetTime) async { 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 (!_messages.any((m) => m.id == id)) { + await _loadMessageWindow(id, targetTime); if (!mounted) return; await WidgetsBinding.instance.endOfFrame; if (!mounted) return; } - _scrollToLoadedMessage(result.id); + if (!_messages.any((m) => m.id == id)) { + if (mounted) showCustomNotification(context, 'Сообщение не загружено'); + _finishTargetNavigation(); + return; + } + + _highlightTimer?.cancel(); + _highlightMessageId.value = id; + _highlightTimer = Timer(const Duration(milliseconds: 2200), () { + if (!mounted) return; + if (_highlightMessageId.value == id) _highlightMessageId.value = null; + }); + + await _scrollToMessagePrecise(id); + _finishTargetNavigation(); } - void _scrollToLoadedMessage(String messageId) { - if (!_scrollController.hasClients) return; + ({int min, int max})? _laidOutMessageRange(List items) { + int? lo; + int? hi; + for (var i = 0; i < items.length; i++) { + final it = items[i]; + if (it is! _MessageItem) continue; + final ro = _keyForMessage( + it.message.id, + ).currentContext?.findRenderObject(); + if (ro is RenderBox && ro.attached) { + lo ??= i; + hi = i; + } + } + if (lo == null) return null; + return (min: lo, max: hi!); + } + + Future _scrollToMessagePrecise( + String id, { + double alignment = 0.32, + }) async { + if (!mounted || !_scrollController.hasClients) return; + if (_messages.indexWhere((m) => m.id == id) == -1) return; + + _historyAutoloadSuppressCount++; + try { + var stable = 0; + for (var iter = 0; iter < 120; iter++) { + if (!mounted || !_scrollController.hasClients) return; + final listObj = _listKey.currentContext?.findRenderObject(); + final boxObj = _keyForMessage(id).currentContext?.findRenderObject(); + final p = _scrollController.position; + + if (listObj is RenderBox && boxObj is RenderBox && boxObj.attached) { + final viewportH = listObj.size.height; + final actualTop = boxObj + .localToGlobal(Offset.zero, ancestor: listObj) + .dy; + final desiredTop = alignment * viewportH; + final delta = desiredTop - actualTop; + final target = (p.pixels + delta).clamp( + p.minScrollExtent, + p.maxScrollExtent, + ); + + if (delta.abs() <= 2.0 || (target - p.pixels).abs() <= 1.0) { + stable++; + if (stable >= 4) return; + await Future.delayed(const Duration(milliseconds: 60)); + continue; + } + stable = 0; + _scrollController.jumpTo(target); + await WidgetsBinding.instance.endOfFrame; + continue; + } + + stable = 0; + final items = _buildCombinedItems(); + final pos = items.indexWhere( + (it) => it is _MessageItem && it.message.id == id, + ); + if (pos == -1) return; + + final viewportH = listObj is RenderBox ? listObj.size.height : 600.0; + var stepMag = viewportH * 0.8; + if (stepMag > 700) stepMag = 700; + + final range = _laidOutMessageRange(items); + final step = (range != null && pos > range.max) ? -stepMag : stepMag; + + final target = (p.pixels + step).clamp( + p.minScrollExtent, + p.maxScrollExtent, + ); + if ((target - p.pixels).abs() < 1.0) return; + _scrollController.jumpTo(target); + await WidgetsBinding.instance.endOfFrame; + } + } finally { + _historyAutoloadSuppressCount--; + } + } + + Future _openSearchResult(MessageSearchResult result) async { + _closeSearch(); + if (_messages.any((m) => m.id == result.id)) { + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + _scrollToLoadedMessage(result.id); + return; + } + setState(_beginTargetNavigation); + await _runGoToMessage(result.id, result.time); + } + + void _scrollToLoadedMessage( + String messageId, { + double alignment = 0.4, + bool highlight = true, + bool notifyIfMissing = true, + VoidCallback? onSettled, + }) { + if (!_scrollController.hasClients) { + onSettled?.call(); + return; + } final items = _buildCombinedItems(); final pos = items.indexWhere( (it) => it is _MessageItem && it.message.id == messageId, ); if (pos == -1) { - showCustomNotification(context, 'Сообщение не загружено'); + if (notifyIfMissing) { + showCustomNotification(context, 'Сообщение не загружено'); + } + onSettled?.call(); return; } - var below = 0.0; - for (var i = pos + 1; i < items.length; i++) { - below += items[i] is _DateSeparatorItem ? 44.0 : _avgMessageHeight; - } - final maxExtent = _scrollController.position.maxScrollExtent; - final estimate = below.clamp(0.0, maxExtent).toDouble(); - _scrollController.jumpTo(estimate); - - _highlightTimer?.cancel(); - _highlightMessageId.value = messageId; - _highlightTimer = Timer(const Duration(milliseconds: 1600), () { - if (!mounted) return; - if (_highlightMessageId.value == messageId) { - _highlightMessageId.value = null; + final laidOut = _keyForMessage( + 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()); + } + + if (highlight) { + _highlightTimer?.cancel(); + _highlightMessageId.value = messageId; + _highlightTimer = Timer(const Duration(milliseconds: 1600), () { + if (!mounted) return; + if (_highlightMessageId.value == messageId) { + _highlightMessageId.value = null; + } + }); + } WidgetsBinding.instance.addPostFrameCallback( - (_) => _ensureVisibleRetry(messageId, 0), + (_) => _alignLoadedMessage(messageId, alignment, 0, onSettled: onSettled), ); } - void _ensureVisibleRetry(String messageId, int attempt) { - if (!mounted) return; - final ctx = _keyForMessage(messageId).currentContext; - if (ctx != null) { - Scrollable.ensureVisible( - ctx, - duration: const Duration(milliseconds: 280), - curve: Curves.easeOut, - alignment: 0.4, + void _alignLoadedMessage( + String messageId, + double alignment, + int attempt, { + VoidCallback? onSettled, + }) { + if (!mounted || !_scrollController.hasClients) { + 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) { + onSettled?.call(); + return; + } + WidgetsBinding.instance.addPostFrameCallback( + (_) => _alignLoadedMessage( + messageId, + alignment, + attempt + 1, + onSettled: onSettled, + ), ); return; } - if (attempt >= 6) return; + + final viewportHeight = listBox.size.height; + final actualTop = box.localToGlobal(Offset.zero, ancestor: listBox).dy; + final desiredTop = alignment.clamp(0.0, 1.0) * viewportHeight; + final delta = desiredTop - actualTop; + final pos = _scrollController.position; + final target = (pos.pixels + delta).clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + + if (viewportHeight <= 0 || + delta.abs() <= 0.5 || + (target - pos.pixels).abs() <= 0.5 || + attempt >= 8) { + onSettled?.call(); + return; + } + + _scrollController.jumpTo(target); WidgetsBinding.instance.addPostFrameCallback( - (_) => _ensureVisibleRetry(messageId, attempt + 1), + (_) => _alignLoadedMessage( + messageId, + alignment, + attempt + 1, + onSettled: onSettled, + ), ); } @@ -2890,11 +4090,23 @@ class _ChatScreenState extends State return null; } + int _firstUnreadIndex() { + final anchor = _unreadAnchorTime; + if (anchor == null) return -1; + return _messages.indexWhere((m) => m.time > anchor); + } + List _buildCombinedItems() { - final key = Object.hash(_messagesRev.value, _messages.length); + final key = Object.hash( + _messagesRev.value, + _messages.length, + _unreadAnchorTime, + ); final cached = _combinedItemsCache; if (cached != null && _combinedItemsKey == key) return cached; + final unreadIndex = _firstUnreadIndex(); + final List items = []; final Set usedDates = {}; @@ -2931,6 +4143,10 @@ class _ChatScreenState extends State ); } + if (i == unreadIndex) { + items.add(const _UnreadSeparatorItem()); + } + items.add(_MessageItem(msg, i)); } @@ -3027,7 +4243,7 @@ class _ChatScreenState extends State final cs = Theme.of(context).colorScheme; return Padding( key: key, - padding: const EdgeInsets.symmetric(vertical: 8), + padding: EdgeInsets.symmetric(vertical: floating ? 2 : 8), child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), @@ -3048,13 +4264,55 @@ class _ChatScreenState extends State ); } + Widget _buildUnreadSeparatorWidget(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final accent = cs.primary; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), + child: Row( + children: [ + Expanded( + child: Container( + height: 1.5, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(1), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + 'Непрочитанные сообщения', + style: TextStyle( + color: accent, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ), + Expanded( + child: Container( + height: 1.5, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final theme = _prank.active ? _prank.pinkTheme(Theme.of(context)) : Theme.of(context); final cs = theme.colorScheme; - final underlap = AppChatChrome.current.value != ChatChromeStyle.color; + final underlap = _effectiveChrome != ChatChromeStyle.color; // TODO: Локализация // TODO: Cклонения @@ -3063,13 +4321,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(); } @@ -3093,7 +4360,10 @@ class _ChatScreenState extends State ), child: AnimatedBuilder( animation: _searchAnim, - child: underlap ? _buildUnderlapBody() : _buildColorBody(), + child: LottieHoldScope( + isHeld: _animojiHold, + child: underlap ? _buildUnderlapBody() : _buildColorBody(), + ), builder: (context, body) => Scaffold( backgroundColor: cs.surface, extendBodyBehindAppBar: underlap, @@ -3108,10 +4378,34 @@ class _ChatScreenState extends State ); } + Widget? _buildPinnedBanner({required bool floating}) { + final pinned = chat; + if (pinned == null || !pinned.hasPinnedMessage) return null; + return _PinnedMessageBanner( + text: pinned.pinnedMsgText, + isPreview: pinned.pinnedMsgIsPreview, + floating: floating, + frosted: _effectiveChrome == ChatChromeStyle.transparent, + liquid: _liquidChrome, + backdropKey: _pillBackdrop, + onTap: _jumpToPinnedMessage, + onUnpin: pinned.canPinMessages(_myId) + ? () => unawaited(_unpinCurrentMessage()) + : null, + ); + } + 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, Expanded( child: Stack( fit: StackFit.expand, @@ -3120,17 +4414,18 @@ class _ChatScreenState extends State Positioned.fill( child: ChatWallpaperView(wallpaper: _wallpaper!), ), - Positioned.fill( - child: _isLoading && _messages.isEmpty - ? ShimmerLoading(shimmer: _shimmerController) - : _buildMessagesList(), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: CommandPanelView(commandPanel: _commandPanel), + Positioned.fill(child: _buildMessagesArea()), + ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, _) => Positioned( + left: 0, + right: 0, + bottom: frosted ? height : 0, + child: CommandPanelView(commandPanel: _commandPanel), + ), ), + if (frosted) + Positioned(left: 0, right: 0, bottom: 0, child: composer), SearchOverlay( cs: cs, searchAnim: _searchAnim, @@ -3142,24 +4437,39 @@ class _ChatScreenState extends State ], ), ), - _buildComposerArea(context), + if (!frosted) composer, ], ); } + double _pinnedBannerTop() { + final glossy = AppVisualStyle.current.value.glossyChrome; + return MediaQuery.paddingOf(context).top + + (glossy ? _glossyHeaderHeight : kToolbarHeight) - + _pinnedBannerLift; + } + + void _resetPinnedBannerHeight() { + if (_pinnedBannerHeight.value == 0) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && chat?.hasPinnedMessage != true) { + _pinnedBannerHeight.value = 0; + } + }); + } + Widget _buildUnderlapBody() { final cs = Theme.of(context).colorScheme; - final vignette = AppChatChrome.current.value == ChatChromeStyle.none; + final vignette = _effectiveChrome == ChatChromeStyle.none; + final bannerTop = _pinnedBannerTop(); + final banner = _buildPinnedBanner(floating: true); + if (banner == null) _resetPinnedBannerHeight(); return Stack( fit: StackFit.expand, children: [ if (_wallpaper != null) Positioned.fill(child: ChatWallpaperView(wallpaper: _wallpaper!)), - Positioned.fill( - child: _isLoading && _messages.isEmpty - ? ShimmerLoading(shimmer: _shimmerController) - : _buildMessagesList(), - ), + Positioned.fill(child: _buildMessagesArea()), SearchOverlay( cs: cs, searchAnim: _searchAnim, @@ -3185,6 +4495,16 @@ class _ChatScreenState extends State ), ), ], + if (banner != null) + Positioned( + top: bannerTop, + left: 8, + right: 8, + child: _MeasureSize( + onHeight: (value) => _pinnedBannerHeight.value = value, + child: banner, + ), + ), ValueListenableBuilder( valueListenable: _composerHeight, builder: (context, height, _) => Positioned( @@ -3222,7 +4542,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); @@ -3241,6 +4561,29 @@ class _ChatScreenState extends State ); } + Widget _buildMessagesArea() { + final showShimmer = _messages.isEmpty + ? _isLoading + : (_awaitingPosition || _navigatingToTarget); + return Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: showShimmer ? 0.0 : 1.0, + child: NotificationListener( + onNotification: (_) { + _updateReadMarker(); + return false; + }, + child: _buildMessagesList(), + ), + ), + if (showShimmer) + Positioned.fill(child: ShimmerLoading(shimmer: _shimmerController)), + ], + ); + } + Widget _buildMessagesList() => _messageListWidget ??= _ChatMessageList(this, key: _messageListKey); @@ -3252,14 +4595,15 @@ class _ChatScreenState extends State return EdgeInsets.only(top: topInset + 8, bottom: 8); } - double _floatingDateTop(BuildContext context) { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + double _floatingDateTop(double pinnedHeight) { if (AppChatChrome.current.value == ChatChromeStyle.color) { + final glossy = AppVisualStyle.current.value.glossyChrome; return glossy ? 2 : 4; } - return MediaQuery.paddingOf(context).top + - (glossy ? _glossyHeaderHeight - 16 : kToolbarHeight) + - 4; + if (chat?.hasPinnedMessage == true && pinnedHeight > 0) { + return _pinnedBannerTop() + pinnedHeight + 2; + } + return _pinnedBannerTop() + 2; } Widget _buildLoadMoreIndicator() { @@ -3267,14 +4611,7 @@ class _ChatScreenState extends State 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: SmallSpinner(size: 22, color: cs.onSurfaceVariant), ), ); } @@ -3298,157 +4635,233 @@ class _ChatScreenState extends State children: [ ValueListenableBuilder( valueListenable: AppCacheExtent.current, - builder: (context, cacheExtent, _) => ListView.builder( - controller: _scrollController, - reverse: true, - padding: _messagesListPadding(context), - 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]; + builder: (context, userCacheExtent, _) => + ValueListenableBuilder( + valueListenable: _jumpCacheExtent, + builder: (context, jumpExtent, _) { + final cacheExtent = + jumpExtent != null && jumpExtent < userCacheExtent + ? jumpExtent + : userCacheExtent; + return CustomScrollView( + controller: _scrollController, + reverse: true, + cacheExtent: cacheExtent, + 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 _DateSeparatorItem) { + return _buildDateSeparatorWidget( + context, + item.date, + key: item.key, + ); + } - 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; + if (item is _UnreadSeparatorItem) { + return _buildUnreadSeparatorWidget(context); + } - 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, - ); + 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 canReport = !isMe && !message.isControl; - final reportTypeId = _complaintTypeId( - chat?.type ?? widget.chatType, - ); + final bubble = MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + chatType: chat?.type ?? 'CHAT', + chatId: widget.chatId, + photoActions: _photoActions, + overrideStatus: _effectiveStatus(message), + otherReadTime: _otherReadTime, + reactionsListenable: _reactionNotifierFor( + message, + ), + uploadProgress: _photoProgressFor(message), + onReplyTap: (id) => + _jumpToMessage(id, fromId: message.id), + onAvatarTap: _openSenderProfile, + onStickerTap: _openStickerPack, + onReactionTap: message.isControl + ? null + : (emoji) => + _reactToMessage(message, emoji), + peerName: widget.name, + peerAvatarUrl: widget.imageUrl, + textSelection: _textSelection, + onExitTextSelection: _exitTextSelection, + ); - 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), - loadReportReasons: canReport - ? () => _loadReportReasons(reportTypeId) - : null, - onReport: canReport - ? (reasonId) => - _reportMessage(message, reportTypeId, reasonId) - : null, - child: bubble, - ); + final canReport = !isMe && !message.isControl; + final reportTypeId = _complaintTypeId( + chat?.type ?? widget.chatType, + ); - final isChannel = (chat?.type ?? widget.chatType) == 'CHANNEL'; - final swipeable = (message.isControl || isChannel) - ? pressable - : _SwipeToReply( - isMe: isMe, - onReply: () => _startReply(message), - child: pressable, - ); + final pressable = _SelectableMessageRow( + message: message, + isMe: isMe, + selectedIds: _selectedIds, + selectionAnim: _selectionAnim, + isSelectionActive: () => _selectionMode, + onToggleSelection: () => + _toggleSelection(message), + onEnterSelection: () => + _enterSelection(message), + onStartTextSelection: (pos) => + _startTextSelection(message, pos), + onDelete: () => + _confirmDeleteMessage(message.id, 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), + 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 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 isChannel = + (chat?.type ?? widget.chatType) == 'CHANNEL'; + final swipeable = (message.isControl || isChannel) + ? pressable + : _SwipeToReply( + isMe: isMe, + onReply: () => _startReply(message), + child: pressable, + ); - 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 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 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; - }, - ), + 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), + ), + ), + ), + ], + ); + }, + ), ), - Positioned( - top: _floatingDateTop(context), - left: 0, - right: 0, + ValueListenableBuilder( + valueListenable: _pinnedBannerHeight, + builder: (context, pinnedHeight, child) => Positioned( + top: _floatingDateTop(pinnedHeight), + left: 0, + right: 0, + child: child!, + ), child: IgnorePointer( child: ValueListenableBuilder( valueListenable: _floatingDate, @@ -3476,10 +4889,55 @@ 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: 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(); + return Opacity( + opacity: t, + child: Transform.scale(scale: 0.82 + 0.18 * t, child: child), + ); + }, + child: SizedBox( + width: 46, + height: 46, + child: GlossyPill( + color: frosted || _liquidChrome ? AppFrost.pillTint(cs) : null, + blurSigma: frosted && !_liquidChrome ? AppFrost.sigma : null, + liquid: _liquidChrome, + backdropKey: _pillBackdrop, + elevated: true, + onTap: _onScrollDownTap, + child: Center( + child: Icon( + Symbols.keyboard_arrow_down, + color: cs.onSurface, + weight: 500, + size: 26, + ), + ), + ), + ), + ), + ); + } + Uint8List _buildWave(List amps, {int bars = 80}) { final out = Uint8List(bars); if (amps.isEmpty) return out; @@ -3829,12 +5287,7 @@ class _ChatScreenState extends State _scrollToBottom(); try { - final tokens = await Future.wait( - List.generate( - files.length, - (i) => _uploadOnePhoto(files[i], i, progress), - ), - ); + final tokens = await _uploadPhotos(files, progress); if (!mounted) { _disposePhotoProgress(tempId); return; @@ -4022,12 +5475,7 @@ class _ChatScreenState extends State List.filled(files.length, 0), ); try { - final tokens = await Future.wait( - List.generate( - files.length, - (i) => _uploadOnePhoto(files[i], i, progress), - ), - ); + final tokens = await _uploadPhotos(files, progress); if (!mounted) return; if (tokens.any((t) => t == null)) { showCustomNotification(context, 'Не удалось загрузить фото'); @@ -4113,21 +5561,16 @@ class _ChatScreenState extends State void _toggleStickerPanel() { if (_stickers.showPanel.value) { _stickers.hide(); - _messageFocusNode.requestFocus(); + if (_keyboardBeforeStickers) _messageFocusNode.requestFocus(); return; } final keyboard = MediaQuery.viewInsetsOf(context).bottom; + _keyboardBeforeStickers = keyboard > 120 || _messageFocusNode.hasFocus; if (keyboard > 120) _stickers.panelHeight = keyboard; FocusManager.instance.primaryFocus?.unfocus(); _stickers.showPanel.value = true; } - void _onComposerFocusChanged() { - if (_messageFocusNode.hasFocus && _stickers.showPanel.value) { - _stickers.hide(); - } - } - Future _sendSticker(StickerItem sticker) async { _stickers.hide(); await _sendAttachMessage([ @@ -4141,6 +5584,12 @@ class _ChatScreenState extends State ], () => messagesModule.sendStickerMessage(widget.chatId, sticker.id)); } + void _insertAnimoji(Animoji animoji) { + _messageController.insertAnimoji(animoji); + unawaited(animojiModule.noteUsed(animoji)); + Haptics.selection(); + } + Future _shareLocation() async { final position = await _resolveCurrentPosition(); if (position == null || !mounted) return; @@ -4194,26 +5643,76 @@ class _ChatScreenState extends State ); } + static const int _photoUploadConcurrency = 3; + static const int _photoUploadAttempts = 3; + + Future> _uploadPhotos( + List files, + ValueNotifier> progress, + ) async { + final tokens = List.filled(files.length, null); + var nextIndex = 0; + var failed = false; + + Future worker() async { + while (!failed) { + final i = nextIndex++; + if (i >= files.length) return; + final token = await _uploadOnePhoto(files[i], i, progress); + if (token == null) { + failed = true; + return; + } + tokens[i] = token; + } + } + + final workerCount = math.min(_photoUploadConcurrency, files.length); + await Future.wait(List.generate(workerCount, (_) => worker())); + return tokens; + } + 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; - } - }, - ); + for (var attempt = 0; attempt < _photoUploadAttempts; attempt++) { + if (attempt > 0) { + await Future.delayed(Duration(seconds: attempt)); + if (!mounted) return null; + _setPhotoProgress(progress, index, 0); + } + try { + final url = await messagesModule.requestPhotoUploadUrl(); + if (url == null || url.isEmpty) continue; + final token = await fileUploader.uploadPhoto( + Uri.parse(url), + file, + filename: _photoFilename(file), + onProgress: (sent, total) { + if (total <= 0) return; + _setPhotoProgress(progress, index, (sent / total).clamp(0.0, 1.0)); + }, + ); + if (token != null) return token; + } catch (e) { + logger.w('uploadOnePhoto attempt ${attempt + 1}: $e'); + } + } + return null; + } + + void _setPhotoProgress( + ValueNotifier> progress, + int index, + double value, + ) { + final next = List.from(progress.value); + if (index < next.length) { + next[index] = value; + progress.value = next; + } } String _photoFilename(File file) { @@ -4474,6 +5973,191 @@ class _SwipeToReplyState extends State<_SwipeToReply> } } +class _PinnedMessageBanner extends StatelessWidget { + final String? text; + final bool isPreview; + final VoidCallback onTap; + final VoidCallback? onUnpin; + final bool floating; + final bool frosted; + final bool liquid; + final BackdropKey? backdropKey; + + const _PinnedMessageBanner({ + required this.text, + required this.isPreview, + required this.onTap, + this.onUnpin, + this.floating = false, + this.frosted = false, + this.liquid = false, + this.backdropKey, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final content = Material( + color: frosted + ? AppFrost.panelTint(cs) + : floating + ? cs.surfaceContainerHigh.withValues(alpha: 0.92) + : cs.surfaceContainerHigh, + borderRadius: floating ? BorderRadius.circular(16) : null, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Container( + width: 3, + height: 34, + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.pinnedMessageTitle, + style: TextStyle( + color: cs.primary, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + _PinnedMessageText( + text: text, + isPreview: isPreview, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + if (onUnpin != null) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + iconSize: 20, + visualDensity: VisualDensity.compact, + onPressed: onUnpin, + ), + ], + ], + ), + ), + ), + ); + + 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: bottomBorder), + child: content, + ); + } + return content; + } +} + +class _PinnedMessageText extends StatefulWidget { + final String? text; + final bool isPreview; + final Color color; + + const _PinnedMessageText({ + required this.text, + required this.isPreview, + required this.color, + }); + + @override + State<_PinnedMessageText> createState() => _PinnedMessageTextState(); +} + +class _PinnedMessageTextState extends State<_PinnedMessageText> { + late String? _primaryText; + late bool _primaryIsPreview; + late String? _secondaryText; + late bool _secondaryIsPreview; + bool _showSecondary = false; + + @override + void initState() { + super.initState(); + _primaryText = widget.text; + _primaryIsPreview = widget.isPreview; + _secondaryText = widget.text; + _secondaryIsPreview = widget.isPreview; + } + + @override + void didUpdateWidget(covariant _PinnedMessageText oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.text == oldWidget.text && + widget.isPreview == oldWidget.isPreview) { + return; + } + if (_showSecondary) { + _primaryText = widget.text; + _primaryIsPreview = widget.isPreview; + } else { + _secondaryText = widget.text; + _secondaryIsPreview = widget.isPreview; + } + _showSecondary = !_showSecondary; + } + + @override + Widget build(BuildContext context) { + return ClipRect( + child: AnimatedTextSwap( + showAlternate: _showSecondary, + alternate: _buildText(context, _secondaryText, _secondaryIsPreview), + child: _buildText(context, _primaryText, _primaryIsPreview), + ), + ); + } + + Widget _buildText(BuildContext context, String? text, bool isPreview) { + final label = text == null || text.isEmpty + ? AppLocalizations.of(context)!.msgActionsNoText + : text; + return Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: widget.color, + fontSize: 14, + fontStyle: isPreview ? FontStyle.italic : null, + ), + ); + } +} + class _SelectableMessageRow extends StatefulWidget { final Widget child; final CachedMessage message; @@ -4483,13 +6167,20 @@ class _SelectableMessageRow extends StatefulWidget { final bool Function() isSelectionActive; final VoidCallback onToggleSelection; final VoidCallback onEnterSelection; + final void Function(Offset globalPosition) onStartTextSelection; final VoidCallback onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; 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; + final ValueListenable?>? reactions; const _SelectableMessageRow({ required this.child, @@ -4500,13 +6191,20 @@ class _SelectableMessageRow extends StatefulWidget { required this.isSelectionActive, required this.onToggleSelection, required this.onEnterSelection, + required this.onStartTextSelection, required this.onDelete, this.onEdit, this.onReply, this.onForward, this.onMarkUnread, + this.onPin, + required this.isPinned, + this.loadReadBy, + this.onReaderTap, this.loadReportReasons, this.onReport, + this.onReact, + this.reactions, }); @override @@ -4518,6 +6216,15 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { final GlobalKey _boundaryKey = GlobalKey(); Offset? _lastTapDown; + Timer? _openTimer; + + bool _isPinnedNow() => widget.isPinned(); + + @override + void dispose() { + _openTimer?.cancel(); + super.dispose(); + } void _openMenu() { final ctx = _boundaryKey.currentContext; @@ -4551,6 +6258,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { 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, @@ -4558,10 +6267,45 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onReply: widget.onReply, onForward: widget.onForward, onMarkUnread: widget.onMarkUnread, + onPin: widget.onPin, + isPinned: _isPinnedNow(), + onReact: widget.onReact, + selectedReaction: widget.reactions?.value?['yourReaction']?.toString(), + quickReactions: _quickReactionEmojis(), + loadReactionEmojis: () async { + await animojiModule.ensureLoaded(); + return _animojiReactionEmojis(); + }, onDispose: controller.dispose, ); } + List _quickReactionEmojis() { + final quick = animojiModule.quickAnimojis; + if (quick.isEmpty) { + return AnimojiModule.fallbackReactions + .map((e) => ReactionEmoji(emoji: e)) + .toList(); + } + return quick.map(_toReactionEmoji).toList(); + } + + List _animojiReactionEmojis() { + final list = animojiModule.animojis; + if (list.isEmpty) { + return AnimojiModule.fallbackReactions + .map((e) => ReactionEmoji(emoji: e)) + .toList(); + } + return list.map(_toReactionEmoji).toList(); + } + + ReactionEmoji _toReactionEmoji(Animoji a) => ReactionEmoji( + emoji: a.emoji, + animationUrl: a.lottieUrl, + staticUrl: a.iconUrl, + ); + void _onSecondaryTapDown(TapDownDetails details) { final ctx = _boundaryKey.currentContext; if (ctx == null) return; @@ -4582,6 +6326,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { 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, @@ -4589,6 +6335,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onReply: widget.onReply, onForward: widget.onForward, onMarkUnread: widget.onMarkUnread, + onPin: widget.onPin, + isPinned: _isPinnedNow(), onDispose: controller.dispose, ); } @@ -4596,16 +6344,33 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { void _handleTap() { if (widget.isSelectionActive()) { widget.onToggleSelection(); - } else { - _openMenu(); + return; } + final react = widget.onReact; + if (react != null && (_openTimer?.isActive ?? false)) { + _openTimer?.cancel(); + _openTimer = null; + Haptics.tap(); + react('❤️'); + return; + } + _openTimer?.cancel(); + _openTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted && !widget.isSelectionActive()) _openMenu(); + }); } - void _handleLongPress() { - if (widget.isSelectionActive()) { - widget.onToggleSelection(); - } else { + void _handleLongPressStart(Offset globalPosition) { + if (!widget.isSelectionActive()) { widget.onEnterSelection(); + return; + } + final selected = widget.selectedIds.value.contains(widget.message.id); + final hasText = widget.message.text?.isNotEmpty ?? false; + if (selected && hasText && !widget.message.isControl) { + widget.onStartTextSelection(globalPosition); + } else { + widget.onToggleSelection(); } } @@ -4650,7 +6415,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { behavior: HitTestBehavior.opaque, onTapDown: (d) => _lastTapDown = d.globalPosition, onTap: _handleTap, - onLongPress: _handleLongPress, + onLongPressStart: (d) => _handleLongPressStart(d.globalPosition), onSecondaryTapDown: active ? null : _onSecondaryTapDown, child: ColoredBox( color: isSelected diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index b7566fd..d36b925 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, diff --git a/lib/frontend/screens/chats/scheduled_messages_screen.dart b/lib/frontend/screens/chats/scheduled_messages_screen.dart index 2e98c14..d10c6e1 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,7 @@ 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'; class ScheduledMessagesScreen extends StatefulWidget { final int chatId; @@ -258,10 +261,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..ed80827 100644 --- a/lib/frontend/screens/chats/search_screen.dart +++ b/lib/frontend/screens/chats/search_screen.dart @@ -10,8 +10,9 @@ 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 { @@ -147,13 +148,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 +166,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: result.name ?? 'User #${result.id}', + avatarUrl: result.avatarUrl, ), ); } @@ -234,7 +233,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, 'Ничего не найдено'); } 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 609397c..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 == 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/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index be9f462..c3cbcff 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/config/debug_test.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; @@ -10,34 +11,11 @@ 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/springy_tap.dart'; import '../chats/chat_info_screen.dart'; import 'nfc_exchange_sheet.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, - ), - ), - ); -} +import 'open_contact_profile.dart'; class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); @@ -102,6 +80,18 @@ class _ContactsTabState extends State { } Future _loadContacts() async { + if (DebugTest.enabled) { + final debug = ContactsModule.debugContacts() + ..sort((a, b) => a.firstName.compareTo(b.firstName)); + if (mounted) { + setState(() { + _contacts = debug; + _isLoading = false; + }); + } + return; + } + final p = await AppDatabase.loadActiveProfile(); if (p == null) { if (mounted) setState(() => _isLoading = false); @@ -127,83 +117,85 @@ class _ContactsTabState extends State { .trim(); final nameToDisplay = 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, + ), + ], + ), ), - ), - ], + ], + ), ), ), ), @@ -255,7 +247,7 @@ class _ContactsTabState extends State { ), Expanded( child: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : _contacts.isEmpty ? Center( child: Text( @@ -469,11 +461,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { 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/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index ab35f3e..2ff68dc 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -15,6 +15,7 @@ import '../../../main.dart'; import '../../../models/contact_info.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/small_spinner.dart'; enum _Stage { checking, @@ -243,7 +244,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); @@ -420,11 +421,7 @@ class _NfcExchangeSheetState extends State 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..e5390f9 100644 --- a/lib/frontend/screens/digital_id/digital_id_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart' + show ExpressiveRefreshIndicator; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/digital_id.dart'; @@ -10,6 +12,7 @@ import '../../../models/digital_id.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/error_view.dart'; +import '../../widgets/small_spinner.dart'; import '../webapp/web_app_screen.dart'; String _documentLabel(AppLocalizations l10n, String type) { @@ -198,7 +201,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 +209,7 @@ class _DigitalIdScreenState extends State { if (_docs == null) { return _buildOnboarding(cs); } - return RefreshIndicator( + return ExpressiveRefreshIndicator( onRefresh: _load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -278,11 +281,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/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index 43f8a6b..7e904b5 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -10,12 +10,16 @@ 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/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'; class AppearanceScreen extends StatefulWidget { const AppearanceScreen({super.key}); @@ -119,6 +123,10 @@ 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(), ], ), @@ -127,6 +135,25 @@ class _AppearanceScreenState extends State { } } +void _applyVisualStyle(VisualStyle style) { + AppVisualStyle.save(style); + if (style == VisualStyle.liquidGlass) { + 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(); @@ -159,7 +186,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 +201,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); } }, ); @@ -218,26 +255,209 @@ 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 GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + depth: 6, + 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 SegmentedButton( segments: [ ButtonSegment( - value: ChatChromeStyle.color, - label: Text(l10n.appearanceChatChromeColor), + value: ComposerStyle.glossy, + label: Text(l10n.appearanceVisualStyleGlossy), ), ButtonSegment( - value: ChatChromeStyle.blur, - label: Text(l10n.appearanceChatChromeBlur), - ), - ButtonSegment( - value: ChatChromeStyle.none, - label: Text(l10n.appearanceChatChromeNone), + value: ComposerStyle.materialYou, + label: Text(l10n.appearanceVisualStyleMaterialYou), ), ], selected: {current}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); - AppChatChrome.save(set.first); + 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: ComposerBackground.standard, + label: Text(l10n.appearanceComposerBackgroundStandard), + ), + ButtonSegment( + value: ComposerBackground.frostBlur, + label: Text(l10n.appearanceComposerBackgroundFrost), + ), + if (LiquidGlass.isSupported) + ButtonSegment( + value: ComposerBackground.liquidGlass, + label: Text(l10n.appearanceGlassMaterial), + ), + ], + selected: {selectable}, + onSelectionChanged: (set) { + if (set.isNotEmpty) { + Haptics.selection(); + AppComposerBackground.save(set.first); + } + }, + ); + }, + ), + ], + ), + ); + } +} + +class _NavPillStyleCard extends StatelessWidget { + const _NavPillStyleCard(); + + @override + 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, + 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 SegmentedButton( + showSelectedIcon: false, + segments: [ + 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); } }, ); @@ -498,7 +718,12 @@ 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( diff --git a/lib/frontend/screens/profile/chat_background_screen.dart b/lib/frontend/screens/profile/chat_background_screen.dart new file mode 100644 index 0000000..0a10d4a --- /dev/null +++ b/lib/frontend/screens/profile/chat_background_screen.dart @@ -0,0 +1,299 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/config/app_wallpaper_tint.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/chat_wallpaper_store.dart'; +import '../../widgets/chat_wallpaper_sheet.dart'; +import '../../widgets/chat_wallpaper_view.dart'; +import '../../widgets/custom_notification.dart'; +import '../chats/chat_wallpaper_preview_screen.dart'; + +class ChatBackgroundScreen extends StatefulWidget { + const ChatBackgroundScreen({super.key}); + + @override + State createState() => _ChatBackgroundScreenState(); +} + +class _ChatBackgroundScreenState extends State { + int _accountId = 0; + ChatWallpaper? _wallpaper; + bool _ready = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + await ChatWallpaperStore.instance.load(); + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + setState(() { + _accountId = profile?.id ?? 0; + _wallpaper = ChatWallpaperStore.instance + .get(_accountId, kGlobalWallpaperChatId); + _ready = true; + }); + } + + void _refresh() { + if (!mounted) return; + setState(() { + _wallpaper = ChatWallpaperStore.instance + .get(_accountId, kGlobalWallpaperChatId); + }); + } + + Future _openPicker() async { + if (_accountId == 0) return; + final pick = await showChatWallpaperSheet(context, current: _wallpaper); + if (pick == null || !mounted) return; + final store = ChatWallpaperStore.instance; + switch (pick.type) { + case WallpaperPickType.none: + await store.clear(_accountId, kGlobalWallpaperChatId); + _refresh(); + break; + case WallpaperPickType.theme: + final theme = pick.theme; + if (theme == null) break; + await store.setTheme(_accountId, kGlobalWallpaperChatId, theme.id); + _refresh(); + break; + case WallpaperPickType.gallery: + await _pickFromGallery(); + break; + } + } + + Future _pickFromGallery() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + final bytes = result.files.first.bytes; + if (bytes == null) { + if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); + return; + } + if (!mounted) return; + final settings = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatWallpaperPreviewScreen(imageBytes: bytes), + ), + ); + if (settings == null || !mounted) return; + final wp = await ChatWallpaperStore.instance.setImage( + _accountId, + kGlobalWallpaperChatId, + bytes, + settings: settings, + ); + if (!mounted) return; + if (wp == null) { + showCustomNotification(context, 'Не удалось сохранить обои'); + return; + } + _refresh(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + title: const Text( + 'Фон чатов', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + body: SafeArea( + top: false, + child: Column( + children: [ + Expanded(child: _preview(cs)), + _panel(cs), + ], + ), + ), + ); + } + + Widget _preview(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(28), + child: Stack( + fit: StackFit.expand, + children: [ + if (_wallpaper != null) + ChatWallpaperView(wallpaper: _wallpaper!) + else + ColoredBox(color: cs.surfaceContainerHighest), + _SampleBubbles(cs: cs), + ], + ), + ), + ); + } + + Widget _panel(ColorScheme cs) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Эти обои применяются ко всем чатам, где не выбран свой фон.', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + height: 1.35, + ), + ), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: AppWallpaperTint.current, + builder: (context, enabled, _) => Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подстраивать интерфейс под обои', + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'Акцентный цвет приложения возьмётся из фона', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12.5, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Switch( + value: enabled, + onChanged: (v) => AppWallpaperTint.save(v), + ), + ], + ), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: _ready ? _openPicker : null, + child: Container( + height: 52, + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + 'Выбрать обои', + style: TextStyle( + color: cs.onPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _SampleBubbles extends StatelessWidget { + final ColorScheme cs; + + const _SampleBubbles({required this.cs}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _bubble( + text: 'Единый фон для всех чатов', + color: cs.surfaceContainerHighest.withValues(alpha: 0.94), + textColor: cs.onSurface, + alignment: Alignment.centerLeft, + ), + const SizedBox(height: 8), + _bubble( + text: 'Красиво ✨', + color: cs.primary, + textColor: cs.onPrimary, + alignment: Alignment.centerRight, + ), + ], + ), + ), + ); + } + + Widget _bubble({ + required String text, + required Color color, + required Color textColor, + required Alignment alignment, + }) { + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(18), + ), + child: Text( + text, + style: TextStyle( + color: textColor, + fontSize: 15, + fontFamily: 'Outfit', + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 4016087..7a40dee 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -18,6 +18,7 @@ import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; enum _EnvState { loading, notConfigured, ready } @@ -333,7 +334,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), }, @@ -375,14 +376,7 @@ class _CloudStorageScreenState extends State ), ), 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( @@ -1053,14 +1047,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, @@ -1221,14 +1208,7 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { ), ), 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_screen.dart b/lib/frontend/screens/profile/customization_screen.dart deleted file mode 100644 index 76e2f66..0000000 --- a/lib/frontend/screens/profile/customization_screen.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; - -import '../../widgets/connection_status.dart'; - -import '../../../core/utils/haptics.dart'; -import '../../widgets/glossy_pill.dart'; -import 'app_icon_screen.dart'; -import 'appearance_screen.dart'; -import 'font_settings_screen.dart'; -import 'message_actions_screen.dart'; -import 'theme_settings_screen.dart'; - -class _CustomizationCategory { - final IconData icon; - final String title; - final String subtitle; - final WidgetBuilder builder; - - const _CustomizationCategory({ - required this.icon, - required this.title, - required this.subtitle, - required this.builder, - }); -} - -class CustomizationScreen extends StatelessWidget { - const CustomizationScreen({super.key}); - - static const List<_CustomizationCategory> _categories = [ - _CustomizationCategory( - icon: Symbols.dark_mode, - title: 'Тема', - subtitle: 'Светлая, тёмная, AMOLED, расписание', - builder: _buildThemeSettings, - ), - _CustomizationCategory( - icon: Symbols.palette, - title: 'Внешний вид', - subtitle: 'Акцентный цвет интерфейса', - builder: _buildAppearance, - ), - _CustomizationCategory( - icon: Symbols.text_fields, - title: 'Шрифты', - subtitle: 'Шрифт приложения, свои шрифты, размер текста', - builder: _buildFontSettings, - ), - _CustomizationCategory( - icon: Symbols.touch_app, - title: 'Меню действий', - subtitle: 'Радиальное или список — для долгого нажатия на сообщение', - builder: _buildMessageActions, - ), - _CustomizationCategory( - icon: Symbols.apps, - title: 'Иконка приложения', - subtitle: 'Default или Minimal — иконка на главном экране', - builder: _buildAppIcon, - ), - ]; - - static Widget _buildAppearance(BuildContext context) => - const AppearanceScreen(); - - static Widget _buildFontSettings(BuildContext context) => - const FontSettingsScreen(); - - static Widget _buildThemeSettings(BuildContext context) => - const ThemeSettingsScreen(); - - static Widget _buildMessageActions(BuildContext context) => - const MessageActionsScreen(); - - static Widget _buildAppIcon(BuildContext context) => const AppIconScreen(); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - - return Scaffold( - backgroundColor: cs.surface, - appBar: ConnectionTitleBar( - titleText: 'Кастомизация', - backgroundColor: cs.surface, - ), - body: SafeArea( - top: false, - child: ListView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), - children: [ - for (final category in _categories) ...[ - _CategoryCard( - category: category, - onTap: () { - Haptics.tap(); - Navigator.push( - context, - MaterialPageRoute(builder: category.builder), - ); - }, - ), - const SizedBox(height: 12), - ], - ], - ), - ), - ); - } -} - -class _CategoryCard extends StatelessWidget { - final _CustomizationCategory category; - final VoidCallback onTap; - - const _CategoryCard({required this.category, required this.onTap}); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18), - depth: 6, - onTap: onTap, - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: cs.primaryContainer, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Icon( - category.icon, - color: cs.onPrimaryContainer, - size: 24, - weight: 500, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.title, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - category.subtitle, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - height: 1.3, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Icon(Symbols.chevron_right, color: cs.outline, size: 22), - ], - ), - ); - } -} diff --git a/lib/frontend/screens/profile/customization_section.dart b/lib/frontend/screens/profile/customization_section.dart new file mode 100644 index 0000000..b0aac73 --- /dev/null +++ b/lib/frontend/screens/profile/customization_section.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/utils/haptics.dart'; +import '../../widgets/glossy_pill.dart'; +import '../../widgets/settings_card.dart'; +import 'app_icon_screen.dart'; +import 'appearance_screen.dart'; +import 'chat_background_screen.dart'; +import 'font_settings_screen.dart'; +import 'message_actions_screen.dart'; +import 'theme_settings_screen.dart'; + +class _CustomizationCategory { + final IconData icon; + final String title; + final WidgetBuilder builder; + + const _CustomizationCategory({ + required this.icon, + required this.title, + required this.builder, + }); +} + +class CustomizationSection extends StatefulWidget { + const CustomizationSection({super.key}); + + @override + State createState() => _CustomizationSectionState(); +} + +class _CustomizationSectionState extends State { + bool _expanded = false; + + static final List<_CustomizationCategory> _categories = [ + _CustomizationCategory( + icon: Symbols.dark_mode, + title: 'Тема', + builder: (context) => const ThemeSettingsScreen(), + ), + _CustomizationCategory( + icon: Symbols.styler, + title: 'Внешний вид', + builder: (context) => const AppearanceScreen(), + ), + _CustomizationCategory( + icon: Symbols.wallpaper, + title: 'Фон чатов', + builder: (context) => const ChatBackgroundScreen(), + ), + _CustomizationCategory( + icon: Symbols.text_fields, + title: 'Шрифты', + builder: (context) => const FontSettingsScreen(), + ), + _CustomizationCategory( + icon: Symbols.touch_app, + title: 'Меню действий', + builder: (context) => const MessageActionsScreen(), + ), + _CustomizationCategory( + icon: Symbols.apps, + title: 'Иконка приложения', + builder: (context) => const AppIconScreen(), + ), + ]; + + void _toggle() { + Haptics.tap(); + setState(() => _expanded = !_expanded); + } + + void _open(_CustomizationCategory category) { + Haptics.tap(); + Navigator.push(context, MaterialPageRoute(builder: category.builder)); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + child: Column( + children: [ + _buildHeader(cs), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: _expanded + ? Column(children: _buildCategoryTiles(cs)) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } + + Widget _buildHeader(ColorScheme cs) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _toggle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.palette, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + 'Кастомизация', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + AnimatedRotation( + duration: const Duration(milliseconds: 200), + turns: _expanded ? 0.5 : 0, + child: Icon( + Symbols.expand_more, + color: cs.outline, + size: 22, + weight: 400, + ), + ), + ], + ), + ), + ), + ); + } + + List _buildCategoryTiles(ColorScheme cs) { + final tiles = []; + for (var i = 0; i < _categories.length; i++) { + final category = _categories[i]; + tiles.add( + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ); + tiles.add( + SettingsNavTile( + icon: category.icon, + label: category.title, + onTap: () => _open(category), + isLast: i == _categories.length - 1, + ), + ); + } + return tiles; + } +} diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 53a15f3..96ce1fa 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -14,6 +14,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.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'; @@ -533,14 +534,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/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index b528f40..b2a6cad 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( diff --git a/lib/frontend/screens/profile/komet_settings_screen.dart b/lib/frontend/screens/profile/komet_settings_screen.dart index fea5314..18545da 100644 --- a/lib/frontend/screens/profile/komet_settings_screen.dart +++ b/lib/frontend/screens/profile/komet_settings_screen.dart @@ -67,6 +67,40 @@ class KometSettingsScreen extends StatelessWidget { ], ), const SizedBox(height: 20), + const SectionHeader( + 'Папки', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), + SettingsCard( + children: [ + ValueListenableBuilder( + valueListenable: KometSettings.hideAllChatsFolder, + builder: (context, value, _) => SettingsToggleTile( + icon: Symbols.folder_off, + label: 'Hide "All" folder', + subtitle: + 'Скрыть папку «Все», когда есть другие папки. ' + 'Чаты сортируются только по вашим папкам', + value: value, + onChanged: KometSettings.setHideAllChatsFolder, + ), + ), + ValueListenableBuilder( + valueListenable: KometSettings.showHiddenChats, + builder: (context, value, _) => SettingsToggleTile( + icon: Symbols.visibility_lock, + label: 'Show hidden chats', + subtitle: + 'Показывать скрытые чаты (например, от групповых ' + 'звонков), которые обычно не отображаются в списке', + value: value, + onChanged: KometSettings.setShowHiddenChats, + ), + ), + ], + ), + const SizedBox(height: 20), const SectionHeader( 'Ghost Mode', padding: EdgeInsets.fromLTRB(8, 0, 8, 8), diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index bb8fd25..ec0144a 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -8,6 +8,7 @@ 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'; class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); @@ -101,7 +102,7 @@ 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), diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index cfca998..1256a46 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -7,6 +7,7 @@ import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/primary_loading_button.dart'; +import '../../widgets/small_spinner.dart'; class PasswordEntryScreen extends StatefulWidget { const PasswordEntryScreen({super.key}); @@ -142,7 +143,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)), ); } diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 8feed08..26aab4e 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -12,6 +12,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import 'password_entry_screen.dart'; class SecurityScreen extends StatefulWidget { @@ -219,14 +220,7 @@ class _SecurityScreenState extends State 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), ), ], ), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 5691271..7f8eb94 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -17,6 +17,8 @@ import '../../widgets/info_action_sheet.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/settings_card.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; +import '../../widgets/custom_notification.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; import '../../../core/config/app_digital_id_mode.dart'; @@ -25,7 +27,7 @@ import '../digital_id/digital_id_screen.dart'; import '../digital_id/digital_id_web_screen.dart'; import '../webapp/web_app_screen.dart'; import 'cloud_storage_screen.dart'; -import 'customization_screen.dart'; +import 'customization_section.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'edit_profile_screen.dart'; @@ -198,7 +200,12 @@ class _SettingsTabState extends State { Future _doLogout() async { final navState = KometApp.navigatorKey.currentState; - await accountModule.logout(); + try { + await accountModule.logout(); + } catch (e) { + if (mounted) showCustomNotification(context, 'Не удалось выйти: $e'); + return; + } await resetDigitalIdSession(); try { await api.connect(); @@ -216,7 +223,7 @@ class _SettingsTabState extends State { final cs = Theme.of(context).colorScheme; if (_profile == null) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } final String fullName = @@ -292,26 +299,10 @@ class _SettingsTabState extends State { ), ), ), - SliverToBoxAdapter( + const SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _buildSection( - context, - items: [ - _SettingsItem( - icon: Symbols.palette, - label: 'Кастомизация', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CustomizationScreen(), - ), - ); - }, - ), - ], - ), + padding: EdgeInsets.fromLTRB(16, 12, 16, 0), + child: CustomizationSection(), ), ), SliverToBoxAdapter( diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index a84a334..faf295e 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -18,6 +18,7 @@ import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/small_spinner.dart'; import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -444,7 +445,7 @@ class _SpoofScreenState extends State { centerTitle: true, ), body: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), child: Column( diff --git a/lib/frontend/screens/stories/story_composer_screen.dart b/lib/frontend/screens/stories/story_composer_screen.dart new file mode 100644 index 0000000..f6d26bc --- /dev/null +++ b/lib/frontend/screens/stories/story_composer_screen.dart @@ -0,0 +1,224 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/utils/haptics.dart'; +import '../../../main.dart' show fileUploader, messagesModule, storiesModule; +import '../../widgets/custom_notification.dart'; +import '../../widgets/primary_loading_button.dart'; + +const int _storyExpiration = 86400; + +class StoryComposerScreen extends StatefulWidget { + final File file; + + const StoryComposerScreen({super.key, required this.file}); + + @override + State createState() => _StoryComposerScreenState(); +} + +class _StoryComposerScreenState extends State { + final ValueNotifier _publishing = ValueNotifier(false); + int _audience = 1; // 1 = все, 2 = контакты + + @override + void dispose() { + _publishing.dispose(); + super.dispose(); + } + + Future _publish() async { + if (_publishing.value) return; + _publishing.value = true; + try { + final url = await messagesModule.requestPhotoUploadUrl(); + 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, + ); + if (!mounted) return; + Haptics.success(); + Navigator.of(context).pop(); + showCustomNotification(context, 'История опубликована'); + storiesModule.loadFeed(); + } catch (e) { + _fail(e.toString()); + } + } + + void _fail(String message) { + if (!mounted) { + _publishing.value = false; + return; + } + Haptics.error(); + _publishing.value = false; + showCustomNotification(context, message); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Image.file(widget.file, fit: BoxFit.contain), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: 120, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black54, Colors.transparent], + ), + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(6), + child: IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 8), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _AudienceToggle( + value: _audience, + onChanged: (v) { + Haptics.selection(); + setState(() => _audience = v); + }, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: PrimaryLoadingButton( + loading: _publishing, + onPressed: _publish, + child: const Text( + 'Опубликовать', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _AudienceToggle extends StatelessWidget { + final int value; + final ValueChanged onChanged; + + const _AudienceToggle({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withValues(alpha: 0.16)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _segment(context, 1, Symbols.public, 'Все'), + _segment(context, 2, Symbols.group, 'Контакты'), + ], + ), + ); + } + + Widget _segment(BuildContext context, int v, IconData icon, String label) { + final selected = value == v; + final cs = Theme.of(context).colorScheme; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(v), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + decoration: BoxDecoration( + color: selected ? cs.primary : Colors.transparent, + borderRadius: BorderRadius.circular(26), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: selected ? cs.onPrimary : Colors.white70, + ), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: selected ? cs.onPrimary : Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/stories/story_owner_info.dart b/lib/frontend/screens/stories/story_owner_info.dart new file mode 100644 index 0000000..a376ea5 --- /dev/null +++ b/lib/frontend/screens/stories/story_owner_info.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; + +import '../../../backend/modules/messages.dart' show ContactCache; +import '../../../core/cache/info_cache.dart'; +import '../../../main.dart' show messagesModule; +import '../../../models/story.dart'; + +class StoryOwnerInfo { + final String name; + final String? avatarUrl; + const StoryOwnerInfo({required this.name, this.avatarUrl}); +} + +StoryOwnerInfo? peekStoryOwnerInfo(StoryOwner owner) { + if (owner.isUser) { + // 1) Локальный кэш контактов (имя из адресной книги) — самый надёжный. + final cachedName = ContactCache.get(owner.ownerId); + final cachedAvatar = ContactCache.getAvatar(owner.ownerId); + if (cachedName != null && cachedName.isNotEmpty) { + return StoryOwnerInfo(name: cachedName, avatarUrl: cachedAvatar); + } + // 2) Серверный кэш ContactInfo. + final c = ContactInfoFetch.peek(owner.ownerId); + final name = c?.displayName ?? c?.firstName; + if (name != null && name.isNotEmpty) { + return StoryOwnerInfo(name: name, avatarUrl: c?.avatarUrl ?? cachedAvatar); + } + return null; + } + final chat = ChatInfoFetch.peek(owner.ownerId); + if (chat == null) return null; + final title = (chat.raw['title'] as String?)?.trim(); + if (title == null || title.isEmpty) return null; + return StoryOwnerInfo(name: title, avatarUrl: chat.raw['baseUrl'] as String?); +} + +Future fetchStoryOwnerInfo(StoryOwner owner) async { + final peeked = peekStoryOwnerInfo(owner); + if (peeked != null && peeked.name.isNotEmpty) return peeked; + + if (owner.isUser) { + // Канонический путь приложения: подтягивает имена и кладёт их в ContactCache. + await messagesModule.ensureContactNames({owner.ownerId}); + final cachedName = ContactCache.get(owner.ownerId); + final cachedAvatar = ContactCache.getAvatar(owner.ownerId); + if (cachedName != null && cachedName.isNotEmpty) { + return StoryOwnerInfo(name: cachedName, avatarUrl: cachedAvatar); + } + // Запасной путь через серверный ContactInfo. + final c = await ContactInfoFetch.get(owner.ownerId); + final name = c?.displayName ?? c?.firstName; + final avatar = c?.avatarUrl ?? cachedAvatar; + if (name != null && name.isNotEmpty) { + ContactCache.put(owner.ownerId, name); + if (avatar != null) ContactCache.putAvatar(owner.ownerId, avatar); + return StoryOwnerInfo(name: name, avatarUrl: avatar); + } + return avatar == null ? null : StoryOwnerInfo(name: '', avatarUrl: avatar); + } + + final chat = await ChatInfoFetch.get(owner.ownerId); + if (chat == null) return null; + final title = (chat.raw['title'] as String?)?.trim(); + if (title == null || title.isEmpty) return null; + return StoryOwnerInfo(name: title, avatarUrl: chat.raw['baseUrl'] as String?); +} + +/// Резолвит имя/аватар владельца истории (из кэша, с дозагрузкой) и отдаёт их +/// в [builder]. [override] позволяет подставить готовые данные (напр. свой +/// профиль) без обращения к кэшу. +class StoryOwnerBuilder extends StatefulWidget { + final StoryOwner owner; + final StoryOwnerInfo? overrideInfo; + final Widget Function(BuildContext context, StoryOwnerInfo? info) builder; + + const StoryOwnerBuilder({ + super.key, + required this.owner, + required this.builder, + this.overrideInfo, + }); + + @override + State createState() => _StoryOwnerBuilderState(); +} + +class _StoryOwnerBuilderState extends State { + StoryOwnerInfo? _info; + bool _fetching = false; + + @override + void initState() { + super.initState(); + _info = widget.overrideInfo ?? peekStoryOwnerInfo(widget.owner); + } + + @override + void didUpdateWidget(StoryOwnerBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.owner != widget.owner || + oldWidget.overrideInfo != widget.overrideInfo) { + _info = widget.overrideInfo ?? peekStoryOwnerInfo(widget.owner); + } + } + + /// Пока имя не найдено — пробуем дозагрузить при каждой перестройке. + /// Повторные попытки дешёвые: серверные запросы дросселируются кэшем + /// (TTL/бэкофф), а локальный ContactCache проверяется синхронно. Так имя + /// «дорезолвится» само, когда появится соединение или прогреются контакты. + void _ensureResolved() { + if (_info != null || _fetching) return; + _fetching = true; + fetchStoryOwnerInfo(widget.owner).then((info) { + _fetching = false; + if (!mounted || info == null) return; + setState(() => _info = info); + }).catchError((_) { + _fetching = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_info == null && widget.overrideInfo == null) _ensureResolved(); + return widget.builder(context, _info); + } +} diff --git a/lib/frontend/screens/stories/story_ring.dart b/lib/frontend/screens/stories/story_ring.dart new file mode 100644 index 0000000..3315c1a --- /dev/null +++ b/lib/frontend/screens/stories/story_ring.dart @@ -0,0 +1,396 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/utils/haptics.dart'; +import '../../../models/story.dart'; +import '../../widgets/komet_avatar.dart'; +import 'story_owner_info.dart'; + +/// Кольцо-превью истории владельца в шапке списка чатов. +class StoryRing extends StatefulWidget { + final StoryPreview preview; + final StoryOwnerInfo? ownerOverride; + final String? selfLabel; + final void Function(Offset? center) onTap; + final double avatarRadius; + + const StoryRing({ + super.key, + required this.preview, + required this.onTap, + this.ownerOverride, + this.selfLabel, + this.avatarRadius = 26, + }); + + @override + State createState() => _StoryRingState(); +} + +class _StoryRingState extends State { + bool _pressed = false; + + void _handleTap() { + Haptics.tap(); + Offset? center; + final box = context.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) { + center = box.localToGlobal(box.size.center(Offset.zero)); + } + widget.onTap(center); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final hasUnread = widget.preview.hasUnread; + final diameter = widget.avatarRadius * 2; + + return StoryOwnerBuilder( + owner: widget.preview.owner, + overrideInfo: widget.ownerOverride, + builder: (context, info) { + final name = widget.selfLabel ?? + (info?.name.isNotEmpty == true ? info!.name : '…'); + return Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 68, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedScale( + scale: _pressed ? 0.9 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + 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, + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: hasUnread + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +/// Прерывистое кольцо: одна дуга на каждую историю; прочитанные приглушены. +class _SegmentedRingPainter extends CustomPainter { + final int total; + final int read; + final List unreadColors; + final Color readColor; + final double strokeWidth; + + _SegmentedRingPainter({ + required this.total, + required this.read, + required this.unreadColors, + required this.readColor, + required this.strokeWidth, + }); + + @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 segment = (2 * math.pi) / n; + final gap = n == 1 ? 0.0 : math.min(0.16, segment * 0.30); + final sweep = segment - gap; + + final unreadPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = n == 1 ? StrokeCap.butt : StrokeCap.round + ..shader = SweepGradient( + startAngle: 0, + endAngle: 2 * math.pi, + colors: [...unreadColors, unreadColors.first], + transform: const GradientRotation(-math.pi / 2), + ).createShader(rect); + + final readPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = n == 1 ? StrokeCap.butt : StrokeCap.round + ..color = readColor; + + 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); + } + } + + @override + bool shouldRepaint(_SegmentedRingPainter old) => + old.total != total || + old.read != read || + old.readColor != readColor || + old.strokeWidth != strokeWidth || + !listEquals(old.unreadColors, unreadColors); +} + +/// Ведущая плитка «Ваша история»: показывает своё кольцо (если истории есть) +/// и всегда — бейдж «+» для публикации. Тап по кольцу открывает свои истории, +/// тап по «+» — композер. Если своих историй нет — вся плитка ведёт в композер. +class StorySelfTile extends StatefulWidget { + final StoryPreview? preview; + final StoryOwnerInfo? selfInfo; + final void Function(Offset? center) onOpen; + final VoidCallback onAdd; + final double avatarRadius; + + const StorySelfTile({ + super.key, + required this.onOpen, + required this.onAdd, + this.preview, + this.selfInfo, + this.avatarRadius = 26, + }); + + @override + State createState() => _StorySelfTileState(); +} + +class _StorySelfTileState extends State { + bool _pressed = false; + + bool get _hasStories => widget.preview != null; + + void _handleTap() { + Haptics.tap(); + if (!_hasStories) { + widget.onAdd(); + return; + } + Offset? center; + final box = context.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) { + center = box.localToGlobal(box.size.center(Offset.zero)); + } + widget.onOpen(center); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final diameter = widget.avatarRadius * 2; + final preview = widget.preview; + + return Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 68, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedScale( + scale: _pressed ? 0.9 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: diameter + 12, + height: diameter + 12, + child: Stack( + alignment: Alignment.center, + children: [ + if (preview != null) + CustomPaint( + size: Size.square(diameter + 12), + painter: _SegmentedRingPainter( + total: preview.totalCount, + read: preview.readCount, + unreadColors: [cs.primary, cs.tertiary, cs.primary], + readColor: cs.outlineVariant, + strokeWidth: 2.8, + ), + ) + else + Container( + width: diameter + 6, + height: diameter + 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.outlineVariant, + width: 2, + ), + ), + ), + Container( + width: diameter + 4, + height: diameter + 4, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surface, + ), + ), + KometAvatar( + name: widget.selfInfo?.name.isNotEmpty == true + ? widget.selfInfo!.name + : '+', + size: diameter, + imageUrl: widget.selfInfo?.avatarUrl, + ), + Positioned( + right: 1, + bottom: 1, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Haptics.tap(); + widget.onAdd(); + }, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surface, + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary, + ), + child: Icon( + Icons.add, + size: 14, + color: cs.onPrimary, + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + 'Ваша история', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Свёрнутая мини-стопка колец, показывается в заголовке при закрытом доке. +class FoldedStoryStack extends StatelessWidget { + final List previews; + final double opacity; + + const FoldedStoryStack({ + super.key, + required this.previews, + this.opacity = 1.0, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final shown = previews.take(3).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: 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 new file mode 100644 index 0000000..18f2aad --- /dev/null +++ b/lib/frontend/screens/stories/story_viewer_screen.dart @@ -0,0 +1,1073 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +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 '../../../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/liquid_glass.dart'; +import '../../widgets/small_spinner.dart'; +import 'story_owner_info.dart'; + +const _quickReactions = ['❤️', '🔥', '😍', '👏', '😂', '😮']; +const Duration _photoDuration = Duration(seconds: 5); + +/// Открывает вьюер историй. Если задан [origin] (глобальный центр нажатого +/// кольца) — открытие анимируется расширяющимся из этой точки кругом; иначе — +/// масштабным «зумом». +void openStoryViewer( + BuildContext context, { + required List previews, + int initialIndex = 0, + Map ownerOverrides = const {}, + Offset? origin, +}) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + transitionDuration: const Duration(milliseconds: 420), + reverseTransitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, _, _) => StoryViewerScreen( + previews: previews, + initialIndex: initialIndex, + ownerOverrides: ownerOverrides, + ), + transitionsBuilder: (context, animation, _, child) { + return AnimatedBuilder( + animation: animation, + child: child, + builder: (context, child) { + final closing = animation.status == AnimationStatus.reverse || + animation.status == AnimationStatus.dismissed; + // Круговое раскрытие — только на открытии; закрытие всегда + // мягким fade + scale (круг «схлопыванием» резал кадр). + if (origin != null && !closing) { + final f = Curves.easeOutCubic.transform(animation.value); + return ClipPath( + clipper: _CircleRevealClipper(center: origin, fraction: f), + child: child, + ); + } + final v = animation.value; + return Opacity( + opacity: v.clamp(0.0, 1.0), + child: Transform.scale(scale: 0.92 + 0.08 * v, child: child), + ); + }, + ); + }, + ), + ); +} + +class _CircleRevealClipper extends CustomClipper { + final Offset center; + final double fraction; + + const _CircleRevealClipper({required this.center, required this.fraction}); + + @override + Path getClip(Size size) { + final farthest = Offset( + center.dx < size.width / 2 ? size.width : 0, + center.dy < size.height / 2 ? size.height : 0, + ); + final maxRadius = (farthest - center).distance; + final radius = ui.lerpDouble(28, maxRadius, fraction.clamp(0.0, 1.0))!; + return Path()..addOval(Rect.fromCircle(center: center, radius: radius)); + } + + @override + bool shouldReclip(_CircleRevealClipper oldClipper) => + oldClipper.fraction != fraction || oldClipper.center != center; +} + +class StoryViewerScreen extends StatefulWidget { + final List previews; + final int initialIndex; + final Map ownerOverrides; + + const StoryViewerScreen({ + super.key, + required this.previews, + this.initialIndex = 0, + this.ownerOverrides = const {}, + }); + + @override + State createState() => _StoryViewerScreenState(); +} + +class _StoryViewerScreenState extends State + with SingleTickerProviderStateMixin { + late final PageController _ownerController; + late int _ownerIndex; + late final AnimationController _photoProgress; + + final Map> _stories = {}; + final Map _loading = {}; + final Set _marked = {}; + + final ValueNotifier _segment = ValueNotifier(0); + int _storyIndex = 0; + bool _paused = false; + + double _dragDy = 0; + bool _dragging = false; + static const double _dismissThreshold = 120; + + final List<_Burst> _bursts = []; + int _burstSeq = 0; + + VideoPlayerController? _video; + + StoryPreview get _owner => widget.previews[_ownerIndex]; + + List get _ownerStories => _stories[_owner.owner.ownerId] ?? const []; + + Story? get _currentStory { + final list = _ownerStories; + if (_storyIndex < 0 || _storyIndex >= list.length) return null; + return list[_storyIndex]; + } + + @override + void initState() { + super.initState(); + _ownerIndex = widget.initialIndex.clamp(0, widget.previews.length - 1); + _ownerController = PageController(initialPage: _ownerIndex); + _photoProgress = AnimationController(vsync: this, duration: _photoDuration) + ..addListener(() => _segment.value = _photoProgress.value) + ..addStatusListener((s) { + if (s == AnimationStatus.completed) _advance(); + }); + _loadOwner(_ownerIndex, autostart: true); + } + + @override + void dispose() { + _disposeVideo(); + _photoProgress.dispose(); + _segment.dispose(); + _ownerController.dispose(); + super.dispose(); + } + + void _disposeVideo() { + _video?.removeListener(_onVideoTick); + _video?.dispose(); + _video = null; + } + + Future _loadOwner(int index, {bool autostart = false}) async { + final ownerId = widget.previews[index].owner.ownerId; + if (_stories.containsKey(ownerId)) { + if (autostart) _startStory(_resumeIndex(index, _stories[ownerId]!)); + return; + } + setState(() => _loading[ownerId] = true); + final stories = await storiesModule.getByOwner(widget.previews[index].owner); + if (!mounted) return; + setState(() { + _stories[ownerId] = stories; + _loading[ownerId] = false; + }); + if (autostart && index == _ownerIndex) { + _startStory(_resumeIndex(index, stories)); + } + } + + /// Индекс, с которого начать показ: сначала — сохранённая позиция просмотра, + /// иначе — первая непрочитанная. + 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; + } + + void _startStory(int index) { + _disposeVideo(); + _photoProgress.stop(); + _segment.value = 0; + _paused = false; + setState(() => _storyIndex = index); + + final story = _currentStory; + if (story == null) return; + _markViewed(story); + storiesModule.setLastViewed(story.owner.ownerId, story.id); + + final media = story.media; + if (media != null && media.isVideo && (media.url?.isNotEmpty ?? false)) { + _startVideo(media.url!); + } else { + _photoProgress.forward(from: 0); + } + } + + Future _startVideo(String url) async { + final controller = VideoPlayerController.networkUrl(Uri.parse(url)); + _video = controller; + try { + await controller.initialize(); + if (!mounted || _video != controller) { + controller.dispose(); + return; + } + controller.addListener(_onVideoTick); + await controller.play(); + setState(() {}); + } catch (_) { + if (_video == controller) { + _disposeVideo(); + _photoProgress.forward(from: 0); + } + } + } + + void _onVideoTick() { + final c = _video; + if (c == null || !c.value.isInitialized) return; + final total = c.value.duration.inMilliseconds; + if (total <= 0) return; + _segment.value = (c.value.position.inMilliseconds / total).clamp(0.0, 1.0); + if (c.value.position >= c.value.duration && !c.value.isPlaying) { + _advance(); + } + } + + void _markViewed(Story story) { + if (story.id == 0 || _marked.contains(story.id)) return; + _marked.add(story.id); + storiesModule.mark(story.owner, story.id); + } + + void _advance() { + Haptics.selection(); + if (_storyIndex + 1 < _ownerStories.length) { + _startStory(_storyIndex + 1); + } else { + _nextOwner(); + } + } + + void _rewind() { + Haptics.selection(); + if (_storyIndex > 0) { + _startStory(_storyIndex - 1); + } else { + _prevOwner(); + } + } + + void _nextOwner() { + if (_ownerIndex + 1 < widget.previews.length) { + _ownerController.nextPage( + duration: const Duration(milliseconds: 320), + curve: Curves.easeInOutCubic, + ); + } else { + Navigator.of(context).maybePop(); + } + } + + void _prevOwner() { + if (_ownerIndex > 0) { + _ownerController.previousPage( + duration: const Duration(milliseconds: 320), + curve: Curves.easeInOutCubic, + ); + } + } + + void _onOwnerPageChanged(int index) { + _disposeVideo(); + _photoProgress.stop(); + _segment.value = 0; + setState(() { + _ownerIndex = index; + _storyIndex = 0; + }); + _loadOwner(index, autostart: true); + } + + void _setPaused(bool paused) { + if (_paused == paused) return; + setState(() => _paused = paused); + final video = _video; + if (video != null && video.value.isInitialized) { + paused ? video.pause() : video.play(); + } else { + paused ? _photoProgress.stop() : _photoProgress.forward(); + } + } + + 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); + } + + void _onDragUpdate(DragUpdateDetails d) { + setState(() => _dragDy = (_dragDy + d.delta.dy).clamp(-40.0, 600.0)); + } + + void _onDragEnd(DragEndDetails d) { + final v = d.primaryVelocity ?? 0; + if (_dragDy > _dismissThreshold || v > 700) { + Navigator.of(context).maybePop(); + return; + } + setState(() { + _dragging = false; + _dragDy = 0; + }); + _setPaused(false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + TweenAnimationBuilder( + tween: Tween(end: _dragDy), + duration: _dragging + ? Duration.zero + : const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + child: PageView.builder( + controller: _ownerController, + onPageChanged: _onOwnerPageChanged, + itemCount: widget.previews.length, + physics: const BouncingScrollPhysics(), + itemBuilder: (context, index) { + final content = index == _ownerIndex + ? _buildActiveOwner() + : _OwnerCover( + preview: widget.previews[index], + overrideInfo: widget.ownerOverrides[ + widget.previews[index].owner.ownerId], + ); + return _CubePage( + controller: _ownerController, + index: index, + fallbackPage: _ownerIndex.toDouble(), + child: content, + ); + }, + ), + builder: (context, dy, child) { + final p = (dy.abs() / 320).clamp(0.0, 1.0); + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: IgnorePointer( + child: ColoredBox( + color: Colors.black.withValues(alpha: 1.0 - p * 0.7), + ), + ), + ), + Transform.translate( + offset: Offset(0, dy), + child: Transform.scale( + scale: 1.0 - p * 0.12, + child: ClipRRect( + borderRadius: BorderRadius.circular(p * 26), + child: child, + ), + ), + ), + ], + ); + }, + ), + for (final burst in _bursts) + _FloatingReaction( + key: ValueKey(burst.id), + emoji: burst.emoji, + alignment: burst.from, + onDone: () => _removeBurst(burst.id), + ), + ], + ), + ); + } + + Widget _buildActiveOwner() { + final ownerId = _owner.owner.ownerId; + final loading = _loading[ownerId] ?? false; + final stories = _ownerStories; + final story = _currentStory; + + return GestureDetector( + onTapUp: (details) { + final width = MediaQuery.of(context).size.width; + if (details.localPosition.dx < width * 0.32) { + _rewind(); + } else { + _advance(); + } + }, + onLongPressStart: (_) => _setPaused(true), + onLongPressEnd: (_) => _setPaused(false), + onVerticalDragStart: _onDragStart, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: story?.media != null + ? KeyedSubtree( + key: ValueKey('$ownerId:${story!.id}'), + child: _StoryMediaView(media: story.media!, video: _video), + ) + : (loading + ? const SizedBox.expand(key: ValueKey('loading')) + : const Center( + key: ValueKey('empty'), + child: Text( + 'Историй нет', + style: TextStyle( + color: Colors.white70, + fontSize: 16, + ), + ), + )), + ), + const _TopScrim(), + if (loading) + const Center( + child: SmallSpinner(size: 28, color: Colors.white), + ), + SafeArea( + child: Column( + children: [ + _buildProgressBars(stories.length), + _buildHeader(), + const Spacer(), + if (story != null) _buildReactionBar(story), + ], + ), + ), + ], + ), + ); + } + + Widget _buildProgressBars(int count) { + if (count <= 0) count = 1; + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _paused ? 0.35 : 1.0, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 4), + child: Row( + children: [ + for (var i = 0; i < count; i++) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.5), + child: _SegmentBar( + state: i < _storyIndex + ? _SegmentState.done + : i > _storyIndex + ? _SegmentState.upcoming + : _SegmentState.active, + progress: _segment, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + final preview = _owner; + final story = _currentStory; + return Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 8, 0), + child: StoryOwnerBuilder( + owner: preview.owner, + overrideInfo: widget.ownerOverrides[preview.owner.ownerId], + builder: (context, info) => Row( + children: [ + Container( + padding: const EdgeInsets.all(1.6), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.85), + width: 1.6, + ), + ), + child: KometAvatar( + name: info?.name.isNotEmpty == true ? info!.name : '?', + size: 34, + imageUrl: info?.avatarUrl, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + info?.name.isNotEmpty == true ? info!.name : '…', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + shadows: [ + Shadow(color: Colors.black54, blurRadius: 4), + ], + ), + ), + if (story != null && story.time > 0) + Text( + _timeAgo(story.time), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 12, + shadows: const [ + Shadow(color: Colors.black54, blurRadius: 4), + ], + ), + ), + ], + ), + ), + _RoundIconButton( + icon: Symbols.close, + onTap: () => Navigator.of(context).maybePop(), + ), + ], + ), + ), + ); + } + + 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: GlassSurface( + borderRadius: BorderRadius.circular(30), + frostTint: Colors.white.withValues(alpha: 0.12), + frostSigma: 14, + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + 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 ──────────────────────────────────────── +class _CubePage extends StatelessWidget { + final PageController controller; + final int index; + final double fallbackPage; + final Widget child; + + const _CubePage({ + required this.controller, + required this.index, + required this.fallbackPage, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + child: child, + builder: (context, child) { + double page = fallbackPage; + if (controller.hasClients && controller.position.haveDimensions) { + page = controller.page ?? fallbackPage; + } + final delta = (index - page).clamp(-1.0, 1.0); + final rotation = delta * (math.pi / 2.4); + final transform = Matrix4.identity() + ..setEntry(3, 2, 0.0012) + ..rotateY(rotation); + return Transform( + alignment: delta >= 0 ? Alignment.centerLeft : Alignment.centerRight, + transform: transform, + child: Stack( + fit: StackFit.expand, + children: [ + child!, + if (delta != 0) + IgnorePointer( + child: ColoredBox( + color: Colors.black.withValues( + alpha: (delta.abs() * 0.55).clamp(0.0, 0.55), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +// ─── Segmented progress bar ─────────────────────────────────────────────── +enum _SegmentState { done, active, upcoming } + +class _SegmentBar extends StatelessWidget { + final _SegmentState state; + final ValueListenable progress; + + const _SegmentBar({required this.state, required this.progress}); + + @override + Widget build(BuildContext context) { + final track = Colors.white.withValues(alpha: 0.28); + return ClipRRect( + borderRadius: BorderRadius.circular(3), + child: SizedBox( + height: 3, + child: switch (state) { + _SegmentState.done => const ColoredBox(color: Colors.white), + _SegmentState.upcoming => ColoredBox(color: track), + _SegmentState.active => ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, _) => Stack( + children: [ + Positioned.fill(child: ColoredBox(color: track)), + Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: value.clamp(0.0, 1.0), + heightFactor: 1.0, + child: const DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow(color: Colors.white54, blurRadius: 4), + ], + ), + ), + ), + ), + ], + ), + ), + }, + ), + ); + } +} + +// ─── 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; + final VoidCallback onTap; + + const _RoundIconButton({required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.14), + ), + child: Icon(icon, color: Colors.white, size: 22), + ), + ); + } +} + +// ─── Top scrim ──────────────────────────────────────────────────────────── +class _TopScrim extends StatelessWidget { + const _TopScrim(); + + @override + Widget build(BuildContext context) { + return const IgnorePointer( + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: 150, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black54, Colors.transparent], + ), + ), + ), + ), + ), + ); + } +} + +// ─── 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; + if (diff < 60) return 'только что'; + if (diff < 3600) return '${diff ~/ 60} мин'; + if (diff < 86400) return '${diff ~/ 3600} ч'; + return '${diff ~/ 86400} дн'; +} + +ImageProvider? _previewProvider(String? previewData) { + if (previewData == null) return null; + final comma = previewData.indexOf(','); + if (comma < 0) return null; + try { + return MemoryImage(base64Decode(previewData.substring(comma + 1))); + } catch (_) { + return null; + } +} + +class _StoryMediaView extends StatelessWidget { + final StoryMedia media; + final VideoPlayerController? video; + + const _StoryMediaView({required this.media, this.video}); + + @override + Widget build(BuildContext context) { + final preview = _previewProvider(media.previewData); + final Widget blurBg = preview != null + ? Positioned.fill( + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Image(image: preview, fit: BoxFit.cover), + ), + ) + : const SizedBox.shrink(); + + if (media.isVideo) { + final c = video; + Widget fg; + if (c != null && c.value.isInitialized) { + fg = Center( + child: AspectRatio( + aspectRatio: c.value.aspectRatio, + child: VideoPlayer(c), + ), + ); + } else if (media.thumbnailUrl?.isNotEmpty ?? false) { + fg = CachedNetworkImage( + imageUrl: media.thumbnailUrl!, + fit: BoxFit.contain, + ); + } else if (preview != null) { + fg = Center(child: Image(image: preview, fit: BoxFit.contain)); + } else { + fg = const SizedBox.shrink(); + } + 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)) + : const SizedBox.shrink(); + } else { + fg = CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 200), + placeholder: preview != null + ? (context, _) => Center(child: Image(image: preview, fit: BoxFit.contain)) + : null, + errorWidget: (context, _, _) => preview != null + ? Center(child: Image(image: preview, fit: BoxFit.contain)) + : const Center( + child: Icon(Symbols.broken_image, color: Colors.white54, size: 48), + ), + ); + } + return Stack( + fit: StackFit.expand, + children: [ + blurBg, + fg, + ], + ); + } +} + +class _OwnerCover extends StatelessWidget { + final StoryPreview preview; + final StoryOwnerInfo? overrideInfo; + + const _OwnerCover({required this.preview, this.overrideInfo}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.black, + child: Center( + child: StoryOwnerBuilder( + owner: preview.owner, + overrideInfo: overrideInfo, + builder: (context, info) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + KometAvatar( + name: info?.name.isNotEmpty == true ? info!.name : '?', + size: 92, + imageUrl: info?.avatarUrl, + ), + const SizedBox(height: 14), + const SmallSpinner(size: 22, color: Colors.white30), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index 114db51..e850b6d 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -6,8 +6,10 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/webapp.dart'; import '../../../core/storage/spoofing_service.dart'; +import '../../../main.dart' show api; import '../../widgets/connection_status.dart'; import '../../widgets/error_view.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/webview_permission_prompt.dart'; class WebAppScreen extends StatefulWidget { @@ -63,7 +65,11 @@ class _WebAppScreenState extends State { _launch = null; }); try { - _userAgent = await SpoofingService.getWebViewUserAgent() ?? ''; + // Тот же UA, что уходит в sessionInit (из handshake-устройства ядра), + // чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа. + _userAgent = api.session?.userAgent() ?? + await SpoofingService.getWebViewUserAgent() ?? + ''; final launch = await widget.loader(); if (!mounted) return; setState(() => _launch = launch); @@ -132,7 +138,7 @@ class _WebAppScreenState extends State { } final launch = _launch; if (launch == null) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } return InAppWebView( initialUrlRequest: URLRequest(url: WebUri(launch.url)), diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart index 7755f26..cc39617 100644 --- a/lib/frontend/widgets/adaptive_shell.dart +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/config/debug_test.dart'; import '../../core/utils/update_checker.dart'; import '../screens/chats/chat_list_screen.dart'; import '../screens/chats/chat_screen.dart'; @@ -52,6 +53,7 @@ class _AdaptiveShellState extends State { } Future _maybeCheckUpdate() async { + if (DebugTest.enabled) return; final update = await UpdateChecker.check(); if (update == null || !mounted) return; await showUpdateDialog(context, update); diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 9142cfe..4856c19 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -4,6 +4,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.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/utils/format.dart'; import 'package:komet/frontend/widgets/attachment/media_preview_screen.dart'; @@ -13,6 +16,8 @@ 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) => [ @@ -392,7 +397,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); @@ -739,13 +744,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.navPillTint(cs) + : _composerColor(cs)), + borderColor: _composerBorderColor(cs), + ); + }, + ), ); }, ), diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart index d4a6e0a..17f37f9 100644 --- a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -8,6 +8,7 @@ import '../../../../core/config/komet_settings.dart'; import '../../../../core/utils/format.dart'; import '../../../../models/attachment.dart'; import '../../formatted_message_text.dart'; +import '../../photo_viewer.dart'; enum MessageType { text, attachment, voice, control } @@ -62,6 +63,8 @@ class BubbleContext { final bool isMe; final int myId; final String chatType; + final int? chatId; + final PhotoViewerActions? photoActions; final String? overrideStatus; final ValueListenable? otherReadTime; final ValueListenable>? uploadProgress; @@ -79,6 +82,8 @@ class BubbleContext { required this.isMe, required this.myId, required this.chatType, + this.chatId, + this.photoActions, this.overrideStatus, this.otherReadTime, this.uploadProgress, @@ -154,6 +159,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 +170,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 +193,8 @@ 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); + return Icon(v.icon, size: size, color: v.color); } } diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart index 0c0826a..1e86714 100644 --- a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -156,7 +156,7 @@ class PhotoBubble extends StatelessWidget { Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => _openPhotoViewer(ctx.context, photo), + onTap: () => _openPhotoViewer(ctx.context, 0), ), ), ], @@ -284,28 +284,51 @@ class PhotoBubble extends StatelessWidget { 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: 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: 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) { final cachePx = (BubbleContext.photoMaxSize / @@ -330,7 +353,7 @@ class PhotoBubble extends StatelessWidget { Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => _openPhotoViewer(ctx.context, photo), + onTap: () => _openPhotoViewer(ctx.context, index), ), ), ], @@ -378,6 +401,13 @@ class PhotoBubble extends StatelessWidget { ), if (ctx.uploadProgress != null) _buildUploadOverlay(ctx.uploadProgress!, index), + if (ctx.uploadProgress == null) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openPhotoViewer(ctx.context, index), + ), + ), ], ), ); @@ -407,13 +437,17 @@ class PhotoBubble extends StatelessWidget { ); } - void _openPhotoViewer(BuildContext context, PhotoAttachment photo) { - final url = photo.baseUrl ?? ''; - if (url.isEmpty) return; + void _openPhotoViewer(BuildContext context, int index) { Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => PhotoViewerScreen(baseUrl: url), + builder: (_) => PhotoViewerScreen( + photos: photos, + initialIndex: index, + chatId: ctx.chatId, + message: ctx.message, + actions: ctx.photoActions, + ), ), ); } diff --git a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart index 1cd38f0..2c15657 100644 --- a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../../models/attachment.dart'; -import '../../sticker_image.dart'; +import '../../lottie_image.dart'; import 'bubble_context.dart'; class StickerBubble extends StatelessWidget { @@ -25,7 +25,7 @@ class StickerBubble extends StatelessWidget { SizedBox( width: 150, height: 150, - child: StickerImage( + child: LottieImage( url: staticUrl, lottieUrl: lottieUrl, size: 150, diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart index 30b59e4..e29850b 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -10,6 +10,7 @@ import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/logger.dart'; import '../../../../core/utils/media_cache.dart'; import '../../../../models/attachment.dart'; +import '../../small_spinner.dart'; class VideoNoteBubble extends StatefulWidget { final VideoAttachment attachment; @@ -182,10 +183,7 @@ class _VideoNoteBubbleState extends State { child: _loading ? const Padding( padding: EdgeInsets.all(14), - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), + child: SmallSpinner(size: 36, color: Colors.white), ) : Icon( _error ? Symbols.error : Symbols.play_arrow, diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart index db11091..f08fd4e 100644 --- a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -13,6 +13,7 @@ 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; @@ -231,8 +232,8 @@ class _VoiceMessageBubbleState extends State { child: _loadingAudio ? Padding( padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, + child: SmallSpinner( + size: 36, color: widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary, @@ -273,13 +274,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( 'Т', diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index bc8dfe9..b471628 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -10,6 +10,7 @@ import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import '../../../core/config/app_colors.dart'; +import '../small_spinner.dart'; const Color _kBar = Color(0xFF1E1E1E); @@ -218,11 +219,7 @@ 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 const SmallSpinner(size: 36, color: Colors.white24); } return Image.file(file, fit: BoxFit.contain, gaplessPlayback: true); } diff --git a/lib/frontend/widgets/attachment/photo_editor.dart b/lib/frontend/widgets/attachment/photo_editor.dart index d5a4fb8..e238a81 100644 --- a/lib/frontend/widgets/attachment/photo_editor.dart +++ b/lib/frontend/widgets/attachment/photo_editor.dart @@ -405,7 +405,7 @@ class _PhotoCropEditorState extends State { final img = _image; if (img == null) { return const Center( - child: CircularProgressIndicator(color: Colors.white), + child: SmallSpinner(size: 36, color: Colors.white), ); } return LayoutBuilder( @@ -2350,7 +2350,7 @@ class _PhotoAdjustEditorState extends State { final img = _image; if (img == null) { return const Center( - child: CircularProgressIndicator(color: Colors.white), + child: SmallSpinner(size: 36, color: Colors.white), ); } return LayoutBuilder( diff --git a/lib/frontend/widgets/avatar_history_screen.dart b/lib/frontend/widgets/avatar_history_screen.dart index 1f57b89..4fa47b4 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; @@ -210,14 +211,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 +270,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), @@ -293,7 +287,7 @@ class _AvatarHistoryScreenState extends State { fit: BoxFit.contain, fadeInDuration: const Duration(milliseconds: 120), placeholder: (_, _) => const Center( - child: CircularProgressIndicator(color: Colors.white), + child: SmallSpinner(size: 36, color: Colors.white), ), errorWidget: (_, _, _) => const Icon(Symbols.broken_image, color: Colors.white54, size: 64), diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart new file mode 100644 index 0000000..d1aaa44 --- /dev/null +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -0,0 +1,1237 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +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 CachedMessage, ContactCache; +import '../../../backend/modules/shared_content.dart'; +import '../../../core/cache/info_cache.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'; +import '../../screens/chats/chat_screen.dart'; +import '../custom_notification.dart'; +import '../komet_avatar.dart'; +import '../photo_viewer.dart'; +import '../small_spinner.dart'; +import '../swipe_route.dart'; +import '../video_player_screen.dart'; + +enum SharedContentKind { media, files, voice, links } + +extension on SharedContentKind { + List get attachTypes { + switch (this) { + case SharedContentKind.media: + return const ['PHOTO', 'VIDEO']; + case SharedContentKind.files: + return const ['FILE']; + case SharedContentKind.voice: + return const ['AUDIO']; + case SharedContentKind.links: + return const ['SHARE']; + } + } +} + +const List _ruMonthsFull = [ + 'Январь', + 'Февраль', + 'Март', + 'Апрель', + 'Май', + 'Июнь', + 'Июль', + 'Август', + 'Сентябрь', + 'Октябрь', + 'Ноябрь', + 'Декабрь', +]; + +const List _enMonthsFull = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +String _monthHeader(String locale, DateTime date) { + final months = locale.startsWith('ru') ? _ruMonthsFull : _enMonthsFull; + final now = DateTime.now(); + final name = months[date.month - 1]; + final label = date.year == now.year ? name : '$name ${date.year}'; + return label.toUpperCase(); +} + +Widget _emptyState(ColorScheme cs, String label, IconData icon) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + size: 48, + ), + const SizedBox(height: 12), + Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), + ], + ), + ); +} + +Widget _loadingState(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 56), + child: Center( + child: SmallSpinner(size: 26, color: cs.primary), + ), + ); +} + +Widget _sectionHeader(ColorScheme cs, String label) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 12, 4, 8), + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + ), + ), + ); +} + +List<({DateTime month, List items})> _groupByMonth( + List items, +) { + final groups = <({DateTime month, List items})>[]; + DateTime? current; + for (final item in items) { + final dt = DateTime.fromMillisecondsSinceEpoch(item.time); + final monthStart = DateTime(dt.year, dt.month); + if (current == null || current != monthStart) { + current = monthStart; + groups.add((month: monthStart, items: [item])); + } else { + groups.last.items.add(item); + } + } + return groups; +} + +class _MenuAction { + final IconData icon; + final String label; + final Future Function() onTap; + const _MenuAction(this.icon, this.label, this.onTap); +} + +Future _showItemMenu(BuildContext context, List<_MenuAction> actions) { + final cs = Theme.of(context).colorScheme; + return showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 10), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + for (final action in actions) + ListTile( + leading: Icon(action.icon, color: cs.onSurface), + title: Text( + action.label, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + onTap: () { + Navigator.pop(sheetContext); + action.onTap(); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); +} + +Widget _moreButton(ColorScheme cs, VoidCallback onTap, {bool overlay = false}) { + if (overlay) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Symbols.more_horiz, color: Colors.white, size: 18), + ), + ); + } + return IconButton( + onPressed: onTap, + icon: Icon(Symbols.more_vert, color: cs.onSurfaceVariant, size: 22), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + ); +} + +void _notifySave(BuildContext context, MediaSaveResult result) { + if (!context.mounted) return; + if (result.ok) { + showCustomNotification( + context, + result.toGallery ? 'Сохранено в галерею' : 'Файл сохранён', + ); + } else { + showCustomNotification( + context, + 'Не удалось сохранить: ${result.error ?? ''}', + ); + } +} + +Future _downloadAttachment( + BuildContext context, + SharedMediaItem item, +) async { + final att = item.attachment; + final now = DateTime.now().millisecondsSinceEpoch; + + if (att is PhotoAttachment) { + final url = att.baseUrl ?? ''; + if (url.isEmpty) return; + final result = await saveMediaFile( + cacheName: 'photo_${att.photoId ?? url.hashCode}.jpg', + resolveUrl: () async => url, + saveName: 'IMG_$now.jpg', + kind: SaveMediaKind.image, + ); + if (context.mounted) _notifySave(context, result); + return; + } + + if (att is VideoAttachment) { + final result = await saveMediaFile( + cacheName: 'video_${att.videoId ?? item.messageId}.mp4', + resolveUrl: () async { + final sources = await messagesModule.getVideoSources( + messageId: item.messageId, + chatId: item.chatId, + token: att.videoToken ?? '', + videoId: att.videoId ?? 0, + ); + return sources.values.isEmpty ? null : sources.values.first; + }, + saveName: 'VID_$now.mp4', + kind: SaveMediaKind.video, + ); + if (context.mounted) _notifySave(context, result); + return; + } + + if (att is FileAttachment) { + final fileId = att.fileId; + if (fileId == null) return; + final name = att.name ?? 'file_$now'; + final result = await saveMediaFile( + cacheName: '${fileId}_$name', + resolveUrl: () => messagesModule.getFileUrl( + messageId: item.messageId, + chatId: item.chatId, + fileId: fileId, + ), + saveName: name, + kind: SaveMediaKind.file, + ); + if (context.mounted) _notifySave(context, result); + return; + } + + if (att is AudioAttachment) { + final url = att.fileUrl ?? att.baseUrl ?? ''; + if (url.isEmpty) return; + final result = await saveMediaFile( + cacheName: '${att.audioId ?? item.messageId}.ogg', + resolveUrl: () async => url, + saveName: 'AUD_$now.ogg', + kind: SaveMediaKind.file, + ); + if (context.mounted) _notifySave(context, result); + } +} + +class CommonChatsTab extends StatefulWidget { + final int userId; + final String emptyLabel; + + const CommonChatsTab({ + super.key, + required this.userId, + required this.emptyLabel, + }); + + @override + State createState() => _CommonChatsTabState(); +} + +class _CommonChatsTabState extends State { + bool _loading = true; + List _chats = const []; + Map _onlineByChat = const {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final chats = await sharedContentModule.fetchCommonChats(widget.userId); + + final allIds = {}; + for (final c in chats) { + allIds.addAll(c.participantIds); + } + + final onlineByChat = {}; + if (allIds.isNotEmpty) { + try { + final presence = await PresenceFetch.getMany(allIds.toList()); + for (final c in chats) { + var online = 0; + for (final id in c.participantIds) { + if ((presence[id]?['status'] as int?) == 1) online++; + } + onlineByChat[c.id] = online; + } + } catch (e) { + logger.w('CommonChatsTab presence failed: $e'); + } + } + + if (!mounted) return; + setState(() { + _chats = chats; + _onlineByChat = onlineByChat; + _loading = false; + }); + } + + void _openChat(CommonChatEntry chat) { + final type = chat.type == 'CHANNEL' ? 'CHANNEL' : 'CHAT'; + pushSwipeable( + context, + (_) => ChatScreen( + chatId: chat.id, + name: chat.title, + imageUrl: chat.iconUrl ?? '', + chatType: type, + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (_loading) return _loadingState(cs); + if (_chats.isEmpty) { + return _emptyState(cs, widget.emptyLabel, Icons.group); + } + + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + for (int i = 0; i < _chats.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 68, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + _tile(cs, _chats[i]), + ], + ], + ), + ); + } + + Widget _tile(ColorScheme cs, CommonChatEntry chat) { + final l10n = AppLocalizations.of(context)!; + final online = _onlineByChat[chat.id] ?? 0; + final total = chat.participantsCount; + final subtitle = online > 0 + ? l10n.chatInfoOnlineOfTotal('$online', '$total') + : l10n.sharedMembersCount(total); + + return InkWell( + onTap: () => _openChat(chat), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + KometAvatar( + name: chat.title, + imageUrl: chat.iconUrl, + size: 46, + fontSize: 18, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + chat.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class SharedMediaTab extends StatefulWidget { + final int chatId; + final String anchorMessageId; + final int myId; + final SharedContentKind kind; + final String emptyLabel; + final IconData emptyIcon; + final void Function(String messageId, int time) onGoToMessage; + final ScrollController? scrollController; + + const SharedMediaTab({ + super.key, + required this.chatId, + required this.anchorMessageId, + required this.myId, + required this.kind, + required this.emptyLabel, + required this.emptyIcon, + required this.onGoToMessage, + this.scrollController, + }); + + @override + State createState() => _SharedMediaTabState(); +} + +class _SharedMediaTabState extends State { + static const int _pageSize = 60; + + bool _loading = true; + bool _loadingMore = false; + bool _canLoadMore = false; + int _total = 0; + final List _items = []; + final Set _seen = {}; + + @override + void initState() { + super.initState(); + widget.scrollController?.addListener(_onScroll); + _load(widget.anchorMessageId, initial: true); + } + + @override + void dispose() { + widget.scrollController?.removeListener(_onScroll); + super.dispose(); + } + + void _onScroll() { + if (!_hasMore || _loadingMore || _loading) return; + final controller = widget.scrollController; + if (controller == null || !controller.hasClients) return; + final position = controller.position; + if (position.pixels >= position.maxScrollExtent - 800) { + _loadMore(); + } + } + + Future _load(String anchor, {required bool initial}) async { + final page = await sharedContentModule.fetchMedia( + chatId: widget.chatId, + anchorMessageId: anchor, + attachTypes: widget.kind.attachTypes, + forward: initial ? _pageSize : 0, + backward: _pageSize, + ); + if (!mounted) return; + + var added = 0; + for (final item in page.items) { + if (_seen.add(item.dedupKey)) { + _items.add(item); + added++; + } + } + _items.sort((a, b) => b.time.compareTo(a.time)); + _total = page.total > _total ? page.total : _total; + + setState(() { + _canLoadMore = added > 0 && _items.length < _total; + _loading = false; + _loadingMore = false; + }); + + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoLoad()); + } + + void _maybeAutoLoad() { + if (!mounted || !_hasMore || _loadingMore) return; + final controller = widget.scrollController; + if (controller == null || !controller.hasClients) return; + final position = controller.position; + if (position.maxScrollExtent - position.pixels <= 800) { + _loadMore(); + } + } + + Future _loadMore() async { + if (_loadingMore || _loading || _items.isEmpty) return; + setState(() => _loadingMore = true); + await _load(_items.last.messageId, initial: false); + } + + bool get _hasMore => _canLoadMore; + + String _resolveName(int senderId) { + final l10n = AppLocalizations.of(context)!; + if (senderId == widget.myId) return l10n.callParticipantYou; + return ContactCache.get(senderId) ?? '#$senderId'; + } + + void _goTo(SharedMediaItem item) => + widget.onGoToMessage(item.messageId, item.time); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (_loading) return _loadingState(cs); + if (_items.isEmpty) { + return _emptyState(cs, widget.emptyLabel, widget.emptyIcon); + } + + final l10n = AppLocalizations.of(context)!; + final locale = l10n.localeName; + final groups = _groupByMonth(_items); + final children = []; + + for (final group in groups) { + children.add(_sectionHeader(cs, _monthHeader(locale, group.month))); + switch (widget.kind) { + case SharedContentKind.media: + children.add(_mediaGrid(cs, group.items)); + case SharedContentKind.files: + children.addAll( + group.items.map((i) => _FileRow(item: i, onGoTo: () => _goTo(i))), + ); + case SharedContentKind.voice: + children.addAll( + group.items.map( + (i) => _ProfileVoiceTile( + item: i, + senderName: _resolveName(i.senderId), + onGoTo: () => _goTo(i), + ), + ), + ); + case SharedContentKind.links: + children.addAll( + group.items.map((i) => _LinkRow(item: i, onGoTo: () => _goTo(i))), + ); + } + } + + if (_hasMore) { + children.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: _loadingMore + ? SmallSpinner(size: 22, color: cs.primary) + : TextButton( + onPressed: _loadMore, + child: Text(l10n.sharedLoadMore), + ), + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ); + } + + Widget _mediaGrid(ColorScheme cs, List items) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 3, + crossAxisSpacing: 3, + ), + itemCount: items.length, + itemBuilder: (context, index) => + _MediaTile( + item: items[index], + onGoTo: () => _goTo(items[index]), + onGoToMessage: widget.onGoToMessage, + ), + ); + } +} + +class _MediaTile extends StatelessWidget { + final SharedMediaItem item; + final VoidCallback onGoTo; + final void Function(String messageId, int time) onGoToMessage; + + const _MediaTile({ + required this.item, + required this.onGoTo, + required this.onGoToMessage, + }); + + void _menu(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + _showItemMenu(context, [ + _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { + onGoTo(); + }), + _MenuAction(Symbols.download, l10n.sharedDownload, () async { + await _downloadAttachment(context, item); + }), + ]); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final att = item.attachment; + final video = att is VideoAttachment ? att : null; + final duration = video?.duration ?? 0; + final thumb = att.baseUrl?.isNotEmpty == true + ? att.baseUrl + : att.previewData; + + return GestureDetector( + onTap: () => _open(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + Container(color: cs.surfaceContainerHighest), + if (thumb != null && thumb.isNotEmpty) + CachedNetworkImage( + imageUrl: thumb, + fit: BoxFit.cover, + memCacheWidth: 300, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => Icon( + video != null ? Symbols.movie : Symbols.image, + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + ), + ), + if (video != null) ...[ + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.center, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black54], + ), + ), + ), + const Center( + child: Icon(Symbols.play_arrow, color: Colors.white, size: 34), + ), + if (duration > 0) + Positioned( + left: 6, + bottom: 6, + child: Text( + formatSecondsMmSs((duration / 1000).round()), + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + Positioned( + top: 4, + right: 4, + child: _moreButton(cs, () => _menu(context), overlay: true), + ), + ], + ), + ), + ); + } + + Future _open(BuildContext context) async { + final att = item.attachment; + if (att is VideoAttachment) { + final sources = await messagesModule.getVideoSources( + messageId: item.messageId, + chatId: item.chatId, + token: att.videoToken ?? '', + videoId: att.videoId ?? 0, + ); + if (!context.mounted) return; + if (sources.isEmpty) { + showCustomNotification(context, 'Не удалось загрузить видео'); + return; + } + pushSwipeable(context, (_) => VideoPlayerScreen(sources: sources)); + return; + } + final url = att.baseUrl ?? att.previewData ?? ''; + if (url.isEmpty) return; + + 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, + time: item.time, + ), + actions: PhotoViewerActions(goToMessage: onGoToMessage), + ), + ); + } +} + +class _FileRow extends StatelessWidget { + final SharedMediaItem item; + final VoidCallback onGoTo; + + const _FileRow({required this.item, required this.onGoTo}); + + void _menu(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + _showItemMenu(context, [ + _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { + onGoTo(); + }), + _MenuAction(Symbols.download, l10n.sharedDownload, () async { + await _downloadAttachment(context, item); + }), + ]); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final att = item.attachment as FileAttachment; + final fullName = att.name ?? 'file'; + final dot = fullName.lastIndexOf('.'); + final ext = dot > 0 && dot < fullName.length - 1 + ? fullName.substring(dot + 1).toUpperCase() + : ''; + final displayName = dot > 0 ? fullName.substring(0, dot) : fullName; + final size = att.size ?? 0; + final cacheName = '${att.fileId}_$fullName'; + + return InkWell( + onTap: () => _open(context, cacheName), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + _badge(cs, ext, cacheName), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + ext.isEmpty + ? formatBytes(size) + : '$ext • ${formatBytes(size)}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + _moreButton(cs, () => _menu(context)), + ], + ), + ), + ); + } + + Widget _badge(ColorScheme cs, String ext, String cacheName) { + return SizedBox( + width: 46, + height: 46, + child: ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: progress != null + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.2, + value: progress > 0 ? progress : null, + color: cs.primary, + ), + ) + : Stack( + alignment: Alignment.center, + children: [ + Icon( + Symbols.download, + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + size: 26, + ), + if (ext.isNotEmpty) + Positioned( + bottom: 4, + child: Text( + ext, + style: TextStyle( + color: cs.onSurface, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + + Future _open(BuildContext context, String cacheName) async { + final att = item.attachment as FileAttachment; + final fileId = att.fileId; + if (fileId == null) return; + if (MediaDownloadProgress.notifier(cacheName).value != null) return; + + MediaDownloadProgress.set(cacheName, 0); + final result = await openCachedFile( + cacheName, + () => messagesModule.getFileUrl( + messageId: item.messageId, + chatId: item.chatId, + fileId: fileId, + ), + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + ); + MediaDownloadProgress.set(cacheName, null); + + if (!context.mounted) return; + if (!result.ok) { + showCustomNotification(context, 'Не удалось открыть файл'); + } + } +} + +class _LinkRow extends StatelessWidget { + final SharedMediaItem item; + final VoidCallback onGoTo; + + const _LinkRow({required this.item, required this.onGoTo}); + + void _menu(BuildContext context, String url) { + final l10n = AppLocalizations.of(context)!; + _showItemMenu(context, [ + _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { + onGoTo(); + }), + if (url.isNotEmpty) + _MenuAction(Symbols.content_copy, l10n.sharedCopyLink, () async { + await Clipboard.setData(ClipboardData(text: url)); + if (context.mounted) { + showCustomNotification(context, l10n.sharedLinkCopied); + } + }), + ]); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final att = item.attachment as ShareAttachment; + final url = att.url ?? ''; + final host = + att.host ?? (url.isNotEmpty ? Uri.tryParse(url)?.host ?? '' : ''); + final title = att.title ?? url; + final image = att.image; + final thumb = image?.baseUrl?.isNotEmpty == true + ? image!.baseUrl + : image?.previewData; + + return InkWell( + onTap: url.isEmpty ? null : () => openExternalUrl(context, url), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 46, + height: 46, + color: cs.surfaceContainerHighest, + child: (thumb != null && thumb.isNotEmpty) + ? CachedNetworkImage( + imageUrl: thumb, + fit: BoxFit.cover, + memCacheWidth: 120, + errorWidget: (_, _, _) => Icon( + Symbols.link, + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + ), + ) + : Icon( + Symbols.link, + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (host.isNotEmpty) + Text( + host, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 11, + ), + ), + if (title.isNotEmpty) + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (att.description != null && + att.description!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + att.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.25, + ), + ), + ], + if (url.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.primary, fontSize: 13), + ), + ], + ], + ), + ), + _moreButton(cs, () => _menu(context, url)), + ], + ), + ), + ); + } +} + +class _ProfileVoiceTile extends StatefulWidget { + final SharedMediaItem item; + final String senderName; + final VoidCallback onGoTo; + + const _ProfileVoiceTile({ + required this.item, + required this.senderName, + required this.onGoTo, + }); + + @override + State<_ProfileVoiceTile> createState() => _ProfileVoiceTileState(); +} + +class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { + OggOpusPlayer? _player; + bool _isPlaying = false; + bool _loadingAudio = false; + Timer? _ticker; + final ValueNotifier _progress = ValueNotifier(0.0); + + AudioAttachment get _audio => widget.item.attachment as AudioAttachment; + int get _durationSec => ((_audio.duration ?? 0) / 1000).round(); + + @override + void dispose() { + _ticker?.cancel(); + _player?.state.removeListener(_onPlayerState); + _player?.dispose(); + _progress.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() { + 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; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final date = DateTime.fromMillisecondsSinceEpoch(widget.item.time); + final subtitle = + '${formatSecondsMmSs(_durationSec)} • ${formatDateTimeWords(date)}'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Row( + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: _loadingAudio + ? const Padding( + padding: EdgeInsets.all(13), + child: SmallSpinner(size: 36, 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, + color: cs.onPrimary, + size: 24, + fill: 1, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.senderName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + _moreButton(cs, () => _menu(context)), + ], + ), + ); + } + + void _menu(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + _showItemMenu(context, [ + _MenuAction(Symbols.arrow_forward, l10n.sharedGoToMessage, () async { + widget.onGoTo(); + }), + _MenuAction(Symbols.download, l10n.sharedDownload, () async { + await _downloadAttachment(context, widget.item); + }), + ]); + } +} diff --git a/lib/frontend/widgets/chat_wallpaper_sheet.dart b/lib/frontend/widgets/chat_wallpaper_sheet.dart index 1131ed0..aa69820 100644 --- a/lib/frontend/widgets/chat_wallpaper_sheet.dart +++ b/lib/frontend/widgets/chat_wallpaper_sheet.dart @@ -3,7 +3,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/chat_wallpaper_themes.dart'; import 'package:komet/core/storage/chat_wallpaper_store.dart'; -import 'package:komet/frontend/widgets/sheet_helpers.dart'; enum WallpaperPickType { none, theme, gallery } @@ -24,105 +23,241 @@ Future showChatWallpaperSheet( BuildContext context, { required ChatWallpaper? current, }) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - barrierColor: Colors.black.withValues(alpha: 0.45), - builder: (_) => _ChatWallpaperSheet(current: current), + return Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => ChatWallpaperGalleryScreen(current: current), + ), ); } -class _ChatWallpaperSheet extends StatelessWidget { +class ChatWallpaperGalleryScreen extends StatefulWidget { final ChatWallpaper? current; - const _ChatWallpaperSheet({required this.current}); + const ChatWallpaperGalleryScreen({super.key, required this.current}); - bool get _isNoneSelected => current == null; + @override + State createState() => + _ChatWallpaperGalleryScreenState(); +} + +class _ChatWallpaperGalleryScreenState + extends State { + ChatWallpaperTheme? _selected; + bool _isImage = false; + + @override + void initState() { + super.initState(); + final current = widget.current; + _isImage = current?.isImage ?? false; + _selected = current == null || current.isImage + ? null + : chatWallpaperThemeById(current.themeId); + } + + bool get _changed { + if (_isImage) return _selected != null; + return _selected?.id != chatWallpaperThemeById(widget.current?.themeId)?.id; + } + + void _apply() { + if (_selected == null) { + Navigator.pop(context, const WallpaperPick.none()); + } else { + Navigator.pop(context, WallpaperPick.theme(_selected)); + } + } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return SafeArea( - top: false, - child: Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Symbols.arrow_back), + onPressed: () => Navigator.pop(context), ), - child: Column( - mainAxisSize: MainAxisSize.min, + title: const Text( + 'Обои', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + body: Column( + children: [ + Expanded(child: _preview(cs)), + _panel(cs), + ], + ), + ); + } + + Widget _preview(ColorScheme cs) { + final theme = _selected; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(28), + child: Stack( + fit: StackFit.expand, children: [ - const SheetGrabber(), - _header(context, cs), - const SizedBox(height: 12), - _themeRow(context, cs), - const SizedBox(height: 20), - _galleryButton(context, cs), - const SizedBox(height: 12), + if (theme != null) + theme.buildBackground() + else + ColoredBox(color: cs.surfaceContainerHighest), + const IgnorePointer(child: _PreviewScrim()), + _SampleBubbles(theme: theme), ], ), ), ); } - Widget _header(BuildContext context, ColorScheme cs) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - IconButton( - icon: Icon(Symbols.close, color: cs.onSurface), - onPressed: () => Navigator.pop(context), + Widget _panel(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 14), + SizedBox( + height: 150, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + _NoneTile( + selected: _selected == null && !_isImage, + onTap: () => setState(() { + _selected = null; + _isImage = false; + }), + ), + for (final theme in kChatWallpaperThemes) + _ThemeTile( + theme: theme, + selected: _selected?.id == theme.id, + onTap: () => setState(() { + _selected = theme; + _isImage = false; + }), + ), + ], + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row( + children: [ + Expanded( + child: _GalleryButton( + onTap: () => + Navigator.pop(context, const WallpaperPick.gallery()), + ), + ), + const SizedBox(width: 12), + Expanded(child: _ApplyButton(enabled: _changed, onTap: _apply)), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _PreviewScrim extends StatelessWidget { + const _PreviewScrim(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x14000000), Color(0x00000000), Color(0x1F000000)], + stops: [0.0, 0.5, 1.0], + ), + ), + child: SizedBox.expand(), + ); + } +} + +class _SampleBubbles extends StatelessWidget { + final ChatWallpaperTheme? theme; + + const _SampleBubbles({required this.theme}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _bubble( + text: 'Как насчёт новых обоев для этого чата?', + color: cs.surfaceContainerHighest.withValues(alpha: 0.94), + textColor: cs.onSurface, + alignment: Alignment.centerLeft, + ), + const SizedBox(height: 8), + _bubble( + text: 'Выглядит отлично 🔥', + color: cs.primary, + textColor: cs.onPrimary, + alignment: Alignment.centerRight, + ), + ], + ), + ), + ); + } + + Widget _bubble({ + required String text, + required Color color, + required Color textColor, + required Alignment alignment, + }) { + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(18), ), - const SizedBox(width: 4), - Text( - 'Выбрать тему', + child: Text( + text, style: TextStyle( - color: cs.onSurface, - fontSize: 22, - fontWeight: FontWeight.w700, + color: textColor, + fontSize: 15, fontFamily: 'Outfit', ), ), - ], - ), - ); - } - - Widget _themeRow(BuildContext context, ColorScheme cs) { - return SizedBox( - height: 172, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - _NoneTile( - selected: _isNoneSelected, - onTap: () => Navigator.pop(context, const WallpaperPick.none()), - ), - for (final theme in kChatWallpaperThemes) - _ThemeTile( - theme: theme, - selected: current?.themeId == theme.id, - onTap: () => - Navigator.pop(context, WallpaperPick.theme(theme)), - ), - ], - ), - ); - } - - Widget _galleryButton(BuildContext context, ColorScheme cs) { - return TextButton( - onPressed: () => Navigator.pop(context, const WallpaperPick.gallery()), - child: Text( - 'Выбрать обои из галереи', - style: TextStyle( - color: cs.primary, - fontSize: 17, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', ), ), ); @@ -133,11 +268,13 @@ class _TileFrame extends StatelessWidget { final bool selected; final VoidCallback onTap; final Widget child; + final String label; const _TileFrame({ required this.selected, required this.onTap, required this.child, + required this.label, }); @override @@ -147,20 +284,62 @@ class _TileFrame extends StatelessWidget { padding: const EdgeInsets.only(right: 12), child: GestureDetector( onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - width: 112, - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: selected ? cs.primary : Colors.transparent, - width: 2.5, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(15), - child: child, + child: SizedBox( + width: 96, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 160), + height: 116, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? cs.primary : Colors.transparent, + width: 2.5, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: Stack( + fit: StackFit.expand, + children: [ + child, + if (selected) + Align( + alignment: Alignment.bottomRight, + child: Container( + margin: const EdgeInsets.all(6), + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: Icon( + Symbols.check, + size: 16, + color: cs.onPrimary, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? cs.primary : cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], ), ), ), @@ -180,25 +359,11 @@ class _NoneTile extends StatelessWidget { return _TileFrame( selected: selected, onTap: onTap, + label: 'Без обоев', child: ColoredBox( color: cs.surfaceContainerHighest, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Без\nтемы', - textAlign: TextAlign.center, - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - height: 1.1, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - const SizedBox(height: 14), - const Icon(Symbols.close, color: Color(0xFFFF3B30), size: 40), - ], + child: const Center( + child: Icon(Symbols.block, color: Color(0xFFFF3B30), size: 34), ), ), ); @@ -221,7 +386,82 @@ class _ThemeTile extends StatelessWidget { return _TileFrame( selected: selected, onTap: onTap, + label: theme.name, child: theme.buildPreview(), ); } } + +class _GalleryButton extends StatelessWidget { + final VoidCallback onTap; + + const _GalleryButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + height: 52, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Symbols.image, color: cs.onSurface, size: 22), + const SizedBox(width: 8), + Text( + 'Из галереи', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + ), + ); + } +} + +class _ApplyButton extends StatelessWidget { + final bool enabled; + final VoidCallback onTap; + + const _ApplyButton({required this.enabled, required this.onTap}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onTap: enabled ? onTap : null, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 140), + opacity: enabled ? 1 : 0.4, + child: Container( + height: 52, + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + 'Применить', + style: TextStyle( + color: cs.onPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + ), + ), + ); + } +} 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/emoji_panel.dart b/lib/frontend/widgets/emoji_panel.dart new file mode 100644 index 0000000..5f0ac7a --- /dev/null +++ b/lib/frontend/widgets/emoji_panel.dart @@ -0,0 +1,332 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../main.dart' show animojiModule; +import '../../models/animoji.dart'; +import 'lottie_image.dart'; +import 'small_spinner.dart'; + +class _DragScrollBehavior extends MaterialScrollBehavior { + const _DragScrollBehavior(); + + @override + Set get dragDevices => const { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + }; +} + +class _EmojiSection { + final String title; + final IconData icon; + final List items; + + const _EmojiSection({ + required this.title, + required this.icon, + required this.items, + }); +} + +class EmojiPanel extends StatefulWidget { + final void Function(Animoji animoji) onEmojiTap; + + const EmojiPanel({super.key, required this.onEmojiTap}); + + @override + State createState() => _EmojiPanelState(); +} + +class _EmojiPanelState extends State { + static const double _tabBarHeight = 46; + static const double _headerHeight = 30; + + final ScrollController _scroll = ScrollController(); + final ValueNotifier _scrolling = ValueNotifier(false); + bool _loading = true; + Object? _error; + int _selectedTab = 0; + List<_EmojiSection> _sections = const []; + List _heights = const []; + List _offsets = const []; + + @override + void initState() { + super.initState(); + _scroll.addListener(_onScroll); + _load(); + } + + @override + void dispose() { + _scroll.removeListener(_onScroll); + _scroll.dispose(); + _scrolling.dispose(); + super.dispose(); + } + + Future _load() async { + try { + await animojiModule.ensureRecentsLoaded(); + await animojiModule.ensureLoaded(); + if (!mounted) return; + _buildSections(); + setState(() => _loading = false); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e; + }); + } + } + + void _buildSections() { + final sections = <_EmojiSection>[]; + final recent = animojiModule.recentAnimojis; + if (recent.isNotEmpty) { + sections.add( + _EmojiSection( + title: 'Недавние', + icon: Symbols.schedule, + items: recent, + ), + ); + } + final all = animojiModule.animojis; + if (all.isNotEmpty) { + sections.add( + _EmojiSection( + title: 'Animated', + icon: Symbols.animation, + items: all, + ), + ); + } + _sections = sections; + } + + void _onScroll() { + if (_offsets.isEmpty) return; + final pixels = _scroll.position.pixels; + var index = 0; + for (var i = 0; i < _offsets.length; i++) { + if (pixels + 1 >= _offsets[i]) index = i; + } + if (index != _selectedTab) setState(() => _selectedTab = index); + } + + bool _onScrollNotification(ScrollNotification n) { + if (n is ScrollStartNotification || n is ScrollUpdateNotification) { + if (!_scrolling.value) _scrolling.value = true; + } else if (n is ScrollEndNotification) { + if (_scrolling.value) _scrolling.value = false; + } + return false; + } + + void _jumpTo(int index) { + if (!mounted || index >= _offsets.length || !_scroll.hasClients) return; + setState(() => _selectedTab = index); + final max = _scroll.position.maxScrollExtent; + _scroll.animateTo( + _offsets[index].clamp(0.0, max), + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + if (_loading) return const Center(child: SmallSpinner()); + if (_error != null || _sections.isEmpty) { + return Center( + child: Text( + _error != null ? 'Не удалось загрузить эмодзи' : 'Нет эмодзи', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ); + } + + return ScrollConfiguration( + behavior: const _DragScrollBehavior(), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = (width / 44).floor().clamp(6, 10); + final cell = width / columns; + + final heights = []; + final offsets = []; + var acc = 0.0; + for (final s in _sections) { + final rows = (s.items.length / columns).ceil(); + final h = _headerHeight + rows * cell; + offsets.add(acc); + heights.add(h); + acc += h; + } + _heights = heights; + _offsets = offsets; + + return Column( + children: [ + _buildTabBar(cs), + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + Expanded(child: _buildContent(columns, cell)), + ], + ); + }, + ), + ); + } + + Widget _buildTabBar(ColorScheme cs) { + return SizedBox( + height: _tabBarHeight, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6), + itemCount: _sections.length, + itemBuilder: (context, i) { + final s = _sections[i]; + final selected = i == _selectedTab; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _jumpTo(i), + child: Container( + width: 40, + height: 40, + margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 3), + decoration: BoxDecoration( + color: selected + ? cs.surfaceContainerHighest + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + s.icon, + size: 22, + color: selected ? cs.primary : cs.onSurfaceVariant, + ), + ), + ); + }, + ), + ); + } + + Widget _buildContent(int columns, double cell) { + return LottieScrollScope( + isScrolling: _scrolling, + child: NotificationListener( + onNotification: _onScrollNotification, + child: CustomScrollView( + controller: _scroll, + slivers: [ + SliverVariedExtentList( + itemExtentBuilder: (i, _) => _heights[i], + delegate: SliverChildBuilderDelegate( + (context, i) => _EmojiSectionView( + key: ValueKey(_sections[i].title + i.toString()), + section: _sections[i], + columns: columns, + cell: cell, + headerHeight: _headerHeight, + onTap: widget.onEmojiTap, + ), + childCount: _sections.length, + ), + ), + ], + ), + ), + ); + } +} + +class _EmojiSectionView extends StatelessWidget { + final _EmojiSection section; + final int columns; + final double cell; + final double headerHeight; + final void Function(Animoji animoji) onTap; + + const _EmojiSectionView({ + super.key, + required this.section, + required this.columns, + required this.cell, + required this.headerHeight, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final items = section.items; + final rows = (items.length / columns).ceil(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: headerHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + section.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + for (var r = 0; r < rows; r++) + Row( + children: [ + for (var c = 0; c < columns; c++) + SizedBox( + width: cell, + height: cell, + child: r * columns + c < items.length + ? _cell(items[r * columns + c]) + : null, + ), + ], + ), + ], + ); + } + + Widget _cell(Animoji animoji) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(animoji), + child: Padding( + padding: const EdgeInsets.all(5), + child: LottieImage( + url: animoji.iconUrl, + lottieUrl: animoji.lottieUrl, + memCacheWidth: 120, + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/formatted_message_text.dart b/lib/frontend/widgets/formatted_message_text.dart index 849f6ef..ec22c2a 100644 --- a/lib/frontend/widgets/formatted_message_text.dart +++ b/lib/frontend/widgets/formatted_message_text.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../core/utils/link_opener.dart'; import '../../core/utils/text_format.dart'; import 'link_text.dart'; +import 'lottie_image.dart'; class FormattedMessageText extends StatefulWidget { final String text; @@ -122,6 +123,36 @@ class _FormattedMessageTextState extends State { quoteColor: quoteColor, ); final content = widget.text.substring(segment.start, segment.end); + if (segment.animojiUrl != null) { + final fontSize = widget.style.fontSize ?? 16; + final box = fontSize * 1.5; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox( + width: box, + height: box, + child: Stack( + alignment: Alignment.center, + children: [ + Text( + content, + style: widget.style.copyWith(fontSize: fontSize * 1.15), + ), + LottieImage( + lottieUrl: segment.animojiUrl, + size: box, + memCacheWidth: 120, + shimmer: false, + eager: true, + ), + ], + ), + ), + ), + ); + continue; + } if (segment.url != null) { final url = segment.url!; final recognizer = TapGestureRecognizer() diff --git a/lib/frontend/widgets/glossy_pill.dart b/lib/frontend/widgets/glossy_pill.dart index 6a1b513..b3f5a8d 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,9 @@ class GlossyPill extends StatelessWidget { final double depth; final bool elevated; final BorderSide? borderSide; + final double? blurSigma; + final bool liquid; + final BackdropKey? backdropKey; const GlossyPill({ super.key, @@ -102,15 +108,22 @@ class GlossyPill extends StatelessWidget { this.depth = 10, this.elevated = false, this.borderSide, + this.blurSigma, + this.liquid = false, + this.backdropKey, }) : borderRadius = borderRadius ?? const BorderRadius.all(Radius.circular(100)); + 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 +132,41 @@ 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: GlossyDecor.rimBorder(base), + boxShadow: [GlossyDecor.dropShadow(base, depth)], + ), + child: LiquidGlassSurface( + borderRadius: borderRadius, + tint: Colors.transparent, + child: onTap == null && onLongPress == null + ? 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), @@ -137,12 +180,23 @@ class GlossyPill extends StatelessWidget { ? 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( @@ -158,6 +212,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( 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 new file mode 100644 index 0000000..5192010 --- /dev/null +++ b/lib/frontend/widgets/lottie_image.dart @@ -0,0 +1,450 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:lottie/lottie.dart'; + +import '../../core/media/rlottie/rlottie.dart'; + +class LottieLoadGovernor { + LottieLoadGovernor._() { + _budgetMs = _resolveBudgetMs(); + _avgMs = _budgetMs; + SchedulerBinding.instance.addTimingsCallback(_onTimings); + } + + static final LottieLoadGovernor instance = LottieLoadGovernor._(); + + final ValueNotifier throttled = ValueNotifier(false); + double _budgetMs = 1000 / 60; + double _avgMs = 1000 / 60; + + static double _resolveBudgetMs() { + final displays = ui.PlatformDispatcher.instance.displays; + var hz = displays.isEmpty ? 60.0 : displays.first.refreshRate; + if (!hz.isFinite || hz < 30) hz = 60; + return 1000 / hz; + } + + void _onTimings(List timings) { + for (final t in timings) { + final build = t.buildDuration.inMicroseconds; + final raster = t.rasterDuration.inMicroseconds; + final ms = (build > raster ? build : raster) / 1000.0; + _avgMs = _avgMs * 0.6 + ms * 0.4; + } + final enterMs = _budgetMs * 1.5; + final exitMs = _budgetMs * 0.8; + if (!throttled.value && _avgMs > enterMs) { + throttled.value = true; + } else if (throttled.value && _avgMs < exitMs) { + throttled.value = false; + } + } +} + +class LottieScrollScope extends InheritedWidget { + final ValueListenable isScrolling; + + const LottieScrollScope({ + super.key, + required this.isScrolling, + required super.child, + }); + + static ValueListenable? of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.isScrolling; + + @override + bool updateShouldNotify(LottieScrollScope oldWidget) => + !identical(oldWidget.isScrolling, isScrolling); +} + +class LottieHoldScope extends InheritedWidget { + final ValueListenable isHeld; + + const LottieHoldScope({ + super.key, + required this.isHeld, + required super.child, + }); + + static ValueListenable? of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.isHeld; + + @override + bool updateShouldNotify(LottieHoldScope oldWidget) => + !identical(oldWidget.isHeld, isHeld); +} + +class LottiePlayer extends StatefulWidget { + final String lottieUrl; + final String? fallbackUrl; + final double? size; + final int? memCacheWidth; + final bool shimmer; + final bool eager; + + const LottiePlayer({ + super.key, + required this.lottieUrl, + this.fallbackUrl, + this.size, + this.memCacheWidth, + this.shimmer = true, + this.eager = false, + }); + + @override + State createState() => _LottiePlayerState(); +} + +class _LottiePlayerState extends State + with SingleTickerProviderStateMixin { + static const int _leadFrames = 6; + static const double _slowSpeed = 0.5; + static const double _rampMs = 200.0; + + final ValueNotifier _frameIndex = ValueNotifier(0); + late final Ticker _ticker; + late final bool _native; + RlottieClip? _clip; + ValueListenable? _scrollState; + ValueListenable? _holdState; + int? _px; + bool _started = false; + bool _showedFrames = false; + Timer? _deferTimer; + + static const Duration _maxLoadDefer = Duration(milliseconds: 700); + + double _speed = 1.0; + double _targetSpeed = 1.0; + double _playheadMs = 0.0; + double? _lastElapsedMs; + + bool get _isScrolling => _scrollState?.value ?? false; + bool get _isHeld => _holdState?.value ?? false; + bool get _canLoad => + !_isScrolling && + !_isHeld && + (widget.eager || !LottieLoadGovernor.instance.throttled.value); + + @override + void initState() { + super.initState(); + _native = RlottieEngine.instance.available; + _ticker = createTicker(_onTick); + LottieLoadGovernor.instance.throttled.addListener(_onGateChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final state = LottieScrollScope.of(context); + if (!identical(state, _scrollState)) { + _scrollState?.removeListener(_onGateChanged); + _scrollState = state; + _scrollState?.addListener(_onGateChanged); + } + final hold = LottieHoldScope.of(context); + if (!identical(hold, _holdState)) { + _holdState?.removeListener(_onGateChanged); + _holdState = hold; + _holdState?.addListener(_onGateChanged); + } + } + + @override + void didUpdateWidget(LottiePlayer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.lottieUrl != widget.lottieUrl) { + _ticker.stop(); + _releaseClip(); + _deferTimer?.cancel(); + _deferTimer = null; + _started = false; + _showedFrames = false; + _playheadMs = 0.0; + _speed = 1.0; + _targetSpeed = _isScrolling ? _slowSpeed : 1.0; + _lastElapsedMs = null; + } + } + + @override + void dispose() { + _deferTimer?.cancel(); + LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged); + _scrollState?.removeListener(_onGateChanged); + _holdState?.removeListener(_onGateChanged); + _ticker.dispose(); + _releaseClip(); + _frameIndex.dispose(); + super.dispose(); + } + + void _releaseClip() { + final clip = _clip; + if (clip != null) { + clip.ready.removeListener(_onReady); + RlottieEngine.instance.release(clip); + _clip = null; + } + } + + void _onTick(Duration elapsed) { + final clip = _clip; + if (clip == null || clip.frameCount <= 1) return; + final periodMs = clip.durationMs; + if (periodMs <= 0) return; + + final nowMs = elapsed.inMicroseconds / 1000.0; + final last = _lastElapsedMs; + _lastElapsedMs = nowMs; + if (last == null) return; + var dt = nowMs - last; + if (dt < 0) dt = 0; + if (dt > 64) dt = 64; + + if (_speed != _targetSpeed) { + final step = dt / _rampMs * (1.0 - _slowSpeed); + final diff = _targetSpeed - _speed; + _speed = diff.abs() <= step ? _targetSpeed : _speed + step * diff.sign; + } + + _playheadMs = (_playheadMs + dt * _speed) % periodMs; + final t = _playheadMs / periodMs; + final index = + (t * (clip.frameCount - 1)).round().clamp(0, clip.frameCount - 1); + if (index != _frameIndex.value) _frameIndex.value = index; + } + + void _onGateChanged() { + if (!mounted) return; + _targetSpeed = _isScrolling ? _slowSpeed : 1.0; + final clip = _clip; + if (clip != null) { + _maybeStartTicker(clip); + } else if (_canLoad && !_started) { + _startLoad(); + } + } + + void _onReady() { + if (!mounted) return; + final clip = _clip; + if (clip != null) _maybeStartTicker(clip); + if (mounted) setState(() {}); + } + + void _maybeStartTicker(RlottieClip clip) { + if (clip.frameCount <= 1) return; + final lead = clip.frameCount < _leadFrames ? clip.frameCount : _leadFrames; + if (clip.ready.value >= lead && !_ticker.isActive) { + _lastElapsedMs = null; + _ticker.start(); + } + } + + void _ensure(double box) { + if (_clip != null) return; + final dpr = MediaQuery.devicePixelRatioOf(context); + final raw = (box * dpr.clamp(1.0, 2.0)).clamp(96.0, 384.0); + _px = (raw / 32).ceil() * 32; + if (_started) return; + if (_canLoad) { + _startLoad(); + } else if (!_isScrolling && !_isHeld) { + _deferTimer ??= Timer(_maxLoadDefer, _forceDeferredLoad); + } + } + + void _forceDeferredLoad() { + _deferTimer = null; + if (mounted && !_started && _clip == null && !_isScrolling && !_isHeld) { + _startLoad(); + } + } + + void _startLoad() { + final px = _px; + if (_started || px == null) return; + _deferTimer?.cancel(); + _deferTimer = null; + _started = true; + RlottieEngine.instance.acquire(widget.lottieUrl, px).then((clip) { + if (clip == null) return; + if (!mounted) { + RlottieEngine.instance.release(clip); + return; + } + clip.ready.addListener(_onReady); + setState(() => _clip = clip); + _maybeStartTicker(clip); + }); + } + + @override + Widget build(BuildContext context) { + if (!_native) return _nativeFallback(); + return LayoutBuilder( + builder: (context, constraints) { + final box = + widget.size ?? + (constraints.hasBoundedWidth + ? constraints.biggest.shortestSide + : 96.0); + _ensure(box); + final clip = _clip; + if (clip == null || + clip.ready.value == 0 || + (_isScrolling && !_showedFrames)) { + return _staticFallback(box); + } + _showedFrames = true; + return ValueListenableBuilder( + valueListenable: _frameIndex, + builder: (_, index, _) => RawImage( + image: clip.frameAt(index), + width: box, + height: box, + fit: BoxFit.contain, + ), + ); + }, + ); + } + + Widget _nativeFallback() { + return Lottie.network( + widget.lottieUrl, + width: widget.size, + height: widget.size, + fit: BoxFit.contain, + frameRate: FrameRate.max, + errorBuilder: (context, _, _) => _staticFallback(widget.size ?? 96.0), + ); + } + + Widget _staticFallback(double box) { + final url = widget.fallbackUrl ?? ''; + if (url.isEmpty) { + return widget.shimmer + ? LottieShimmer(size: box) + : SizedBox(width: box, height: box); + } + return CachedNetworkImage( + imageUrl: url, + width: box, + height: box, + fit: BoxFit.contain, + memCacheWidth: widget.memCacheWidth, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => LottieShimmer(size: box), + errorWidget: (_, _, _) => SizedBox(width: box, height: box), + ); + } +} + +class LottieImage extends StatelessWidget { + final String? url; + final String? lottieUrl; + final double? size; + final int? memCacheWidth; + final bool shimmer; + final bool eager; + + const LottieImage({ + super.key, + this.url, + this.lottieUrl, + this.size, + this.memCacheWidth, + this.shimmer = true, + this.eager = false, + }); + + @override + Widget build(BuildContext context) { + if (lottieUrl != null && lottieUrl!.isNotEmpty) { + return LottiePlayer( + lottieUrl: lottieUrl!, + fallbackUrl: url, + size: size, + memCacheWidth: memCacheWidth, + shimmer: shimmer, + eager: eager, + ); + } + return _static(); + } + + Widget _static() { + final src = url ?? ''; + if (src.isEmpty) return SizedBox(width: size, height: size); + return CachedNetworkImage( + imageUrl: src, + width: size, + height: size, + fit: BoxFit.contain, + memCacheWidth: memCacheWidth, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => LottieShimmer(size: size), + errorWidget: (_, _, _) => SizedBox(width: size, height: size), + ); + } +} + +class LottieShimmer extends StatefulWidget { + final double? size; + + const LottieShimmer({super.key, this.size}); + + @override + State createState() => _LottieShimmerState(); +} + +class _LottieShimmerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + )..repeat(reverse: true); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final base = Theme.of(context).colorScheme.onSurfaceVariant; + final box = widget.size; + final inset = box == null ? 2.0 : box * 0.06; + final radius = box == null ? 8.0 : (box * 0.2).clamp(6.0, 26.0); + return SizedBox( + width: box, + height: box, + child: Padding( + padding: EdgeInsets.all(inset), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) => DecoratedBox( + decoration: BoxDecoration( + color: base.withValues(alpha: 0.12 + 0.16 * _controller.value), + borderRadius: BorderRadius.circular(radius), + ), + child: const SizedBox.expand(), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart index 4fe7346..477cdc9 100644 --- a/lib/frontend/widgets/max_link_handler.dart +++ b/lib/frontend/widgets/max_link_handler.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../backend/modules/chats.dart'; @@ -6,7 +8,7 @@ import '../../core/links/max_link.dart'; import '../../core/storage/app_database.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'; @@ -75,13 +77,12 @@ void _openContact(BuildContext context, Map contact) { showCustomNotification(context, 'Не удалось открыть профиль'); return; } - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ContactProfileScreen( - contactId: id, - initialName: _contactName(contact), - initialAvatarUrl: contact['baseUrl'] as String?, - ), + unawaited( + openContactDialogProfile( + context, + contactId: id, + name: _contactName(contact), + avatarUrl: contact['baseUrl'] as String?, ), ); } diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 3e24a79..62609a6 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -7,10 +7,40 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../core/config/app_message_actions_style.dart'; +import '../../core/utils/emoji_keyword_index.dart'; 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, + }); +} + +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, + }); +} enum MessageActionsInteraction { dragAndRelease, click, tap } @@ -78,6 +108,8 @@ void showMessageActions({ 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, @@ -85,6 +117,19 @@ void showMessageActions({ VoidCallback? onReply, VoidCallback? onForward, VoidCallback? onMarkUnread, + VoidCallback? onPin, + bool isPinned = false, + void Function(String emoji)? onReact, + String? selectedReaction, + List quickReactions = const [ + ReactionEmoji(emoji: '👍'), + ReactionEmoji(emoji: '❤️'), + ReactionEmoji(emoji: '🔥'), + ReactionEmoji(emoji: '🤣'), + ReactionEmoji(emoji: '😭'), + ReactionEmoji(emoji: '😍'), + ], + Future> Function()? loadReactionEmojis, MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { @@ -101,6 +146,8 @@ void showMessageActions({ style: style, interaction: interaction, editHistory: editHistory, + loadReadBy: loadReadBy, + onReaderTap: onReaderTap, loadReportReasons: loadReportReasons, onReport: onReport, onDelete: onDelete, @@ -108,6 +155,12 @@ void showMessageActions({ onReply: onReply, onForward: onForward, onMarkUnread: onMarkUnread, + onPin: onPin, + isPinned: isPinned, + onReact: onReact, + selectedReaction: selectedReaction, + quickReactions: quickReactions, + loadReactionEmojis: loadReactionEmojis, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -128,6 +181,8 @@ class _MessageActionsLayer extends StatefulWidget { 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; @@ -135,6 +190,12 @@ class _MessageActionsLayer extends StatefulWidget { final VoidCallback? onReply; final VoidCallback? onForward; final VoidCallback? onMarkUnread; + final VoidCallback? onPin; + final bool isPinned; + final void Function(String emoji)? onReact; + final String? selectedReaction; + final List quickReactions; + final Future> Function()? loadReactionEmojis; const _MessageActionsLayer({ required this.snapshot, @@ -147,6 +208,8 @@ class _MessageActionsLayer extends StatefulWidget { required this.interaction, required this.onDismiss, this.editHistory, + this.loadReadBy, + this.onReaderTap, this.loadReportReasons, this.onReport, this.onDelete, @@ -154,6 +217,12 @@ class _MessageActionsLayer extends StatefulWidget { this.onReply, this.onForward, this.onMarkUnread, + this.onPin, + this.isPinned = false, + this.onReact, + this.selectedReaction, + this.quickReactions = const [], + this.loadReactionEmojis, }); @override @@ -164,6 +233,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> with SingleTickerProviderStateMixin { late final AnimationController _animController; late final Animation _animation; + late final AnimationController _expandController; + late final Animation _expandAnim; + bool _reactionsExpanded = false; + bool _reactionsPanelReady = false; + Widget? _pickerCache; bool _closing = false; static const double _radius = 92.0; static const double _arcSpan = math.pi * 0.62; @@ -184,13 +258,21 @@ 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() { super.initState(); + if (_reactionsEnabled) { + EmojiKeywordIndex.instance.ensureLoaded(); + } _animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 320), @@ -201,9 +283,34 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); + _expandController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 360), + reverseDuration: const Duration(milliseconds: 260), + ); + _expandAnim = CurvedAnimation( + parent: _expandController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _expandController.addStatusListener(_onExpandStatus); _animController.forward(); } + void _onExpandStatus(AnimationStatus status) { + if (!mounted) return; + if (status == AnimationStatus.completed) { + if (!_reactionsPanelReady) setState(() => _reactionsPanelReady = true); + } else if (status == AnimationStatus.dismissed) { + if (_reactionsPanelReady) setState(() => _reactionsPanelReady = false); + } + } + + bool get _reactionsEnabled => + widget.onReact != null && + widget.quickReactions.isNotEmpty && + widget.interaction != MessageActionsInteraction.click; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -377,6 +484,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> @override void dispose() { _animController.dispose(); + _expandController.dispose(); widget.controller.removeListener(_onControllerUpdate); widget.snapshot?.dispose(); super.dispose(); @@ -387,13 +495,19 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final hasText = widget.messageText != null && widget.messageText!.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 (hasText) _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.onMarkUnread != null) _Action( Symbols.mark_chat_unread, @@ -402,6 +516,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, @@ -409,7 +525,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _showReportView, destructive: true, ), - _Action(Symbols.delete, l10n.msgActionsDelete, _delete, destructive: true), + _Action( + Symbols.delete, + l10n.msgActionsDelete, + _delete, + destructive: true, + ), ]; } @@ -418,6 +539,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(() { @@ -451,6 +587,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> setState(() { _showHistory = false; _showReport = false; + _showReadBy = false; }); } @@ -503,7 +640,10 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (text != null && text.isNotEmpty) { await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; - showCustomNotification(context, AppLocalizations.of(context)!.msgActionsCopied); + showCustomNotification( + context, + AppLocalizations.of(context)!.msgActionsCopied, + ); } await _close(); } @@ -538,16 +678,24 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> onMarkUnread?.call(); } + Future _pin() async { + final onPin = widget.onPin; + await _close(); + onPin?.call(); + } + @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); final isClick = widget.interaction == MessageActionsInteraction.click; + final showReactions = _reactionsEnabled && !isClick; return AnimatedBuilder( - animation: _animation, + animation: Listenable.merge([_animation, _expandController]), builder: (ctx, _) { final t = _animation.value.clamp(0.0, 1.0); - final blurSigma = 14.0 * t; - final bubbleScale = 1.0 + 0.05 * t; + final e = showReactions ? _expandAnim.value.clamp(0.0, 1.0) : 0.0; + final bubbleScale = 1.0 + 0.02 * t; + final menuHidden = _panelOpen || _reactionsExpanded; return GestureDetector( onTap: _close, @@ -556,14 +704,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> children: [ if (!isClick) ...[ Positioned.fill( - child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: blurSigma, - sigmaY: blurSigma, - ), - child: ColoredBox( - color: Colors.black.withValues(alpha: 0.22 * t), - ), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.22 * t + 0.28 * e), ), ), if (widget.snapshot != null) @@ -572,22 +714,25 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> top: widget.originRect.top, width: widget.originRect.width, height: widget.originRect.height, - child: Transform.scale( - scale: bubbleScale, - child: RawImage( - image: widget.snapshot, - width: widget.originRect.width, - height: widget.originRect.height, - fit: BoxFit.fill, + child: Opacity( + opacity: 1.0 - 0.35 * e, + child: Transform.scale( + scale: bubbleScale, + child: RawImage( + image: widget.snapshot, + width: widget.originRect.width, + height: widget.originRect.height, + fit: BoxFit.fill, + ), ), ), ), ], Positioned.fill( child: IgnorePointer( - ignoring: _showHistory || _showReport, + ignoring: menuHidden, child: AnimatedOpacity( - opacity: (_showHistory || _showReport) ? 0.0 : 1.0, + opacity: menuHidden ? 0.0 : 1.0, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, child: Stack( @@ -604,9 +749,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( @@ -614,12 +759,26 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (_showReport) _buildReportMenu() else if (_showHistory) - _buildHistoryMenu(), + _buildHistoryMenu() + else if (_showReadBy) + _buildReadByMenu(), ], ), ), ), ), + if (showReactions) + 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), + ), + ), + ), ], ), ); @@ -627,10 +786,328 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } - Widget _buildAnchoredPanel({required String title, required Widget body}) { + void _toggleReactionsExpanded() { + Haptics.tap(); + setState(() => _reactionsExpanded = !_reactionsExpanded); + if (_reactionsExpanded) { + _expandController.forward(); + } else { + _expandController.reverse(); + } + } + + Future _onReactionPicked(String emoji) async { + final cb = widget.onReact; + Haptics.medium(); + await _close(); + cb?.call(emoji); + } + + bool _isSelectedReaction(String emoji) { + final sel = widget.selectedReaction; + if (sel == null) return false; + return EmojiKeywordIndex.normalize(sel) == + EmojiKeywordIndex.normalize(emoji); + } + + Rect _reactionAnchorRect() { + if (_effectiveStyle == MessageActionsStyle.list && _menuRect != Rect.zero) { + return _menuRect; + } + if (_buttonHitRects.isNotEmpty) { + var box = _buttonHitRects.first; + for (final r in _buttonHitRects.skip(1)) { + box = box.expandToInclude(r); + } + return box; + } + return widget.originRect; + } + + Widget _buildReactionStrip(double t, double e) { final cs = Theme.of(context).colorScheme; final size = MediaQuery.sizeOf(context); - const menuWidth = 220.0; + final padding = MediaQuery.paddingOf(context); + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + + final safeTop = padding.top + 8; + final safeBottom = + size.height - math.max(padding.bottom, keyboardInset) - 8; + + final quick = widget.quickReactions; + const chevronCell = 38.0; + const pillPad = 6.0; + const pillHeight = 46.0; + const gap = 10.0; + const maxCell = 36.0; + + double pillWidth = pillPad * 2 + quick.length * maxCell + chevronCell; + final maxPillWidth = size.width - 16; + double cell = maxCell; + if (pillWidth > maxPillWidth) { + cell = ((maxPillWidth - pillPad * 2 - chevronCell) / quick.length).clamp( + 28.0, + maxCell, + ); + pillWidth = pillPad * 2 + quick.length * cell + chevronCell; + } + + final anchor = _reactionAnchorRect(); + double pillLeft = anchor.left.clamp( + 8.0, + math.max(8.0, size.width - 8 - pillWidth), + ); + + final pillAbove = (anchor.top - safeTop) >= pillHeight + gap; + double pillTop = pillAbove + ? anchor.top - gap - pillHeight + : anchor.bottom + gap; + pillTop = pillTop.clamp( + safeTop, + math.max(safeTop, safeBottom - pillHeight), + ); + final collapsed = Rect.fromLTWH(pillLeft, pillTop, pillWidth, pillHeight); + + final panelWidth = math.min(size.width - 24, 300.0); + const desiredHeight = 300.0; + double panelLeft = collapsed.left.clamp( + 8.0, + math.max(8.0, size.width - 8 - panelWidth), + ); + double panelTop; + double panelHeight; + if (pillAbove) { + panelTop = collapsed.top; + panelHeight = math.min(desiredHeight, safeBottom - panelTop); + } else { + final panelBottom = collapsed.bottom; + panelTop = math.max(safeTop, panelBottom - desiredHeight); + panelHeight = panelBottom - panelTop; + } + final expanded = Rect.fromLTWH( + panelLeft, + panelTop, + panelWidth, + panelHeight, + ); + + final morph = Rect.lerp(collapsed, expanded, e)!; + final radius = ui.lerpDouble(pillHeight / 2, 20.0, e)!; + final entryAlign = pillAbove ? Alignment.bottomLeft : Alignment.topLeft; + + return IgnorePointer( + ignoring: t < 0.5, + child: Opacity( + opacity: t, + child: Stack( + children: [ + if (e < 0.999) _buildCloudTail(collapsed, pillAbove, cs, t, e), + Positioned.fromRect( + rect: morph, + child: Transform.scale( + scale: 0.9 + 0.1 * t, + alignment: entryAlign, + child: _buildReactionSurface( + cs, + radius, + e, + cell, + quick, + expanded.size, + pillAbove, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildReactionSurface( + ColorScheme cs, + double radius, + double e, + double cell, + List quick, + Size expandedSize, + bool pillAbove, + ) { + final borderRadius = BorderRadius.circular(radius); + _pickerCache ??= RepaintBoundary( + child: _ReactionEmojiPicker( + onPick: _onReactionPicked, + loadEmojis: widget.loadReactionEmojis, + ), + ); + + return DecoratedBox( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: borderRadius, + child: GestureDetector( + onTap: () {}, + behavior: HitTestBehavior.opaque, + child: Stack( + fit: StackFit.expand, + children: [ + if (_reactionsPanelReady) + Positioned( + left: 0, + top: pillAbove ? 0 : null, + bottom: pillAbove ? null : 0, + width: expandedSize.width, + height: expandedSize.height, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + child: _pickerCache, + builder: (_, value, child) => + Opacity(opacity: value, child: child), + ), + ), + if (!_reactionsPanelReady) + Positioned( + left: 0, + right: 0, + top: pillAbove ? 0 : null, + bottom: pillAbove ? null : 0, + height: 46, + child: Opacity( + opacity: (1.0 - e).clamp(0.0, 1.0), + child: IgnorePointer( + ignoring: e > 0.05, + child: _buildQuickRow(cs, cell, quick), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildQuickRow(ColorScheme cs, double cell, List quick) { + return Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 6), + for (final reaction in quick) _quickEmoji(cs, reaction, cell), + _chevronButton(cs), + const SizedBox(width: 4), + ], + ), + ); + } + + Widget _quickEmoji(ColorScheme cs, ReactionEmoji reaction, double cell) { + final selected = _isSelectedReaction(reaction.emoji); + return SizedBox( + width: cell, + height: cell, + child: Material( + color: selected + ? cs.primary.withValues(alpha: 0.22) + : Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _onReactionPicked(reaction.emoji), + child: Center( + child: _ReactionGlyph(reaction: reaction, size: cell * 0.72), + ), + ), + ), + ); + } + + Widget _chevronButton(ColorScheme cs) { + return SizedBox( + width: 40, + height: 40, + child: Material( + color: cs.surfaceContainerHighest, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: _toggleReactionsExpanded, + child: Icon( + Symbols.keyboard_arrow_down, + color: cs.onSurfaceVariant, + size: 24, + ), + ), + ), + ); + } + + Widget _buildCloudTail( + Rect pill, + bool above, + ColorScheme cs, + double t, + double e, + ) { + final fade = (t * (1.0 - e * 1.4)).clamp(0.0, 1.0); + final baseX = pill.right - 22; + final edgeY = above ? pill.bottom - 2 : pill.top + 2; + final dir = above ? 1.0 : -1.0; + final big = Offset(baseX, edgeY + dir * 6); + final small = Offset(baseX + 8, edgeY + dir * 17); + + return IgnorePointer( + child: Opacity( + opacity: fade, + child: Stack( + children: [_tailCircle(big, 8.0, cs), _tailCircle(small, 5.0, cs)], + ), + ), + ); + } + + Widget _tailCircle(Offset c, double r, ColorScheme cs) { + return Positioned( + left: c.dx - r, + top: c.dy - r, + width: r * 2, + height: r * 2, + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.28), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + ), + ); + } + + Widget _buildAnchoredPanel({ + required String title, + required Widget body, + double width = 220.0, + }) { + final cs = Theme.of(context).colorScheme; + final size = MediaQuery.sizeOf(context); + final panelWidth = math.min(width, size.width - 16.0); double left; double top; @@ -639,21 +1116,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( @@ -743,6 +1220,89 @@ 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)!; @@ -751,11 +1311,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> body = const Padding( padding: EdgeInsets.symmetric(vertical: 28), child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2.4), - ), + child: SmallSpinner(size: 24), ), ); } else { @@ -991,6 +1547,230 @@ class _Action { const _Action(this.icon, this.label, this.onTap, {this.destructive = false}); } +class _ReactionEmojiPicker extends StatefulWidget { + final ValueChanged onPick; + final Future> Function()? loadEmojis; + const _ReactionEmojiPicker({required this.onPick, this.loadEmojis}); + + @override + State<_ReactionEmojiPicker> createState() => _ReactionEmojiPickerState(); +} + +class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { + final TextEditingController _searchCtrl = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final ValueNotifier _scrolling = ValueNotifier(false); + List _all = const []; + List _results = const []; + String _query = ''; + bool _loaded = false; + + bool _onScrollNotification(ScrollNotification n) { + if (n is ScrollStartNotification || n is ScrollUpdateNotification) { + if (!_scrolling.value) _scrolling.value = true; + } else if (n is ScrollEndNotification) { + if (_scrolling.value) _scrolling.value = false; + } + return false; + } + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + await EmojiKeywordIndex.instance.ensureLoaded(); + final loader = widget.loadEmojis; + final emojis = loader != null + ? await loader() + : EmojiKeywordIndex.instance.all + .map((e) => ReactionEmoji(emoji: e)) + .toList(); + if (!mounted) return; + setState(() { + _all = emojis; + _results = _all; + _loaded = true; + }); + } + + void _onQueryChanged(String value) { + final q = value.trim(); + setState(() { + _query = q; + if (q.isEmpty) { + _results = _all; + } else { + final matches = EmojiKeywordIndex.instance + .search(q) + .map(EmojiKeywordIndex.normalize) + .toSet(); + _results = _all + .where( + (e) => matches.contains(EmojiKeywordIndex.normalize(e.emoji)), + ) + .toList(); + } + }); + } + + void _clearSearch() { + _searchCtrl.clear(); + _onQueryChanged(''); + } + + @override + void dispose() { + _searchCtrl.dispose(); + _searchFocus.dispose(); + _scrolling.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + type: MaterialType.transparency, + child: Column( + children: [ + _buildSearchField(cs), + Expanded( + child: !_loaded + ? const Center( + child: SmallSpinner(size: 26), + ) + : _results.isEmpty + ? const SizedBox.shrink() + : LottieScrollScope( + isScrolling: _scrolling, + child: NotificationListener( + onNotification: _onScrollNotification, + child: GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 2, 8, 10), + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + addAutomaticKeepAlives: false, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 48, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + ), + itemCount: _results.length, + itemBuilder: (context, i) { + final reaction = _results[i]; + return _EmojiCell( + reaction: reaction, + onTap: () => widget.onPick(reaction.emoji), + ); + }, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSearchField(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 6), + child: Container( + height: 42, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(21), + ), + child: Row( + children: [ + const SizedBox(width: 12), + Icon(Symbols.search, size: 22, color: cs.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _searchCtrl, + focusNode: _searchFocus, + onChanged: _onQueryChanged, + textInputAction: TextInputAction.search, + cursorColor: cs.primary, + style: TextStyle(color: cs.onSurface, fontSize: 15), + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: AppLocalizations.of(context)!.emojiSearchHint, + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + ), + ), + ), + ), + if (_query.isEmpty) + const SizedBox(width: 12) + else + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _clearSearch, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Icon( + Symbols.close, + size: 20, + color: cs.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _EmojiCell extends StatelessWidget { + final ReactionEmoji reaction; + final VoidCallback onTap; + const _EmojiCell({required this.reaction, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Center(child: _ReactionGlyph(reaction: reaction, size: 34)), + ); + } +} + +class _ReactionGlyph extends StatelessWidget { + final ReactionEmoji reaction; + final double size; + const _ReactionGlyph({required this.reaction, required this.size}); + + @override + Widget build(BuildContext context) { + final anim = reaction.animationUrl; + final still = reaction.staticUrl; + final hasAsset = + (anim != null && anim.isNotEmpty) || (still != null && still.isNotEmpty); + if (!hasAsset) { + return Center( + child: Text(reaction.emoji, style: TextStyle(fontSize: size * 0.9)), + ); + } + return LottieImage( + lottieUrl: anim, + url: still, + size: size, + memCacheWidth: (size * 3).round(), + ); + } +} + class _ListMenuItem extends StatelessWidget { final _Action action; final bool highlighted; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 470aaa0..4b1343e 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; @@ -12,10 +13,13 @@ import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/bubble_radius.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 'photo_viewer.dart'; +import 'selectable_message_text.dart'; import '../../models/attachment.dart'; import '../../models/reaction_info.dart'; import 'attachment/bubbles/voice_bubble.dart'; @@ -30,6 +34,7 @@ import 'attachment/bubbles/photo_bubble.dart'; import 'attachment/bubbles/video_bubble.dart'; import 'attachment/bubbles/file_bubble.dart'; import 'attachment/bubbles/forwarded_bubble.dart'; +import 'lottie_image.dart'; final Expando _contentTypeCache = Expando(); @@ -49,6 +54,61 @@ class _RenderZeroIntrinsicWidth extends RenderProxyBox { double computeMaxIntrinsicWidth(double height) => 0; } +/// A [Wrap] that reports its single-line width as the max intrinsic width, so an +/// enclosing [IntrinsicWidth] grows the bubble to fit the chips on one line +/// instead of collapsing to the widest single chip (which makes them stack). +/// It still wraps to multiple lines when the available width is smaller. +class _ReactionsWrap extends Wrap { + const _ReactionsWrap({ + super.spacing, + super.runSpacing, + required super.children, + }); + + @override + RenderWrap createRenderObject(BuildContext context) { + return _RenderReactionsWrap( + direction: direction, + alignment: alignment, + spacing: spacing, + runAlignment: runAlignment, + runSpacing: runSpacing, + crossAxisAlignment: crossAxisAlignment, + textDirection: textDirection ?? Directionality.maybeOf(context), + verticalDirection: verticalDirection, + clipBehavior: clipBehavior, + ); + } +} + +class _RenderReactionsWrap extends RenderWrap { + _RenderReactionsWrap({ + super.direction, + super.alignment, + super.spacing, + super.runAlignment, + super.runSpacing, + super.crossAxisAlignment, + super.textDirection, + super.verticalDirection, + super.clipBehavior, + }); + + @override + double computeMaxIntrinsicWidth(double height) { + var total = 0.0; + var count = 0; + RenderBox? child = firstChild; + while (child != null) { + total += child.getMaxIntrinsicWidth(double.infinity); + count++; + child = childAfter(child); + } + if (count > 1) total += spacing * (count - 1); + return total; + } +} + class MessageBubble extends StatelessWidget { static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); static const BorderRadius _reactionChipRadius = BorderRadius.all( @@ -66,6 +126,8 @@ 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; @@ -73,6 +135,11 @@ class MessageBubble extends StatelessWidget { final void Function(String messageId)? onReplyTap; final void Function(int senderId)? onAvatarTap; final void Function(StickerAttachment sticker)? onStickerTap; + final void Function(String emoji)? onReactionTap; + final String? peerName; + final String? peerAvatarUrl; + final ValueListenable<({String id, Offset pos})?>? textSelection; + final VoidCallback? onExitTextSelection; const MessageBubble({ super.key, @@ -82,6 +149,8 @@ class MessageBubble extends StatelessWidget { this.prevMessage, this.nextMessage, required this.chatType, + this.chatId, + this.photoActions, this.overrideStatus, this.otherReadTime, this.reactionsListenable, @@ -89,6 +158,11 @@ class MessageBubble extends StatelessWidget { this.onReplyTap, this.onAvatarTap, this.onStickerTap, + this.onReactionTap, + this.peerName, + this.peerAvatarUrl, + this.textSelection, + this.onExitTextSelection, }); bool _computeHasPhotoWithCaption() { @@ -149,6 +223,17 @@ class MessageBubble extends StatelessWidget { return a.first is StickerAttachment; } + static const int _jumboAnimojiLimit = 4; + + List? get _jumboAnimojiUrls { + if (message.attachments?.isNotEmpty ?? false) return null; + return animojiOnlyLottieUrls( + message.text, + message.formatRanges, + limit: _jumboAnimojiLimit, + ); + } + MessageType get _contentType { if (_hasShareAttachment) return _computeContentType(); return _contentTypeCache[message] ??= _computeContentType(); @@ -392,7 +477,10 @@ class MessageBubble extends StatelessWidget { final topMargin = _topMarginFor(contentType, shape); final bottomMargin = _bottomMarginFor(contentType, shape); - final padding = _paddingFor(contentType, shape); + final jumboAnimoji = _jumboAnimojiUrls; + final padding = jumboAnimoji != null + ? EdgeInsets.zero + : _paddingFor(contentType, shape); final showAvatarSlot = !isMe; final showAvatar = @@ -404,10 +492,10 @@ class MessageBubble extends StatelessWidget { chatType == "CHAT" && prevMessage?.senderId != message.senderId; - final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; + final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0); final keyboard = _inlineKeyboard; final isVideoNote = _isVideoNote; - final noBubbleBackground = isVideoNote || _isSticker; + final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null; final bubbleColor = noBubbleBackground ? Colors.transparent : (isMe ? cs.primaryContainer : cs.surfaceContainerHighest); @@ -424,6 +512,8 @@ class MessageBubble extends StatelessWidget { isMe: isMe, myId: myId, chatType: chatType, + chatId: chatId, + photoActions: photoActions, overrideStatus: overrideStatus, otherReadTime: otherReadTime, uploadProgress: uploadProgress, @@ -446,7 +536,7 @@ class MessageBubble extends StatelessWidget { Widget withReply(Widget content) { if (reply == null) return content; final quote = _buildReplyQuote(context, cs, textColor, reply); - if (contentType != MessageType.text) { + if (contentType != MessageType.text || jumboAnimoji != null) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -468,8 +558,8 @@ class MessageBubble extends StatelessWidget { return Padding( padding: EdgeInsets.only( - left: 12, - right: 12, + left: 8, + right: 8, top: topMargin, bottom: bottomMargin, ), @@ -483,9 +573,7 @@ class MessageBubble extends StatelessWidget { children: [ if (showAvatar) _buildLeadingAvatar(cs) - else if (showAvatarSlot && chatType != "CHAT") - const SizedBox(width: 0) - else if (showAvatarSlot) + else if (showAvatarSlot && chatType == "CHAT") const CircleAvatar( radius: 15, backgroundColor: Color(0x00000000), @@ -718,9 +806,10 @@ class MessageBubble extends StatelessWidget { } Map? _resolveReactionInfo() { - if (reactionsListenable != null) { - final v = reactionsListenable!.value; - if (v != null) return v; + final listenable = reactionsListenable; + if (listenable != null) { + final v = listenable.value; + return v is Map ? v : null; } final info = message.payload?['reactionInfo']; if (info is Map) return info; @@ -749,6 +838,8 @@ class MessageBubble extends StatelessWidget { } Widget _buildContent(BubbleContext ctx) { + final jumbo = _jumboAnimojiUrls; + if (jumbo != null) return _buildJumboAnimojiContent(ctx, jumbo); switch (ctx.contentType) { case MessageType.control: return _buildControlContent(ctx.cs); @@ -761,6 +852,86 @@ class MessageBubble extends StatelessWidget { } } + Widget _buildJumboAnimojiContent(BubbleContext ctx, List urls) { + final n = urls.length; + final size = switch (n) { + 1 => 96.0, + 2 => 76.0, + 3 => 64.0, + _ => 56.0, + }; + final cache = (size * 2).round(); + + final animations = Stack( + children: [ + Wrap( + spacing: 2, + runSpacing: 2, + alignment: ctx.isMe ? WrapAlignment.end : WrapAlignment.start, + children: [ + for (final url in urls) + SizedBox( + width: size, + height: size, + child: LottieImage( + lottieUrl: url, + size: size, + memCacheWidth: cache, + eager: true, + ), + ), + ], + ), + Positioned( + bottom: BubbleContext.compactTimePadding, + right: BubbleContext.compactTimePadding, + child: _buildJumboAnimojiMeta(ctx), + ), + ], + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: ctx.isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [animations, _buildReactionsBarFor(ctx.cs, ctx.reactionInfo)], + ); + } + + Widget _buildJumboAnimojiMeta(BubbleContext ctx) { + final status = ctx.overrideStatus ?? ctx.message.status; + final statusVisual = messageStatusVisual(status, dimColor: Colors.white); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + ctx.clockText, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + if (ctx.isMe) ...[ + const SizedBox(width: 3), + Icon(statusVisual.icon, size: 13, color: statusVisual.color), + ], + if (ctx.message.deleted) ...[ + const SizedBox(width: 3), + const Icon(Symbols.delete, size: 12, color: Colors.white), + ], + ], + ), + ); + } + Widget _buildReactionsBar(ColorScheme cs) { final info = message.payload?['reactionInfo']; return _buildReactionsBarFor(cs, info is Map ? info : null); @@ -778,40 +949,88 @@ class MessageBubble extends StatelessWidget { List _buildReactionChipsFor(ColorScheme cs, ReactionInfo? info) { if (info == null) return const []; final yourReaction = info.yourReaction; + final isDialog = chatType == 'DIALOG'; final chips = []; for (final c in info.counters) { final isYours = yourReaction == c.reaction; - chips.add( - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isYours - ? cs.primary.withValues(alpha: 0.22) - : _reactionChipBg, - borderRadius: _reactionChipRadius, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(c.reaction, style: const TextStyle(fontSize: 13)), - if (c.count > 1) ...[ - const SizedBox(width: 3), - Text( - c.count.toString(), - style: TextStyle( - color: isYours ? cs.primary : cs.onSurfaceVariant, - fontSize: 11, - fontWeight: FontWeight.w600, - ), + + Widget? avatar; + if (isDialog) { + final peerReacted = (c.count - (isYours ? 1 : 0)) >= 1; + avatar = peerReacted + ? _reactionAvatar(cs, peerAvatarUrl, peerName) + : _reactionAvatar( + cs, + ContactCache.getAvatar(myId), + ContactCache.get(myId), + ); + } + + Widget chip = Container( + padding: EdgeInsets.fromLTRB(7, 2, avatar != null ? 3 : 7, 2), + decoration: BoxDecoration( + color: isYours ? cs.primary.withValues(alpha: 0.22) : _reactionChipBg, + borderRadius: _reactionChipRadius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(c.reaction, style: const TextStyle(fontSize: 13)), + if (c.count > 1) ...[ + const SizedBox(width: 3), + Text( + c.count.toString(), + style: TextStyle( + color: isYours ? cs.primary : cs.onSurfaceVariant, + fontSize: 11, + fontWeight: FontWeight.w600, ), - ], + ), ], - ), + if (avatar != null) ...[const SizedBox(width: 5), avatar], + ], + ), + ); + + final onTap = onReactionTap; + if (onTap != null) { + chip = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(c.reaction), + child: chip, + ); + } + + chips.add(chip); + } + return chips; + } + + Widget _reactionAvatar(ColorScheme cs, String? url, String? name) { + const double diameter = 17; + if (url != null && url.isNotEmpty) { + return CircleAvatar( + radius: diameter / 2, + backgroundColor: cs.primaryContainer, + backgroundImage: CachedNetworkImageProvider( + url, + maxWidth: 64, + maxHeight: 64, ), ); } - return chips; + final letter = (name != null && name.isNotEmpty) + ? name[0].toUpperCase() + : '?'; + return CircleAvatar( + radius: diameter / 2, + backgroundColor: cs.primaryContainer, + child: Text( + letter, + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ); } Widget _buildControlContent(ColorScheme cs) { @@ -847,6 +1066,10 @@ class MessageBubble extends StatelessWidget { text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату'; break; + case 'pin': + text = + '${ContactCache.get(message.senderId) ?? 'Пользователь'} закрепил(а) сообщение'; + break; default: text = control.title; } @@ -871,6 +1094,25 @@ class MessageBubble extends StatelessWidget { ); } + Widget _wrapSelectable(Widget textWidget) { + final listenable = textSelection; + if (listenable == null || (message.text?.isEmpty ?? true)) { + return textWidget; + } + 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, + onExit: onExitTextSelection ?? () {}, + child: child!, + ); + }, + child: textWidget, + ); + } + Widget _buildTextContent(BubbleContext ctx) { final attachments = message.attachments; final isForwardedContact = @@ -891,7 +1133,7 @@ class MessageBubble extends StatelessWidget { final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); final ranges = message.formatRanges; - final textWidget = isForwarded + final baseTextWidget = isForwarded ? _buildForwardedInlineText(ctx, forwarded) : (FormattedMessageText.isFormatted(message.text, ranges) ? FormattedMessageText( @@ -900,6 +1142,7 @@ class MessageBubble extends StatelessWidget { style: textStyle, ) : Text(message.text ?? '', style: textStyle)); + final textWidget = _wrapSelectable(baseTextWidget); final metaWidget = Text( message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText, @@ -917,7 +1160,7 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: Wrap( + child: _ReactionsWrap( spacing: 4, runSpacing: 4, children: reactionChips, diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index a82cc7c..9af43f5 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -1,57 +1,709 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.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 'liquid_glass.dart'; +import '../../core/utils/format.dart'; +import '../../core/utils/media_cache.dart'; +import '../../core/utils/media_saver.dart'; +import '../../l10n/app_localizations.dart'; +import '../../main.dart'; +import '../../models/attachment.dart'; +import 'chat_menu_overlay.dart'; +import 'custom_notification.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? viewAllPhotos; - String get _url => baseUrl; + const PhotoViewerActions({ + this.goToMessage, + this.forward, + this.delete, + this.viewAllPhotos, + }); + + bool get isEmpty => + goToMessage == null && + forward == null && + delete == null && + viewAllPhotos == null; +} + +class _ViewerPhoto { + final String id; + final PhotoAttachment photo; + final String messageId; + final int senderId; + final int time; + final String? caption; + + const _ViewerPhoto({ + required this.id, + required this.photo, + required this.messageId, + required this.senderId, + required this.time, + this.caption, + }); + + factory _ViewerPhoto.fromFeed(SharedMediaItem item) { + final photo = item.attachment as PhotoAttachment; + return _ViewerPhoto( + id: item.dedupKey, + photo: photo, + messageId: item.messageId, + senderId: item.senderId, + time: item.time, + caption: item.text, + ); + } +} + +class PhotoViewerScreen extends StatefulWidget { + final List photos; + final int initialIndex; + final int? chatId; + final CachedMessage? message; + final PhotoViewerActions? actions; + + const PhotoViewerScreen({ + super.key, + required this.photos, + this.initialIndex = 0, + this.chatId, + this.message, + this.actions, + }); + + PhotoViewerScreen.single(String baseUrl, {super.key}) + : photos = [PhotoAttachment(baseUrl: baseUrl)], + initialIndex = 0, + chatId = null, + message = null, + actions = null; + + @override + State createState() => _PhotoViewerScreenState(); +} + +class _PhotoViewerScreenState extends State { + static const int _prefetchThreshold = 3; + + late PageController _controller; + late List<_ViewerPhoto> _items; + late int _index; + int _pager = 0; + + final Map _quarterTurns = {}; + 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(); + _items = _localItems(); + _index = widget.initialIndex.clamp(0, _items.length - 1); + _controller = PageController(initialPage: _index); + unawaited(_loadFeed()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + List<_ViewerPhoto> _localItems() { + final message = widget.message; + return [ + for (var i = 0; i < widget.photos.length; i++) + _ViewerPhoto( + id: _localId(widget.photos[i], message, i), + photo: widget.photos[i], + messageId: message?.id ?? '', + senderId: message?.senderId ?? 0, + time: message?.time ?? 0, + caption: message?.text, + ), + ]; + } + + String _localId(PhotoAttachment photo, CachedMessage? message, int at) { + final key = _feedKey(photo, message); + return key ?? 'local:${message?.id ?? ''}:$at'; + } + + String? _feedKey(PhotoAttachment photo, CachedMessage? message) { + if (message == null || widget.chatId == null) return null; + if (photo.photoId == null && (photo.baseUrl ?? '').isEmpty) return null; + return photoDedupKey(message.id, photo); + } + + _ViewerPhoto get _current => _items[_index]; + + bool get _feedPending => + !_feedLoaded && + !_feedFailed && + widget.chatId != null && + _feedKey(_current.photo, widget.message) != null; + + Future _loadFeed() async { + final chatId = widget.chatId; + final key = _feedKey(_items[_index].photo, widget.message); + if (chatId == null || key == null) return; + + final feed = await sharedContentModule.photoFeedFor( + chatId: chatId, + photoKey: key, + resolveAnchor: () => _resolveAnchor(chatId), + ); + if (!mounted) return; + if (feed == null) { + setState(() => _feedFailed = true); + return; + } + + final items = feed.items.map(_ViewerPhoto.fromFeed).toList(); + final at = items.indexWhere((i) => i.id == key); + if (at == -1) { + setState(() => _feedFailed = true); + return; + } + + _adoptFeed(items, at, feed); + } + + void _adoptFeed(List<_ViewerPhoto> items, int at, ChatPhotoFeed 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); + } + }); + + 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.loadMorePhotos( + chatId: chatId, + resolveAnchor: () => _resolveAnchor(chatId), + ); + if (!mounted) return; + + final items = feed.items.map(_ViewerPhoto.fromFeed).toList(); + 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; + } + + void _onPageChanged(int index) { + setState(() => _index = index); + 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() { + setState(() { + _quarterTurns[_current.id] = ((_quarterTurns[_current.id] ?? 0) + 1) % 4; + }); + } + + void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible); + + String _cacheNameFor(PhotoAttachment photo, String url) => + 'photo_${photo.photoId ?? (url.hashCode & 0x7fffffff)}.jpg'; + + 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 _save() async { + if (_saving) return; + setState(() => _saving = true); + final photo = _current.photo; + final localPath = photo.localPath; + final url = photo.baseUrl ?? ''; + + 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: _cacheNameFor(photo, url), + resolveUrl: () async => url, + saveName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', + kind: SaveMediaKind.image, + ); + } + + if (!mounted) return; + setState(() => _saving = false); + if (result.ok) { + showCustomNotification( + context, + result.toGallery ? 'Сохранено в галерею' : 'Файл сохранён', + ); + } else { + showCustomNotification( + context, + 'Не удалось сохранить: ${result.error ?? ''}', + ); + } + } + + Future _saveAs() async { + final file = await _fileFor(_current.photo); + if (!mounted) return; + if (file == null) { + showCustomNotification(context, 'Не удалось загрузить фото'); + return; + } + + final bytes = await file.readAsBytes(); + if (!mounted) return; + + final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); + final path = await FilePicker.platform.saveFile( + dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs, + fileName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', + type: FileType.any, + bytes: isMobile ? bytes : null, + ); + if (path == null || !mounted) return; + + if (!isMobile) { + await File(path).writeAsBytes(bytes); + if (!mounted) return; + } + showCustomNotification(context, 'Файл сохранён'); + } + + void _openMenu(BuildContext anchorContext) { + final actions = widget.actions; + if (actions == null) 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.viewAllPhotos != null) + ChatMenuItem( + icon: Symbols.grid_view, + label: l10n.photoViewerViewAll, + onTap: () => _popThen(actions.viewAllPhotos!), + ), + ], + ); + } + + void _popThen(VoidCallback action) { + Navigator.of(context).pop(); + action(); + } @override Widget build(BuildContext context) { + final padding = MediaQuery.of(context).padding; + final hasMenu = !(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, + itemCount: _items.length, + onPageChanged: _onPageChanged, + itemBuilder: (_, i) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleChrome, + child: InteractiveViewer( + minScale: 1, + maxScale: 5, + child: Center( + child: RotatedBox( + quarterTurns: _quarterTurns[_items[i].id] ?? 0, + child: _buildImage(_items[i].photo), ), ), + ), + ), + ), ), - ), + 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 > 0) + Align( + alignment: Alignment.centerLeft, + child: _arrow(Symbols.chevron_left, () => _step(-1)), + ), + if (_index < _items.length - 1) + 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), + ), + ], + ), + ), + ), + ), + ], ), - 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(), - ), + ), + ), + ); + } + + 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; + + return Container( + padding: EdgeInsets.fromLTRB(16, 12, 8, 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 (caption != null && caption.isNotEmpty) ...[ + _buildCaption(caption), + const SizedBox(height: 12), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _buildInfo(l10n)), + 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 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: Container( + constraints: const BoxConstraints(maxHeight: 120), + 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(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_feedLoaded) + Text( + l10n.photoViewerCounter(_total - _index, _total), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ) + else if (_feedPending) + const _CounterShimmer(), + 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, _ViewerPhoto item) { + final sender = 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 _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: 120, + height: 16, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ); + } } diff --git a/lib/frontend/widgets/poll_view.dart b/lib/frontend/widgets/poll_view.dart index 6f0d2c8..b68cf62 100644 --- a/lib/frontend/widgets/poll_view.dart +++ b/lib/frontend/widgets/poll_view.dart @@ -6,6 +6,7 @@ import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../models/poll.dart'; import 'custom_notification.dart'; +import 'small_spinner.dart'; class PollView extends StatefulWidget { final int chatId; @@ -222,14 +223,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), ], ), ), @@ -253,14 +247,7 @@ class _PollViewState extends State ), ), child: _voting - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: widget.accentColor, - ), - ) + ? SmallSpinner(size: 16, color: widget.accentColor) : const Text('Проголосовать'), ), ), @@ -355,12 +342,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..7581d1c 100644 --- a/lib/frontend/widgets/primary_loading_button.dart +++ b/lib/frontend/widgets/primary_loading_button.dart @@ -1,6 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'small_spinner.dart'; +import 'springy_tap.dart'; + class PrimaryLoadingButton extends StatelessWidget { final ValueListenable loading; final VoidCallback? onPressed; @@ -23,23 +26,22 @@ 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: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + 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/rich_message_controller.dart b/lib/frontend/widgets/rich_message_controller.dart index ef65efd..70a82ba 100644 --- a/lib/frontend/widgets/rich_message_controller.dart +++ b/lib/frontend/widgets/rich_message_controller.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../../core/utils/text_format.dart'; +import '../../models/animoji.dart'; +import 'lottie_image.dart'; const List composerFormats = [ TextFormat.strong, @@ -16,11 +18,114 @@ class _Interval { _Interval(this.start, this.end); } +class _AnimojiEntity { + final int uid; + int offset; + final String emoji; + final String lottieUrl; + final int entityId; + + _AnimojiEntity({ + required this.uid, + required this.offset, + required this.emoji, + required this.lottieUrl, + required this.entityId, + }); +} + class RichMessageController extends TextEditingController { + static const String _animojiPlaceholder = ''; + final Map> _intervals = {}; + final List<_AnimojiEntity> _animoji = []; + int _entitySeq = 0; RichMessageController({super.text}); + void insertAnimoji(Animoji animoji) { + final lottie = animoji.lottieUrl ?? animoji.lottiePlayUrl; + if (lottie == null || lottie.isEmpty) return; + + final selection = value.selection; + final oldText = value.text; + final start = selection.isValid ? selection.start : oldText.length; + final end = selection.isValid ? selection.end : oldText.length; + final newText = oldText.replaceRange(start, end, _animojiPlaceholder); + + value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: start + _animojiPlaceholder.length, + ), + ); + + _animoji.add( + _AnimojiEntity( + uid: _entitySeq++, + offset: start, + emoji: animoji.emoji, + lottieUrl: lottie, + entityId: animoji.id, + ), + ); + _animoji.sort((a, b) => a.offset.compareTo(b.offset)); + 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 sb = StringBuffer(); + var last = 0; + for (final e in entities) { + if (e.offset < last || e.offset >= src.length) continue; + sb.write(src.substring(last, e.offset)); + sb.write(e.emoji); + last = e.offset + _animojiPlaceholder.length; + } + sb.write(src.substring(last)); + final glyphText = sb.toString(); + + int glyphOffset(int p) { + var shift = 0; + for (final e in entities) { + if (e.offset < p && e.offset < src.length) { + shift += e.emoji.length - _animojiPlaceholder.length; + } + } + return p + shift; + } + + final elements = >[]; + for (final e in entities) { + if (e.offset >= src.length) continue; + elements.add({ + 'type': 'ANIMOJI', + 'from': glyphOffset(e.offset), + 'length': e.emoji.length, + 'entityId': e.entityId, + 'attributes': {'animojiLottieUrl': e.lottieUrl}, + }); + } + for (final range in _toFormatRanges()) { + final from = glyphOffset(range.start); + final to = glyphOffset(range.end); + if (to <= from) continue; + elements.add({ + 'type': textFormatToServer(range.format), + 'from': from, + 'length': to - from, + }); + } + return (text: glyphText, elements: elements); + } + @override set value(TextEditingValue newValue) { final oldText = value.text; @@ -95,7 +200,7 @@ class RichMessageController extends TextEditingController { } void _remap(String oldText, String newText) { - if (_intervals.isEmpty) return; + if (_intervals.isEmpty && _animoji.isEmpty) return; final oldLen = oldText.length; final newLen = newText.length; @@ -126,6 +231,15 @@ class RichMessageController extends TextEditingController { return changeStart; } + if (_animoji.isNotEmpty) { + _animoji.removeWhere( + (e) => e.offset >= changeStart && e.offset < oldChangeEnd, + ); + for (final e in _animoji) { + if (e.offset >= oldChangeEnd) e.offset += delta; + } + } + final empty = []; _intervals.forEach((format, list) { for (final interval in list) { @@ -204,26 +318,66 @@ class RichMessageController extends TextEditingController { }) { final baseStyle = style ?? const TextStyle(); final content = text; - if (!hasFormatting || content.isEmpty) { + if ((!hasFormatting && _animoji.isEmpty) || content.isEmpty) { return TextSpan(style: baseStyle, text: content); } final ranges = _toFormatRanges(); - final baseColor = baseStyle.color; final quoteColor = baseColor?.withValues(alpha: 0.85); final segments = segmentizeFormats(content, ranges); - final spans = [ - for (final segment in segments) - TextSpan( - text: content.substring(segment.start, segment.end), - style: applyTextFormats( - baseStyle, - segment.formats, - quoteColor: quoteColor, + final entityByOffset = {for (final e in _animoji) e.offset: e}; + final box = (baseStyle.fontSize ?? 16) * 1.4; + + final spans = []; + for (final segment in segments) { + final segStyle = applyTextFormats( + baseStyle, + segment.formats, + quoteColor: quoteColor, + ); + var runStart = segment.start; + var i = segment.start; + while (i < segment.end) { + final entity = entityByOffset[i]; + if (entity == null) { + i++; + continue; + } + if (runStart < i) { + spans.add( + TextSpan(text: content.substring(runStart, i), style: segStyle), + ); + } + spans.add(_animojiSpan(entity, box)); + i += _animojiPlaceholder.length; + runStart = i; + } + if (runStart < segment.end) { + spans.add( + TextSpan( + text: content.substring(runStart, segment.end), + style: segStyle, ), - ), - ]; + ); + } + } return TextSpan(style: baseStyle, children: spans); } + + WidgetSpan _animojiSpan(_AnimojiEntity entity, double box) { + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: SizedBox( + key: ValueKey('composer-animoji-${entity.uid}'), + width: box, + height: box, + child: LottieImage( + lottieUrl: entity.lottieUrl, + size: box, + memCacheWidth: 120, + ), + ), + ); + } } diff --git a/lib/frontend/widgets/segmented_pill_toggle.dart b/lib/frontend/widgets/segmented_pill_toggle.dart new file mode 100644 index 0000000..bbecd8c --- /dev/null +++ b/lib/frontend/widgets/segmented_pill_toggle.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +class SegmentedPillToggle extends StatelessWidget { + final List labels; + final int selected; + final ValueChanged onChanged; + final double segmentWidth; + final double height; + + const SegmentedPillToggle({ + super.key, + required this.labels, + required this.selected, + required this.onChanged, + this.segmentWidth = 88, + this.height = 34, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + const pad = 3.0; + final sel = selected.clamp(0, labels.length - 1); + + return Container( + height: height, + padding: const EdgeInsets.all(pad), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(height / 2), + ), + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + left: sel * segmentWidth, + top: 0, + bottom: 0, + width: segmentWidth, + child: DecoratedBox( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular((height - 2 * pad) / 2), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(labels.length, (i) { + final active = i == sel; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(i), + child: SizedBox( + width: segmentWidth, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + style: TextStyle( + color: active ? cs.onPrimary : cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + child: Text(labels[i]), + ), + ), + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/widgets/selectable_message_text.dart b/lib/frontend/widgets/selectable_message_text.dart new file mode 100644 index 0000000..8971ad2 --- /dev/null +++ b/lib/frontend/widgets/selectable_message_text.dart @@ -0,0 +1,482 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +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'; + +RenderParagraph? _findParagraph(RenderObject? ro) { + if (ro == null) return null; + if (ro is RenderParagraph) return ro; + RenderParagraph? found; + ro.visitChildren((child) { + found ??= _findParagraph(child); + }); + return found; +} + +bool _isSpace(int c) => + c == 0x20 || + c == 0x09 || + c == 0x0A || + c == 0x0D || + c == 0x0C || + c == 0xA0; + +class SelectableMessageText extends StatefulWidget { + final Widget child; + final Offset initialGlobalPosition; + final VoidCallback onExit; + + const SelectableMessageText({ + super.key, + required this.child, + required this.initialGlobalPosition, + required this.onExit, + }); + + @override + State createState() => _SelectableMessageTextState(); +} + +class _SelectableMessageTextState extends State + with SingleTickerProviderStateMixin { + static const double _ballRadius = 8.0; + static const double _hitSize = 44.0; + + final GlobalKey _textKey = GlobalKey(); + final ValueNotifier _toolbarVisible = ValueNotifier(false); + + late final AnimationController _entrance; + OverlayEntry? _overlay; + Timer? _settle; + TextSelection _selection = const TextSelection.collapsed(offset: 0); + String? _cachedText; + bool _dragging = false; + bool _exiting = false; + + RenderParagraph? _cachedParagraph; + + RenderParagraph? get _paragraph { + final cached = _cachedParagraph; + if (cached != null && cached.attached) return cached; + return _cachedParagraph = _findParagraph( + _textKey.currentContext?.findRenderObject(), + ); + } + + String _text(RenderParagraph rp) => _cachedText ??= rp.text.toPlainText(); + + @override + void initState() { + super.initState(); + _entrance = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + )..addListener(() => _overlay?.markNeedsBuild()); + WidgetsBinding.instance.addPostFrameCallback((_) => _init(4)); + } + + @override + void dispose() { + _settle?.cancel(); + _entrance.dispose(); + _overlay?.remove(); + _overlay = null; + _toolbarVisible.dispose(); + super.dispose(); + } + + void _init(int retries) { + if (!mounted) return; + final rp = _paragraph; + if (rp == null || !rp.hasSize) { + if (retries > 0) { + WidgetsBinding.instance.addPostFrameCallback((_) => _init(retries - 1)); + } else { + _requestExit(); + } + return; + } + _selectWordAt(widget.initialGlobalPosition, rp); + 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(RenderParagraph rp, Offset globalPos) { + final text = _text(rp); + final len = text.length; + if (len == 0) return const TextRange.collapsed(0); + final local = rp.globalToLocal(globalPos); + var off = rp.getPositionForOffset(local).offset.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, RenderParagraph rp) { + final range = _wordRange(rp, globalPos); + if (range.isCollapsed) { + _applySelection(_normalize(0, _text(rp).length), animate: true); + } else { + _applySelection(_normalize(range.start, range.end), animate: true); + } + } + + void _onBackgroundTap(Offset globalPos) { + final rp = _paragraph; + if (rp == null || !rp.hasSize) { + _requestExit(); + return; + } + final local = rp.globalToLocal(globalPos); + if (rp.size.contains(local)) { + _dragging = false; + _settle?.cancel(); + _selectWordAt(globalPos, rp); + _toolbarVisible.value = true; + } else { + _requestExit(); + } + } + + void _onHandleDragStart() { + _dragging = true; + _settle?.cancel(); + _entrance.value = 1.0; + _toolbarVisible.value = false; + } + + void _onHandleDrag(Offset globalPos, bool isStart) { + final rp = _paragraph; + if (rp == null || !rp.hasSize) return; + final len = _text(rp).length; + final off = rp.getPositionForOffset(rp.globalToLocal(globalPos)).offset; + 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; + _settle?.cancel(); + _settle = Timer(const Duration(milliseconds: 140), () { + if (!mounted || _dragging) return; + _overlay?.markNeedsBuild(); + _toolbarVisible.value = true; + }); + } + + void _copy() { + final rp = _paragraph; + if (rp != null && _selection.isValid && !_selection.isCollapsed) { + final text = _text(rp); + 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() { + final rp = _paragraph; + if (rp == null) return; + Haptics.tap(); + _applySelection(_normalize(0, _text(rp).length), animate: true); + _toolbarVisible.value = true; + } + + Widget _buildOverlay(BuildContext ctx) { + final rp = _paragraph; + if (rp == null || !rp.hasSize || !rp.attached) { + return const SizedBox.shrink(); + } + final cs = Theme.of(ctx).colorScheme; + + final List boxes = + (_selection.isValid && !_selection.isCollapsed) + ? rp.getBoxesForSelection(_selection) + : const []; + + final children = [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (d) => _onBackgroundTap(d.globalPosition), + ), + ), + ]; + + if (boxes.isNotEmpty) { + final first = boxes.first.toRect(); + final last = boxes.last.toRect(); + final startBottom = rp.localToGlobal(Offset(first.left, first.bottom)); + final endBottom = rp.localToGlobal(Offset(last.right, last.bottom)); + + children.add(_handle(cs, startBottom, isStart: true)); + children.add(_handle(cs, endBottom, isStart: false)); + children.add(_toolbar(ctx, rp, first, last)); + } + + return Stack(children: children); + } + + Widget _handle(ColorScheme cs, Offset lineBottomGlobal, {required bool isStart}) { + final center = Offset( + lineBottomGlobal.dx, + lineBottomGlobal.dy + _ballRadius, + ); + return Positioned( + left: center.dx - _hitSize / 2, + top: center.dy - _hitSize / 2, + width: _hitSize, + height: _hitSize, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => _onHandleDragStart(), + onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart), + onPanEnd: (_) => _onHandleDragEnd(), + onPanCancel: _onHandleDragEnd, + child: Center( + 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, RenderParagraph rp, Rect first, Rect last) { + final media = MediaQuery.of(ctx); + final size = media.size; + final safeTop = media.padding.top + 8; + final safeBottom = size.height - media.padding.bottom - 8; + const height = 48.0; + const gap = 10.0; + + final topGlobal = rp.localToGlobal(first.topLeft).dy; + final bottomGlobal = rp.localToGlobal(Offset(last.right, last.bottom)).dy; + + double top = topGlobal - gap - height; + if (top < safeTop) top = bottomGlobal + gap; + top = top.clamp(safeTop, math.max(safeTop, safeBottom - height)); + + return Positioned( + left: 12, + right: 12, + top: top, + 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 CustomPaint( + painter: _HighlightPainter( + paragraph: _paragraph, + 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 RenderParagraph? paragraph; + final TextSelection selection; + final Animation animation; + final Color fill; + final Color stem; + + _HighlightPainter({ + required this.paragraph, + 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 rp = paragraph; + if (rp == null || !rp.hasSize || !rp.attached) return; + final boxes = rp.getBoxesForSelection(selection); + if (boxes.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 box in boxes) { + final rect = box.toRect().inflate(0.5); + final cy = rect.center.dy; + final h = rect.height * grow; + final animRect = Rect.fromLTRB( + rect.left, + cy - h / 2, + rect.right, + cy + h / 2, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(animRect, const Radius.circular(3)), + fillPaint, + ); + } + + final stemPaint = Paint() + ..color = stem.withValues(alpha: stem.a * eased) + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round; + final first = boxes.first.toRect(); + final last = boxes.last.toRect(); + 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.paragraph != paragraph || + old.fill != fill || + old.stem != stem; +} diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart index c877d32..1f9e768 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.navPillTint(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/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_image.dart b/lib/frontend/widgets/sticker_image.dart deleted file mode 100644 index 1e9bd55..0000000 --- a/lib/frontend/widgets/sticker_image.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; - -import 'sticker_lottie.dart'; - -class StickerImage extends StatelessWidget { - final String? url; - final String? lottieUrl; - final double? size; - final int? memCacheWidth; - - const StickerImage({ - super.key, - this.url, - this.lottieUrl, - this.size, - this.memCacheWidth, - }); - - @override - Widget build(BuildContext context) { - if (lottieUrl != null && lottieUrl!.isNotEmpty) { - return StickerLottie( - lottieUrl: lottieUrl!, - fallbackUrl: url, - size: size, - memCacheWidth: memCacheWidth, - ); - } - return _static(); - } - - Widget _static() { - final src = url ?? ''; - final blank = SizedBox(width: size, height: size); - if (src.isEmpty) return blank; - return CachedNetworkImage( - imageUrl: src, - width: size, - height: size, - fit: BoxFit.contain, - memCacheWidth: memCacheWidth, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => blank, - errorWidget: (_, _, _) => blank, - ); - } -} diff --git a/lib/frontend/widgets/sticker_lottie.dart b/lib/frontend/widgets/sticker_lottie.dart deleted file mode 100644 index 1b685f1..0000000 --- a/lib/frontend/widgets/sticker_lottie.dart +++ /dev/null @@ -1,384 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:lottie/lottie.dart'; - -class StickerLoadGovernor { - StickerLoadGovernor._() { - _budgetMs = _resolveBudgetMs(); - _avgMs = _budgetMs; - SchedulerBinding.instance.addTimingsCallback(_onTimings); - } - - static final StickerLoadGovernor instance = StickerLoadGovernor._(); - - final ValueNotifier throttled = ValueNotifier(false); - double _budgetMs = 1000 / 60; - double _avgMs = 1000 / 60; - - static double _resolveBudgetMs() { - final displays = ui.PlatformDispatcher.instance.displays; - var hz = displays.isEmpty ? 60.0 : displays.first.refreshRate; - if (!hz.isFinite || hz < 30) hz = 60; - return 1000 / hz; - } - - void _onTimings(List timings) { - for (final t in timings) { - final build = t.buildDuration.inMicroseconds; - final raster = t.rasterDuration.inMicroseconds; - final ms = (build > raster ? build : raster) / 1000.0; - _avgMs = _avgMs * 0.6 + ms * 0.4; - } - final enterMs = _budgetMs * 1.5; - final exitMs = _budgetMs * 0.8; - if (!throttled.value && _avgMs > enterMs) { - throttled.value = true; - } else if (throttled.value && _avgMs < exitMs) { - throttled.value = false; - } - } -} - -class _StickerFrames { - final LottieDrawable drawable; - final int frameCount; - final Duration duration; - final int pxSize; - final List _images; - ui.Image? _lastImage; - int bytes = 0; - int lastUsed = 0; - int active = 0; - - _StickerFrames({ - required this.drawable, - required this.frameCount, - required this.duration, - required this.pxSize, - }) : _images = List.filled(frameCount, null); - - ui.Image frameAt(int index) { - final existing = _images[index]; - if (existing != null) { - _lastImage = existing; - return existing; - } - - final last = _lastImage; - if (last != null && StickerLoadGovernor.instance.throttled.value) { - return last; - } - - final progress = frameCount <= 1 ? 0.0 : index / (frameCount - 1); - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - drawable.setProgress(progress); - drawable.draw( - canvas, - Rect.fromLTWH(0, 0, pxSize.toDouble(), pxSize.toDouble()), - fit: BoxFit.contain, - ); - final picture = recorder.endRecording(); - final image = picture.toImageSync(pxSize, pxSize); - picture.dispose(); - - _images[index] = image; - _lastImage = image; - final added = pxSize * pxSize * 4; - bytes += added; - _StickerFrameCache.instance._onBytesAdded(added); - return image; - } - - void dispose() { - for (final image in _images) { - image?.dispose(); - } - _images.fillRange(0, _images.length, null); - _lastImage = null; - bytes = 0; - } -} - -class _StickerFrameCache { - _StickerFrameCache._(); - static final _StickerFrameCache instance = _StickerFrameCache._(); - - static const int _maxBytes = 384 * 1024 * 1024; - static const double _fps = 30; - - final Map _entries = {}; - final Map> _loading = {}; - int _totalBytes = 0; - int _clock = 0; - - int _tick() => ++_clock; - - Future<_StickerFrames?> acquire(String url, int pxSize) async { - final key = '$url@$pxSize'; - final cached = _entries[key]; - if (cached != null) { - cached.lastUsed = _tick(); - cached.active++; - return cached; - } - final pending = _loading[key]; - if (pending != null) { - final entry = await pending; - if (entry != null) { - entry.lastUsed = _tick(); - entry.active++; - } - return entry; - } - final future = _load(url, pxSize, key); - _loading[key] = future; - final entry = await future; - _loading.remove(key); - if (entry != null) { - entry.lastUsed = _tick(); - entry.active++; - } - return entry; - } - - void release(_StickerFrames frames) { - if (frames.active > 0) frames.active--; - frames.lastUsed = _tick(); - _evictIfNeeded(); - } - - Future<_StickerFrames?> _load(String url, int pxSize, String key) async { - try { - final composition = await NetworkLottie( - url, - backgroundLoading: true, - ).load(); - final durationMs = composition.duration.inMilliseconds; - var frameCount = (durationMs / 1000 * _fps).round(); - frameCount = frameCount.clamp(1, 120); - final entry = _StickerFrames( - drawable: LottieDrawable(composition), - frameCount: frameCount, - duration: durationMs <= 0 - ? const Duration(seconds: 1) - : composition.duration, - pxSize: pxSize, - ); - _entries[key] = entry; - return entry; - } catch (_) { - return null; - } - } - - void _onBytesAdded(int bytes) { - _totalBytes += bytes; - _evictIfNeeded(); - } - - void _evictIfNeeded() { - if (_totalBytes <= _maxBytes) return; - final candidates = - _entries.entries.where((e) => e.value.active <= 0).toList() - ..sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); - for (final candidate in candidates) { - if (_totalBytes <= _maxBytes) break; - _totalBytes -= candidate.value.bytes; - candidate.value.dispose(); - _entries.remove(candidate.key); - } - } -} - -class StickerScrollScope extends InheritedWidget { - final ValueListenable isScrolling; - - const StickerScrollScope({ - super.key, - required this.isScrolling, - required super.child, - }); - - static ValueListenable? of(BuildContext context) => context - .dependOnInheritedWidgetOfExactType() - ?.isScrolling; - - @override - bool updateShouldNotify(StickerScrollScope oldWidget) => - !identical(oldWidget.isScrolling, isScrolling); -} - -class StickerLottie extends StatefulWidget { - final String lottieUrl; - final String? fallbackUrl; - final double? size; - final int? memCacheWidth; - - const StickerLottie({ - super.key, - required this.lottieUrl, - this.fallbackUrl, - this.size, - this.memCacheWidth, - }); - - @override - State createState() => _StickerLottieState(); -} - -class _StickerLottieState extends State - with SingleTickerProviderStateMixin { - final ValueNotifier _frameIndex = ValueNotifier(0); - late final Ticker _ticker; - _StickerFrames? _frames; - ValueListenable? _scrollState; - int? _px; - bool _started = false; - bool _showedFrames = false; - - bool get _isScrolling => _scrollState?.value ?? false; - bool get _canLoad => - !_isScrolling && !StickerLoadGovernor.instance.throttled.value; - - @override - void initState() { - super.initState(); - _ticker = createTicker(_onTick); - StickerLoadGovernor.instance.throttled.addListener(_onGateChanged); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final state = StickerScrollScope.of(context); - if (!identical(state, _scrollState)) { - _scrollState?.removeListener(_onGateChanged); - _scrollState = state; - _scrollState?.addListener(_onGateChanged); - } - } - - @override - void didUpdateWidget(StickerLottie oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.lottieUrl != widget.lottieUrl) { - _ticker.stop(); - final previous = _frames; - if (previous != null) _StickerFrameCache.instance.release(previous); - _frames = null; - _started = false; - _showedFrames = false; - } - } - - @override - void dispose() { - StickerLoadGovernor.instance.throttled.removeListener(_onGateChanged); - _scrollState?.removeListener(_onGateChanged); - _ticker.dispose(); - final frames = _frames; - if (frames != null) _StickerFrameCache.instance.release(frames); - _frameIndex.dispose(); - super.dispose(); - } - - void _onTick(Duration elapsed) { - final frames = _frames; - if (frames == null || frames.frameCount <= 1) return; - final periodMs = frames.duration.inMilliseconds; - if (periodMs <= 0) return; - final t = (elapsed.inMilliseconds % periodMs) / periodMs; - final index = (t * (frames.frameCount - 1)).round().clamp( - 0, - frames.frameCount - 1, - ); - if (index != _frameIndex.value) _frameIndex.value = index; - } - - void _onGateChanged() { - if (!mounted) return; - if (_isScrolling) { - if (_ticker.isActive) _ticker.stop(); - return; - } - final frames = _frames; - if (frames != null) { - if (!_ticker.isActive && frames.frameCount > 1) _ticker.start(); - } else if (_canLoad && !_started) { - _startLoad(); - } - } - - void _ensure(double box) { - if (_frames != null) return; - final dpr = MediaQuery.devicePixelRatioOf(context); - final raw = (box * dpr.clamp(1.0, 2.0)).clamp(96.0, 512.0); - _px = (raw / 32).ceil() * 32; - if (_started || !_canLoad) return; - _startLoad(); - } - - void _startLoad() { - final px = _px; - if (_started || px == null) return; - _started = true; - _StickerFrameCache.instance.acquire(widget.lottieUrl, px).then((frames) { - if (frames == null) return; - if (!mounted) { - _StickerFrameCache.instance.release(frames); - return; - } - setState(() => _frames = frames); - if (!_isScrolling && frames.frameCount > 1) _ticker.start(); - }); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final box = - widget.size ?? - (constraints.hasBoundedWidth - ? constraints.biggest.shortestSide - : 96.0); - _ensure(box); - final frames = _frames; - if (frames == null || (_isScrolling && !_showedFrames)) { - return _fallback(box); - } - _showedFrames = true; - return ValueListenableBuilder( - valueListenable: _frameIndex, - builder: (_, index, _) => RawImage( - image: frames.frameAt(index), - width: box, - height: box, - fit: BoxFit.contain, - ), - ); - }, - ); - } - - Widget _fallback(double box) { - final url = widget.fallbackUrl ?? ''; - final blank = SizedBox(width: box, height: box); - if (url.isEmpty) return blank; - return CachedNetworkImage( - imageUrl: url, - width: box, - height: box, - fit: BoxFit.contain, - memCacheWidth: widget.memCacheWidth, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => blank, - errorWidget: (_, _, _) => blank, - ); - } -} diff --git a/lib/frontend/widgets/sticker_pack_sheet.dart b/lib/frontend/widgets/sticker_pack_sheet.dart index 0c718f2..eae24c4 100644 --- a/lib/frontend/widgets/sticker_pack_sheet.dart +++ b/lib/frontend/widgets/sticker_pack_sheet.dart @@ -8,7 +8,7 @@ import '../../models/sticker.dart'; import '../screens/chats/chat_list_screen.dart'; import 'custom_notification.dart'; import 'small_spinner.dart'; -import 'sticker_image.dart'; +import 'lottie_image.dart'; import 'sticker_peek.dart'; enum _PackAction { forward, copyLink } @@ -278,7 +278,7 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { tags: item.tags, child: Padding( padding: const EdgeInsets.all(6), - child: StickerImage( + child: LottieImage( url: item.url, lottieUrl: item.lottieUrl, memCacheWidth: 220, @@ -310,13 +310,9 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { ), 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 82fbdf1..995bb78 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -1,16 +1,23 @@ +import 'dart:async'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../core/utils/debouncer.dart'; import '../../core/utils/emoji_keyword_index.dart'; import '../../main.dart' show stickersModule; +import '../../models/animoji.dart'; import '../../models/sticker.dart'; +import 'emoji_panel.dart'; +import 'segmented_pill_toggle.dart'; import 'small_spinner.dart'; -import 'sticker_image.dart'; -import 'sticker_lottie.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(); @@ -42,11 +49,13 @@ class _Section { class StickerPanel extends StatefulWidget { final double height; final void Function(StickerItem sticker) onStickerTap; + final void Function(Animoji animoji)? onEmojiTap; const StickerPanel({ super.key, required this.height, required this.onStickerTap, + this.onEmojiTap, }); @override @@ -58,6 +67,12 @@ class _StickerPanelState extends State static const double _tabBarHeight = 52; static const double _headerHeight = 34; static const double _searchFieldHeight = 50; + static const double _toggleBarHeight = 48; + static const int _modeEmoji = 0; + static const int _modeStickers = 1; + static const String _modePrefKey = 'komet_panel_mode'; + static int _persistedMode = _modeStickers; + static bool _persistedModeLoaded = false; final ScrollController _scroll = ScrollController(); final ValueNotifier _scrolling = ValueNotifier(false); @@ -69,6 +84,8 @@ class _StickerPanelState extends State late final AnimationController _shimmer; bool _loading = true; Object? _error; + late int _mode; + bool _modeUserChosen = false; int _selectedTab = 0; List<_Section> _sections = const []; List _heights = const []; @@ -80,6 +97,8 @@ class _StickerPanelState extends State @override void initState() { super.initState(); + _mode = widget.onEmojiTap == null ? _modeStickers : _persistedMode; + if (!_persistedModeLoaded) unawaited(_loadPersistedMode()); _shimmer = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), @@ -228,55 +247,122 @@ class _StickerPanelState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Container( + return SizedBox( height: widget.height, - color: cs.surface, - child: _loading - ? Center(child: SmallSpinner()) - : _error != null || _sections.isEmpty - ? Center( - child: Text( - _error != null - ? 'Не удалось загрузить стикеры' - : 'Нет стикеров', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - ) - : ScrollConfiguration( - behavior: const _DragScrollBehavior(), - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final columns = (width / 84).floor().clamp(4, 8); - final cell = width / columns; - - final heights = []; - final offsets = []; - var acc = _searchFieldHeight; - for (final s in _sections) { - final rows = (s.stickerIds.length / columns).ceil(); - final h = _headerHeight + rows * cell; - offsets.add(acc); - heights.add(h); - acc += h; - } - _heights = heights; - _offsets = offsets; - - return Column( - children: [ - _buildTabBar(cs), - Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.3), - ), - Expanded(child: _buildContent(cs, columns, cell)), - ], - ); - }, - ), + child: GlassSurface( + liquid: false, + frostTint: AppFrost.panelTint(cs), + border: Border(top: AppFrost.hairline(cs)), + child: Column( + children: [ + Expanded( + child: _mode == _modeEmoji && widget.onEmojiTap != null + ? EmojiPanel(onEmojiTap: widget.onEmojiTap!) + : _buildStickerBody(cs), ), + if (widget.onEmojiTap != null) _buildToggleBar(cs), + ], + ), + ), + ); + } + + Widget _buildStickerBody(ColorScheme cs) { + if (_loading) return Center(child: SmallSpinner()); + if (_error != null || _sections.isEmpty) { + return Center( + child: Text( + _error != null ? 'Не удалось загрузить стикеры' : 'Нет стикеров', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ); + } + return ScrollConfiguration( + behavior: const _DragScrollBehavior(), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = (width / 84).floor().clamp(4, 8); + final cell = width / columns; + + final heights = []; + final offsets = []; + var acc = _searchFieldHeight; + for (final s in _sections) { + final rows = (s.stickerIds.length / columns).ceil(); + final h = _headerHeight + rows * cell; + offsets.add(acc); + heights.add(h); + acc += h; + } + _heights = heights; + _offsets = offsets; + + return Column( + children: [ + _buildTabBar(cs), + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + Expanded(child: _buildContent(cs, columns, cell)), + ], + ); + }, + ), + ); + } + + Future _loadPersistedMode() async { + try { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getInt(_modePrefKey); + _persistedModeLoaded = true; + if (value != _modeEmoji && value != _modeStickers) return; + _persistedMode = value!; + if (!mounted || _modeUserChosen || widget.onEmojiTap == null) return; + if (_mode != value) setState(() => _mode = value); + } catch (_) { + _persistedModeLoaded = true; + } + } + + void _setMode(int mode) { + if (mode == _mode) return; + _modeUserChosen = true; + _persistedMode = mode; + setState(() => _mode = mode); + unawaited(_persistMode(mode)); + } + + Future _persistMode(int mode) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_modePrefKey, mode); + } catch (_) {} + } + + Widget _buildToggleBar(ColorScheme cs) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + SizedBox( + height: _toggleBarHeight, + child: Center( + child: SegmentedPillToggle( + labels: const ['Эмодзи', 'Стикеры'], + selected: _mode, + onChanged: _setMode, + ), + ), + ), + ], ); } @@ -327,7 +413,7 @@ class _StickerPanelState extends State } Widget _buildContent(ColorScheme cs, int columns, double cell) { - return StickerScrollScope( + return LottieScrollScope( isScrolling: _scrolling, child: StickerPeekScope( child: NotificationListener( @@ -456,7 +542,7 @@ class _StickerPanelState extends State onTap: () => widget.onStickerTap(item), child: Padding( padding: const EdgeInsets.all(6), - child: StickerImage( + child: LottieImage( url: item.url, lottieUrl: item.lottieUrl, memCacheWidth: 220, @@ -572,7 +658,7 @@ class _StickerSectionState extends State<_StickerSection> { onTap: () => widget.onTap(item), child: Padding( padding: const EdgeInsets.all(6), - child: StickerImage( + child: LottieImage( url: item.url, lottieUrl: item.lottieUrl, memCacheWidth: 220, diff --git a/lib/frontend/widgets/sticker_peek.dart b/lib/frontend/widgets/sticker_peek.dart index d744013..99d5330 100644 --- a/lib/frontend/widgets/sticker_peek.dart +++ b/lib/frontend/widgets/sticker_peek.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../core/utils/haptics.dart'; -import 'sticker_image.dart'; +import 'lottie_image.dart'; class _PeekData { final String? url; @@ -235,7 +235,7 @@ class _PeekOverlay extends StatelessWidget { SizedBox( width: previewSize, height: previewSize, - child: StickerImage( + child: LottieImage( url: d.url, lottieUrl: d.lottieUrl, size: previewSize, diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart index 6ed451a..26263db 100644 --- a/lib/frontend/widgets/video_player_screen.dart +++ b/lib/frontend/widgets/video_player_screen.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; import '../../core/utils/format.dart'; +import 'small_spinner.dart'; class VideoPlayerScreen extends StatefulWidget { final Map sources; @@ -133,11 +134,11 @@ class _VideoPlayerScreenState extends State { aspectRatio: c.value.aspectRatio, child: VideoPlayer(c), ) - : const CircularProgressIndicator(color: Colors.white), + : const SmallSpinner(size: 36, color: Colors.white), ), if (buffering) const Center( - child: CircularProgressIndicator(color: Colors.white), + child: SmallSpinner(size: 36, color: Colors.white), ), if (!_error) AnimatedOpacity( diff --git a/lib/frontend/widgets/web_qr_login.dart b/lib/frontend/widgets/web_qr_login.dart index 96feca7..d216afd 100644 --- a/lib/frontend/widgets/web_qr_login.dart +++ b/lib/frontend/widgets/web_qr_login.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../main.dart' show accountModule; import 'custom_notification.dart'; import 'sheet_helpers.dart'; +import 'small_spinner.dart'; Future showWebQrLoginConfirmSheet(BuildContext context) async { final agreed = await showModalBottomSheet( @@ -88,7 +89,7 @@ Future confirmAndAuthorizeWebQrLogin( color: cs.surfaceContainerHigh, child: const Padding( padding: EdgeInsets.all(28), - child: CircularProgressIndicator(), + child: SmallSpinner(size: 36), ), ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7a7e9f6..b4d6bb0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -183,11 +183,19 @@ "registrationSubtitle": "Add your name and pick an avatar", "registrationChooseAvatar": "Choose an avatar", "msgActionsCopy": "Copy", + "msgActionsSelectAll": "Select all", + "emojiSearchHint": "Search emoji", "msgActionsEdit": "Edit", "msgActionsReply": "Reply", "msgActionsForward": "Forward", "msgActionsMarkUnread": "Mark as unread", + "msgActionsPin": "Pin", + "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", @@ -282,11 +290,22 @@ "appearanceVisualStyleSubtitle": "Material You or dimensional Glossy capsules", "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": "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", "appearanceGradientTitle": "Gradient", "appearanceGradientSubtitle": "Depth and highlights in Glossy capsules", "appearanceAccentColorTitle": "Accent color", @@ -494,6 +513,58 @@ } } }, + "sharedMembersCount": "{count, plural, =1{1 member} other{{count} members}}", + "@sharedMembersCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sharedLoadMore": "Show more", + "sharedGoToMessage": "Go to message", + "sharedDownload": "Download", + "photoViewerCounter": "Photo {index} of {total}", + "@photoViewerCounter": { + "placeholders": { + "index": { + "type": "int" + }, + "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", + "sharedCopyLink": "Copy link", + "sharedLinkCopied": "Link copied", "chatInfoActionLeave": "Leave", "chatInfoBio": "About", "chatInfoInviteLink": "Invite link", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d940139..2956c43 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1094,6 +1094,18 @@ 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: + /// **'Search emoji'** + String get emojiSearchHint; + /// No description provided for @msgActionsEdit. /// /// In en, this message translates to: @@ -1118,12 +1130,48 @@ abstract class AppLocalizations { /// **'Mark as unread'** String get msgActionsMarkUnread; + /// No description provided for @msgActionsPin. + /// + /// In en, this message translates to: + /// **'Pin'** + String get msgActionsPin; + + /// No description provided for @msgActionsUnpin. + /// + /// In en, this message translates to: + /// **'Unpin'** + String get msgActionsUnpin; + + /// No description provided for @pinnedMessageTitle. + /// + /// In en, this message translates to: + /// **'Pinned message'** + String get pinnedMessageTitle; + /// No description provided for @msgActionsEditHistory. /// /// In en, this message translates to: /// **'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: @@ -1478,6 +1526,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: @@ -1508,6 +1568,60 @@ abstract class AppLocalizations { /// **'None'** String get appearanceChatChromeNone; + /// No description provided for @appearanceChatChromeTransparent. + /// + /// In en, this message translates to: + /// **'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 @appearanceGradientTitle. /// /// In en, this message translates to: @@ -2444,6 +2558,78 @@ abstract class AppLocalizations { /// **'{online} of {total} online'** String chatInfoOnlineOfTotal(String online, String total); + /// No description provided for @sharedMembersCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 member} other{{count} members}}'** + String sharedMembersCount(int count); + + /// No description provided for @sharedLoadMore. + /// + /// In en, this message translates to: + /// **'Show more'** + String get sharedLoadMore; + + /// No description provided for @sharedGoToMessage. + /// + /// In en, this message translates to: + /// **'Go to message'** + String get sharedGoToMessage; + + /// No description provided for @sharedDownload. + /// + /// In en, this message translates to: + /// **'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 @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 @sharedCopyLink. + /// + /// In en, this message translates to: + /// **'Copy link'** + String get sharedCopyLink; + + /// No description provided for @sharedLinkCopied. + /// + /// In en, this message translates to: + /// **'Link copied'** + String get sharedLinkCopied; + /// No description provided for @chatInfoActionLeave. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 02b0e19..36a35aa 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -524,6 +524,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get msgActionsCopy => 'Copy'; + @override + String get msgActionsSelectAll => 'Select all'; + + @override + String get emojiSearchHint => 'Search emoji'; + @override String get msgActionsEdit => 'Edit'; @@ -536,9 +542,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get msgActionsMarkUnread => 'Mark as unread'; + @override + String get msgActionsPin => 'Pin'; + + @override + String get msgActionsUnpin => 'Unpin'; + + @override + String get pinnedMessageTitle => 'Pinned message'; + @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'; @@ -734,6 +758,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceVisualStyleGlossy => 'Glossy'; + @override + String get appearanceVisualStyleLiquidGlass => 'Liquid Glass'; + + @override + String get appearanceGlassMaterial => 'Glass'; + @override String get appearanceChatChromeTitle => 'Chat screen elements'; @@ -750,6 +780,35 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceChatChromeNone => 'None'; + @override + 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 appearanceGradientTitle => 'Gradient'; @@ -1235,6 +1294,56 @@ class AppLocalizationsEn extends AppLocalizations { return '$online of $total online'; } + @override + String sharedMembersCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count members', + one: '1 member', + ); + return '$_temp0'; + } + + @override + String get sharedLoadMore => 'Show more'; + + @override + String get sharedGoToMessage => 'Go to message'; + + @override + String get sharedDownload => 'Download'; + + @override + String photoViewerCounter(int index, int total) { + return 'Photo $index 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 get sharedCopyLink => 'Copy link'; + + @override + String get sharedLinkCopied => 'Link copied'; + @override String get chatInfoActionLeave => 'Leave'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 031c554..98f6636 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -527,6 +527,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get msgActionsCopy => 'Копировать'; + @override + String get msgActionsSelectAll => 'Выбрать всё'; + + @override + String get emojiSearchHint => 'Поиск эмодзи'; + @override String get msgActionsEdit => 'Изменить'; @@ -539,9 +545,27 @@ class AppLocalizationsRu extends AppLocalizations { @override String get msgActionsMarkUnread => 'Непрочитанное'; + @override + String get msgActionsPin => 'Закрепить'; + + @override + String get msgActionsUnpin => 'Открепить'; + + @override + String get pinnedMessageTitle => 'Закреплённое сообщение'; + @override String get msgActionsEditHistory => 'История изменений'; + @override + String get msgActionsReadBy => 'Кем прочитано'; + + @override + String get msgActionsReadByEmpty => 'Пока никто не прочитал'; + + @override + String get msgActionsReadByUnknownUser => 'Пользователь'; + @override String get msgActionsReport => 'Пожаловаться'; @@ -737,6 +761,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceVisualStyleGlossy => 'Glossy'; + @override + String get appearanceVisualStyleLiquidGlass => 'Liquid Glass'; + + @override + String get appearanceGlassMaterial => 'Стекло'; + @override String get appearanceChatChromeTitle => 'Элементы экрана чата'; @@ -753,6 +783,34 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appearanceChatChromeNone => 'Нет'; + @override + 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 appearanceGradientTitle => 'Градиент'; @@ -1240,6 +1298,58 @@ class AppLocalizationsRu extends AppLocalizations { return '$online из $total в сети'; } + @override + String sharedMembersCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count участника', + many: '$count участников', + few: '$count участника', + one: '1 участник', + ); + return '$_temp0'; + } + + @override + String get sharedLoadMore => 'Показать ещё'; + + @override + String get sharedGoToMessage => 'Перейти к сообщению'; + + @override + String get sharedDownload => 'Скачать'; + + @override + String photoViewerCounter(int index, int total) { + return 'Фото $index из $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 get sharedCopyLink => 'Копировать ссылку'; + + @override + String get sharedLinkCopied => 'Ссылка скопирована'; + @override String get chatInfoActionLeave => 'Покинуть'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fd206c3..2548d5c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -183,11 +183,19 @@ "registrationSubtitle": "Укажите имя и выберите аватар", "registrationChooseAvatar": "Выберите аватар", "msgActionsCopy": "Копировать", + "msgActionsSelectAll": "Выбрать всё", + "emojiSearchHint": "Поиск эмодзи", "msgActionsEdit": "Изменить", "msgActionsReply": "Ответить", "msgActionsForward": "Переслать", "msgActionsMarkUnread": "Непрочитанное", + "msgActionsPin": "Закрепить", + "msgActionsUnpin": "Открепить", + "pinnedMessageTitle": "Закреплённое сообщение", "msgActionsEditHistory": "История изменений", + "msgActionsReadBy": "Кем прочитано", + "msgActionsReadByEmpty": "Пока никто не прочитал", + "msgActionsReadByUnknownUser": "Пользователь", "msgActionsReport": "Пожаловаться", "msgActionsDelete": "Удалить", "msgActionsCopied": "Скопировано", @@ -247,11 +255,22 @@ "appearanceVisualStyleSubtitle": "Material You или объёмные Glossy-капсулы", "appearanceVisualStyleMaterialYou": "Material You", "appearanceVisualStyleGlossy": "Glossy", + "appearanceVisualStyleLiquidGlass": "Liquid Glass", + "appearanceGlassMaterial": "Стекло", "appearanceChatChromeTitle": "Элементы экрана чата", "appearanceChatChromeSubtitle": "Фон панелей сверху и снизу: цвет, размытие или прозрачно. При размытии и прозрачности сообщения заходят под панели", "appearanceChatChromeColor": "Цвет", "appearanceChatChromeBlur": "Блюр", "appearanceChatChromeNone": "Нет", + "appearanceChatChromeTransparent": "Frost blur", + "appearanceComposerTitle": "Вид панели ввода", + "appearanceComposerSubtitle": "Стиль и фон панели ввода сообщений", + "appearanceComposerBackgroundStandard": "Default", + "appearanceComposerBackgroundFrost": "Frost blur", + "appearanceNavPillTitle": "Вид переключателей", + "appearanceNavPillSubtitle": "Переключатель разделов на экране чатов", + "appearanceNavPillGlossy": "Glossy", + "appearanceNavPillFrost": "G-FrostBlur", "appearanceGradientTitle": "Градиент", "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", "appearanceAccentColorTitle": "Акцентный цвет", @@ -414,6 +433,18 @@ "chatInfoEmptyVoice": "Нет голосовых", "chatInfoEmptyLinks": "Нет ссылок", "chatInfoOnlineOfTotal": "{online} из {total} в сети", + "sharedMembersCount": "{count, plural, =1{1 участник} few{{count} участника} many{{count} участников} other{{count} участника}}", + "sharedLoadMore": "Показать ещё", + "sharedGoToMessage": "Перейти к сообщению", + "sharedDownload": "Скачать", + "photoViewerCounter": "Фото {index} из {total}", + "photoViewerSentToday": "{sender} • сегодня в {time}", + "photoViewerSentOn": "{sender} • {date} в {time}", + "photoViewerSaveAs": "Сохранить как…", + "photoViewerViewAll": "Все фото чата", + "photoViewerRotate": "Повернуть", + "sharedCopyLink": "Копировать ссылку", + "sharedLinkCopied": "Ссылка скопирована", "chatInfoActionLeave": "Покинуть", "chatInfoBio": "О себе", "chatInfoInviteLink": "Ссылка-приглашение", diff --git a/lib/main.dart b/lib/main.dart index 1dfb8e1..a583f13 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'; @@ -17,11 +18,13 @@ import 'core/utils/logger.dart'; 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/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_show_extra_info.dart'; import 'core/config/app_bubble_behavior.dart'; import 'core/config/komet_settings.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'; @@ -36,6 +39,12 @@ import 'core/config/app_media_cache.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'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'core/config/app_digital_id_mode.dart'; @@ -47,7 +56,10 @@ import 'backend/modules/messages.dart'; import 'backend/modules/outbox.dart'; import 'backend/modules/polls.dart'; import 'backend/modules/stickers.dart'; +import 'backend/modules/animoji.dart'; +import 'backend/modules/stories.dart'; import 'backend/modules/self_check.dart'; +import 'backend/modules/shared_content.dart'; import 'backend/modules/webapp.dart'; import 'backend/modules/digital_id.dart'; import 'core/calls/call_bridge.dart'; @@ -67,21 +79,29 @@ 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'; final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); +final sharedContentModule = SharedContentModule(api); final pollsModule = PollsModule(api); final stickersModule = StickersModule(api); +final animojiModule = AnimojiModule(api); final webAppModule = WebAppModule(api); final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); +final storiesModule = StoriesModule(api); final RouteObserver> appRouteObserver = RouteObserver>(); bool isOnemeFlavor = false; +const ProgressIndicatorThemeData _expressiveProgressTheme = + ProgressIndicatorThemeData(year2023: false); + const PageTransitionsTheme _appPageTransitions = PageTransitionsTheme( builders: { TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), @@ -143,8 +163,10 @@ void _installLogCapture() { }; } -void main() async { +void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + await initKolibri(); + DebugTest.parse(args); _installLogCapture(); VideoPlayerMediaKit.ensureInitialized( windows: true, @@ -161,6 +183,8 @@ void main() async { } attachInfoCacheApi(api); chats.attachGlobalPushHandlers(api); + storiesModule.attach(); + unawaited(storiesModule.loadCache()); unawaited(DeepLinkService.instance.init()); final packageInfoFuture = PackageInfo.fromPlatform(); @@ -175,7 +199,12 @@ void main() 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(); @@ -199,6 +228,7 @@ void main() async { final prefs = await prefsFuture; await FileHistoryCache.load(prefs); await DraftStore.instance.load(); + await ArchivedChatsStore.instance.load(); await KometSettings.load(); if (KometSettings.ghostMode.value) SelfPresence.markOffline(); await ContactCache.load(); @@ -224,7 +254,12 @@ void main() async { amoledFuture, pillGradientFuture, visualStyleFuture, + liquidGlassFuture, chatChromeFuture, + composerStyleFuture, + composerBackgroundFuture, + navPillStyleFuture, + wallpaperTintFuture, themeScheduleFuture, messageActionsFuture, swipeBackFuture, @@ -298,6 +333,7 @@ class KometAppState extends State late final ValueNotifier accentSeed = ValueNotifier( widget.initialAccentSeed, ); + final ValueNotifier wallpaperSeed = ValueNotifier(null); StreamSubscription? _sessionExpiredSub; StreamSubscription? _loginStatusSub; StreamSubscription? _vpnBypassSub; @@ -333,8 +369,11 @@ class KometAppState extends State AppThemeModeConfig.current.addListener(_onThemeModeChanged); AppAmoled.current.addListener(_onAmoledChanged); AppThemeSchedule.current.addListener(_onScheduleChanged); + AppWallpaperTint.current.addListener(_onWallpaperTintChanged); + ChatWallpaperStore.instance.revision.addListener(_onWallpaperTintChanged); _lastAppliedThemeMode = _effectiveThemeMode; _rescheduleSwitch(); + unawaited(_refreshWallpaperSeed()); api.setReconnectCallback(() async { try { @@ -353,6 +392,7 @@ class KometAppState extends State _loginStatusSub = accountModule.loginStatusStream.listen((status) async { if (status == LoginStatus.success) { DeepLinkService.instance.markReady(); + unawaited(_refreshWallpaperSeed()); CallController.instance.init(api); OutboxService.instance.init(api, messagesModule); SelfCheckService.instance.init(api); @@ -404,9 +444,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(); @@ -498,6 +538,10 @@ class KometAppState extends State AppThemeModeConfig.current.removeListener(_onThemeModeChanged); AppAmoled.current.removeListener(_onAmoledChanged); AppThemeSchedule.current.removeListener(_onScheduleChanged); + AppWallpaperTint.current.removeListener(_onWallpaperTintChanged); + ChatWallpaperStore.instance.revision.removeListener( + _onWallpaperTintChanged, + ); WidgetsBinding.instance.removeObserver(this); _profileUpdateController.close(); fpsOverlayEnabled.dispose(); @@ -505,6 +549,7 @@ class KometAppState extends State tlsInsecureEnabled.dispose(); fontScale.dispose(); accentSeed.dispose(); + wallpaperSeed.dispose(); super.dispose(); } @@ -707,6 +752,29 @@ class KometAppState extends State accentSeed.value = seed; } + void _onWallpaperTintChanged() => unawaited(_refreshWallpaperSeed()); + + Future _refreshWallpaperSeed() async { + if (!AppWallpaperTint.current.value) { + wallpaperSeed.value = null; + return; + } + final profile = await AppDatabase.loadActiveProfile(); + final accountId = profile?.id ?? 0; + if (accountId == 0) { + wallpaperSeed.value = null; + return; + } + await ChatWallpaperStore.instance.load(); + final wallpaper = ChatWallpaperStore.instance.get( + accountId, + kGlobalWallpaperChatId, + ); + final seed = await computeWallpaperSeed(wallpaper); + if (!mounted) return; + wallpaperSeed.value = seed; + } + Future applyAppFont(String fontId) async { if (_fontId == fontId) return; final prefs = await SharedPreferences.getInstance(); @@ -771,6 +839,7 @@ class KometAppState extends State useMaterial3: true, colorScheme: light, pageTransitionsTheme: _appPageTransitions, + progressIndicatorTheme: _expressiveProgressTheme, textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.light).textTheme, @@ -782,6 +851,7 @@ class KometAppState extends State useMaterial3: true, colorScheme: dark, pageTransitionsTheme: _appPageTransitions, + progressIndicatorTheme: _expressiveProgressTheme, textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.dark).textTheme, @@ -838,9 +908,17 @@ class KometAppState extends State Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - return ValueListenableBuilder( - valueListenable: accentSeed, - builder: (context, seed, _) { + return ListenableBuilder( + listenable: Listenable.merge([ + accentSeed, + wallpaperSeed, + AppWallpaperTint.current, + ]), + builder: (context, _) { + final seed = + AppWallpaperTint.current.value && wallpaperSeed.value != null + ? wallpaperSeed.value + : accentSeed.value; final ColorScheme lightBase; final ColorScheme darkBase; if (seed != null) { @@ -930,6 +1008,17 @@ class _StartupScreenState extends State<_StartupScreen> { } Future _tryAutoLogin() async { + if (DebugTest.enabled) { + await Future.delayed(Duration.zero); + if (!mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + ); + KometApp.stateOf(context)?.markShellReady(); + return; + } + unawaited(api.connect()); int? accountId = await TokenStorage.getActiveAccountId(); @@ -981,7 +1070,7 @@ class _StartupScreenState extends State<_StartupScreen> { return Scaffold( backgroundColor: cs.surface, body: Center( - child: CircularProgressIndicator(color: cs.primary, strokeWidth: 2), + child: SmallSpinner(size: 36, color: cs.primary), ), ); } diff --git a/lib/models/animoji.dart b/lib/models/animoji.dart new file mode 100644 index 0000000..0ace5cf --- /dev/null +++ b/lib/models/animoji.dart @@ -0,0 +1,31 @@ +class Animoji { + final int id; + final String emoji; + final int setId; + final String? iconUrl; + final String? lottieUrl; + final String? lottiePlayUrl; + + const Animoji({ + required this.id, + required this.emoji, + this.setId = 0, + this.iconUrl, + this.lottieUrl, + this.lottiePlayUrl, + }); + + static Animoji? fromMap(Map map) { + final id = map['id']; + final emoji = map['emoji']?.toString(); + if (id is! int || emoji == null || emoji.isEmpty) return null; + return Animoji( + id: id, + emoji: emoji, + setId: map['setId'] is int ? map['setId'] as int : 0, + iconUrl: map['iconUrl']?.toString(), + lottieUrl: map['lottieUrl']?.toString(), + lottiePlayUrl: map['lottiePlayUrl']?.toString(), + ); + } +} diff --git a/lib/models/story.dart b/lib/models/story.dart new file mode 100644 index 0000000..0b72ad4 --- /dev/null +++ b/lib/models/story.dart @@ -0,0 +1,328 @@ +import '../core/utils/parse.dart'; +import 'attachment.dart'; + +enum StoryOwnerType { user, chat, channel } + +int _ownerTypeToInt(StoryOwnerType type) { + switch (type) { + case StoryOwnerType.user: + return 0; + case StoryOwnerType.chat: + return 1; + case StoryOwnerType.channel: + return 2; + } +} + +StoryOwnerType _ownerTypeFromInt(Object? raw) { + switch (parseIntOrNull(raw)) { + case 1: + return StoryOwnerType.chat; + case 2: + return StoryOwnerType.channel; + default: + return StoryOwnerType.user; + } +} + +Map _asStringMap(Object? raw) { + if (raw is Map) return raw; + if (raw is Map) return Map.from(raw); + return const {}; +} + +class StoryOwner { + final int ownerId; + final StoryOwnerType type; + + const StoryOwner({required this.ownerId, this.type = StoryOwnerType.user}); + + bool get isUser => type == StoryOwnerType.user; + + static StoryOwner? fromMap(Object? raw) { + final map = _asStringMap(raw); + final id = parseIntOrNull(map['ownerId']); + if (id == null || id == 0) return null; + return StoryOwner(ownerId: id, type: _ownerTypeFromInt(map['type'])); + } + + Map toMap() => { + 'ownerId': ownerId, + 'type': _ownerTypeToInt(type), + }; + + @override + bool operator ==(Object other) => + other is StoryOwner && + other.ownerId == ownerId && + other.type == type; + + @override + int get hashCode => Object.hash(ownerId, type); +} + +class StoryReaction { + final int reactionType; // 0 = emoji, 1 = sticker + final String id; + + const StoryReaction({this.reactionType = 0, required this.id}); + + bool get isSticker => reactionType == 1; + + static StoryReaction? fromMap(Object? raw) { + final map = _asStringMap(raw); + final id = map['id']?.toString(); + if (id == null || id.isEmpty) return null; + return StoryReaction( + reactionType: parseIntOrNull(map['reactionType']) ?? 0, + id: id, + ); + } + + Map toMap() => {'reactionType': reactionType, 'id': id}; +} + +class StoryMedia { + final AttachmentType type; + final String? url; + final String? thumbnailUrl; + final String? previewData; + final int? width; + final int? height; + final int? durationMs; + + const StoryMedia({ + required this.type, + this.url, + this.thumbnailUrl, + this.previewData, + this.width, + this.height, + this.durationMs, + }); + + bool get isVideo => type == AttachmentType.video; + bool get isPhoto => type == AttachmentType.photo; + + double get aspectRatio { + final w = width ?? 0; + final h = height ?? 0; + if (w <= 0 || h <= 0) return 9 / 16; + return w / h; + } + + static StoryMedia? fromMap(Object? raw) { + final map = _asStringMap(raw); + final typeStr = (map['_type'] as String? ?? '').toUpperCase(); + final previewData = decodeAttachPreview(map['previewData']); + final width = parseIntOrNull(map['width']); + final height = parseIntOrNull(map['height']); + switch (typeStr) { + case 'PHOTO': + final url = + (map['photoUrl'] ?? map['baseUrl'] ?? map['url'])?.toString(); + return StoryMedia( + type: AttachmentType.photo, + url: url, + previewData: previewData, + width: width, + height: height, + ); + case 'VIDEO': + final url = + (map['mp4Url'] ?? + map['videoUrl'] ?? + map['MP4_1080'] ?? + map['baseUrl']) + ?.toString(); + return StoryMedia( + type: AttachmentType.video, + url: url, + thumbnailUrl: map['thumbnail']?.toString(), + previewData: previewData, + width: width, + height: height, + durationMs: parseIntOrNull(map['duration']), + ); + default: + return StoryMedia( + type: AttachmentType.unknown, + previewData: previewData, + width: width, + height: height, + ); + } + } + + String get _typeName { + switch (type) { + case AttachmentType.photo: + return 'PHOTO'; + case AttachmentType.video: + return 'VIDEO'; + default: + return 'UNKNOWN'; + } + } + + Map toJson() { + final map = { + '_type': _typeName, + if (previewData != null) 'previewData': previewData, + if (width != null) 'width': width, + if (height != null) 'height': height, + }; + if (isVideo) { + if (url != null) map['mp4Url'] = url; + if (thumbnailUrl != null) map['thumbnail'] = thumbnailUrl; + if (durationMs != null) map['duration'] = durationMs; + } else { + if (url != null) map['photoUrl'] = url; + } + return map; + } +} + +class Story { + final int id; + final int cid; + final StoryOwner owner; + final int settings; + final int time; + final int updateTime; + final int expiration; + final StoryMedia? media; + final StoryReaction? reaction; + + const Story({ + required this.id, + required this.owner, + this.cid = 0, + this.settings = 0, + this.time = 0, + this.updateTime = 0, + this.expiration = 0, + this.media, + this.reaction, + }); + + Story copyWith({StoryReaction? reaction, bool clearReaction = false}) { + return Story( + id: id, + cid: cid, + owner: owner, + settings: settings, + time: time, + updateTime: updateTime, + expiration: expiration, + media: media, + reaction: clearReaction ? null : (reaction ?? this.reaction), + ); + } + + static Story? fromMap(Object? raw) { + final map = _asStringMap(raw); + final owner = StoryOwner.fromMap(map['owner']); + if (owner == null) return null; + return Story( + id: parseIntOrNull(map['id']) ?? 0, + cid: parseIntOrNull(map['cid']) ?? 0, + owner: owner, + settings: parseIntOrNull(map['settings']) ?? 0, + time: parseIntOrNull(map['time']) ?? 0, + updateTime: parseIntOrNull(map['updateTime']) ?? 0, + expiration: parseIntOrNull(map['expiration']) ?? 0, + media: StoryMedia.fromMap(map['media']), + reaction: StoryReaction.fromMap(map['reaction']), + ); + } + + Map toJson() => { + 'id': id, + 'cid': cid, + 'owner': owner.toMap(), + 'settings': settings, + 'time': time, + 'updateTime': updateTime, + 'expiration': expiration, + if (media != null) 'media': media!.toJson(), + if (reaction != null) 'reaction': reaction!.toMap(), + }; +} + +class StoryPreview { + final StoryOwner owner; + final int updateTime; + final int totalCount; + final int readCount; + final int lastStoryExpirationTime; + + const StoryPreview({ + required this.owner, + this.updateTime = 0, + this.totalCount = 0, + this.readCount = 0, + this.lastStoryExpirationTime = 0, + }); + + int get unreadCount { + final diff = totalCount - readCount; + return diff < 0 ? 0 : diff; + } + + bool get hasUnread => unreadCount > 0; + + bool get isEmpty => totalCount <= 0; + + StoryPreview copyWith({int? readCount}) => StoryPreview( + owner: owner, + updateTime: updateTime, + totalCount: totalCount, + readCount: readCount ?? this.readCount, + lastStoryExpirationTime: lastStoryExpirationTime, + ); + + static StoryPreview? fromMap(Object? raw) { + final map = _asStringMap(raw); + final owner = StoryOwner.fromMap(map['owner']); + if (owner == null) return null; + return StoryPreview( + owner: owner, + updateTime: parseIntOrNull(map['updateTime']) ?? 0, + totalCount: parseIntOrNull(map['totalCount']) ?? 0, + readCount: parseIntOrNull(map['readCount']) ?? 0, + lastStoryExpirationTime: + parseIntOrNull(map['lastStoryExpirationTime']) ?? 0, + ); + } + + Map toJson() => { + 'owner': owner.toMap(), + 'updateTime': updateTime, + 'totalCount': totalCount, + 'readCount': readCount, + 'lastStoryExpirationTime': lastStoryExpirationTime, + }; +} + +class PeerStories { + final StoryOwner owner; + final List stories; + + const PeerStories({required this.owner, this.stories = const []}); + + static PeerStories? fromMap(Object? raw) { + final map = _asStringMap(raw); + final owner = StoryOwner.fromMap(map['owner']); + if (owner == null) return null; + final rawStories = map['stories']; + final stories = []; + if (rawStories is List) { + for (final s in rawStories) { + final story = Story.fromMap(s); + if (story != null) stories.add(story); + } + } + return PeerStories(owner: owner, stories: stories); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 3e8a807..48cf7f7 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -2,6 +2,12 @@ cmake_minimum_required(VERSION 3.13) project(runner LANGUAGES CXX) +# Capture whether the install prefix is still CMake's default before any +# add_subdirectory() runs. Third-party libraries (rlottie) call project() +# again, which resets CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT to false and +# would otherwise defeat the bundle-directory redirect below. +set(RUNNER_PREFIX_IS_DEFAULT ${CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT}) + # The name of the executable created for the application. Change this to change # the on-disk name of your application. set(BINARY_NAME "Komet") @@ -84,12 +90,15 @@ if(TARGET flutter_webrtc_plugin) target_compile_options(flutter_webrtc_plugin PRIVATE -include cstdint) endif() +set(RLOTTIE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rlottie_build") +add_subdirectory("${RLOTTIE_DIR}" "${CMAKE_BINARY_DIR}/rlottie") + # === Installation === # By default, "installing" just makes a relocatable bundle in the build # directory. set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) +if(RUNNER_PREFIX_IS_DEFAULT OR CMAKE_INSTALL_PREFIX STREQUAL "/usr/local") set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) endif() @@ -116,6 +125,10 @@ foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) COMPONENT Runtime) endforeach(bundled_library) +install(FILES "$" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") install(DIRECTORY "${NATIVE_ASSETS_DIR}" diff --git a/macos/Podfile b/macos/Podfile index ff5ddb3..742cccf 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -29,6 +29,7 @@ flutter_macos_podfile_setup target 'Runner' do use_frameworks! + pod 'rlottie', :path => '../third_party' flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do inherit! :search_paths diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 5b03800..ec4ddd1 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -84,6 +84,8 @@ PODS: - GoogleUtilities/UserDefaults (8.1.1): - GoogleUtilities/Logger - GoogleUtilities/Privacy + - kolibri (0.0.1): + - FlutterMacOS - media_kit_libs_macos_video (1.0.4): - FlutterMacOS - media_kit_video (0.0.1): @@ -108,6 +110,9 @@ 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): - Flutter - FlutterMacOS @@ -137,6 +142,7 @@ 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`) - 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`) @@ -144,6 +150,8 @@ 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`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -191,6 +199,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral geolocator_apple: :path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin + kolibri: + :path: Flutter/ephemeral/.symlinks/plugins/kolibri/macos media_kit_libs_macos_video: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos media_kit_video: @@ -205,6 +215,10 @@ 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: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite_darwin: @@ -237,6 +251,7 @@ SPEC CHECKSUMS: geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da + kolibri: 93062ece67f68ec0b909876b527aa15e198b4a73 media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 @@ -247,6 +262,8 @@ SPEC CHECKSUMS: photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 record_macos: 5d55909f9650314be6424ffd6b123ac75a08c3c1 + rlottie: 206daeeeb0f9dec6594ed248eb0c7e876a289e93 + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd @@ -254,6 +271,6 @@ SPEC CHECKSUMS: wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b WebRTC-SDK: e6006119cd730d6315d875e4a421b6cc8bb88833 -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 +PODFILE CHECKSUM: f9f028435b3eb11958579119e016406574b87759 COCOAPODS: 1.16.2 diff --git a/pubspec.lock b/pubspec.lock index 919aff8..57a1c9b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: @@ -193,14 +201,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: @@ -274,7 +274,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" @@ -359,7 +359,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -499,6 +499,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.35" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a + url: "https://pub.dev" + source: hosted + version: "2.12.0" flutter_secure_storage: dependency: "direct main" description: @@ -547,6 +555,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -573,6 +589,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" geolocator: dependency: "direct main" description: @@ -725,6 +749,13 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + kolibri: + dependency: "direct main" + description: + path: "third_party/kolibri/kolibri-dart" + relative: true + source: path + version: "0.1.0" leak_tracker: dependency: transitive description: @@ -749,14 +780,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: @@ -909,14 +932,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: @@ -1029,6 +1044,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1062,7 +1085,7 @@ packages: source: hosted version: "2.2.1" path_provider_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" @@ -1102,7 +1125,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -1570,6 +1593,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" + url: "https://pub.dev" + source: hosted + version: "1.2.6" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4f29a8c..5b39840 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,12 +34,16 @@ dependencies: sdk: flutter intl: any + # Rust networking core (kolibri) — FFI plugin, replaces the Dart transport. + # DEV: local path for fast iteration; re-pin to third_party/kolibri submodule + # (path: third_party/kolibri/kolibri-dart) before merge. + kolibri: + path: third_party/kolibri/kolibri-dart + # 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 device_info_plus: 12.3.0 flutter_timezone: ^5.0.1 @@ -59,6 +63,7 @@ dependencies: package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 lottie: ^3.3.1 path_provider: ^2.1.4 share_plus: ^10.1.4 @@ -81,6 +86,7 @@ dependencies: media_kit_libs_macos_video: ^1.1.4 sensors_plus: ^7.1.0 smart_auth: ^3.2.0 + flutter_svg: ^2.0.10 dev_dependencies: flutter_test: @@ -93,6 +99,8 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + path_provider_platform_interface: ^2.1.0 + plugin_platform_interface: ^2.1.0 flutter_launcher_icons: image_path: "assets/komet_icon.png" @@ -117,6 +125,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. @@ -127,6 +138,7 @@ flutter: - assets/meteor_icon.png - assets/emoji_keywords.json - assets/lottie/ + - assets/wallpapers/patterns/ 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/animoji_jumbo_test.dart b/test/animoji_jumbo_test.dart new file mode 100644 index 0000000..d23efad --- /dev/null +++ b/test/animoji_jumbo_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:komet/core/utils/text_format.dart'; + +FormatRange _animoji(int start, int length, String url) => FormatRange( + format: TextFormat.animoji, + start: start, + length: length, + attributes: {'animojiLottieUrl': url}, +); + +void main() { + test('single animoji-only message is jumbo', () { + expect(animojiOnlyLottieUrls('❤️', [_animoji(0, 2, 'L1')]), ['L1']); + }); + + test('several animoji with no other text are jumbo, in order', () { + final urls = animojiOnlyLottieUrls('❤️🔥', [ + _animoji(2, 2, 'L2'), + _animoji(0, 2, 'L1'), + ]); + expect(urls, ['L1', 'L2']); + }); + + test('animoji mixed with real text is NOT jumbo', () { + expect( + animojiOnlyLottieUrls('animoji message🤣', [_animoji(15, 2, 'L1')]), + isNull, + ); + }); + + test('plain emoji without an ANIMOJI element is NOT jumbo', () { + expect(animojiOnlyLottieUrls('😀', const []), isNull); + }); + + test('more than the limit is NOT jumbo', () { + final ranges = [ + for (var i = 0; i < 5; i++) _animoji(i * 2, 2, 'L$i'), + ]; + expect(animojiOnlyLottieUrls('❤️❤️❤️❤️❤️', ranges), isNull); + }); + + test('whitespace between animoji is allowed', () { + expect( + animojiOnlyLottieUrls('❤️ ❤️', [_animoji(0, 2, 'L1'), _animoji(3, 2, 'L2')]), + ['L1', 'L2'], + ); + }); +} 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_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/rich_message_controller_animoji_test.dart b/test/rich_message_controller_animoji_test.dart new file mode 100644 index 0000000..bf2ec13 --- /dev/null +++ b/test/rich_message_controller_animoji_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:komet/frontend/widgets/rich_message_controller.dart'; +import 'package:komet/models/animoji.dart'; + +Animoji _a(int id, String emoji, String lottie) => + Animoji(id: id, emoji: emoji, lottieUrl: lottie); + +void main() { + test('standalone animoji builds one ANIMOJI element at offset 0', () { + final c = RichMessageController(); + c.insertAnimoji(_a(125, '❤️', 'L1')); + + final content = c.buildContent(); + expect(content.text, '❤️'); + expect(content.elements, [ + { + 'type': 'ANIMOJI', + 'from': 0, + 'length': 2, + 'entityId': 125, + 'attributes': {'animojiLottieUrl': 'L1'}, + }, + ]); + }); + + test('animoji appended after text gets the correct utf16 offset', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: 'test', + selection: TextSelection.collapsed(offset: 4), + ); + c.insertAnimoji(_a(7, '🤣', 'L2')); + + final content = c.buildContent(); + expect(content.text, 'test🤣'); + expect(content.elements.single['from'], 4); + expect(content.elements.single['length'], 2); + expect(content.elements.single['type'], 'ANIMOJI'); + }); + + test('multiple animoji with surrounding text keep glyph offsets in order', () { + final c = RichMessageController(); + c.value = const TextEditingValue( + text: 'a', + selection: TextSelection.collapsed(offset: 1), + ); + c.insertAnimoji(_a(1, '❤️', 'L1')); + // caret now after first placeholder; type "b" + final t1 = c.value.text; // "a" + c.value = TextEditingValue( + text: '${t1}b', + selection: TextSelection.collapsed(offset: t1.length + 1), + ); + c.insertAnimoji(_a(2, '🔥', 'L3')); + + final content = c.buildContent(); + expect(content.text, 'a❤️b🔥'); + + final froms = content.elements + .where((e) => e['type'] == 'ANIMOJI') + .map((e) => e['from']) + .toList(); + expect(froms, [1, 4]); + }); + + testWidgets('built span plain text matches controller text (caret invariant)', ( + tester, + ) async { + late BuildContext ctx; + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFF000000), + builder: (context, _) { + ctx = context; + return const SizedBox(); + }, + ), + ); + + final c = RichMessageController(); + c.value = const TextEditingValue( + text: 'hi', + selection: TextSelection.collapsed(offset: 2), + ); + c.insertAnimoji(_a(1, '❤️', 'L1')); + c.value = TextEditingValue( + text: '${c.value.text}!', + selection: TextSelection.collapsed(offset: c.value.text.length + 1), + ); + + final span = c.buildTextSpan( + context: ctx, + style: const TextStyle(fontSize: 16), + withComposing: false, + ); + expect(span.toPlainText(), c.text); + }); + + test('deleting the placeholder char drops the entity', () { + final c = RichMessageController(); + c.insertAnimoji(_a(1, '❤️', 'L1')); + expect(c.value.text.length, 1); + // backspace: remove the placeholder + c.value = const TextEditingValue( + text: '', + selection: TextSelection.collapsed(offset: 0), + ); + final content = c.buildContent(); + expect(content.text, ''); + expect(content.elements, isEmpty); + }); +} diff --git a/test/rlottie_disk_cache_test.dart b/test/rlottie_disk_cache_test.dart new file mode 100644 index 0000000..8f7c4ee --- /dev/null +++ b/test/rlottie_disk_cache_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/media/rlottie/rlottie_disk_cache.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _FakePathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + _FakePathProvider(this.dir); + final String dir; + @override + Future getApplicationSupportPath() async => dir; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('disk cache round-trips rendered frames', () async { + final tmp = Directory.systemTemp.createTempSync('rlottie_cache_test'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + + const px = 32; + const frameCount = 5; + final frames = List.generate(frameCount, (f) { + final buf = Uint8List(px * px * 4); + for (var i = 0; i < buf.length; i++) { + buf[i] = (f * 37 + i) & 0xff; + } + return buf; + }); + + const url = 'https://example.com/anim.json'; + await RlottieDiskCache.instance.store( + url: url, + px: px, + frameCount: frameCount, + frameRate: 30, + durationMs: 166, + frames: frames, + ); + + final loaded = await RlottieDiskCache.instance.load(url, px); + expect(loaded, isNotNull); + expect(loaded!.px, px); + expect(loaded.frameCount, frameCount); + expect(loaded.frameRate, 30); + expect(loaded.durationMs, 166); + for (var f = 0; f < frameCount; f++) { + expect(loaded.frames[f], orderedEquals(frames[f]), + reason: 'frame $f bytes preserved'); + } + + expect(await RlottieDiskCache.instance.load('https://other/x.json', px), + isNull); + + tmp.deleteSync(recursive: true); + }); +} diff --git a/test/rlottie_engine_test.dart b/test/rlottie_engine_test.dart new file mode 100644 index 0000000..097971f --- /dev/null +++ b/test/rlottie_engine_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/media/rlottie/rlottie_engine.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('rlottie engine renders a real clip into ui.Image frames', () async { + final libPath = Platform.environment['RLOTTIE_LIB']; + if (libPath == null || !File(libPath).existsSync()) { + markTestSkipped('set RLOTTIE_LIB to librlottie.so to run'); + return; + } + RlottieEngine.debugLibraryPath = libPath; + + expect(RlottieEngine.instance.available, isTrue, + reason: 'library should open'); + + final json = File('assets/lottie/ic_settings.json').readAsStringSync(); + const px = 256; + final clip = await RlottieEngine.instance + .acquire('poc://ic_settings', px, inlineJson: json); + + expect(clip, isNotNull); + expect(clip!.frameCount, greaterThan(1)); + expect(clip.durationMs, greaterThan(0)); + + final target = clip.frameCount; + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (clip.ready.value < target && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 20)); + } + expect(clip.ready.value, target, reason: 'all frames decoded'); + + final mid = clip.frameAt(target ~/ 2); + expect(mid, isNotNull); + expect(mid!.width, px); + expect(mid.height, px); + + final data = await mid.toByteData(format: ui.ImageByteFormat.rawRgba); + final bytes = data!.buffer.asUint8List(); + var opaque = 0; + var transparent = 0; + for (var i = 3; i < bytes.length; i += 4) { + if (bytes[i] == 0) { + transparent++; + } else if (bytes[i] > 250) { + opaque++; + } + } + expect(opaque, greaterThan(0), reason: 'gear pixels present'); + expect(transparent, greaterThan(0), reason: 'transparent background present'); + + final png = await mid.toByteData(format: ui.ImageByteFormat.png); + final out = Platform.environment['RLOTTIE_PNG_OUT']; + if (out != null) { + File(out).writeAsBytesSync(png!.buffer.asUint8List()); + debugPrint('wrote decoded frame PNG: $out'); + } + + RlottieEngine.instance.release(clip); + }); +} diff --git a/third_party/kolibri b/third_party/kolibri new file mode 160000 index 0000000..8f6836c --- /dev/null +++ b/third_party/kolibri @@ -0,0 +1 @@ +Subproject commit 8f6836cac2198027697ed0702044426e84d6f78b diff --git a/third_party/rlottie b/third_party/rlottie new file mode 160000 index 0000000..f487eff --- /dev/null +++ b/third_party/rlottie @@ -0,0 +1 @@ +Subproject commit f487eff2f8086b84ae1c7faa0418abec909e874b diff --git a/third_party/rlottie.podspec b/third_party/rlottie.podspec new file mode 100644 index 0000000..f9c74cc --- /dev/null +++ b/third_party/rlottie.podspec @@ -0,0 +1,64 @@ +Pod::Spec.new do |s| + s.name = 'rlottie' + s.version = '0.2.0' + s.summary = 'Samsung rlottie native Lottie renderer for Komet.' + s.description = 'Compiles the Samsung/rlottie submodule sources into the app so the native animation engine can be reached via dart:ffi (DynamicLibrary.process()).' + s.homepage = 'https://github.com/Samsung/rlottie' + s.license = { :type => 'MIT', :file => 'rlottie/COPYING' } + s.author = { 'Samsung Electronics' => 'opensource@samsung.com' } + s.source = { :path => '.' } + + s.ios.deployment_target = '13.0' + s.osx.deployment_target = '10.15' + s.requires_arc = false + + s.source_files = [ + 'rlottie/inc/*.h', + 'rlottie_build/apple/config.h', + 'rlottie/src/lottie/*.{cpp,h}', + 'rlottie/src/lottie/zip/*.{cpp,h}', + 'rlottie/src/lottie/rapidjson/**/*.h', + 'rlottie/src/vector/*.{cpp,h}', + 'rlottie/src/vector/freetype/*.{cpp,h}', + 'rlottie/src/vector/pixman/pixman-arm-neon-asm.h', + 'rlottie/src/vector/stb/*.{cpp,h}', + 'rlottie/src/binding/c/*.cpp', + ] + # 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', + 'GCC_ENABLE_CPP_RTTI' => 'NO', + 'CLANG_WARN_DOCUMENTATION_COMMENTS' => 'NO', + 'GCC_WARN_INHIBIT_ALL_WARNINGS' => 'YES', + 'DEFINES_MODULE' => 'YES', + 'HEADER_SEARCH_PATHS' => [ + '"${PODS_TARGET_SRCROOT}/rlottie/inc"', + '"${PODS_TARGET_SRCROOT}/rlottie_build/apple"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/lottie"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/lottie/zip"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/lottie/rapidjson"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/vector"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/vector/freetype"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/vector/pixman"', + '"${PODS_TARGET_SRCROOT}/rlottie/src/vector/stb"', + ].join(' '), + } +end diff --git a/third_party/rlottie_build/CMakeLists.txt b/third_party/rlottie_build/CMakeLists.txt new file mode 100644 index 0000000..fa7bc83 --- /dev/null +++ b/third_party/rlottie_build/CMakeLists.txt @@ -0,0 +1,81 @@ +cmake_minimum_required( VERSION 3.10 ) + +project( rlottie VERSION 0.2 LANGUAGES C CXX ASM ) + +set(RLOTTIE "${CMAKE_CURRENT_LIST_DIR}/../rlottie") + +if (NOT EXISTS "${RLOTTIE}/src/CMakeLists.txt") + message(FATAL_ERROR + "rlottie submodule is missing. Run: git submodule update --init --recursive") +endif() + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +if (NOT DEFINED BUILD_SHARED_LIBS) + set(BUILD_SHARED_LIBS ON) +endif() + +add_library( rlottie ) +set_target_properties( rlottie PROPERTIES DEFINE_SYMBOL RLOTTIE_BUILD ) + +set(LOTTIE_MODULE OFF) +set(LOTTIE_THREAD ON) +set(LOTTIE_CACHE ON) +set(LOTTIE_MODULE_PATH "") + +configure_file(${RLOTTIE}/cmake/config.h.in config.h) + +target_include_directories(rlottie + PUBLIC $ + PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" + ) + +if(MSVC) + target_compile_options(rlottie + PRIVATE + /std:c++14 + /EHs-c- + /GR- + /W3 + ) +else() + target_compile_options(rlottie + PRIVATE + -std=c++14 + -fno-exceptions + -fno-unwind-tables + -fno-asynchronous-unwind-tables + -fno-rtti + -Wall + -fvisibility=hidden + -Wnon-virtual-dtor + -Woverloaded-virtual + -Wno-unused-parameter + ) +endif() + +if (WIN32 AND NOT BUILD_SHARED_LIBS) + target_compile_definitions(rlottie PUBLIC -DRLOTTIE_BUILD=0) +endif() + +set( CMAKE_THREAD_PREFER_PTHREAD TRUE ) +find_package( Threads ) + +if(WIN32) + set( OSSPEC_LIBS Shlwapi.lib ) +endif() + +target_link_libraries(rlottie + PUBLIC + "${CMAKE_THREAD_LIBS_INIT}" + ${OSSPEC_LIBS} + ) + +if (CMAKE_ANDROID_ARCH_ABI STREQUAL "armeabi-v7a" + OR CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm|armv7)") + target_compile_options(rlottie PRIVATE -U__ARM_NEON__) +endif() + +add_subdirectory(${RLOTTIE}/src "${CMAKE_CURRENT_BINARY_DIR}/rlottie_src") diff --git a/third_party/rlottie_build/KOMET_NOTES.md b/third_party/rlottie_build/KOMET_NOTES.md new file mode 100644 index 0000000..c4c00ad --- /dev/null +++ b/third_party/rlottie_build/KOMET_NOTES.md @@ -0,0 +1,66 @@ +# rlottie integration (Komet) + +`third_party/rlottie` is a **git submodule** pinned to Samsung/rlottie +`f487eff2f8086b84ae1c7faa0418abec909e874b`. This directory (`rlottie_build/`) +holds Komet's build glue that lives *outside* the submodule (we can't commit into +upstream's tree). + +After cloning or pulling, initialize the submodule: + +``` +git submodule update --init --recursive +``` + +CI does this via `submodules: recursive` on every `actions/checkout` step. + +## What powers what + +The native animated-reaction / animoji / sticker renderer. rlottie renders each +frame to a premultiplied BGRA buffer off the UI thread; the Dart side +(`lib/core/media/rlottie/`) uploads frames to `ui.Image`, caches them in RAM and +on disk, and plays them from cache — so the first playback is as smooth as later +loops. Web has no native path and falls back to the pure-Dart `lottie` player. + +## Files here + +- `CMakeLists.txt` — build wrapper for the CMake platforms (Linux/Windows/Android). + Builds a single self-contained `rlottie` library from the submodule sources with + `LOTTIE_MODULE OFF` (stb compiled in), `LOTTIE_THREAD ON`, `LOTTIE_CACHE ON`. + Bypasses upstream's top-level CMakeLists (which references example/test) and + drives `../rlottie/src` directly. +- `apple/config.h` — static replacement for the CMake-generated `config.h`, used + by the CocoaPods build (which does not run CMake). +- `../rlottie.podspec` — compiles the submodule sources into the app for iOS/macOS + (pod root is `third_party/`, so it can reference both the submodule and this glue). + +## Build wiring + +| Platform | How | Loaded via | Verified | +|----------|-----|-----------|----------| +| Linux | `linux/CMakeLists.txt` → `add_subdirectory(rlottie_build)`, bundled to `lib/` | `DynamicLibrary.open('librlottie.so')` | ✅ full build + bundled .so | +| Android | `android/app/build.gradle.kts` `externalNativeBuild` → `android/app/src/main/cpp/CMakeLists.txt` | `DynamicLibrary.open('librlottie.so')` | ✅ NDK r28c arm64 cross-compile | +| Windows | `windows/CMakeLists.txt` → `add_subdirectory(rlottie_build)`, `rlottie.dll` next to exe | `DynamicLibrary.open('rlottie.dll')` | ⚠️ needs MSVC to verify | +| macOS | `macos/Podfile` `pod 'rlottie', :path => '../third_party'` | `DynamicLibrary.process()` | ⚠️ needs Xcode to verify | +| iOS | `ios/Podfile` `pod 'rlottie', :path => '../third_party'` | `DynamicLibrary.process()` | ⚠️ needs Xcode to verify | + +## Gotchas for the unverified platforms + +- **iOS/macOS symbols:** with `use_frameworks!` the `lottie_animation_*` symbols + live in `rlottie.framework`. If `DynamicLibrary.process()` can't find them, + switch the loader in `lib/core/media/rlottie/rlottie_ffi.dart` to + `DynamicLibrary.open('rlottie.framework/rlottie')`. +- **Windows:** rlottie builds with `/EHs-c- /GR-` and links `Shlwapi.lib` (set in + `CMakeLists.txt`). +- **32-bit ARM (armeabi-v7a):** the compiler predefines `__ARM_NEON__`, which pulls + in `vdrawhelper_neon.cpp`'s hand-written NEON blitter. That blitter calls + `pixman_composite_*_asm_neon`, defined only in `pixman-arm-neon-asm.S`. Upstream + gates that `.S` behind the CMake var `ARCH == arm` (set by its meson/top-level + build, which this glue bypasses), so the symbols are undefined and the armv7 link + fails. Wiring the `.S` back in is a dead end on NDK r28: it's GNU-assembler syntax + that LLVM's integrated assembler rejects, and the NDK no longer ships GNU `as` + (`-fno-integrated-as` has no fallback). So `CMakeLists.txt` here passes + `-U__ARM_NEON__` for 32-bit ARM, which drops the hand-asm path and lets the C + fallback (`memfill32` in `vdrawhelper.cpp`, guarded by the same macro) take over. + The C loops still auto-vectorize to NEON via `-mfpu=neon`. +- **Bumping rlottie:** `cd third_party/rlottie && git checkout `, rebuild, + then re-check `apple/config.h` and the podspec source globs still match upstream. diff --git a/third_party/rlottie_build/apple/config.h b/third_party/rlottie_build/apple/config.h new file mode 100644 index 0000000..7a783d5 --- /dev/null +++ b/third_party/rlottie_build/apple/config.h @@ -0,0 +1,7 @@ +#ifndef KOMET_RLOTTIE_CONFIG_H +#define KOMET_RLOTTIE_CONFIG_H + +#define LOTTIE_THREAD_SUPPORT +#define LOTTIE_CACHE_SUPPORT + +#endif diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 163cf91..89ca94f 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -62,6 +62,9 @@ add_subdirectory("runner") # them to the application. include(flutter/generated_plugins.cmake) +set(RLOTTIE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third_party/rlottie_build") +add_subdirectory("${RLOTTIE_DIR}" "${CMAKE_BINARY_DIR}/rlottie") + # === Installation === # Support files are copied into place next to the executable, so that it can @@ -99,6 +102,10 @@ install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/opus/opus.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) +install(FILES "$" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") install(DIRECTORY "${NATIVE_ASSETS_DIR}"