commit 205d11df0db39ccec83e741259fc6b2115953997 Author: ivan2282 Date: Sat Nov 15 20:06:40 2025 +0300 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..3268d00 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "a402d9a4376add5bc2d6b1e33e53edaae58c07f8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: android + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: ios + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: linux + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: macos + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: web + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: windows + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a5f7549 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 needle10 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d689db --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +
+

Komet Messenger

+

An Open Source alternative unnoficial client for MAX

+
Telegram Group
+ +## How to install? +### Download file from releases tab or our telegram group and install it +## How to build? +### This is app built on flutter, use flutter guide +## How to countibute? +### Create a fork, do everything +### And create pull requeste +### Make sure your commits looks like: +fix: something went worng when user... +add: search by id +edit: refactored something +Other actions should marked as "other:" and discribes what you did + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..808eeea --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,87 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("kotlin-android") + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.gwid.app.gwid" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/main/jniLibs") + } + } + + defaultConfig { + applicationId = "com.gwid.app.gwid" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + ndk { + abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + } + + val keyPropertiesFile = rootProject.file("key.properties") + val keyProperties = Properties() + + signingConfigs { + create("release") { + val envStoreFile = System.getenv("RELEASE_STORE_FILE") + val envStorePassword = System.getenv("RELEASE_STORE_PASSWORD") + val envKeyAlias = System.getenv("RELEASE_KEY_ALIAS") + val envKeyPassword = System.getenv("RELEASE_KEY_PASSWORD") + + if (envStoreFile != null && envStorePassword != null && + envKeyAlias != null && envKeyPassword != null) { + storeFile = file(envStoreFile) + storePassword = envStorePassword + keyAlias = envKeyAlias + keyPassword = envKeyPassword + } else if (keyPropertiesFile.exists()) { + keyProperties.load(FileInputStream(keyPropertiesFile)) + storeFile = file(keyProperties["storeFile"] as String? ?: "") + storePassword = keyProperties["storePassword"] as String? ?: "" + keyAlias = keyProperties["keyAlias"] as String? ?: "" + keyPassword = keyProperties["keyPassword"] as String? ?: "" + } + + } + } + + buildTypes { + getByName("release") { + // Only use release signing if keys are available + if (file(keyPropertiesFile).exists() || + System.getenv("RELEASE_STORE_FILE") != null) { + signingConfig = signingConfigs.getByName("release") + } + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..d866756 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,11 @@ +# Flutter-специфичные правила (у вас уже должны быть) +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.embedding.** { *; } + +# Игнорировать предупреждения о недостающих классах Play Core +-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication +-dontwarn com.google.android.play.core.splitinstall.** +-dontwarn com.google.android.play.core.tasks.** diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..572e202 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1ee3273 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/gwid/app/gwid/MainActivity.kt b/android/app/src/main/kotlin/com/gwid/app/gwid/MainActivity.kt new file mode 100644 index 0000000..515bf2f --- /dev/null +++ b/android/app/src/main/kotlin/com/gwid/app/gwid/MainActivity.kt @@ -0,0 +1,5 @@ +package com.gwid.app.gwid + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..a33b40c Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f797926 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..1dd80f1 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c52be3d Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..516c667 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f86283a Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..344adfe Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2cb8647 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..95173e4 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fdbb825 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d047760 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..f48d51d Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..efed571 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..c9c0416 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..8780c73 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..20c6605 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..c22491d --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..fb605bc --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/icon/bg.png b/assets/icon/bg.png new file mode 100644 index 0000000..5388f59 Binary files /dev/null and b/assets/icon/bg.png differ diff --git a/assets/icon/icon1.jpg b/assets/icon/icon1.jpg new file mode 100644 index 0000000..dc8a43f Binary files /dev/null and b/assets/icon/icon1.jpg differ diff --git a/assets/icon/komet.png b/assets/icon/komet.png new file mode 100644 index 0000000..8c251b9 Binary files /dev/null and b/assets/icon/komet.png differ diff --git a/assets/icon/komet_512.ico b/assets/icon/komet_512.ico new file mode 100644 index 0000000..bb8e279 Binary files /dev/null and b/assets/icon/komet_512.ico differ diff --git a/assets/icon/komet_512.png b/assets/icon/komet_512.png new file mode 100644 index 0000000..1d5e2d1 Binary files /dev/null and b/assets/icon/komet_512.png differ diff --git a/assets/images/komet_512.png b/assets/images/komet_512.png new file mode 100644 index 0000000..1d5e2d1 Binary files /dev/null and b/assets/images/komet_512.png differ diff --git a/assets/images/meteor.png b/assets/images/meteor.png new file mode 100644 index 0000000..4b8b93f Binary files /dev/null and b/assets/images/meteor.png differ diff --git a/assets/images/spermum.webp b/assets/images/spermum.webp new file mode 100644 index 0000000..13d3c2d Binary files /dev/null and b/assets/images/spermum.webp differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..62730f9 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..b347dae Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..71677e5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..27711be Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..96b2312 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..24b9ee1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..b05a5ad Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..4bc86b2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..27711be Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..58001a2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..9c18913 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..ced020d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..75553fd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..e05e2e7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..3bf9cf5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..9c18913 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..614b9e2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..f48d51d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..8780c73 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..9f0d9a6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..e82e508 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..56909ee Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..9c2cf69 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Gwid + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + gwid + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/api_service.dart b/lib/api_service.dart new file mode 100644 index 0000000..468068c --- /dev/null +++ b/lib/api_service.dart @@ -0,0 +1,2860 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:gwid/models/message.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:web_socket_channel/status.dart' as status; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:gwid/spoofing_service.dart'; +import 'package:uuid/uuid.dart'; +import 'package:flutter/services.dart'; +import 'package:gwid/proxy_service.dart'; +import 'package:file_picker/file_picker.dart'; + +class ApiService { + ApiService._privateConstructor(); + static final ApiService instance = ApiService._privateConstructor(); + + + int? _userId; + late int _sessionId; + int _actionId = 1; + bool _isColdStartSent = false; + late int _lastActionTime; + + + bool _isAppInForeground = true; + + final List _wsUrls = ['wss://ws-api.oneme.ru:443/websocket']; + int _currentUrlIndex = 0; + + + List get wsUrls => _wsUrls; + int get currentUrlIndex => _currentUrlIndex; + IOWebSocketChannel? _channel; + StreamSubscription? _streamSubscription; + Timer? _pingTimer; + int _seq = 0; + + + final StreamController _contactUpdatesController = + StreamController.broadcast(); + Stream get contactUpdates => _contactUpdatesController.stream; + + + final StreamController _errorController = + StreamController.broadcast(); + Stream get errorStream => _errorController.stream; + + final _reconnectionCompleteController = StreamController.broadcast(); + Stream get reconnectionComplete => + _reconnectionCompleteController.stream; + + + final Map _presenceData = {}; + String? authToken; + String? userId; + + String? get token => authToken; + + + String? _currentPasswordTrackId; + String? _currentPasswordHint; + String? _currentPasswordEmail; + + bool _isSessionOnline = false; + bool _handshakeSent = false; + Completer? _onlineCompleter; + final List> _messageQueue = []; + + final Map> _messageCache = {}; + + + final Map _contactCache = {}; + DateTime? _lastContactsUpdate; + static const Duration _contactCacheExpiry = Duration( + minutes: 5, + ); // Кэш на 5 минут + + + bool _isLoadingBlockedContacts = false; + + + bool _isSessionReady = false; + + final _messageController = StreamController>.broadcast(); + Stream> get messages => _messageController.stream; + + final _connectionStatusController = StreamController.broadcast(); + Stream get connectionStatus => _connectionStatusController.stream; + + final _connectionLogController = StreamController.broadcast(); + Stream get connectionLog => _connectionLogController.stream; + + + final List _connectionLogCache = []; + List get connectionLogCache => _connectionLogCache; + + + void _log(String message) { + print(message); // Оставляем для дебага в консоли + _connectionLogCache.add(message); + if (!_connectionLogController.isClosed) { + _connectionLogController.add(message); + } + } + + void _emitLocal(Map frame) { + try { + _messageController.add(frame); + } catch (_) {} + } + + bool get isOnline => _isSessionOnline; + Future waitUntilOnline() async { + if (_isSessionOnline && _isSessionReady) return; + _onlineCompleter ??= Completer(); + return _onlineCompleter!.future; + } + + bool get isActuallyConnected { + try { + + if (_channel == null || !_isSessionOnline) { + return false; + } + + + + return true; + } catch (e) { + print("🔴 Ошибка при проверке состояния канала: $e"); + return false; + } + } + + + Completer>? _inflightChatsCompleter; + Map? _lastChatsPayload; + DateTime? _lastChatsAt; + final Duration _chatsCacheTtl = const Duration(seconds: 5); + bool _chatsFetchedInThisSession = false; + + + Map? get lastChatsPayload => _lastChatsPayload; + + Future _connectWithFallback() async { + _log('Начало подключения...'); + + while (_currentUrlIndex < _wsUrls.length) { + final currentUrl = _wsUrls[_currentUrlIndex]; + final logMessage = + 'Попытка ${_currentUrlIndex + 1}/${_wsUrls.length}: $currentUrl'; + _log(logMessage); + _connectionLogController.add(logMessage); + + try { + await _connectToUrl(currentUrl); + final successMessage = _currentUrlIndex == 0 + ? 'Подключено к основному серверу' + : 'Подключено через резервный сервер'; + _connectionLogController.add('✅ $successMessage'); + if (_currentUrlIndex > 0) { + _connectionStatusController.add('Подключено через резервный сервер'); + } + return; // Успешно подключились + } catch (e) { + final errorMessage = '❌ Ошибка: ${e.toString().split(':').first}'; + print('Ошибка подключения к $currentUrl: $e'); + _connectionLogController.add(errorMessage); + _currentUrlIndex++; + + + if (_currentUrlIndex < _wsUrls.length) { + await Future.delayed(const Duration(milliseconds: 500)); + } + } + } + + + _log('❌ Все серверы недоступны'); + _connectionStatusController.add('Все серверы недоступны'); + throw Exception('Не удалось подключиться ни к одному серверу'); + } + + Future _connectToUrl(String url) async { + _isSessionOnline = false; + _onlineCompleter = Completer(); + _chatsFetchedInThisSession = false; + + _connectionStatusController.add('connecting'); + + final uri = Uri.parse(url); + print( + 'Parsed URI: host=${uri.host}, port=${uri.port}, scheme=${uri.scheme}', + ); + + final spoofedData = await SpoofingService.getSpoofedSessionData(); + final userAgent = + spoofedData?['useragent'] as String? ?? + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + + final headers = { + 'Origin': 'https://web.max.ru', + 'User-Agent': userAgent, + 'Sec-WebSocket-Extensions': 'permessage-deflate', + }; + + + final proxySettings = await ProxyService.instance.loadProxySettings(); + + if (proxySettings.isEnabled && proxySettings.host.isNotEmpty) { + + print( + 'Используем HTTP/HTTPS прокси ${proxySettings.host}:${proxySettings.port}', + ); + final customHttpClient = await ProxyService.instance + .getHttpClientWithProxy(); + _channel = IOWebSocketChannel.connect( + uri, + headers: headers, + customClient: customHttpClient, + ); + } else { + + print('Подключение без прокси'); + _channel = IOWebSocketChannel.connect(uri, headers: headers); + } + + await _channel!.ready; + _listen(); + await _sendHandshake(); + _startPinging(); + } + + int _reconnectDelaySeconds = 2; + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 10; + Timer? _reconnectTimer; + bool _isReconnecting = false; + + String generateRandomDeviceId() { + + return const Uuid().v4(); + } + + Future> _buildUserAgentPayload() async { + final spoofedData = await SpoofingService.getSpoofedSessionData(); + + if (spoofedData != null) { + print( + '--- [_buildUserAgentPayload] Используются подменённые данные сессии ---', + ); + final String finalDeviceId; + final String? idFromSpoofing = spoofedData['device_id'] as String?; + + if (idFromSpoofing != null && idFromSpoofing.isNotEmpty) { + + finalDeviceId = idFromSpoofing; + print('Используется deviceId из сессии: $finalDeviceId'); + } else { + + finalDeviceId = generateRandomDeviceId(); + print('device_id не найден в кэше, сгенерирован новый: $finalDeviceId'); + } + return { + 'deviceType': spoofedData['device_type'] as String? ?? 'IOS', + 'locale': spoofedData['locale'] as String? ?? 'ru', + 'deviceLocale': spoofedData['locale'] as String? ?? 'ru', + 'osVersion': spoofedData['os_version'] as String? ?? 'iOS 17.5.1', + 'deviceName': spoofedData['device_name'] as String? ?? 'iPhone', + 'headerUserAgent': + spoofedData['user_agent'] as String? ?? + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + 'appVersion': spoofedData['app_version'] as String? ?? '25.10.10', + 'screen': spoofedData['screen'] as String? ?? '1170x2532 3.0x', + 'timezone': spoofedData['timezone'] as String? ?? 'Europe/Moscow', + }; + } else { + print( + '--- [_buildUserAgentPayload] Используются псевдо-случайные данные ---', + ); + return { + 'deviceType': 'WEB', + 'locale': 'ru', + 'deviceLocale': 'ru', + 'osVersion': 'Windows', + 'deviceName': 'Chrome', + 'headerUserAgent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'appVersion': '25.10.10', + 'screen': '1920x1080 1.0x', + 'timezone': 'Europe/Moscow', + }; + } + } + + void _handleSessionTerminated() { + print("Сессия была завершена сервером"); + _isSessionOnline = false; + _isSessionReady = false; + + + authToken = null; + + + clearAllCaches(); + + + _messageController.add({ + 'type': 'session_terminated', + 'message': 'Твоя сессия больше не активна, войди снова', + }); + } + + void _handleInvalidToken() async { + print("Обработка недействительного токена"); + _isSessionOnline = false; + _isSessionReady = false; + + + authToken = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('authToken'); + + + clearAllCaches(); + + + _channel?.sink.close(); + _channel = null; + _pingTimer?.cancel(); + + + _messageController.add({ + 'type': 'invalid_token', + 'message': 'Токен недействителен, требуется повторная авторизация', + }); + } + + Future _clearAuthToken() async { + print("Очищаем токен авторизации..."); + authToken = null; + _lastChatsPayload = null; + _lastChatsAt = null; + _chatsFetchedInThisSession = false; + + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('authToken'); + + clearAllCaches(); + _connectionStatusController.add("disconnected"); + } + + + Future _sendHandshake() async { + if (_handshakeSent) { + print('Handshake уже отправлен, пропускаем...'); + return; + } + + print('Отправляем handshake...'); + + final userAgentPayload = await _buildUserAgentPayload(); + + final prefs = await SharedPreferences.getInstance(); + final deviceId = + prefs.getString('spoof_deviceid') ?? generateRandomDeviceId(); + + if (prefs.getString('spoof_deviceid') == null) { + await prefs.setString('spoof_deviceid', deviceId); + } + + final payload = {'deviceId': deviceId, 'userAgent': userAgentPayload}; + + print('Отправляем handshake с payload: $payload'); + _sendMessage(6, payload); + _handshakeSent = true; + print('Handshake отправлен, ожидаем ответ...'); + } + + + Future requestOtp(String phoneNumber) async { + + if (_channel == null) { + print('WebSocket не подключен, подключаемся...'); + try { + await connect(); + + await waitUntilOnline(); + } catch (e) { + print('Ошибка подключения к WebSocket: $e'); + throw Exception('Не удалось подключиться к серверу: $e'); + } + } + + final payload = { + "phone": phoneNumber, + "type": "START_AUTH", + "language": "ru", + }; + _sendMessage(17, payload); + } + + + void requestSessions() { + _sendMessage(96, {}); + } + + + void terminateAllSessions() { + _sendMessage(97, {}); + } + + Future blockContact(int contactId) async { + await waitUntilOnline(); + _sendMessage(34, {'contactId': contactId, 'action': 'BLOCK'}); + } + + Future unblockContact(int contactId) async { + await waitUntilOnline(); + _sendMessage(34, {'contactId': contactId, 'action': 'UNBLOCK'}); + } + + Future addContact(int contactId) async { + await waitUntilOnline(); + _sendMessage(34, {'contactId': contactId, 'action': 'ADD'}); + } + + Future subscribeToChat(int chatId, bool subscribe) async { + await waitUntilOnline(); + _sendMessage(75, {'chatId': chatId, 'subscribe': subscribe}); + } + + Future navigateToChat(int currentChatId, int targetChatId) async { + await waitUntilOnline(); + if (currentChatId != 0) { + await subscribeToChat(currentChatId, false); + } + await subscribeToChat(targetChatId, true); + } + + + Future clearChatHistory(int chatId, {bool forAll = false}) async { + await waitUntilOnline(); + final payload = { + 'chatId': chatId, + 'forAll': forAll, + 'lastEventTime': DateTime.now().millisecondsSinceEpoch, + }; + _sendMessage(54, payload); + } + + Future> getChatInfoByLink(String link) async { + await waitUntilOnline(); + + final payload = {'link': link}; + + final int seq = _sendMessage(89, payload); + print('Запрашиваем информацию о чате (seq: $seq) по ссылке: $link'); + + try { + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 10)); + + if (response['cmd'] == 3) { + final errorPayload = response['payload'] ?? {}; + final errorMessage = + errorPayload['localizedMessage'] ?? + errorPayload['message'] ?? + 'Неизвестная ошибка'; + print('Ошибка получения информации о чате: $errorMessage'); + throw Exception(errorMessage); + } + + if (response['cmd'] == 1 && + response['payload'] != null && + response['payload']['chat'] != null) { + print( + 'Информация о чате получена: ${response['payload']['chat']['title']}', + ); + return response['payload']['chat'] as Map; + } else { + print('Не удалось найти "chat" в ответе opcode 89: $response'); + throw Exception('Неверный ответ от сервера'); + } + } on TimeoutException { + print('Таймаут ожидания ответа на getChatInfoByLink (seq: $seq)'); + throw Exception('Сервер не ответил вовремя'); + } catch (e) { + print('Ошибка в getChatInfoByLink: $e'); + rethrow; + } + } + + + void markMessageAsRead(int chatId, String messageId) { + + waitUntilOnline().then((_) { + final payload = { + "type": "READ_MESSAGE", + "chatId": chatId, + "messageId": messageId, + "mark": DateTime.now().millisecondsSinceEpoch, + }; + _sendMessage(50, payload); + print( + 'Отправляем отметку о прочтении для сообщения $messageId в чате $chatId', + ); + }); + } + + + void getBlockedContacts() async { + + if (_isLoadingBlockedContacts) { + print( + 'ApiService: запрос заблокированных контактов уже выполняется, пропускаем', + ); + return; + } + + _isLoadingBlockedContacts = true; + print('ApiService: запрашиваем заблокированные контакты'); + _sendMessage(36, { + 'status': 'BLOCKED', + 'count': 100, + 'from': 0, + + }); + + + Future.delayed(const Duration(seconds: 2), () { + _isLoadingBlockedContacts = false; + }); + } + + + void notifyContactUpdate(Contact contact) { + print( + 'ApiService отправляет обновление контакта: ${contact.name} (ID: ${contact.id}), isBlocked: ${contact.isBlocked}, isBlockedByMe: ${contact.isBlockedByMe}', + ); + _contactUpdatesController.add(contact); + } + + + DateTime? getLastSeen(int userId) { + final userPresence = _presenceData[userId.toString()]; + if (userPresence != null && userPresence['seen'] != null) { + final seenTimestamp = userPresence['seen'] as int; + + return DateTime.fromMillisecondsSinceEpoch(seenTimestamp * 1000); + } + return null; + } + + + void updatePresenceData(Map presenceData) { + _presenceData.addAll(presenceData); + print('ApiService обновил presence данные: $_presenceData'); + } + + + void sendReaction(int chatId, String messageId, String emoji) { + final payload = { + "chatId": chatId, + "messageId": messageId, + "reaction": {"reactionType": "EMOJI", "id": emoji}, + }; + _sendMessage(178, payload); + print('Отправляем реакцию: $emoji на сообщение $messageId в чате $chatId'); + } + + + void removeReaction(int chatId, String messageId) { + final payload = {"chatId": chatId, "messageId": messageId}; + _sendMessage(179, payload); + print('Удаляем реакцию с сообщения $messageId в чате $chatId'); + } + + + void createGroup(String name, List participantIds) { + final payload = {"name": name, "participantIds": participantIds}; + _sendMessage(48, payload); + print('Создаем группу: $name с участниками: $participantIds'); + } + + + + + + + + + + void updateGroup(int chatId, {String? name, List? participantIds}) { + final payload = { + "chatId": chatId, + if (name != null) "name": name, + if (participantIds != null) "participantIds": participantIds, + }; + _sendMessage(272, payload); + print('Обновляем группу $chatId: $payload'); + } + + + void createGroupWithMessage(String name, List participantIds) { + final cid = DateTime.now().millisecondsSinceEpoch; + final payload = { + "message": { + "cid": cid, + "attaches": [ + { + "_type": "CONTROL", + "event": "new", + "chatType": "CHAT", + "title": name, + "userIds": participantIds, + }, + ], + }, + "notify": true, + }; + _sendMessage(64, payload); + print('Создаем группу: $name с участниками: $participantIds'); + } + + + void renameGroup(int chatId, String newName) { + final payload = {"chatId": chatId, "theme": newName}; + _sendMessage(55, payload); + print('Переименовываем группу $chatId в: $newName'); + } + + + void addGroupMember( + int chatId, + List userIds, { + bool showHistory = true, + }) { + final payload = { + "chatId": chatId, + "userIds": userIds, + "showHistory": showHistory, + "operation": "add", + }; + _sendMessage(77, payload); + print('Добавляем участников $userIds в группу $chatId'); + } + + + void removeGroupMember( + int chatId, + List userIds, { + int cleanMsgPeriod = 0, + }) { + final payload = { + "chatId": chatId, + "userIds": userIds, + "operation": "remove", + "cleanMsgPeriod": cleanMsgPeriod, + }; + _sendMessage(77, payload); + print('Удаляем участников $userIds из группы $chatId'); + } + + + void leaveGroup(int chatId) { + final payload = {"chatId": chatId}; + _sendMessage(58, payload); + print('Выходим из группы $chatId'); + } + + + + + void getGroupMembers(int chatId, {int marker = 0, int count = 50}) { + final payload = { + "type": "MEMBER", + "marker": marker, + "chatId": chatId, + "count": count, + }; + _sendMessage(59, payload); + print( + 'Запрашиваем участников группы $chatId (marker: $marker, count: $count)', + ); + } + + + Future getChatIdByUserId(int userId) async { + await waitUntilOnline(); + + final payload = { + "chatIds": [userId], + }; + final int seq = _sendMessage(48, payload); + print('Запрашиваем информацию о чате для userId: $userId (seq: $seq)'); + + try { + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 10)); + + if (response['cmd'] == 3) { + final errorPayload = response['payload'] ?? {}; + final errorMessage = + errorPayload['localizedMessage'] ?? + errorPayload['message'] ?? + 'Неизвестная ошибка'; + print('Ошибка получения информации о чате: $errorMessage'); + return null; + } + + if (response['cmd'] == 1 && response['payload'] != null) { + final chats = response['payload']['chats'] as List?; + if (chats != null && chats.isNotEmpty) { + final chat = chats[0] as Map; + final chatId = chat['id'] as int?; + final chatType = chat['type'] as String?; + + if (chatType == 'DIALOG' && chatId != null) { + print('Получен chatId для диалога с userId $userId: $chatId'); + return chatId; + } + } + } + + print('Не удалось найти chatId для userId: $userId'); + return null; + } on TimeoutException { + print('Таймаут ожидания ответа на getChatIdByUserId (seq: $seq)'); + return null; + } catch (e) { + print('Ошибка при получении chatId для userId $userId: $e'); + return null; + } + } + + + Future> getChatsOnly({bool force = false}) async { + if (authToken == null) { + final prefs = await SharedPreferences.getInstance(); + authToken = prefs.getString('authToken'); + } + if (authToken == null) throw Exception("Auth token not found"); + + + if (!force && _lastChatsPayload != null && _lastChatsAt != null) { + if (DateTime.now().difference(_lastChatsAt!) < _chatsCacheTtl) { + return _lastChatsPayload!; + } + } + + try { + final payload = {"chatsCount": 100}; + + final int chatSeq = _sendMessage(48, payload); + final chatResponse = await messages.firstWhere( + (msg) => msg['seq'] == chatSeq, + ); + + final List chatListJson = + chatResponse['payload']?['chats'] ?? []; + + if (chatListJson.isEmpty) { + final result = {'chats': [], 'contacts': [], 'profile': null}; + _lastChatsPayload = result; + _lastChatsAt = DateTime.now(); + return result; + } + + final contactIds = {}; + for (var chatJson in chatListJson) { + final participants = + chatJson['participants'] as Map? ?? {}; + contactIds.addAll(participants.keys.map((id) => int.parse(id))); + } + + final int contactSeq = _sendMessage(32, { + "contactIds": contactIds.toList(), + }); + final contactResponse = await messages.firstWhere( + (msg) => msg['seq'] == contactSeq, + ); + + final List contactListJson = + contactResponse['payload']?['contacts'] ?? []; + + final result = { + 'chats': chatListJson, + 'contacts': contactListJson, + 'profile': null, + 'presence': null, + }; + _lastChatsPayload = result; + + + final contacts = contactListJson + .map((json) => Contact.fromJson(json)) + .toList(); + updateContactCache(contacts); + _lastChatsAt = DateTime.now(); + return result; + } catch (e) { + print('Ошибка получения чатов: $e'); + rethrow; + } + } + + + Future verifyCode(String token, String code) async { + + _currentPasswordTrackId = null; + _currentPasswordHint = null; + _currentPasswordEmail = null; + + if (_channel == null) { + print('WebSocket не подключен, подключаемся...'); + try { + await connect(); + + await waitUntilOnline(); + } catch (e) { + print('Ошибка подключения к WebSocket: $e'); + throw Exception('Не удалось подключиться к серверу: $e'); + } + } + + + final payload = { + 'token': token, + 'verifyCode': code, + 'authTokenType': 'CHECK_CODE', + }; + + _sendMessage(18, payload); + print('Код верификации отправлен с payload: $payload'); + } + + + Future sendPassword(String trackId, String password) async { + await waitUntilOnline(); + + final payload = {'trackId': trackId, 'password': password}; + + _sendMessage(115, payload); + print('Пароль отправлен с payload: $payload'); + } + + + Map getPasswordAuthData() { + return { + 'trackId': _currentPasswordTrackId, + 'hint': _currentPasswordHint, + 'email': _currentPasswordEmail, + }; + } + + + void clearPasswordAuthData() { + _currentPasswordTrackId = null; + _currentPasswordHint = null; + _currentPasswordEmail = null; + } + + + Future setAccountPassword(String password, String hint) async { + await waitUntilOnline(); + + final payload = {'password': password, 'hint': hint}; + + _sendMessage(116, payload); + print('Запрос на установку пароля отправлен с payload: $payload'); + } + + + Future> joinGroupByLink(String link) async { + await waitUntilOnline(); + + final payload = {'link': link}; + + final int seq = _sendMessage(57, payload); + print('Отправляем запрос на присоединение (seq: $seq) по ссылке: $link'); + + try { + final response = await messages + .firstWhere((msg) => msg['seq'] == seq && msg['opcode'] == 57) + .timeout(const Duration(seconds: 15)); + + if (response['cmd'] == 3) { + final errorPayload = response['payload'] ?? {}; + final errorMessage = + errorPayload['localizedMessage'] ?? + errorPayload['message'] ?? + 'Неизвестная ошибка'; + print('Ошибка присоединения к группе: $errorMessage'); + throw Exception(errorMessage); + } + + if (response['cmd'] == 1 && response['payload'] != null) { + print('Успешно присоединились: ${response['payload']}'); + return response['payload'] as Map; + } else { + print('Неожиданный ответ на joinGroupByLink: $response'); + throw Exception('Неверный ответ от сервера'); + } + } on TimeoutException { + print('Таймаут ожидания ответа на joinGroupByLink (seq: $seq)'); + throw Exception('Сервер не ответил вовремя'); + } catch (e) { + print('Ошибка в joinGroupByLink: $e'); + rethrow; + } + } + + + Future searchContactByPhone(String phone) async { + await waitUntilOnline(); + + final payload = {'phone': phone}; + + _sendMessage(46, payload); + print('Запрос на поиск контакта отправлен с payload: $payload'); + } + + + Future searchChannels(String query) async { + await waitUntilOnline(); + + + final payload = {'contactIds': []}; + + _sendMessage(32, payload); + print('Запрос на поиск каналов отправлен с payload: $payload'); + } + + + Future enterChannel(String link) async { + await waitUntilOnline(); + + final payload = {'link': link}; + + _sendMessage(89, payload); + print('Запрос на вход в канал отправлен с payload: $payload'); + } + + + Future subscribeToChannel(String link) async { + await waitUntilOnline(); + + final payload = {'link': link}; + + _sendMessage(57, payload); + print('Запрос на подписку на канал отправлен с payload: $payload'); + } + + Future> getChatsAndContacts({bool force = false}) async { + await waitUntilOnline(); + + if (authToken == null) { + print("Токен авторизации не найден, требуется повторная авторизация"); + throw Exception("Auth token not found - please re-authenticate"); + } + + + if (!force && _lastChatsPayload != null && _lastChatsAt != null) { + if (DateTime.now().difference(_lastChatsAt!) < _chatsCacheTtl) { + return _lastChatsPayload!; + } + } + + + if (_chatsFetchedInThisSession && _lastChatsPayload != null && !force) { + return _lastChatsPayload!; + } + + + if (_inflightChatsCompleter != null) { + return _inflightChatsCompleter!.future; + } + _inflightChatsCompleter = Completer>(); + + + if (_isSessionOnline && + _isSessionReady && + _lastChatsPayload != null && + !force) { + _inflightChatsCompleter!.complete(_lastChatsPayload!); + _inflightChatsCompleter = null; + return _lastChatsPayload!; + } + + try { + Map chatResponse; + + + final int opcode; + final Map payload; + + final prefs = await SharedPreferences.getInstance(); + final deviceId = + prefs.getString('spoof_deviceid') ?? generateRandomDeviceId(); + + + if (prefs.getString('spoof_deviceid') == null) { + await prefs.setString('spoof_deviceid', deviceId); + } + + if (!_chatsFetchedInThisSession) { + + opcode = 19; + payload = { + "chatsCount": 100, + "chatsSync": 0, + "contactsSync": 0, + "draftsSync": 0, + "interactive": true, + "presenceSync": 0, + "token": authToken, + + + }; + + + if (userId != null) { + payload["userId"] = userId; + } + } else { + + return await getChatsOnly(force: force); + } + + final int chatSeq = _sendMessage(opcode, payload); + chatResponse = await messages.firstWhere((msg) => msg['seq'] == chatSeq); + + if (opcode == 19 && chatResponse['cmd'] == 1) { + print("✅ Авторизация (opcode 19) успешна. Сессия ГОТОВА."); + _isSessionReady = true; // <-- ВОТ ТЕПЕРЬ СЕССИЯ ПОЛНОСТЬЮ ГОТОВА! + + _connectionStatusController.add("ready"); + + final profile = chatResponse['payload']?['profile']; + final contactProfile = profile?['contact']; + + if (contactProfile != null && contactProfile['id'] != null) { + print( + "[getChatsAndContacts] ✅ Профиль и ID пользователя найдены. ID: ${contactProfile['id']}. ЗАПУСКАЕМ АНАЛИТИКУ.", + ); + _userId = contactProfile['id']; + _sessionId = DateTime.now().millisecondsSinceEpoch; + _lastActionTime = _sessionId; + + + sendNavEvent('COLD_START'); + + + _sendInitialSetupRequests(); + } else { + print( + "[getChatsAndContacts] ❌ ВНИМАНИЕ: Профиль или ID в ответе пустой, аналитика не будет отправлена.", + ); + } + + + if (_onlineCompleter != null && !_onlineCompleter!.isCompleted) { + _onlineCompleter!.complete(); + } + + + _startPinging(); + _processMessageQueue(); + } + + final profile = chatResponse['payload']?['profile']; + final presence = chatResponse['payload']?['presence']; + final config = chatResponse['payload']?['config']; + final List chatListJson = + chatResponse['payload']?['chats'] ?? []; + + if (chatListJson.isEmpty) { + final result = { + 'chats': [], + 'contacts': [], + 'profile': profile, + 'config': config, + }; + _lastChatsPayload = result; + _lastChatsAt = DateTime.now(); + _chatsFetchedInThisSession = true; + _inflightChatsCompleter!.complete(_lastChatsPayload!); + _inflightChatsCompleter = null; + return result; + } + + final contactIds = {}; + for (var chatJson in chatListJson) { + final participants = chatJson['participants'] as Map; + contactIds.addAll(participants.keys.map((id) => int.parse(id))); + } + + final int contactSeq = _sendMessage(32, { + "contactIds": contactIds.toList(), + }); + final contactResponse = await messages.firstWhere( + (msg) => msg['seq'] == contactSeq, + ); + + final List contactListJson = + contactResponse['payload']?['contacts'] ?? []; + + + if (presence != null) { + updatePresenceData(presence); + } + + final result = { + 'chats': chatListJson, + 'contacts': contactListJson, + 'profile': profile, + 'presence': presence, + 'config': config, + }; + _lastChatsPayload = result; + + + final contacts = contactListJson + .map((json) => Contact.fromJson(json)) + .toList(); + updateContactCache(contacts); + _lastChatsAt = DateTime.now(); + _chatsFetchedInThisSession = true; + _inflightChatsCompleter!.complete(result); + _inflightChatsCompleter = null; + return result; + } catch (e) { + final error = e; + _inflightChatsCompleter?.completeError(error); + _inflightChatsCompleter = null; + rethrow; + } + } + + + Future> getMessageHistory( + int chatId, { + bool force = false, + }) async { + if (!force && _messageCache.containsKey(chatId)) { + print("Загружаем сообщения для чата $chatId из кэша."); + return _messageCache[chatId]!; + } + + print("Запрашиваем историю для чата $chatId с сервера."); + final payload = { + "chatId": chatId, + + + "from": DateTime.now() + .add(const Duration(days: 1)) + .millisecondsSinceEpoch, + "forward": 0, + "backward": 1000, // Увеличиваем лимит для получения всех сообщений + "getMessages": true, + }; + + try { + final int seq = _sendMessage(49, payload); + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 15)); + + + if (response['cmd'] == 3) { + final error = response['payload']; + print('Ошибка получения истории сообщений: $error'); + + + if (error['error'] == 'proto.state') { + print( + 'Ошибка состояния сессии при получении истории, переподключаемся...', + ); + await reconnect(); + await waitUntilOnline(); + + return getMessageHistory(chatId, force: true); + } + throw Exception('Ошибка получения истории: ${error['message']}'); + } + + final List messagesJson = response['payload']?['messages'] ?? []; + final messagesList = + messagesJson.map((json) => Message.fromJson(json)).toList() + ..sort((a, b) => a.time.compareTo(b.time)); + + _messageCache[chatId] = messagesList; + + return messagesList; + } catch (e) { + print('Ошибка при получении истории сообщений: $e'); + + return []; + } + } + + + Future?> loadOldMessages( + int chatId, + String fromMessageId, + int count, + ) async { + print( + "Запрашиваем старые сообщения для чата $chatId начиная с $fromMessageId", + ); + + final payload = { + "chatId": chatId, + "from": int.parse(fromMessageId), + "forward": 0, + "backward": count, + "getMessages": true, + }; + + try { + final int seq = _sendMessage(49, payload); + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 15)); + + + if (response['cmd'] == 3) { + final error = response['payload']; + print('Ошибка получения старых сообщений: $error'); + return null; + } + + return response['payload']; + } catch (e) { + print('Ошибка при получении старых сообщений: $e'); + return null; + } + } + + void setAppInForeground(bool isForeground) { + _isAppInForeground = isForeground; + } + + + void sendNavEvent(String event, {int? screenTo, int? screenFrom}) { + if (_userId == null) return; + + final now = DateTime.now().millisecondsSinceEpoch; + final Map params = { + 'session_id': _sessionId, + 'action_id': _actionId++, + }; + + switch (event) { + case 'COLD_START': + if (_isColdStartSent) return; + params['screen_to'] = 150; + params['source_id'] = 1; + _isColdStartSent = true; + break; + case 'WARM_START': + params['screen_to'] = + 150; // Предполагаем, что всегда возвращаемся на главный экран + params['screen_from'] = 1; // 1 = приложение свернуто + params['prev_time'] = _lastActionTime; + break; + case 'GO': + params['screen_to'] = screenTo; + params['screen_from'] = screenFrom; + params['prev_time'] = _lastActionTime; + break; + } + + _lastActionTime = now; + _sendMessage(5, { + "events": [ + { + "type": "NAV", + "event": event, + "userId": _userId, + "time": now, + "params": params, + }, + ], + }); + } + + + Future _sendInitialSetupRequests() async { + print("Запускаем отправку единичных запросов при старте..."); + + await Future.delayed(const Duration(seconds: 2)); + _sendMessage(272, {"folderSync": 0}); + await Future.delayed(const Duration(milliseconds: 500)); + _sendMessage(27, {"sync": 0, "type": "STICKER"}); + await Future.delayed(const Duration(milliseconds: 500)); + _sendMessage(27, {"sync": 0, "type": "FAVORITE_STICKER"}); + await Future.delayed(const Duration(milliseconds: 500)); + _sendMessage(79, {"forward": false, "count": 100}); + + await Future.delayed(const Duration(seconds: 5)); + _sendMessage(26, { + "sectionId": "NEW_STICKER_SETS", + "from": 5, + "count": 100, + }); + + print("Единичные запросы отправлены."); + } + + void clearCacheForChat(int chatId) { + _messageCache.remove(chatId); + print("Кэш для чата $chatId очищен."); + } + + void clearChatsCache() { + _lastChatsPayload = null; + _lastChatsAt = null; + _chatsFetchedInThisSession = false; + print("Кэш чатов очищен."); + } + + + Contact? getCachedContact(int contactId) { + if (_contactCache.containsKey(contactId)) { + final contact = _contactCache[contactId]!; + print('Контакт $contactId получен из кэша: ${contact.name}'); + return contact; + } + return null; + } + + + Future> getNetworkStatistics() async { + + final prefs = await SharedPreferences.getInstance(); + + + final totalTraffic = + prefs.getDouble('network_total_traffic') ?? + (150.0 * 1024 * 1024); // 150 MB по умолчанию + final messagesTraffic = + prefs.getDouble('network_messages_traffic') ?? (totalTraffic * 0.15); + final mediaTraffic = + prefs.getDouble('network_media_traffic') ?? (totalTraffic * 0.6); + final syncTraffic = + prefs.getDouble('network_sync_traffic') ?? (totalTraffic * 0.1); + + + final currentSpeed = _isSessionOnline + ? 512.0 * 1024 + : 0.0; // 512 KB/s если онлайн + + + final ping = 25; + + return { + 'totalTraffic': totalTraffic, + 'messagesTraffic': messagesTraffic, + 'mediaTraffic': mediaTraffic, + 'syncTraffic': syncTraffic, + 'otherTraffic': totalTraffic * 0.15, + 'currentSpeed': currentSpeed, + 'isConnected': _isSessionOnline, + 'connectionType': 'Wi-Fi', // Можно определить реальный тип + 'signalStrength': 85, + 'ping': ping, + 'jitter': 2.5, + 'packetLoss': 0.01, + 'hourlyStats': [], // Пока пустой массив, можно реализовать позже + }; + } + + + bool isContactCacheValid() { + if (_lastContactsUpdate == null) return false; + return DateTime.now().difference(_lastContactsUpdate!) < + _contactCacheExpiry; + } + + + void updateContactCache(List contacts) { + _contactCache.clear(); + for (final contact in contacts) { + _contactCache[contact.id] = contact; + } + _lastContactsUpdate = DateTime.now(); + print('Кэш контактов обновлен: ${contacts.length} контактов'); + } + + + void updateCachedContact(Contact contact) { + _contactCache[contact.id] = contact; + print('Контакт ${contact.id} обновлен в кэше: ${contact.name}'); + } + + + void clearContactCache() { + _contactCache.clear(); + _lastContactsUpdate = null; + print("Кэш контактов очищен."); + } + + + void clearAllCaches() { + clearContactCache(); + clearChatsCache(); + _messageCache.clear(); + clearPasswordAuthData(); + print("Все кэши очищены из-за ошибки подключения."); + } + + + Future clearAllData() async { + try { + + clearAllCaches(); + + + authToken = null; + + + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + + + _pingTimer?.cancel(); + await _channel?.sink.close(); + _channel = null; + + + _isSessionOnline = false; + _isSessionReady = false; + _chatsFetchedInThisSession = false; + _reconnectAttempts = 0; + _currentUrlIndex = 0; + + + _messageQueue.clear(); + _presenceData.clear(); + + print("Все данные приложения полностью очищены."); + } catch (e) { + print("Ошибка при полной очистке данных: $e"); + rethrow; + } + } + + + void sendMessage( + int chatId, + String text, { + String? replyToMessageId, + int? cid, + }) { + final int clientMessageId = cid ?? DateTime.now().millisecondsSinceEpoch; + final payload = { + "chatId": chatId, + "message": { + "text": text, + "cid": clientMessageId, + "elements": [], + "attaches": [], + if (replyToMessageId != null) + "link": {"type": "REPLY", "messageId": replyToMessageId}, + }, + "notify": true, + }; + + + clearChatsCache(); + + if (_isSessionOnline) { + _sendMessage(64, payload); + } else { + print("Сессия не онлайн. Сообщение добавлено в очередь."); + _messageQueue.add({'opcode': 64, 'payload': payload}); + } + } + + void _processMessageQueue() { + if (_messageQueue.isEmpty) return; + print("Отправка ${_messageQueue.length} сообщений из очереди..."); + for (var message in _messageQueue) { + _sendMessage(message['opcode'], message['payload']); + } + _messageQueue.clear(); + } + + + Future editMessage(int chatId, String messageId, String newText) async { + final payload = { + "chatId": chatId, + "messageId": messageId, + "text": newText, + "elements": [], + "attachments": [], + }; + + + clearChatsCache(); + + + await waitUntilOnline(); + + + if (!_isSessionOnline) { + print('Сессия не онлайн, пытаемся переподключиться...'); + await reconnect(); + await waitUntilOnline(); + } + + Future sendOnce() async { + try { + final int seq = _sendMessage(67, payload); + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 10)); + + + if (response['cmd'] == 3) { + final error = response['payload']; + print('Ошибка редактирования сообщения: $error'); + + + if (error['error'] == 'proto.state') { + print('Ошибка состояния сессии, переподключаемся...'); + await reconnect(); + await waitUntilOnline(); + return false; // Попробуем еще раз + } + + + if (error['error'] == 'error.edit.invalid.message') { + print( + 'Сообщение не может быть отредактировано: ${error['localizedMessage']}', + ); + throw Exception( + 'Сообщение не может быть отредактировано: ${error['localizedMessage']}', + ); + } + + return false; + } + + return response['cmd'] == 1; // Успешный ответ + } catch (e) { + print('Ошибка при редактировании сообщения: $e'); + return false; + } + } + + + for (int attempt = 0; attempt < 3; attempt++) { + print( + 'Попытка редактирования сообщения $messageId (попытка ${attempt + 1}/3)', + ); + bool ok = await sendOnce(); + if (ok) { + print('Сообщение $messageId успешно отредактировано'); + return; + } + + if (attempt < 2) { + print( + 'Повторяем запрос редактирования для сообщения $messageId через 2 секунды...', + ); + await Future.delayed(const Duration(seconds: 2)); + } + } + + print('Не удалось отредактировать сообщение $messageId после 3 попыток'); + } + + + Future deleteMessage( + int chatId, + String messageId, { + bool forMe = false, + }) async { + final payload = { + "chatId": chatId, + "messageIds": [messageId], + "forMe": forMe, + }; + + + clearChatsCache(); + + + await waitUntilOnline(); + + + if (!_isSessionOnline) { + print('Сессия не онлайн, пытаемся переподключиться...'); + await reconnect(); + await waitUntilOnline(); + } + + Future sendOnce() async { + try { + final int seq = _sendMessage(66, payload); + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 10)); + + + if (response['cmd'] == 3) { + final error = response['payload']; + print('Ошибка удаления сообщения: $error'); + + + if (error['error'] == 'proto.state') { + print('Ошибка состояния сессии, переподключаемся...'); + await reconnect(); + await waitUntilOnline(); + return false; // Попробуем еще раз + } + return false; + } + + return response['cmd'] == 1; // Успешный ответ + } catch (e) { + print('Ошибка при удалении сообщения: $e'); + return false; + } + } + + + for (int attempt = 0; attempt < 3; attempt++) { + print('Попытка удаления сообщения $messageId (попытка ${attempt + 1}/3)'); + bool ok = await sendOnce(); + if (ok) { + print('Сообщение $messageId успешно удалено'); + return; + } + + if (attempt < 2) { + print( + 'Повторяем запрос удаления для сообщения $messageId через 2 секунды...', + ); + await Future.delayed(const Duration(seconds: 2)); + } + } + + print('Не удалось удалить сообщение $messageId после 3 попыток'); + } + + + void sendTyping(int chatId, {String type = "TEXT"}) { + final payload = {"chatId": chatId, "type": type}; + if (_isSessionOnline) { + _sendMessage(65, payload); + } + } + + + void updateProfileText( + String firstName, + String lastName, + String description, + ) { + final payload = { + "firstName": firstName, + "lastName": lastName, + "description": description, + }; + _sendMessage(16, payload); + } + + + Future updateProfilePhoto(String firstName, String lastName) async { + try { + + final picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + if (image == null) return; + + + print("Запрашиваем URL для загрузки фото..."); + final int seq = _sendMessage(80, {"count": 1}); + final response = await messages.firstWhere((msg) => msg['seq'] == seq); + final String uploadUrl = response['payload']['url']; + print("URL получен: $uploadUrl"); + + + print("Загружаем фото на сервер..."); + var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); + request.files.add(await http.MultipartFile.fromPath('file', image.path)); + var streamedResponse = await request.send(); + var httpResponse = await http.Response.fromStream(streamedResponse); + + if (httpResponse.statusCode != 200) { + throw Exception("Ошибка загрузки фото: ${httpResponse.body}"); + } + + final uploadResult = jsonDecode(httpResponse.body); + final String photoToken = uploadResult['photos'].values.first['token']; + print("Фото загружено, получен токен: $photoToken"); + + + print("Привязываем фото к профилю..."); + final payload = { + "firstName": firstName, + "lastName": lastName, + "photoToken": photoToken, + "avatarType": "USER_AVATAR", + }; + _sendMessage(16, payload); + print("Запрос на смену аватара отправлен."); + } catch (e) { + print("!!! Ошибка в процессе смены аватара: $e"); + } + } + + + Future sendPhotoMessage( + int chatId, { + String? localPath, + String? caption, + int? cidOverride, + int? senderId, // my user id to mark local echo as mine + }) async { + try { + XFile? image; + if (localPath != null) { + image = XFile(localPath); + } else { + + final picker = ImagePicker(); + image = await picker.pickImage(source: ImageSource.gallery); + if (image == null) return; + } + + await waitUntilOnline(); + + final int seq80 = _sendMessage(80, {"count": 1}); + final resp80 = await messages.firstWhere((m) => m['seq'] == seq80); + final String uploadUrl = resp80['payload']['url']; + + + var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); + request.files.add(await http.MultipartFile.fromPath('file', image.path)); + var streamed = await request.send(); + var httpResp = await http.Response.fromStream(streamed); + if (httpResp.statusCode != 200) { + throw Exception( + 'Ошибка загрузки фото: ${httpResp.statusCode} ${httpResp.body}', + ); + } + final uploadJson = jsonDecode(httpResp.body) as Map; + final Map photos = uploadJson['photos'] as Map; + if (photos.isEmpty) throw Exception('Не получен токен фото'); + final String photoToken = (photos.values.first as Map)['token']; + + + final int cid = cidOverride ?? DateTime.now().millisecondsSinceEpoch; + final payload = { + "chatId": chatId, + "message": { + "text": caption?.trim() ?? "", + "cid": cid, + "elements": [], + "attaches": [ + {"_type": "PHOTO", "photoToken": photoToken}, + ], + }, + "notify": true, + }; + + clearChatsCache(); + + if (localPath != null) { + _emitLocal({ + 'ver': 11, + 'cmd': 1, + 'seq': -1, + 'opcode': 128, + 'payload': { + 'chatId': chatId, + 'message': { + 'id': 'local_$cid', + 'sender': senderId ?? 0, + 'time': DateTime.now().millisecondsSinceEpoch, + 'text': caption?.trim() ?? '', + 'type': 'USER', + 'cid': cid, + 'attaches': [ + {'_type': 'PHOTO', 'url': 'file://$localPath'}, + ], + }, + }, + }); + } + + _sendMessage(64, payload); + } catch (e) { + print('Ошибка отправки фото-сообщения: $e'); + } + } + + + Future sendPhotoMessages( + int chatId, { + required List localPaths, + String? caption, + int? senderId, + }) async { + if (localPaths.isEmpty) return; + try { + await waitUntilOnline(); + + + final int cid = DateTime.now().millisecondsSinceEpoch; + _emitLocal({ + 'ver': 11, + 'cmd': 1, + 'seq': -1, + 'opcode': 128, + 'payload': { + 'chatId': chatId, + 'message': { + 'id': 'local_$cid', + 'sender': senderId ?? 0, + 'time': DateTime.now().millisecondsSinceEpoch, + 'text': caption?.trim() ?? '', + 'type': 'USER', + 'cid': cid, + 'attaches': [ + for (final p in localPaths) + {'_type': 'PHOTO', 'url': 'file://$p'}, + ], + }, + }, + }); + + + final List> photoTokens = []; + for (final path in localPaths) { + final int seq80 = _sendMessage(80, {"count": 1}); + final resp80 = await messages.firstWhere((m) => m['seq'] == seq80); + final String uploadUrl = resp80['payload']['url']; + + var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); + request.files.add(await http.MultipartFile.fromPath('file', path)); + var streamed = await request.send(); + var httpResp = await http.Response.fromStream(streamed); + if (httpResp.statusCode != 200) { + throw Exception( + 'Ошибка загрузки фото: ${httpResp.statusCode} ${httpResp.body}', + ); + } + final uploadJson = jsonDecode(httpResp.body) as Map; + final Map photos = uploadJson['photos'] as Map; + if (photos.isEmpty) throw Exception('Не получен токен фото'); + final String photoToken = (photos.values.first as Map)['token']; + photoTokens.add({"token": photoToken}); + } + + final payload = { + "chatId": chatId, + "message": { + "text": caption?.trim() ?? "", + "cid": cid, + "elements": [], + "attaches": [ + for (final t in photoTokens) + {"_type": "PHOTO", "photoToken": t["token"]}, + ], + }, + "notify": true, + }; + + clearChatsCache(); + _sendMessage(64, payload); + } catch (e) { + print('Ошибка отправки фото-сообщений: $e'); + } + } + + + Future sendFileMessage( + int chatId, { + String? caption, + int? senderId, // my user id to mark local echo as mine + }) async { + try { + + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.any, + ); + + if (result == null || result.files.single.path == null) { + print("Выбор файла отменен"); + return; + } + + final String filePath = result.files.single.path!; + final String fileName = result.files.single.name; + final int fileSize = result.files.single.size; + + await waitUntilOnline(); + + + final int seq87 = _sendMessage(87, {"count": 1}); + final resp87 = await messages.firstWhere((m) => m['seq'] == seq87); + + if (resp87['payload'] == null || + resp87['payload']['info'] == null || + (resp87['payload']['info'] as List).isEmpty) { + throw Exception('Неверный ответ на Opcode 87: отсутствует "info"'); + } + + final uploadInfo = (resp87['payload']['info'] as List).first; + final String uploadUrl = uploadInfo['url']; + final int fileId = uploadInfo['fileId']; // <-- Ключевое отличие от фото + + print('Получен fileId: $fileId и URL: $uploadUrl'); + + + var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); + request.files.add(await http.MultipartFile.fromPath('file', filePath)); + var streamed = await request.send(); + var httpResp = await http.Response.fromStream(streamed); + if (httpResp.statusCode != 200) { + throw Exception( + 'Ошибка загрузки файла: ${httpResp.statusCode} ${httpResp.body}', + ); + } + + print('Файл успешно загружен на сервер.'); + + + + final int cid = DateTime.now().millisecondsSinceEpoch; + final payload = { + "chatId": chatId, + "message": { + "text": caption?.trim() ?? "", + "cid": cid, + "elements": [], + "attaches": [ + {"_type": "FILE", "fileId": fileId}, // <-- Используем fileId + ], + }, + "notify": true, + }; + + clearChatsCache(); + + + _emitLocal({ + 'ver': 11, + 'cmd': 1, + 'seq': -1, + 'opcode': 128, + 'payload': { + 'chatId': chatId, + 'message': { + 'id': 'local_$cid', + 'sender': senderId ?? 0, + 'time': DateTime.now().millisecondsSinceEpoch, + 'text': caption?.trim() ?? '', + 'type': 'USER', + 'cid': cid, + 'attaches': [ + { + '_type': 'FILE', + 'name': fileName, + 'size': fileSize, + 'url': 'file://$filePath', // Локальный путь для UI + }, + ], + }, + }, + }); + + _sendMessage(64, payload); + print('Сообщение о файле (Opcode 64) отправлено.'); + } catch (e) { + print('Ошибка отправки файла: $e'); + } + } + + void _startPinging() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(const Duration(seconds: 25), (timer) { + if (_isSessionOnline && _isSessionReady) { + print("Отправляем Ping для поддержания сессии..."); + _sendMessage(1, {"interactive": true}); + } else { + print("Сессия не готова, пропускаем ping"); + } + }); + } + + Future saveToken(String token, {String? userId}) async { + print("Сохраняем новый токен: ${token.substring(0, 20)}..."); + if (userId != null) { + print("Сохраняем UserID: $userId"); + } + authToken = token; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('authToken', token); + if (_channel != null) { + disconnect(); + } + await connect(); + await getChatsAndContacts(force: true); + if (userId != null) { + await prefs.setString('userId', userId); + } + print("Токен и UserID успешно сохранены в SharedPreferences"); + } + + Future hasToken() async { + + if (authToken == null) { + final prefs = await SharedPreferences.getInstance(); + authToken = prefs.getString('authToken'); + userId = prefs.getString('userId'); + if (authToken != null) { + print( + "Токен загружен из SharedPreferences: ${authToken!.substring(0, 20)}...", + ); + if (userId != null) { + print("UserID загружен из SharedPreferences: $userId"); + } + } + } + return authToken != null; + } + + Future> fetchContactsByIds(List contactIds) async { + + if (contactIds.isEmpty) { + return []; + } + + print('Запрашиваем данные для ${contactIds.length} контактов...'); + try { + final int contactSeq = _sendMessage(32, {"contactIds": contactIds}); + + + final contactResponse = await messages + .firstWhere((msg) => msg['seq'] == contactSeq) + .timeout(const Duration(seconds: 10)); + + + if (contactResponse['cmd'] == 3) { + print( + "Ошибка при получении контактов по ID: ${contactResponse['payload']}", + ); + return []; + } + + final List contactListJson = + contactResponse['payload']?['contacts'] ?? []; + final contacts = contactListJson + .map((json) => Contact.fromJson(json)) + .toList(); + + + for (final contact in contacts) { + _contactCache[contact.id] = contact; + } + print("Получены и закэшированы данные для ${contacts.length} контактов."); + return contacts; + } catch (e) { + print('Исключение при получении контактов по ID: $e'); + return []; + } + } + + Future logout() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('authToken'); + await prefs.remove('userId'); + authToken = null; + userId = null; + _messageCache.clear(); + _lastChatsPayload = null; + _chatsFetchedInThisSession = false; + _pingTimer?.cancel(); + await _channel?.sink.close(status.goingAway); + _channel = null; + } catch (_) {} + } + + Future connect() async { + + if (_channel != null && _isSessionOnline) { + print("WebSocket уже подключен, пропускаем подключение"); + return; + } + + print("Запускаем подключение к WebSocket..."); + + + _isSessionOnline = false; + _isSessionReady = false; + + + _connectionStatusController.add("connecting"); + await _connectWithFallback(); + } + + Future reconnect() async { + _reconnectAttempts = + 0; // Сбрасываем счетчик попыток при ручном переподключении + _currentUrlIndex = 0; // Сбрасываем индекс для повторной попытки + + _connectionStatusController.add("connecting"); + await _connectWithFallback(); + } + + + + + void sendFullJsonRequest(String jsonString) { + if (_channel == null) { + throw Exception('WebSocket is not connected. Connect first.'); + } + _log('➡️ SEND (raw): $jsonString'); + _channel!.sink.add(jsonString); + } + + + + + int sendRawRequest(int opcode, Map payload) { + if (_channel == null) { + print('WebSocket не подключен!'); + + throw Exception('WebSocket is not connected. Connect first.'); + } + + return _sendMessage(opcode, payload); + } + + + + int sendAndTrackFullJsonRequest(String jsonString) { + if (_channel == null) { + throw Exception('WebSocket is not connected. Connect first.'); + } + + + final message = jsonDecode(jsonString) as Map; + + + final int currentSeq = _seq++; + + + message['seq'] = currentSeq; + + + final encodedMessage = jsonEncode(message); + + _log('➡️ SEND (custom): $encodedMessage'); + print('Отправляем кастомное сообщение (seq: $currentSeq): $encodedMessage'); + + _channel!.sink.add(encodedMessage); + + + return currentSeq; + } + + int _sendMessage(int opcode, Map payload) { + if (_channel == null) { + print('WebSocket не подключен!'); + return -1; + } + final message = { + "ver": 11, + "cmd": 0, + "seq": _seq, + "opcode": opcode, + "payload": payload, + }; + final encodedMessage = jsonEncode(message); + if (opcode == 1) { + + _log('➡️ SEND (ping) seq: $_seq'); + } else if (opcode == 18 || opcode == 19) { + + Map loggablePayload = Map.from(payload); + if (loggablePayload.containsKey('token')) { + String token = loggablePayload['token'] as String; + + loggablePayload['token'] = token.length > 8 + ? '${token.substring(0, 4)}...${token.substring(token.length - 4)}' + : '***'; + } + final loggableMessage = {...message, 'payload': loggablePayload}; + _log('➡️ SEND: ${jsonEncode(loggableMessage)}'); + } else { + _log('➡️ SEND: $encodedMessage'); + } + print('Отправляем сообщение (seq: $_seq): $encodedMessage'); + _channel!.sink.add(encodedMessage); + return _seq++; + } + + void _listen() async { + _streamSubscription?.cancel(); // Отменяем предыдущую подписку + _streamSubscription = _channel?.stream.listen( + (message) { + if (message == null) return; + if (message is String && message.trim().isEmpty) { + + return; + } + + + String loggableMessage = message; + try { + final decoded = jsonDecode(message) as Map; + if (decoded['opcode'] == 2) { + + loggableMessage = '⬅️ RECV (pong) seq: ${decoded['seq']}'; + } else { + Map loggableDecoded = Map.from(decoded); + bool wasModified = false; + if (loggableDecoded.containsKey('payload') && + loggableDecoded['payload'] is Map) { + Map payload = Map.from( + loggableDecoded['payload'], + ); + if (payload.containsKey('token')) { + String token = payload['token'] as String; + payload['token'] = token.length > 8 + ? '${token.substring(0, 4)}...${token.substring(token.length - 4)}' + : '***'; + loggableDecoded['payload'] = payload; + wasModified = true; + } + } + if (wasModified) { + loggableMessage = '⬅️ RECV: ${jsonEncode(loggableDecoded)}'; + } else { + loggableMessage = '⬅️ RECV: $message'; + } + } + } catch (_) { + loggableMessage = '⬅️ RECV (raw): $message'; + } + _log(loggableMessage); + + + try { + final decodedMessage = message is String + ? jsonDecode(message) + : message; + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 97 && + decodedMessage['cmd'] == 1 && + decodedMessage['payload'] != null && + decodedMessage['payload']['token'] != null) { + _handleSessionTerminated(); + return; + } + + if (decodedMessage is Map && + decodedMessage['opcode'] == 6 && + decodedMessage['cmd'] == 1) { + print("Handshake успешен. Сессия ONLINE."); + _isSessionOnline = true; + _isSessionReady = false; + _reconnectDelaySeconds = 2; + _connectionStatusController.add("authorizing"); + + if (_onlineCompleter != null && !_onlineCompleter!.isCompleted) { + _onlineCompleter!.complete(); + } + _startPinging(); + _processMessageQueue(); + } + + + if (decodedMessage is Map && decodedMessage['cmd'] == 3) { + final error = decodedMessage['payload']; + print('Ошибка сервера: $error'); + + if (error != null && error['localizedMessage'] != null) { + _errorController.add(error['localizedMessage']); + } else if (error != null && error['message'] != null) { + _errorController.add(error['message']); + } + + if (error != null && error['message'] == 'FAIL_WRONG_PASSWORD') { + _errorController.add('FAIL_WRONG_PASSWORD'); + } + + + if (error != null && error['error'] == 'password.invalid') { + _errorController.add('Неверный пароль'); + } + + if (error != null && error['error'] == 'proto.state') { + print('Ошибка состояния сессии, переподключаемся...'); + _chatsFetchedInThisSession = false; + _reconnect(); + return; + } + + if (error != null && error['error'] == 'login.token') { + print('Токен недействителен, очищаем и завершаем сессию...'); + _handleInvalidToken(); + return; + } + + if (error != null && error['message'] == 'FAIL_WRONG_PASSWORD') { + print('Неверный токен авторизации, очищаем токен...'); + _clearAuthToken().then((_) { + _chatsFetchedInThisSession = false; + _messageController.add({ + 'type': 'invalid_token', + 'message': + 'Токен авторизации недействителен. Требуется повторная авторизация.', + }); + _reconnect(); + }); + return; + } + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 18 && + decodedMessage['cmd'] == 1 && + decodedMessage['payload'] != null) { + final payload = decodedMessage['payload']; + if (payload['passwordChallenge'] != null) { + final challenge = payload['passwordChallenge']; + _currentPasswordTrackId = challenge['trackId']; + _currentPasswordHint = challenge['hint']; + _currentPasswordEmail = challenge['email']; + + print( + 'Получен запрос на ввод пароля: trackId=${challenge['trackId']}, hint=${challenge['hint']}, email=${challenge['email']}', + ); + + + _messageController.add({ + 'type': 'password_required', + 'trackId': _currentPasswordTrackId, + 'hint': _currentPasswordHint, + 'email': _currentPasswordEmail, + }); + return; + } + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 22 && + decodedMessage['cmd'] == 1) { + final payload = decodedMessage['payload']; + print('Настройки приватности успешно обновлены: $payload'); + + + _messageController.add({ + 'type': 'privacy_settings_updated', + 'settings': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 116 && + decodedMessage['cmd'] == 1) { + final payload = decodedMessage['payload']; + print('Пароль успешно установлен: $payload'); + + + _messageController.add({ + 'type': 'password_set_success', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 57 && + decodedMessage['cmd'] == 1) { + final payload = decodedMessage['payload']; + print('Успешно присоединились к группе: $payload'); + + + _messageController.add({ + 'type': 'group_join_success', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 46 && + decodedMessage['cmd'] == 1) { + final payload = decodedMessage['payload']; + print('Контакт найден: $payload'); + + + _messageController.add({ + 'type': 'contact_found', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 46 && + decodedMessage['cmd'] == 3) { + final payload = decodedMessage['payload']; + print('Контакт не найден: $payload'); + + + _messageController.add({ + 'type': 'contact_not_found', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 32 && + decodedMessage['cmd'] == 1) { + final payload = decodedMessage['payload']; + print('Каналы найдены: $payload'); + + + _messageController.add({ + 'type': 'channels_found', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 32 && + decodedMessage['cmd'] == 3) { + final payload = decodedMessage['payload']; + print('Каналы не найдены: $payload'); + + + _messageController.add({ + 'type': 'channels_not_found', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 89 && + decodedMessage['cmd'] == 1) { + final payload = decodedMessage['payload']; + print('Вход в канал успешен: $payload'); + + + _messageController.add({ + 'type': 'channel_entered', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 89 && + decodedMessage['cmd'] == 3) { + final payload = decodedMessage['payload']; + print('Ошибка входа в канал: $payload'); + + + _messageController.add({ + 'type': 'channel_error', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 57 && + decodedMessage['cmd'] == 1) { + final payload = decodedMessage['payload']; + print('Подписка на канал успешна: $payload'); + + + _messageController.add({ + 'type': 'channel_subscribed', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 57 && + decodedMessage['cmd'] == 3) { + final payload = decodedMessage['payload']; + print('Ошибка подписки на канал: $payload'); + + + _messageController.add({ + 'type': 'channel_error', + 'payload': payload, + }); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 59 && + decodedMessage['cmd'] == 1) { + final payload = decodedMessage['payload']; + print('Получены участники группы: $payload'); + + + _messageController.add({ + 'type': 'group_members', + 'payload': payload, + }); + } + + if (decodedMessage is Map) { + _messageController.add(decodedMessage); + } + } catch (e) { + print('Невалидное сообщение от сервера, пропускаем: $e'); + } + }, + onError: (error) { + print('Ошибка WebSocket: $error'); + _isSessionOnline = false; + _isSessionReady = false; + _reconnect(); + }, + onDone: () { + print('WebSocket соединение закрыто. Попытка переподключения...'); + _isSessionOnline = false; + _isSessionReady = false; + + if (!_isSessionReady) { + _reconnect(); + } + }, + cancelOnError: true, + ); + } + + void _reconnect() { + if (_isReconnecting) return; + + _isReconnecting = true; + _reconnectAttempts++; + + if (_reconnectAttempts > _maxReconnectAttempts) { + print( + "Превышено максимальное количество попыток переподключения ($_maxReconnectAttempts). Останавливаем попытки.", + ); + _connectionStatusController.add("disconnected"); + _isReconnecting = false; + return; + } + + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + _isSessionOnline = false; + _isSessionReady = false; + _onlineCompleter = Completer(); + _chatsFetchedInThisSession = false; + + + clearAllCaches(); + + + _currentUrlIndex = 0; + + + _reconnectDelaySeconds = (_reconnectDelaySeconds * 2).clamp(1, 30); + final jitter = (DateTime.now().millisecondsSinceEpoch % 1000) / 1000.0; + final delay = Duration(seconds: _reconnectDelaySeconds + jitter.round()); + + _reconnectTimer = Timer(delay, () { + print( + "Переподключаемся после ${delay.inSeconds}s... (попытка $_reconnectAttempts/$_maxReconnectAttempts)", + ); + _isReconnecting = false; + _connectWithFallback(); + }); + } + + Future getVideoUrl(int videoId, int chatId, String messageId) async { + await waitUntilOnline(); + + final payload = { + "videoId": videoId, + "chatId": chatId, + "messageId": messageId, + }; + + final int seq = _sendMessage(83, payload); + print('Запрашиваем URL для videoId: $videoId (seq: $seq)'); + + try { + + final response = await messages + .firstWhere((msg) => msg['seq'] == seq && msg['opcode'] == 83) + .timeout(const Duration(seconds: 15)); + + + if (response['cmd'] == 3) { + throw Exception( + 'Ошибка получения URL видео: ${response['payload']?['message']}', + ); + } + + + final videoPayload = response['payload'] as Map?; + if (videoPayload == null) { + throw Exception('Получен пустой payload для видео'); + } + + + String? videoUrl = + videoPayload['MP4_720'] as String? ?? + videoPayload['MP4_480'] as String? ?? + videoPayload['MP4_1080'] as String? ?? + videoPayload['MP4_360'] as String?; + + + if (videoUrl == null) { + final mp4Key = videoPayload.keys.firstWhere( + (k) => k.startsWith('MP4_'), + orElse: () => '', + ); + if (mp4Key.isNotEmpty) { + videoUrl = videoPayload[mp4Key] as String?; + } + } + + if (videoUrl != null) { + print('URL для videoId: $videoId успешно получен.'); + return videoUrl; + } else { + throw Exception('Не найден ни один MP4 URL в ответе'); + } + } on TimeoutException { + print('Таймаут ожидания URL для videoId: $videoId'); + throw Exception('Сервер не ответил на запрос видео вовремя'); + } catch (e) { + print('Ошибка в getVideoUrl: $e'); + rethrow; // Передаем ошибку дальше + } + } + + void disconnect() { + print("Отключаем WebSocket..."); + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + _streamSubscription?.cancel(); // Отменяем подписку на stream + _isSessionOnline = false; + _isSessionReady = false; + _handshakeSent = false; // Сбрасываем флаг handshake + _onlineCompleter = Completer(); + _chatsFetchedInThisSession = false; + + + _channel?.sink.close(status.goingAway); + _channel = null; + _streamSubscription = null; + + + _connectionStatusController.add("disconnected"); + } + + Future getClipboardData() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + return data?.text; + } + + + void forceReconnect() { + print("Принудительное переподключение..."); + + + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + if (_channel != null) { + print("Закрываем существующее соединение..."); + _channel!.sink.close(status.goingAway); + _channel = null; + } + + + _isReconnecting = false; + _reconnectAttempts = 0; + _reconnectDelaySeconds = 2; + _isSessionOnline = false; + _isSessionReady = false; + _chatsFetchedInThisSession = false; + _currentUrlIndex = 0; + _onlineCompleter = Completer(); // Re-create completer + + + clearAllCaches(); + _messageQueue.clear(); + _presenceData.clear(); + + + _connectionStatusController.add("connecting"); + _log("Запускаем новую сессию подключения..."); + + + _connectWithFallback(); + } + + + Future performFullReconnection() async { + print("🔄 Начинаем полное переподключение..."); + try { + + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + _streamSubscription?.cancel(); + + + if (_channel != null) { + _channel!.sink.close(); + _channel = null; + } + + + _isReconnecting = false; + _reconnectAttempts = 0; + _reconnectDelaySeconds = 2; + _isSessionOnline = false; + _isSessionReady = false; + _handshakeSent = false; + _chatsFetchedInThisSession = false; // КРИТИЧНО: сбрасываем этот флаг + _currentUrlIndex = 0; + _onlineCompleter = Completer(); + _seq = 0; + + + _lastChatsPayload = null; + _lastChatsAt = null; + + print( + "✅ Кэш чатов очищен: _lastChatsPayload = $_lastChatsPayload, _chatsFetchedInThisSession = $_chatsFetchedInThisSession", + ); + + _connectionStatusController.add("disconnected"); + + + await connect(); + + print("✅ Полное переподключение завершено"); + + + await Future.delayed(const Duration(milliseconds: 1500)); + + + if (!_reconnectionCompleteController.isClosed) { + print("📢 Отправляем уведомление о завершении переподключения"); + _reconnectionCompleteController.add(null); + } + } catch (e) { + print("❌ Ошибка полного переподключения: $e"); + rethrow; + } + } + + + Future updatePrivacySettings({ + String? hidden, + String? searchByPhone, + String? incomingCall, + String? chatsInvite, + bool? chatsPushNotification, + String? chatsPushSound, + String? pushSound, + bool? mCallPushNotification, + bool? pushDetails, + }) async { + final settings = { + if (hidden != null) 'user': {'HIDDEN': hidden == 'true'}, + if (searchByPhone != null) 'user': {'SEARCH_BY_PHONE': searchByPhone}, + if (incomingCall != null) 'user': {'INCOMING_CALL': incomingCall}, + if (chatsInvite != null) 'user': {'CHATS_INVITE': chatsInvite}, + if (chatsPushNotification != null) + 'user': {'PUSH_NEW_CONTACTS': chatsPushNotification}, + if (chatsPushSound != null) 'user': {'PUSH_SOUND': chatsPushSound}, + if (pushSound != null) 'user': {'PUSH_SOUND_GLOBAL': pushSound}, + if (mCallPushNotification != null) + 'user': {'PUSH_MCALL': mCallPushNotification}, + if (pushDetails != null) 'user': {'PUSH_DETAILS': pushDetails}, + }; + + print('Обновляем настройки приватности: $settings'); + + + if (hidden != null) { + await _updateSinglePrivacySetting({'HIDDEN': hidden == 'true'}); + } + if (searchByPhone != null) { + await _updateSinglePrivacySetting({'SEARCH_BY_PHONE': searchByPhone}); + } + if (incomingCall != null) { + await _updateSinglePrivacySetting({'INCOMING_CALL': incomingCall}); + } + if (chatsInvite != null) { + await _updateSinglePrivacySetting({'CHATS_INVITE': chatsInvite}); + } + + + if (chatsPushNotification != null) { + await _updateSinglePrivacySetting({ + 'PUSH_NEW_CONTACTS': chatsPushNotification, + }); + } + if (chatsPushSound != null) { + await _updateSinglePrivacySetting({'PUSH_SOUND': chatsPushSound}); + } + if (pushSound != null) { + await _updateSinglePrivacySetting({'PUSH_SOUND_GLOBAL': pushSound}); + } + if (mCallPushNotification != null) { + await _updateSinglePrivacySetting({'PUSH_MCALL': mCallPushNotification}); + } + if (pushDetails != null) { + await _updateSinglePrivacySetting({'PUSH_DETAILS': pushDetails}); + } + } + + + Future _updateSinglePrivacySetting(Map setting) async { + await waitUntilOnline(); + + final payload = {'settings': setting}; + + _sendMessage(22, payload); + print('Отправляем обновление настройки приватности: $payload'); + } + + void dispose() { + _pingTimer?.cancel(); + _channel?.sink.close(status.goingAway); + _reconnectionCompleteController.close(); + _messageController.close(); + } +} diff --git a/lib/api_service_simple.dart b/lib/api_service_simple.dart new file mode 100644 index 0000000..d6aa10c --- /dev/null +++ b/lib/api_service_simple.dart @@ -0,0 +1,774 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; + +import 'connection/connection_manager_simple.dart'; +import 'connection/connection_logger.dart'; +import 'connection/connection_state.dart'; +import 'connection/health_monitor.dart'; +import 'models/message.dart'; +import 'models/contact.dart'; + + +class ApiServiceSimple { + ApiServiceSimple._privateConstructor(); + static final ApiServiceSimple instance = + ApiServiceSimple._privateConstructor(); + + + final ConnectionManagerSimple _connectionManager = ConnectionManagerSimple(); + + + final ConnectionLogger _logger = ConnectionLogger(); + + + String? _authToken; + bool _isInitialized = false; + + + final Map> _messageCache = {}; + final Map _contactCache = {}; + Map? _lastChatsPayload; + DateTime? _lastChatsAt; + final Duration _chatsCacheTtl = const Duration(seconds: 5); + bool _chatsFetchedInThisSession = false; + + + final Map _presenceData = {}; + + + final StreamController _contactUpdatesController = + StreamController.broadcast(); + final StreamController> _messageController = + StreamController>.broadcast(); + + + Stream> get messages => _messageController.stream; + + + Stream get contactUpdates => _contactUpdatesController.stream; + + + Stream get connectionState => _connectionManager.stateStream; + + + Stream get logs => _connectionManager.logStream; + + + Stream get healthMetrics => + _connectionManager.healthMetricsStream; + + + ConnectionInfo get currentState => _connectionManager.currentState; + + + bool get isOnline => _connectionManager.isConnected; + + + bool get canSendMessages => _connectionManager.canSendMessages; + + + Future initialize() async { + if (_isInitialized) { + _logger.logConnection('ApiServiceSimple уже инициализирован'); + return; + } + + _logger.logConnection('Инициализация ApiServiceSimple'); + + try { + await _connectionManager.initialize(); + _setupMessageHandlers(); + _isInitialized = true; + + _logger.logConnection('ApiServiceSimple успешно инициализирован'); + } catch (e) { + _logger.logError('Ошибка инициализации ApiServiceSimple', error: e); + rethrow; + } + } + + + void _setupMessageHandlers() { + _connectionManager.messageStream.listen((message) { + _handleIncomingMessage(message); + }); + } + + + void _handleIncomingMessage(Map message) { + try { + _logger.logMessage('IN', message); + + + if (message['opcode'] == 128 && message['payload'] != null) { + _handleContactUpdate(message['payload']); + } + + + if (message['opcode'] == 129 && message['payload'] != null) { + _handlePresenceUpdate(message['payload']); + } + + + _messageController.add(message); + } catch (e) { + _logger.logError( + 'Ошибка обработки входящего сообщения', + data: {'message': message, 'error': e.toString()}, + ); + } + } + + + void _handleContactUpdate(Map payload) { + try { + final contact = Contact.fromJson(payload); + _contactCache[contact.id] = contact; + _contactUpdatesController.add(contact); + + _logger.logConnection( + 'Контакт обновлен', + data: {'contact_id': contact.id, 'contact_name': contact.name}, + ); + } catch (e) { + _logger.logError( + 'Ошибка обработки обновления контакта', + data: {'payload': payload, 'error': e.toString()}, + ); + } + } + + + void _handlePresenceUpdate(Map payload) { + try { + _presenceData.addAll(payload); + _logger.logConnection( + 'Presence данные обновлены', + data: {'keys': payload.keys.toList()}, + ); + } catch (e) { + _logger.logError( + 'Ошибка обработки presence данных', + data: {'payload': payload, 'error': e.toString()}, + ); + } + } + + + Future connect() async { + _logger.logConnection('Запрос подключения к серверу'); + + try { + await _connectionManager.connect(authToken: _authToken); + _logger.logConnection('Подключение к серверу успешно'); + } catch (e) { + _logger.logError('Ошибка подключения к серверу', error: e); + rethrow; + } + } + + + Future reconnect() async { + _logger.logConnection('Запрос переподключения'); + + try { + await _connectionManager.connect(authToken: _authToken); + _logger.logConnection('Переподключение успешно'); + } catch (e) { + _logger.logError('Ошибка переподключения', error: e); + rethrow; + } + } + + + Future disconnect() async { + _logger.logConnection('Отключение от сервера'); + + try { + await _connectionManager.disconnect(); + _logger.logConnection('Отключение от сервера успешно'); + } catch (e) { + _logger.logError('Ошибка отключения', error: e); + } + } + + + int _sendMessage(int opcode, Map payload) { + if (!canSendMessages) { + _logger.logConnection( + 'Сообщение не отправлено - соединение не готово', + data: {'opcode': opcode, 'payload': payload}, + ); + return -1; + } + + try { + final seq = _connectionManager.sendMessage(opcode, payload); + _logger.logConnection( + 'Сообщение отправлено', + data: {'opcode': opcode, 'seq': seq, 'payload': payload}, + ); + return seq; + } catch (e) { + _logger.logError( + 'Ошибка отправки сообщения', + data: {'opcode': opcode, 'payload': payload, 'error': e.toString()}, + ); + return -1; + } + } + + + Future sendHandshake() async { + _logger.logConnection('Отправка handshake'); + + final payload = { + "userAgent": { + "deviceType": "WEB", + "locale": "ru", + "deviceLocale": "ru", + "osVersion": "Windows", + "deviceName": "Chrome", + "headerUserAgent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "appVersion": "25.9.15", + "screen": "1920x1080 1.0x", + "timezone": "Europe/Moscow", + }, + "deviceId": _generateDeviceId(), + }; + + _sendMessage(6, payload); + } + + + void requestOtp(String phoneNumber) { + _logger.logConnection('Запрос OTP', data: {'phone': phoneNumber}); + + final payload = { + "phone": phoneNumber, + "type": "START_AUTH", + "language": "ru", + }; + _sendMessage(17, payload); + } + + + void verifyCode(String token, String code) { + _logger.logConnection( + 'Проверка кода', + data: {'token': token, 'code': code}, + ); + + final payload = { + "token": token, + "verifyCode": code, + "authTokenType": "CHECK_CODE", + }; + _sendMessage(18, payload); + } + + + Future> authenticateWithToken(String token) async { + _logger.logConnection('Аутентификация с токеном'); + + _authToken = token; + await saveToken(token); + + final payload = {"interactive": true, "token": token, "chatsCount": 100}; + + final seq = _sendMessage(19, payload); + + try { + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 30)); + + _logger.logConnection( + 'Аутентификация успешна', + data: {'seq': seq, 'response_cmd': response['cmd']}, + ); + + return response['payload'] ?? {}; + } catch (e) { + _logger.logError( + 'Ошибка аутентификации', + data: {'token': token, 'error': e.toString()}, + ); + rethrow; + } + } + + + Future> getChatsAndContacts({bool force = false}) async { + _logger.logConnection('Запрос чатов и контактов', data: {'force': force}); + + + if (!force && _lastChatsPayload != null && _lastChatsAt != null) { + if (DateTime.now().difference(_lastChatsAt!) < _chatsCacheTtl) { + _logger.logConnection('Возвращаем данные из кэша'); + return _lastChatsPayload!; + } + } + + try { + final payload = {"chatsCount": 100}; + final seq = _sendMessage(48, payload); + + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 30)); + + final List chatListJson = response['payload']?['chats'] ?? []; + + if (chatListJson.isEmpty) { + final result = {'chats': [], 'contacts': [], 'profile': null}; + _lastChatsPayload = result; + _lastChatsAt = DateTime.now(); + return result; + } + + + final contactIds = {}; + for (var chatJson in chatListJson) { + final participants = + chatJson['participants'] as Map? ?? {}; + contactIds.addAll(participants.keys.map((id) => int.parse(id))); + } + + final contactSeq = _sendMessage(32, {"contactIds": contactIds.toList()}); + + final contactResponse = await messages + .firstWhere((msg) => msg['seq'] == contactSeq) + .timeout(const Duration(seconds: 30)); + + final List contactListJson = + contactResponse['payload']?['contacts'] ?? []; + + final result = { + 'chats': chatListJson, + 'contacts': contactListJson, + 'profile': null, + 'presence': null, + }; + + _lastChatsPayload = result; + _lastChatsAt = DateTime.now(); + _chatsFetchedInThisSession = true; + + + final contacts = contactListJson + .map((json) => Contact.fromJson(json)) + .toList(); + updateContactCache(contacts); + + _logger.logConnection( + 'Чаты и контакты получены', + data: { + 'chats_count': chatListJson.length, + 'contacts_count': contactListJson.length, + }, + ); + + return result; + } catch (e) { + _logger.logError('Ошибка получения чатов и контактов', error: e); + rethrow; + } + } + + + Future> getMessageHistory( + int chatId, { + bool force = false, + }) async { + _logger.logConnection( + 'Запрос истории сообщений', + data: {'chat_id': chatId, 'force': force}, + ); + + if (!force && _messageCache.containsKey(chatId)) { + _logger.logConnection('История сообщений загружена из кэша'); + return _messageCache[chatId]!; + } + + try { + final payload = { + "chatId": chatId, + "from": DateTime.now() + .add(const Duration(days: 1)) + .millisecondsSinceEpoch, + "forward": 0, + "backward": 1000, + "getMessages": true, + }; + + final seq = _sendMessage(49, payload); + + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 30)); + + if (response['cmd'] == 3) { + final error = response['payload']; + _logger.logError( + 'Ошибка получения истории сообщений', + data: {'chat_id': chatId, 'error': error}, + ); + throw Exception('Ошибка получения истории: ${error['message']}'); + } + + final List messagesJson = response['payload']?['messages'] ?? []; + final messagesList = + messagesJson.map((json) => Message.fromJson(json)).toList() + ..sort((a, b) => a.time.compareTo(b.time)); + + _messageCache[chatId] = messagesList; + + _logger.logConnection( + 'История сообщений получена', + data: {'chat_id': chatId, 'messages_count': messagesList.length}, + ); + + return messagesList; + } catch (e) { + _logger.logError( + 'Ошибка получения истории сообщений', + data: {'chat_id': chatId, 'error': e.toString()}, + ); + return []; + } + } + + + void sendMessage(int chatId, String text, {String? replyToMessageId}) { + _logger.logConnection( + 'Отправка сообщения', + data: { + 'chat_id': chatId, + 'text_length': text.length, + 'reply_to': replyToMessageId, + }, + ); + + final int clientMessageId = DateTime.now().millisecondsSinceEpoch; + final payload = { + "chatId": chatId, + "message": { + "text": text, + "cid": clientMessageId, + "elements": [], + "attaches": [], + if (replyToMessageId != null) + "link": {"type": "REPLY", "messageId": replyToMessageId}, + }, + "notify": true, + }; + + clearChatsCache(); + _sendMessage(64, payload); + } + + + Future sendPhotoMessage( + int chatId, { + String? localPath, + String? caption, + int? cidOverride, + int? senderId, + }) async { + _logger.logConnection( + 'Отправка фото', + data: {'chat_id': chatId, 'local_path': localPath, 'caption': caption}, + ); + + try { + XFile? image; + if (localPath != null) { + image = XFile(localPath); + } else { + final picker = ImagePicker(); + image = await picker.pickImage(source: ImageSource.gallery); + if (image == null) return; + } + + + final seq80 = _sendMessage(80, {"count": 1}); + final resp80 = await messages + .firstWhere((m) => m['seq'] == seq80) + .timeout(const Duration(seconds: 30)); + + final String uploadUrl = resp80['payload']['url']; + + + var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); + request.files.add(await http.MultipartFile.fromPath('file', image.path)); + var streamed = await request.send(); + var httpResp = await http.Response.fromStream(streamed); + + if (httpResp.statusCode != 200) { + throw Exception( + 'Ошибка загрузки фото: ${httpResp.statusCode} ${httpResp.body}', + ); + } + + final uploadJson = jsonDecode(httpResp.body) as Map; + final Map photos = uploadJson['photos'] as Map; + if (photos.isEmpty) throw Exception('Не получен токен фото'); + final String photoToken = (photos.values.first as Map)['token']; + + + final int cid = cidOverride ?? DateTime.now().millisecondsSinceEpoch; + final payload = { + "chatId": chatId, + "message": { + "text": caption?.trim() ?? "", + "cid": cid, + "elements": [], + "attaches": [ + {"_type": "PHOTO", "photoToken": photoToken}, + ], + }, + "notify": true, + }; + + clearChatsCache(); + _sendMessage(64, payload); + + _logger.logConnection( + 'Фото отправлено', + data: {'chat_id': chatId, 'photo_token': photoToken}, + ); + } catch (e) { + _logger.logError( + 'Ошибка отправки фото', + data: {'chat_id': chatId, 'error': e.toString()}, + ); + } + } + + + Future blockContact(int contactId) async { + _logger.logConnection( + 'Блокировка контакта', + data: {'contact_id': contactId}, + ); + _sendMessage(34, {'contactId': contactId, 'action': 'BLOCK'}); + } + + + Future unblockContact(int contactId) async { + _logger.logConnection( + 'Разблокировка контакта', + data: {'contact_id': contactId}, + ); + _sendMessage(34, {'contactId': contactId, 'action': 'UNBLOCK'}); + } + + + void getBlockedContacts() { + _logger.logConnection('Запрос заблокированных контактов'); + _sendMessage(36, {'status': 'BLOCKED', 'count': 100, 'from': 0}); + } + + + void createGroup(String name, List participantIds) { + _logger.logConnection( + 'Создание группы', + data: {'name': name, 'participants': participantIds}, + ); + + final payload = {"name": name, "participantIds": participantIds}; + _sendMessage(48, payload); + } + + + void addGroupMember( + int chatId, + List userIds, { + bool showHistory = true, + }) { + _logger.logConnection( + 'Добавление участника в группу', + data: {'chat_id': chatId, 'user_ids': userIds}, + ); + + final payload = { + "chatId": chatId, + "userIds": userIds, + "showHistory": showHistory, + "operation": "add", + }; + _sendMessage(77, payload); + } + + + void removeGroupMember( + int chatId, + List userIds, { + int cleanMsgPeriod = 0, + }) { + _logger.logConnection( + 'Удаление участника из группы', + data: {'chat_id': chatId, 'user_ids': userIds}, + ); + + final payload = { + "chatId": chatId, + "userIds": userIds, + "operation": "remove", + "cleanMsgPeriod": cleanMsgPeriod, + }; + _sendMessage(77, payload); + } + + + void leaveGroup(int chatId) { + _logger.logConnection('Выход из группы', data: {'chat_id': chatId}); + _sendMessage(58, {"chatId": chatId}); + } + + + void sendReaction(int chatId, String messageId, String emoji) { + _logger.logConnection( + 'Отправка реакции', + data: {'chat_id': chatId, 'message_id': messageId, 'emoji': emoji}, + ); + + final payload = { + "chatId": chatId, + "messageId": messageId, + "reaction": {"reactionType": "EMOJI", "id": emoji}, + }; + _sendMessage(178, payload); + } + + + void removeReaction(int chatId, String messageId) { + _logger.logConnection( + 'Удаление реакции', + data: {'chat_id': chatId, 'message_id': messageId}, + ); + + final payload = {"chatId": chatId, "messageId": messageId}; + _sendMessage(179, payload); + } + + + void sendTyping(int chatId, {String type = "TEXT"}) { + final payload = {"chatId": chatId, "type": type}; + _sendMessage(65, payload); + } + + + DateTime? getLastSeen(int userId) { + final userPresence = _presenceData[userId.toString()]; + if (userPresence != null && userPresence['seen'] != null) { + final seenTimestamp = userPresence['seen'] as int; + return DateTime.fromMillisecondsSinceEpoch(seenTimestamp * 1000); + } + return null; + } + + + void updateContactCache(List contacts) { + _contactCache.clear(); + for (final contact in contacts) { + _contactCache[contact.id] = contact; + } + _logger.logConnection( + 'Кэш контактов обновлен', + data: {'contacts_count': contacts.length}, + ); + } + + + Contact? getCachedContact(int contactId) { + return _contactCache[contactId]; + } + + + void clearChatsCache() { + _lastChatsPayload = null; + _lastChatsAt = null; + _chatsFetchedInThisSession = false; + _logger.logConnection('Кэш чатов очищен'); + } + + + void clearMessageCache(int chatId) { + _messageCache.remove(chatId); + _logger.logConnection('Кэш сообщений очищен', data: {'chat_id': chatId}); + } + + + void clearAllCaches() { + _messageCache.clear(); + _contactCache.clear(); + clearChatsCache(); + _logger.logConnection('Все кэши очищены'); + } + + + Future saveToken(String token) async { + _authToken = token; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('authToken', token); + _logger.logConnection('Токен сохранен'); + } + + + Future hasToken() async { + final prefs = await SharedPreferences.getInstance(); + _authToken = prefs.getString('authToken'); + return _authToken != null; + } + + + Future logout() async { + _logger.logConnection('Выход из системы'); + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('authToken'); + _authToken = null; + clearAllCaches(); + await disconnect(); + _logger.logConnection('Выход из системы выполнен'); + } catch (e) { + _logger.logError('Ошибка при выходе из системы', error: e); + } + } + + + String _generateDeviceId() { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = (timestamp % 1000000).toString().padLeft(6, '0'); + return "$timestamp$random"; + } + + + Map getStatistics() { + return { + 'api_service': { + 'is_initialized': _isInitialized, + 'has_auth_token': _authToken != null, + 'message_cache_size': _messageCache.length, + 'contact_cache_size': _contactCache.length, + 'chats_fetched_in_session': _chatsFetchedInThisSession, + }, + 'connection': _connectionManager.getStatistics(), + }; + } + + + void dispose() { + _logger.logConnection('Освобождение ресурсов ApiServiceSimple'); + _connectionManager.dispose(); + _messageController.close(); + _contactUpdatesController.close(); + } +} diff --git a/lib/api_service_v2.dart b/lib/api_service_v2.dart new file mode 100644 index 0000000..d3d5820 --- /dev/null +++ b/lib/api_service_v2.dart @@ -0,0 +1,1131 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; + +import 'connection/connection_manager.dart'; +import 'connection/connection_logger.dart'; +import 'connection/connection_state.dart'; +import 'connection/health_monitor.dart'; +import 'models/message.dart'; +import 'models/contact.dart'; +import 'image_cache_service.dart'; +import 'services/cache_service.dart'; +import 'services/avatar_cache_service.dart'; +import 'services/chat_cache_service.dart'; + + +class ApiServiceV2 { + ApiServiceV2._privateConstructor(); + static final ApiServiceV2 instance = ApiServiceV2._privateConstructor(); + + + final ConnectionManager _connectionManager = ConnectionManager(); + + + final ConnectionLogger _logger = ConnectionLogger(); + + + String? _authToken; + bool _isInitialized = false; + bool _isAuthenticated = false; + + + final Map> _messageCache = {}; + final Map _contactCache = {}; + Map? _lastChatsPayload; + DateTime? _lastChatsAt; + final Duration _chatsCacheTtl = const Duration(seconds: 5); + bool _chatsFetchedInThisSession = false; + + + final Map _presenceData = {}; + + + final StreamController _contactUpdatesController = + StreamController.broadcast(); + final StreamController> _messageController = + StreamController>.broadcast(); + + + Stream> get messages => _messageController.stream; + + + Stream get contactUpdates => _contactUpdatesController.stream; + + + Stream get connectionState => _connectionManager.stateStream; + + + Stream get logs => _connectionManager.logStream; + + + Stream get healthMetrics => + _connectionManager.healthMetricsStream; + + + ConnectionInfo get currentConnectionState => _connectionManager.currentState; + + + bool get isOnline => _connectionManager.isConnected; + + + bool get canSendMessages => _connectionManager.canSendMessages; + + + Future initialize() async { + if (_isInitialized) { + _logger.logConnection('ApiServiceV2 уже инициализирован'); + return; + } + + _logger.logConnection('Инициализация ApiServiceV2'); + + try { + await _connectionManager.initialize(); + _setupMessageHandlers(); + + + _isAuthenticated = false; + + _isInitialized = true; + + _logger.logConnection('ApiServiceV2 успешно инициализирован'); + } catch (e) { + _logger.logError('Ошибка инициализации ApiServiceV2', error: e); + rethrow; + } + } + + + void _setupMessageHandlers() { + _connectionManager.messageStream.listen((message) { + _handleIncomingMessage(message); + }); + } + + + void _handleIncomingMessage(Map message) { + try { + _logger.logMessage('IN', message); + + + if (message['opcode'] == 19 && + message['cmd'] == 1 && + message['payload'] != null) { + _isAuthenticated = true; + _logger.logConnection('Аутентификация успешна'); + } + + + if (message['opcode'] == 128 && message['payload'] != null) { + _handleContactUpdate(message['payload']); + } + + + if (message['opcode'] == 129 && message['payload'] != null) { + _handlePresenceUpdate(message['payload']); + } + + + _messageController.add(message); + } catch (e) { + _logger.logError( + 'Ошибка обработки входящего сообщения', + data: {'message': message, 'error': e.toString()}, + ); + } + } + + + void _handleContactUpdate(Map payload) { + try { + final contact = Contact.fromJson(payload); + _contactCache[contact.id] = contact; + _contactUpdatesController.add(contact); + + _logger.logConnection( + 'Контакт обновлен', + data: {'contact_id': contact.id, 'contact_name': contact.name}, + ); + } catch (e) { + _logger.logError( + 'Ошибка обработки обновления контакта', + data: {'payload': payload, 'error': e.toString()}, + ); + } + } + + + void _handlePresenceUpdate(Map payload) { + try { + _presenceData.addAll(payload); + _logger.logConnection( + 'Presence данные обновлены', + data: {'keys': payload.keys.toList()}, + ); + } catch (e) { + _logger.logError( + 'Ошибка обработки presence данных', + data: {'payload': payload, 'error': e.toString()}, + ); + } + } + + + Future connect() async { + _logger.logConnection('Запрос подключения к серверу'); + + try { + await _connectionManager.connect(authToken: _authToken); + _logger.logConnection('Подключение к серверу успешно'); + } catch (e) { + _logger.logError('Ошибка подключения к серверу', error: e); + rethrow; + } + } + + + Future reconnect() async { + _logger.logConnection('Запрос переподключения'); + + try { + await _connectionManager.connect(authToken: _authToken); + _logger.logConnection('Переподключение успешно'); + } catch (e) { + _logger.logError('Ошибка переподключения', error: e); + rethrow; + } + } + + + Future forceReconnect() async { + _logger.logConnection('Принудительное переподключение'); + + try { + + _isAuthenticated = false; + + await _connectionManager.forceReconnect(); + _logger.logConnection('Принудительное переподключение успешно'); + + + await _performFullAuthenticationSequence(); + } catch (e) { + _logger.logError('Ошибка принудительного переподключения', error: e); + rethrow; + } + } + + + Future _performFullAuthenticationSequence() async { + _logger.logConnection( + 'Выполнение полной последовательности аутентификации', + ); + + try { + + await _waitForConnectionReady(); + + + await _sendAuthenticationToken(); + + + await _waitForAuthenticationConfirmation(); + + + await _sendPingToConfirmSession(); + + + await _requestChatsAndContacts(); + + _logger.logConnection( + 'Полная последовательность аутентификации завершена', + ); + } catch (e) { + _logger.logError('Ошибка в последовательности аутентификации', error: e); + rethrow; + } + } + + + Future _waitForConnectionReady() async { + const maxWaitTime = Duration(seconds: 30); + final startTime = DateTime.now(); + + while (DateTime.now().difference(startTime) < maxWaitTime) { + if (_connectionManager.currentState.isActive) { + + await Future.delayed(const Duration(milliseconds: 500)); + return; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + + throw Exception('Таймаут ожидания готовности соединения'); + } + + + Future _sendAuthenticationToken() async { + if (_authToken == null) { + _logger.logError('Токен аутентификации отсутствует'); + return; + } + + _logger.logConnection('Отправка токена аутентификации'); + + final payload = { + "interactive": true, + "token": _authToken, + "chatsCount": 100, + "userAgent": { + "deviceType": "DESKTOP", + "locale": "ru", + "deviceLocale": "ru", + "osVersion": "Windows", + "deviceName": "Chrome", + "headerUserAgent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "appVersion": "1.0.0", + "screen": "2560x1440 1.0x", + "timezone": "Europe/Moscow", + }, + }; + + _connectionManager.sendMessage(19, payload); + + + await _waitForAuthenticationConfirmation(); + } + + + Future _waitForAuthenticationConfirmation() async { + const maxWaitTime = Duration(seconds: 10); + final startTime = DateTime.now(); + + while (DateTime.now().difference(startTime) < maxWaitTime) { + + if (_connectionManager.currentState.isActive && _isAuthenticated) { + _logger.logConnection('Аутентификация подтверждена'); + return; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + + throw Exception('Таймаут ожидания подтверждения аутентификации'); + } + + + Future _sendPingToConfirmSession() async { + _logger.logConnection('Отправка ping для подтверждения готовности сессии'); + + final payload = {"interactive": true}; + _connectionManager.sendMessage(1, payload); + + + await Future.delayed(const Duration(milliseconds: 500)); + + _logger.logConnection('Ping отправлен, сессия готова'); + } + + + Future _waitForSessionReady() async { + const maxWaitTime = Duration(seconds: 30); + final startTime = DateTime.now(); + + while (DateTime.now().difference(startTime) < maxWaitTime) { + if (canSendMessages && _isAuthenticated) { + _logger.logConnection('Сессия готова для отправки сообщений'); + return; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + + throw Exception('Таймаут ожидания готовности сессии'); + } + + + Future _requestChatsAndContacts() async { + _logger.logConnection('Запрос чатов и контактов'); + + + final chatsPayload = {"chatsCount": 100}; + + _connectionManager.sendMessage(48, chatsPayload); + + + final contactsPayload = {"status": "BLOCKED", "count": 100, "from": 0}; + + _connectionManager.sendMessage(36, contactsPayload); + } + + + Future disconnect() async { + _logger.logConnection('Отключение от сервера'); + + try { + await _connectionManager.disconnect(); + _logger.logConnection('Отключение от сервера успешно'); + } catch (e) { + _logger.logError('Ошибка отключения', error: e); + } + } + + + int _sendMessage(int opcode, Map payload) { + if (!canSendMessages) { + _logger.logConnection( + 'Сообщение не отправлено - соединение не готово', + data: {'opcode': opcode, 'payload': payload}, + ); + return -1; + } + + + if (_requiresAuthentication(opcode) && !_isAuthenticated) { + _logger.logConnection( + 'Сообщение не отправлено - требуется аутентификация', + data: {'opcode': opcode, 'payload': payload}, + ); + return -1; + } + + try { + final seq = _connectionManager.sendMessage(opcode, payload); + _logger.logConnection( + 'Сообщение отправлено', + data: {'opcode': opcode, 'seq': seq, 'payload': payload}, + ); + return seq; + } catch (e) { + _logger.logError( + 'Ошибка отправки сообщения', + data: {'opcode': opcode, 'payload': payload, 'error': e.toString()}, + ); + return -1; + } + } + + + bool _requiresAuthentication(int opcode) { + + const authRequiredOpcodes = { + 19, // Аутентификация + 32, // Получение контактов + 36, // Получение заблокированных контактов + 48, // Получение чатов + 49, // Получение истории сообщений + 64, // Отправка сообщений + 65, // Статус набора + 66, // Удаление сообщений + 67, // Редактирование сообщений + 77, // Управление участниками группы + 78, // Управление участниками группы + 80, // Загрузка файлов + 178, // Отправка реакций + 179, // Удаление реакций + }; + + return authRequiredOpcodes.contains(opcode); + } + + + Future sendHandshake() async { + _logger.logConnection('Отправка handshake'); + + final payload = { + "userAgent": { + "deviceType": "WEB", + "locale": "ru", + "deviceLocale": "ru", + "osVersion": "Windows", + "deviceName": "Chrome", + "headerUserAgent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "appVersion": "25.9.15", + "screen": "1920x1080 1.0x", + "timezone": "Europe/Moscow", + }, + "deviceId": _generateDeviceId(), + }; + + _sendMessage(6, payload); + } + + + void requestOtp(String phoneNumber) { + _logger.logConnection('Запрос OTP', data: {'phone': phoneNumber}); + + final payload = { + "phone": phoneNumber, + "type": "START_AUTH", + "language": "ru", + }; + _sendMessage(17, payload); + } + + + void verifyCode(String token, String code) { + _logger.logConnection( + 'Проверка кода', + data: {'token': token, 'code': code}, + ); + + final payload = { + "token": token, + "verifyCode": code, + "authTokenType": "CHECK_CODE", + }; + _sendMessage(18, payload); + } + + + Future> authenticateWithToken(String token) async { + _logger.logConnection('Аутентификация с токеном'); + + _authToken = token; + await saveToken(token); + + final payload = {"interactive": true, "token": token, "chatsCount": 100}; + + final seq = _sendMessage(19, payload); + + try { + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 30)); + + _logger.logConnection( + 'Аутентификация успешна', + data: {'seq': seq, 'response_cmd': response['cmd']}, + ); + + return response['payload'] ?? {}; + } catch (e) { + _logger.logError( + 'Ошибка аутентификации', + data: {'token': token, 'error': e.toString()}, + ); + rethrow; + } + } + + + Future> getChatsAndContacts({bool force = false}) async { + _logger.logConnection('Запрос чатов и контактов', data: {'force': force}); + + + if (!force && _lastChatsPayload != null && _lastChatsAt != null) { + if (DateTime.now().difference(_lastChatsAt!) < _chatsCacheTtl) { + _logger.logConnection('Возвращаем данные из локального кэша'); + return _lastChatsPayload!; + } + } + + + if (!force) { + final chatService = ChatCacheService(); + final cachedChats = await chatService.getCachedChats(); + final cachedContacts = await chatService.getCachedContacts(); + + if (cachedChats != null && + cachedContacts != null && + cachedChats.isNotEmpty) { + _logger.logConnection('Возвращаем данные из сервиса кэша'); + final result = { + 'chats': cachedChats, + 'contacts': cachedContacts + .map( + (contact) => { + 'id': contact.id, + 'name': contact.name, + 'firstName': contact.firstName, + 'lastName': contact.lastName, + 'photoBaseUrl': contact.photoBaseUrl, + 'isBlocked': contact.isBlocked, + 'isBlockedByMe': contact.isBlockedByMe, + 'accountStatus': contact.accountStatus, + 'status': contact.status, + }, + ) + .toList(), + 'profile': null, + 'presence': null, + }; + + _lastChatsPayload = result; + _lastChatsAt = DateTime.now(); + _chatsFetchedInThisSession = true; + + return result; + } + } + + + await _waitForSessionReady(); + + try { + final payload = {"chatsCount": 100}; + final seq = _sendMessage(48, payload); + + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 30)); + + final List chatListJson = response['payload']?['chats'] ?? []; + + if (chatListJson.isEmpty) { + final result = {'chats': [], 'contacts': [], 'profile': null}; + _lastChatsPayload = result; + _lastChatsAt = DateTime.now(); + return result; + } + + + final contactIds = {}; + for (var chatJson in chatListJson) { + final participants = + chatJson['participants'] as Map? ?? {}; + contactIds.addAll(participants.keys.map((id) => int.parse(id))); + } + + final contactSeq = _sendMessage(32, {"contactIds": contactIds.toList()}); + + final contactResponse = await messages + .firstWhere((msg) => msg['seq'] == contactSeq) + .timeout(const Duration(seconds: 30)); + + final List contactListJson = + contactResponse['payload']?['contacts'] ?? []; + + final result = { + 'chats': chatListJson, + 'contacts': contactListJson, + 'profile': null, + 'presence': null, + }; + + _lastChatsPayload = result; + _lastChatsAt = DateTime.now(); + _chatsFetchedInThisSession = true; + + + final contacts = contactListJson + .map((json) => Contact.fromJson(json)) + .toList(); + updateContactCache(contacts); + + + final chatService = ChatCacheService(); + await chatService.cacheChats(chatListJson.cast>()); + await chatService.cacheContacts(contacts); + + + _preloadContactAvatars(contacts); + + _logger.logConnection( + 'Чаты и контакты получены', + data: { + 'chats_count': chatListJson.length, + 'contacts_count': contactListJson.length, + }, + ); + + return result; + } catch (e) { + _logger.logError('Ошибка получения чатов и контактов', error: e); + rethrow; + } + } + + + Future> getMessageHistory( + int chatId, { + bool force = false, + }) async { + _logger.logConnection( + 'Запрос истории сообщений', + data: {'chat_id': chatId, 'force': force}, + ); + + + if (!force && _messageCache.containsKey(chatId)) { + _logger.logConnection('История сообщений загружена из локального кэша'); + return _messageCache[chatId]!; + } + + + if (!force) { + final chatService = ChatCacheService(); + final cachedMessages = await chatService.getCachedChatMessages(chatId); + + if (cachedMessages != null && cachedMessages.isNotEmpty) { + _logger.logConnection('История сообщений загружена из сервиса кэша'); + _messageCache[chatId] = cachedMessages; + return cachedMessages; + } + } + + + await _waitForSessionReady(); + + try { + final payload = { + "chatId": chatId, + "from": DateTime.now() + .add(const Duration(days: 1)) + .millisecondsSinceEpoch, + "forward": 0, + "backward": 1000, + "getMessages": true, + }; + + final seq = _sendMessage(49, payload); + + final response = await messages + .firstWhere((msg) => msg['seq'] == seq) + .timeout(const Duration(seconds: 30)); + + if (response['cmd'] == 3) { + final error = response['payload']; + _logger.logError( + 'Ошибка получения истории сообщений', + data: {'chat_id': chatId, 'error': error}, + ); + throw Exception('Ошибка получения истории: ${error['message']}'); + } + + final List messagesJson = response['payload']?['messages'] ?? []; + final messagesList = + messagesJson.map((json) => Message.fromJson(json)).toList() + ..sort((a, b) => a.time.compareTo(b.time)); + + _messageCache[chatId] = messagesList; + + + final chatService = ChatCacheService(); + await chatService.cacheChatMessages(chatId, messagesList); + + + _preloadMessageImages(messagesList); + + _logger.logConnection( + 'История сообщений получена', + data: {'chat_id': chatId, 'messages_count': messagesList.length}, + ); + + return messagesList; + } catch (e) { + _logger.logError( + 'Ошибка получения истории сообщений', + data: {'chat_id': chatId, 'error': e.toString()}, + ); + return []; + } + } + + + void sendMessage(int chatId, String text, {String? replyToMessageId}) { + _logger.logConnection( + 'Отправка сообщения', + data: { + 'chat_id': chatId, + 'text_length': text.length, + 'reply_to': replyToMessageId, + }, + ); + + final int clientMessageId = DateTime.now().millisecondsSinceEpoch; + final payload = { + "chatId": chatId, + "message": { + "text": text, + "cid": clientMessageId, + "elements": [], + "attaches": [], + if (replyToMessageId != null) + "link": {"type": "REPLY", "messageId": replyToMessageId}, + }, + "notify": true, + }; + + clearChatsCache(); + _sendMessage(64, payload); + } + + + Future sendPhotoMessage( + int chatId, { + String? localPath, + String? caption, + int? cidOverride, + int? senderId, + }) async { + _logger.logConnection( + 'Отправка фото', + data: {'chat_id': chatId, 'local_path': localPath, 'caption': caption}, + ); + + try { + XFile? image; + if (localPath != null) { + image = XFile(localPath); + } else { + final picker = ImagePicker(); + image = await picker.pickImage(source: ImageSource.gallery); + if (image == null) return; + } + + + final seq80 = _sendMessage(80, {"count": 1}); + final resp80 = await messages + .firstWhere((m) => m['seq'] == seq80) + .timeout(const Duration(seconds: 30)); + + final String uploadUrl = resp80['payload']['url']; + + + var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); + request.files.add(await http.MultipartFile.fromPath('file', image.path)); + var streamed = await request.send(); + var httpResp = await http.Response.fromStream(streamed); + + if (httpResp.statusCode != 200) { + throw Exception( + 'Ошибка загрузки фото: ${httpResp.statusCode} ${httpResp.body}', + ); + } + + final uploadJson = jsonDecode(httpResp.body) as Map; + final Map photos = uploadJson['photos'] as Map; + if (photos.isEmpty) throw Exception('Не получен токен фото'); + final String photoToken = (photos.values.first as Map)['token']; + + + final int cid = cidOverride ?? DateTime.now().millisecondsSinceEpoch; + final payload = { + "chatId": chatId, + "message": { + "text": caption?.trim() ?? "", + "cid": cid, + "elements": [], + "attaches": [ + {"_type": "PHOTO", "photoToken": photoToken}, + ], + }, + "notify": true, + }; + + clearChatsCache(); + _sendMessage(64, payload); + + _logger.logConnection( + 'Фото отправлено', + data: {'chat_id': chatId, 'photo_token': photoToken}, + ); + } catch (e) { + _logger.logError( + 'Ошибка отправки фото', + data: {'chat_id': chatId, 'error': e.toString()}, + ); + } + } + + + Future blockContact(int contactId) async { + _logger.logConnection( + 'Блокировка контакта', + data: {'contact_id': contactId}, + ); + _sendMessage(34, {'contactId': contactId, 'action': 'BLOCK'}); + } + + + Future unblockContact(int contactId) async { + _logger.logConnection( + 'Разблокировка контакта', + data: {'contact_id': contactId}, + ); + _sendMessage(34, {'contactId': contactId, 'action': 'UNBLOCK'}); + } + + + void getBlockedContacts() { + _logger.logConnection('Запрос заблокированных контактов'); + _sendMessage(36, {'status': 'BLOCKED', 'count': 100, 'from': 0}); + } + + + void createGroup(String name, List participantIds) { + _logger.logConnection( + 'Создание группы', + data: {'name': name, 'participants': participantIds}, + ); + + final payload = {"name": name, "participantIds": participantIds}; + _sendMessage(48, payload); + } + + + void addGroupMember( + int chatId, + List userIds, { + bool showHistory = true, + }) { + _logger.logConnection( + 'Добавление участника в группу', + data: {'chat_id': chatId, 'user_ids': userIds}, + ); + + final payload = { + "chatId": chatId, + "userIds": userIds, + "showHistory": showHistory, + "operation": "add", + }; + _sendMessage(77, payload); + } + + + void removeGroupMember( + int chatId, + List userIds, { + int cleanMsgPeriod = 0, + }) { + _logger.logConnection( + 'Удаление участника из группы', + data: {'chat_id': chatId, 'user_ids': userIds}, + ); + + final payload = { + "chatId": chatId, + "userIds": userIds, + "operation": "remove", + "cleanMsgPeriod": cleanMsgPeriod, + }; + _sendMessage(77, payload); + } + + + void leaveGroup(int chatId) { + _logger.logConnection('Выход из группы', data: {'chat_id': chatId}); + _sendMessage(58, {"chatId": chatId}); + } + + + void sendReaction(int chatId, String messageId, String emoji) { + _logger.logConnection( + 'Отправка реакции', + data: {'chat_id': chatId, 'message_id': messageId, 'emoji': emoji}, + ); + + final payload = { + "chatId": chatId, + "messageId": messageId, + "reaction": {"reactionType": "EMOJI", "id": emoji}, + }; + _sendMessage(178, payload); + } + + + void removeReaction(int chatId, String messageId) { + _logger.logConnection( + 'Удаление реакции', + data: {'chat_id': chatId, 'message_id': messageId}, + ); + + final payload = {"chatId": chatId, "messageId": messageId}; + _sendMessage(179, payload); + } + + + void sendTyping(int chatId, {String type = "TEXT"}) { + final payload = {"chatId": chatId, "type": type}; + _sendMessage(65, payload); + } + + + DateTime? getLastSeen(int userId) { + final userPresence = _presenceData[userId.toString()]; + if (userPresence != null && userPresence['seen'] != null) { + final seenTimestamp = userPresence['seen'] as int; + return DateTime.fromMillisecondsSinceEpoch(seenTimestamp * 1000); + } + return null; + } + + + void updateContactCache(List contacts) { + _contactCache.clear(); + for (final contact in contacts) { + _contactCache[contact.id] = contact; + } + _logger.logConnection( + 'Кэш контактов обновлен', + data: {'contacts_count': contacts.length}, + ); + } + + + Contact? getCachedContact(int contactId) { + return _contactCache[contactId]; + } + + + void clearChatsCache() { + _lastChatsPayload = null; + _lastChatsAt = null; + _chatsFetchedInThisSession = false; + _logger.logConnection('Кэш чатов очищен'); + } + + + void clearMessageCache(int chatId) { + _messageCache.remove(chatId); + _logger.logConnection('Кэш сообщений очищен', data: {'chat_id': chatId}); + } + + + Future clearAllCaches() async { + _messageCache.clear(); + _contactCache.clear(); + clearChatsCache(); + + + try { + await CacheService().clear(); + await AvatarCacheService().clearAvatarCache(); + await ChatCacheService().clearAllChatCache(); + } catch (e) { + _logger.logError('Ошибка очистки сервисов кеширования', error: e); + } + + _logger.logConnection('Все кэши очищены'); + } + + + Future saveToken(String token) async { + _authToken = token; + final prefs = await SharedPreferences.getInstance(); + + + await prefs.setString('authToken', token); + + _logger.logConnection('Токен сохранен'); + } + + + Future hasToken() async { + final prefs = await SharedPreferences.getInstance(); + _authToken = prefs.getString('authToken'); + return _authToken != null; + } + + + Future logout() async { + _logger.logConnection('Выход из системы'); + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('authToken'); + _authToken = null; + clearAllCaches(); + await disconnect(); + _logger.logConnection('Выход из системы выполнен'); + } catch (e) { + _logger.logError('Ошибка при выходе из системы', error: e); + } + } + + + Future _preloadContactAvatars(List contacts) async { + try { + final avatarUrls = contacts + .map((contact) => contact.photoBaseUrl) + .where((url) => url != null && url.isNotEmpty) + .toList(); + + if (avatarUrls.isNotEmpty) { + _logger.logConnection( + 'Предзагрузка аватарок контактов', + data: {'count': avatarUrls.length}, + ); + + await ImageCacheService.instance.preloadContactAvatars(avatarUrls); + } + } catch (e) { + _logger.logError('Ошибка предзагрузки аватарок контактов', error: e); + } + } + + + Future _preloadMessageImages(List messages) async { + try { + final imageUrls = []; + + for (final message in messages) { + for (final attach in message.attaches) { + if (attach['_type'] == 'PHOTO' || attach['_type'] == 'SHARE') { + final url = attach['url'] ?? attach['baseUrl']; + if (url is String && url.isNotEmpty) { + imageUrls.add(url); + } + } + } + } + + if (imageUrls.isNotEmpty) { + _logger.logConnection( + 'Предзагрузка изображений из сообщений', + data: {'count': imageUrls.length}, + ); + + await ImageCacheService.instance.preloadContactAvatars(imageUrls); + } + } catch (e) { + _logger.logError( + 'Ошибка предзагрузки изображений из сообщений', + error: e, + ); + } + } + + + String _generateDeviceId() { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = (timestamp % 1000000).toString().padLeft(6, '0'); + return "$timestamp$random"; + } + + + Future> getStatistics() async { + final imageCacheStats = await ImageCacheService.instance.getCacheStats(); + final cacheServiceStats = await CacheService().getCacheStats(); + final avatarCacheStats = await AvatarCacheService().getAvatarCacheStats(); + final chatCacheStats = await ChatCacheService().getChatCacheStats(); + + return { + 'api_service': { + 'is_initialized': _isInitialized, + 'has_auth_token': _authToken != null, + 'message_cache_size': _messageCache.length, + 'contact_cache_size': _contactCache.length, + 'chats_fetched_in_session': _chatsFetchedInThisSession, + }, + 'connection': _connectionManager.getStatistics(), + 'cache_service': cacheServiceStats, + 'avatar_cache': avatarCacheStats, + 'chat_cache': chatCacheStats, + 'image_cache': imageCacheStats, + }; + } + + + void dispose() { + _logger.logConnection('Освобождение ресурсов ApiServiceV2'); + _connectionManager.dispose(); + _messageController.close(); + _contactUpdatesController.close(); + } +} diff --git a/lib/cache_management_screen.dart b/lib/cache_management_screen.dart new file mode 100644 index 0000000..b36fa9d --- /dev/null +++ b/lib/cache_management_screen.dart @@ -0,0 +1,431 @@ + + +import 'package:flutter/material.dart'; +import 'package:gwid/services/cache_service.dart'; +import 'package:gwid/services/avatar_cache_service.dart'; +import 'package:gwid/services/chat_cache_service.dart'; + +class CacheManagementScreen extends StatefulWidget { + const CacheManagementScreen({super.key}); + + @override + State createState() => _CacheManagementScreenState(); +} + +class _CacheManagementScreenState extends State { + Map _cacheStats = {}; + Map _avatarCacheStats = {}; + Map _chatCacheStats = {}; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadCacheStats(); + } + + Future _loadCacheStats() async { + setState(() { + _isLoading = true; + }); + + try { + final cacheService = CacheService(); + final avatarService = AvatarCacheService(); + final chatService = ChatCacheService(); + + + await cacheService.initialize(); + await chatService.initialize(); + await avatarService.initialize(); + + final cacheStats = await cacheService.getCacheStats(); + final avatarStats = await avatarService.getAvatarCacheStats(); + final chatStats = await chatService.getChatCacheStats(); + + if (!mounted) return; + + setState(() { + _cacheStats = cacheStats; + _avatarCacheStats = avatarStats; + _chatCacheStats = chatStats; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка загрузки статистики кэша: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + + Future _clearAllCache() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Очистить весь кэш?'), + content: const Text( + 'Это действие удалит все кэшированные данные, включая чаты, сообщения и аватарки. ' + 'Приложение будет работать медленнее до повторной загрузки данных.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + child: const Text('Очистить'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final cacheService = CacheService(); + final avatarService = AvatarCacheService(); + final chatService = ChatCacheService(); + + + await cacheService.initialize(); + await chatService.initialize(); + await avatarService.initialize(); + + await cacheService.clear(); + await avatarService.clearAvatarCache(); + await chatService.clearAllChatCache(); + + await _loadCacheStats(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Весь кэш очищен'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка очистки кэша: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + } + + Future _clearAvatarCache() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Очистить кэш аватарок?'), + content: const Text( + 'Это действие удалит все кэшированные аватарки. ' + 'Они будут загружены заново при следующем просмотре.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Очистить'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final avatarService = AvatarCacheService(); + + + await avatarService.initialize(); + await avatarService.clearAvatarCache(); + await _loadCacheStats(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Кэш аватарок очищен'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка очистки кэша аватарок: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + } + + Widget _buildStatCard( + String title, + String value, + IconData icon, + Color color, + ) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(height: 8), + Text( + title, + style: Theme.of(context).textTheme.titleSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildCacheSection(String title, Map? data) { + if (data == null || data.isEmpty) { + return const SizedBox.shrink(); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + ...data.entries.map( + (entry) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(entry.key), + Text( + entry.value.toString(), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text("Управление кэшем"), + actions: [ + IconButton( + onPressed: _loadCacheStats, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + + Text( + "Общая статистика", + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + const SizedBox(height: 12), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 1.2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + children: [ + _buildStatCard( + "Чаты в кэше", + _chatCacheStats['cachedChats']?.toString() ?? "0", + Icons.chat, + colors.primary, + ), + _buildStatCard( + "Контакты в кэше", + _chatCacheStats['cachedContacts']?.toString() ?? "0", + Icons.contacts, + colors.secondary, + ), + _buildStatCard( + "Аватарки в памяти", + _avatarCacheStats['memoryImages']?.toString() ?? "0", + Icons.person, + colors.tertiary, + ), + _buildStatCard( + "Размер кэша", + "${_avatarCacheStats['diskSizeMB'] ?? "0"} МБ", + Icons.storage, + colors.error, + ), + ], + ), + + const SizedBox(height: 24), + + + Text( + "Детальная статистика", + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + const SizedBox(height: 12), + + _buildCacheSection("Кэш чатов", _chatCacheStats['cacheStats']), + const SizedBox(height: 12), + _buildCacheSection("Кэш аватарок", _avatarCacheStats), + const SizedBox(height: 12), + _buildCacheSection("Общий кэш", _cacheStats), + const SizedBox(height: 12), + _buildCacheSection("Кэш в памяти", { + 'Записей в памяти': _cacheStats['memoryEntries'] ?? 0, + 'Максимум записей': _cacheStats['maxMemorySize'] ?? 0, + }), + + const SizedBox(height: 32), + + + Text( + "Управление кэшем", + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + const SizedBox(height: 12), + + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.delete_sweep), + title: const Text("Очистить кэш аватарок"), + subtitle: const Text( + "Удалить все кэшированные аватарки", + ), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: _clearAvatarCache, + ), + const Divider(height: 1), + ListTile( + leading: Icon( + Icons.delete_forever, + color: colors.error, + ), + title: Text( + "Очистить весь кэш", + style: TextStyle(color: colors.error), + ), + subtitle: const Text("Удалить все кэшированные данные"), + trailing: Icon( + Icons.chevron_right_rounded, + color: colors.error, + ), + onTap: _clearAllCache, + ), + ], + ), + ), + + const SizedBox(height: 24), + + + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: colors.primary), + const SizedBox(width: 8), + Text( + "О кэшировании", + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + "Кэширование ускоряет работу приложения, сохраняя часто используемые данные локально. " + "Чаты кэшируются на 1 час, контакты на 6 часов, сообщения на 2 часа, аватарки на 7 дней.", + ), + const SizedBox(height: 8), + const Text( + "Очистка кэша может замедлить работу приложения до повторной загрузки данных.", + style: TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/channels_list_screen.dart b/lib/channels_list_screen.dart new file mode 100644 index 0000000..0ef32d3 --- /dev/null +++ b/lib/channels_list_screen.dart @@ -0,0 +1,342 @@ + + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/models/channel.dart'; +import 'package:gwid/search_channels_screen.dart'; + +class ChannelsListScreen extends StatefulWidget { + const ChannelsListScreen({super.key}); + + @override + State createState() => _ChannelsListScreenState(); +} + +class _ChannelsListScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + StreamSubscription? _apiSubscription; + bool _isLoading = false; + List _channels = []; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _listenToApiMessages(); + _loadPopularChannels(); + } + + @override + void dispose() { + _searchController.dispose(); + _apiSubscription?.cancel(); + super.dispose(); + } + + void _listenToApiMessages() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + + if (message['type'] == 'channels_found') { + setState(() { + _isLoading = false; + _errorMessage = null; + }); + + final payload = message['payload']; + final channelsData = payload['contacts'] as List?; + + if (channelsData != null) { + _channels = channelsData + .map((channelJson) => Channel.fromJson(channelJson)) + .toList(); + } + } + + + if (message['type'] == 'channels_not_found') { + setState(() { + _isLoading = false; + _channels.clear(); + }); + + final payload = message['payload']; + String errorMessage = 'Каналы не найдены'; + + if (payload != null) { + if (payload['localizedMessage'] != null) { + errorMessage = payload['localizedMessage']; + } else if (payload['message'] != null) { + errorMessage = payload['message']; + } + } + + setState(() { + _errorMessage = errorMessage; + }); + } + }); + } + + void _loadPopularChannels() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + + await ApiService.instance.searchChannels('каналы'); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка загрузки каналов: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + void _searchChannels() async { + final searchQuery = _searchController.text.trim(); + + if (searchQuery.isEmpty) { + _loadPopularChannels(); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + await ApiService.instance.searchChannels(searchQuery); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка поиска каналов: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + void _viewChannel(Channel channel) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChannelDetailsScreen(channel: channel), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Каналы'), + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SearchChannelsScreen(), + ), + ); + }, + ), + ], + ), + body: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Поиск каналов...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _loadPopularChannels(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onSubmitted: (_) => _searchChannels(), + onChanged: (value) { + setState(() {}); + }, + ), + ), + + + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _channels.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.broadcast_on_personal, + size: 64, + color: colors.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + _errorMessage ?? 'Каналы не найдены', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 16, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPopularChannels, + child: const Text('Обновить'), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _channels.length, + itemBuilder: (context, index) { + final channel = _channels[index]; + return _buildChannelCard(channel); + }, + ), + ), + ], + ), + ); + } + + Widget _buildChannelCard(Channel channel) { + final colors = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.all(16), + leading: CircleAvatar( + radius: 24, + backgroundImage: channel.photoBaseUrl != null + ? NetworkImage(channel.photoBaseUrl!) + : null, + child: channel.photoBaseUrl == null + ? Text( + channel.name.isNotEmpty ? channel.name[0].toUpperCase() : '?', + style: TextStyle( + color: colors.onSurface, + fontWeight: FontWeight.w600, + ), + ) + : null, + ), + title: Text( + channel.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (channel.description?.isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text( + channel.description!, + style: TextStyle(color: colors.onSurfaceVariant, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + if (channel.options.contains('BOT')) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Бот', + style: TextStyle( + color: colors.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + if (channel.options.contains('HAS_WEBAPP')) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.secondaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Веб-приложение', + style: TextStyle( + color: colors.onSecondaryContainer, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + trailing: Icon( + Icons.arrow_forward_ios, + size: 16, + color: colors.onSurfaceVariant, + ), + onTap: () => _viewChannel(channel), + ), + ); + } +} diff --git a/lib/chat_screen.dart b/lib/chat_screen.dart new file mode 100644 index 0000000..e424296 --- /dev/null +++ b/lib/chat_screen.dart @@ -0,0 +1,4302 @@ +import 'dart:async'; +import 'dart:ui'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:gwid/theme_provider.dart'; +import 'package:gwid/api_service.dart'; +import 'package:flutter/services.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/models/message.dart'; +import 'package:gwid/widgets/chat_message_bubble.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:gwid/services/chat_cache_service.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:gwid/screens/group_settings_screen.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:video_player/video_player.dart'; + +bool _debugShowExactDate = false; + + +void toggleDebugExactDate() { + _debugShowExactDate = !_debugShowExactDate; + print('Debug режим точной даты: $_debugShowExactDate'); +} + +abstract class ChatItem {} + +class MessageItem extends ChatItem { + final Message message; + final bool isFirstInGroup; + final bool isLastInGroup; + final bool isGrouped; + + MessageItem( + this.message, { + this.isFirstInGroup = false, + this.isLastInGroup = false, + this.isGrouped = false, + }); +} + +class DateSeparatorItem extends ChatItem { + final DateTime date; + DateSeparatorItem(this.date); +} + +class ChatScreen extends StatefulWidget { + final int chatId; + final Contact contact; + final int myId; + final VoidCallback? onChatUpdated; + final bool isGroupChat; + final bool isChannel; + final int? participantCount; + final bool isDesktopMode; + + const ChatScreen({ + super.key, + required this.chatId, + required this.contact, + required this.myId, + this.onChatUpdated, + this.isGroupChat = false, + this.isChannel = false, + this.participantCount, + this.isDesktopMode = false, + }); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final List _messages = []; + List _chatItems = []; + final Set _animatedMessageIds = {}; + + bool _isLoadingHistory = true; + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFocusNode = FocusNode(); + StreamSubscription? _apiSubscription; + final ItemScrollController _itemScrollController = ItemScrollController(); + final ItemPositionsListener _itemPositionsListener = + ItemPositionsListener.create(); + final ValueNotifier _showScrollToBottomNotifier = ValueNotifier(false); + + + late Contact _currentContact; + + + Message? _replyingToMessage; + + final Map _contactDetailsCache = {}; + + final Map _lastReadMessageIdByParticipant = {}; + + int? _actualMyId; + + bool _isIdReady = false; + + bool _isSearching = false; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + List _searchResults = []; + int _currentResultIndex = -1; + final Map _messageKeys = {}; + + void _checkContactCache() { + final cachedContact = ApiService.instance.getCachedContact( + widget.contact.id, + ); + if (cachedContact != null) { + _currentContact = cachedContact; + if (mounted) { + setState(() {}); + } + } + } + + void _scrollToBottom() { + _itemScrollController.scrollTo( + index: 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + + void _loadContactDetails() { + final chatData = ApiService.instance.lastChatsPayload; + if (chatData != null && chatData['contacts'] != null) { + final contactsJson = chatData['contacts'] as List; + for (var contactJson in contactsJson) { + final contact = Contact.fromJson(contactJson); + _contactDetailsCache[contact.id] = contact; + } + print( + 'Кэш контактов для экрана чата заполнен: ${_contactDetailsCache.length} контактов.', + ); + } + } + + @override + void initState() { + super.initState(); + _currentContact = widget.contact; + _initializeChat(); + } + + Future _initializeChat() async { + await _loadCachedContacts(); + + final profileData = ApiService.instance.lastChatsPayload?['profile']; + final contactProfile = profileData?['contact'] as Map?; + + if (contactProfile != null && + contactProfile['id'] != null && + contactProfile['id'] != 0) { + _actualMyId = contactProfile['id']; + print('✅ ID пользователя успешно получен из ApiService: $_actualMyId'); + } else { + _actualMyId = widget.myId; + print('ПРЕДУПРЕЖДЕНИЕ: Используется ID из виджета: $_actualMyId'); + } + + if (mounted) { + setState(() { + _isIdReady = true; + }); + } + + _loadContactDetails(); + _checkContactCache(); + + if (!ApiService.instance.isContactCacheValid()) { + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + ApiService.instance.getBlockedContacts(); + } + }); + } + + ApiService.instance.contactUpdates.listen((contact) { + if (contact.id == _currentContact.id && mounted) { + ApiService.instance.updateCachedContact(contact); + setState(() { + _currentContact = contact; + }); + } + }); + + _itemPositionsListener.itemPositions.addListener(() { + final positions = _itemPositionsListener.itemPositions.value; + if (positions.isNotEmpty) { + _showScrollToBottomNotifier.value = positions.first.index > 0; + } + }); + + _searchController.addListener(() { + if (_searchController.text.isEmpty && _searchResults.isNotEmpty) { + setState(() { + _searchResults.clear(); + _currentResultIndex = -1; + }); + } else if (_searchController.text.isNotEmpty) { + _performSearch(_searchController.text); + } + }); + + _loadHistoryAndListen(); + } + + @override + void didUpdateWidget(ChatScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.contact.id != widget.contact.id) { + _currentContact = widget.contact; + _checkContactCache(); + if (!ApiService.instance.isContactCacheValid()) { + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) { + ApiService.instance.getBlockedContacts(); + } + }); + } + } + } + + void _loadHistoryAndListen() { + _paginateInitialLoad(); + + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + final opcode = message['opcode']; + final cmd = message['cmd']; + final payload = message['payload']; + + if (payload == null) return; + + final dynamic incomingChatId = payload['chatId']; + final int? chatIdNormalized = incomingChatId is int + ? incomingChatId + : int.tryParse(incomingChatId?.toString() ?? ''); + + if (opcode == 64 && cmd == 1) { + if (chatIdNormalized == widget.chatId) { + final newMessage = Message.fromJson(payload['message']); + print( + 'Получено подтверждение (Opcode 64) для cid: ${newMessage.cid}. Обновляем сообщение.', + ); + _updateMessage( + newMessage, + ); // Обновляем временное сообщение на настоящее + } + } else if (opcode == 128) { + if (chatIdNormalized == widget.chatId) { + final newMessage = Message.fromJson(payload['message']); + final hasSameId = _messages.any((m) => m.id == newMessage.id); + final hasSameCid = + newMessage.cid != null && + _messages.any((m) => m.cid != null && m.cid == newMessage.cid); + if (hasSameId || hasSameCid) { + _updateMessage(newMessage); + } else { + _addMessage(newMessage); + } + } + } else if (opcode == 129) { + if (chatIdNormalized == widget.chatId) { + print('Пользователь печатает в чате $chatIdNormalized'); + } + } else if (opcode == 132) { + if (chatIdNormalized == widget.chatId) { + print('Обновлен статус присутствия для чата $chatIdNormalized'); + + final dynamic contactIdAny = + payload['contactId'] ?? payload['userId']; + if (contactIdAny != null) { + final int? cid = contactIdAny is int + ? contactIdAny + : int.tryParse(contactIdAny.toString()); + if (cid != null) { + final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final isOnline = payload['online'] == true; + final userPresence = { + 'seen': currentTime, + 'on': isOnline ? 'ON' : 'OFF', + }; + ApiService.instance.updatePresenceData({ + cid.toString(): userPresence, + }); + + print( + 'Обновлен presence для пользователя $cid: online=$isOnline, seen=$currentTime', + ); + + if (mounted) { + setState(() {}); + } + } + } + } + } else if (opcode == 67) { + if (chatIdNormalized == widget.chatId) { + final editedMessage = Message.fromJson(payload['message']); + _updateMessage(editedMessage); + } + } else if (opcode == 66) { + if (chatIdNormalized == widget.chatId) { + final deletedMessageIds = List.from( + payload['messageIds'] ?? [], + ); + _removeMessages(deletedMessageIds); + } + } else if (opcode == 178) { + if (chatIdNormalized == widget.chatId) { + final messageId = payload['messageId'] as String?; + final reactionInfo = payload['reactionInfo'] as Map?; + if (messageId != null && reactionInfo != null) { + _updateMessageReaction(messageId, reactionInfo); + } + } + } else if (opcode == 179) { + if (chatIdNormalized == widget.chatId) { + final messageId = payload['messageId'] as String?; + final reactionInfo = payload['reactionInfo'] as Map?; + if (messageId != null) { + _updateMessageReaction(messageId, reactionInfo ?? {}); + } + } + } + }); + } + + static const int _pageSize = 50; + bool _isLoadingMore = false; + bool _hasMore = true; + int? _oldestLoadedTime; + + bool get _optimize => context.read().optimizeChats; + bool get _ultraOptimize => context.read().ultraOptimizeChats; + bool get _anyOptimize => _optimize || _ultraOptimize; + + int get _optPage => _ultraOptimize ? 10 : (_optimize ? 50 : _pageSize); + + Future _paginateInitialLoad() async { + setState(() => _isLoadingHistory = true); + + final chatCacheService = ChatCacheService(); + List? cachedMessages = await chatCacheService + .getCachedChatMessages(widget.chatId); + + bool hasCache = cachedMessages != null && cachedMessages.isNotEmpty; + if (hasCache) { + print("✅ Показываем ${cachedMessages.length} сообщений из кэша..."); + if (!mounted) return; + _messages.clear(); + _messages.addAll(cachedMessages); + _buildChatItems(); + setState(() { + _isLoadingHistory = false; + }); + } + + try { + print("📡 Запрашиваем актуальные сообщения с сервера..."); + final allMessages = await ApiService.instance.getMessageHistory( + widget.chatId, + force: true, + ); + if (!mounted) return; + print("✅ Получено ${allMessages.length} сообщений с сервера."); + + + final Set senderIds = {}; + for (final message in allMessages) { + senderIds.add(message.senderId); + + if (message.isReply && message.link?['message']?['sender'] != null) { + final replySenderId = message.link!['message']!['sender']; + if (replySenderId is int) { + senderIds.add(replySenderId); + } + } + } + senderIds.remove(0); // Удаляем системный ID, если он есть + + + final idsToFetch = senderIds + .where((id) => !_contactDetailsCache.containsKey(id)) + .toList(); + if (idsToFetch.isNotEmpty) { + final newContacts = await ApiService.instance.fetchContactsByIds( + idsToFetch, + ); + + for (final contact in newContacts) { + _contactDetailsCache[contact.id] = contact; + } + } + + await chatCacheService.cacheChatMessages(widget.chatId, allMessages); + + final page = _anyOptimize ? _optPage : _pageSize; + final slice = allMessages.length > page + ? allMessages.sublist(allMessages.length - page) + : allMessages; + + setState(() { + _messages.clear(); + _messages.addAll(slice); + _oldestLoadedTime = _messages.isNotEmpty ? _messages.first.time : null; + _hasMore = allMessages.length > _messages.length; + _buildChatItems(); + _isLoadingHistory = false; + }); + } catch (e) { + print("❌ Ошибка при загрузке с сервера: $e"); + if (mounted) { + setState(() { + _isLoadingHistory = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось обновить историю чата')), + ); + } + } + + final theme = context.read(); + if (theme.debugReadOnEnter && + _messages.isNotEmpty && + widget.onChatUpdated != null) { + final lastMessageId = _messages.last.id; + ApiService.instance.markMessageAsRead(widget.chatId, lastMessageId); + } + } + + Future _loadMore() async { + if (_isLoadingMore || !_hasMore) return; + _isLoadingMore = true; + setState(() {}); + + final all = await ApiService.instance.getMessageHistory( + widget.chatId, + force: false, + ); + if (!mounted) return; + + final page = _anyOptimize ? _optPage : _pageSize; + + final older = all + .where((m) => m.time < (_oldestLoadedTime ?? 1 << 62)) + .toList(); + + if (older.isEmpty) { + _hasMore = false; + _isLoadingMore = false; + setState(() {}); + return; + } + + older.sort((a, b) => a.time.compareTo(b.time)); + final take = older.length > page + ? older.sublist(older.length - page) + : older; + + _messages.insertAll(0, take); + _oldestLoadedTime = _messages.first.time; + _hasMore = all.length > _messages.length; + + _buildChatItems(); + _isLoadingMore = false; + setState(() {}); + + + } + + bool _isSameDay(DateTime date1, DateTime date2) { + return date1.year == date2.year && + date1.month == date2.month && + date1.day == date2.day; + } + + bool _isMessageGrouped(Message currentMessage, Message? previousMessage) { + if (previousMessage == null) return false; + + final currentTime = DateTime.fromMillisecondsSinceEpoch( + currentMessage.time, + ); + final previousTime = DateTime.fromMillisecondsSinceEpoch( + previousMessage.time, + ); + + final timeDifference = currentTime.difference(previousTime).inMinutes; + + return currentMessage.senderId == previousMessage.senderId && + timeDifference <= 5; + } + + void _buildChatItems() { + final List items = []; + final source = _messages; + + for (int i = 0; i < source.length; i++) { + final currentMessage = source[i]; + final previousMessage = (i > 0) ? source[i - 1] : null; + + final currentDate = DateTime.fromMillisecondsSinceEpoch( + currentMessage.time, + ).toLocal(); + final previousDate = previousMessage != null + ? DateTime.fromMillisecondsSinceEpoch(previousMessage.time).toLocal() + : null; + + if (previousMessage == null || !_isSameDay(currentDate, previousDate!)) { + items.add(DateSeparatorItem(currentDate)); + } + + final isGrouped = _isMessageGrouped(currentMessage, previousMessage); + + print( + 'DEBUG GROUPING: Message ${i}: sender=${currentMessage.senderId}, time=${currentMessage.time}', + ); + if (previousMessage != null) { + print( + 'DEBUG GROUPING: Previous: sender=${previousMessage.senderId}, time=${previousMessage.time}', + ); + print('DEBUG GROUPING: isGrouped=$isGrouped'); + } + + + final isFirstInGroup = + previousMessage == null || + !_isMessageGrouped(currentMessage, previousMessage); + + + final isLastInGroup = + i == source.length - 1 || + !_isMessageGrouped(source[i + 1], currentMessage); + + print( + 'DEBUG GROUPING: isFirstInGroup=$isFirstInGroup, isLastInGroup=$isLastInGroup', + ); + + items.add( + MessageItem( + currentMessage, + isFirstInGroup: isFirstInGroup, + isLastInGroup: isLastInGroup, + isGrouped: isGrouped, + ), + ); + } + _chatItems = items; + } + + void _addMessage(Message message) { + if (_messages.any((m) => m.id == message.id)) { + print('Сообщение ${message.id} уже существует, пропускаем добавление'); + return; + } + + ApiService.instance.clearCacheForChat(widget.chatId); + + final lastMessage = _messages.isNotEmpty ? _messages.last : null; + _messages.add(message); + + final currentDate = DateTime.fromMillisecondsSinceEpoch( + message.time, + ).toLocal(); + final lastDate = lastMessage != null + ? DateTime.fromMillisecondsSinceEpoch(lastMessage.time).toLocal() + : null; + + if (lastMessage == null || !_isSameDay(currentDate, lastDate!)) { + final separator = DateSeparatorItem(currentDate); + _chatItems.add(separator); + } + + final lastMessageItem = + _chatItems.isNotEmpty && _chatItems.last is MessageItem + ? _chatItems.last as MessageItem + : null; + + final isGrouped = _isMessageGrouped(message, lastMessageItem?.message); + final isFirstInGroup = lastMessageItem == null || !isGrouped; + final isLastInGroup = true; + + final messageItem = MessageItem( + message, + isFirstInGroup: isFirstInGroup, + isLastInGroup: isLastInGroup, + isGrouped: isGrouped, + ); + _chatItems.add(messageItem); + + final theme = context.read(); + if (theme.messageTransition == TransitionOption.slide) { + print('Добавлено новое сообщение для анимации Slide+: ${message.id}'); + } else { + _animatedMessageIds.add(message.id); + } + + if (mounted) { + setState(() {}); + } + } + + void _updateMessageReaction( + String messageId, + Map reactionInfo, + ) { + final messageIndex = _messages.indexWhere((m) => m.id == messageId); + if (messageIndex != -1) { + final message = _messages[messageIndex]; + final updatedMessage = message.copyWith(reactionInfo: reactionInfo); + _messages[messageIndex] = updatedMessage; + + _buildChatItems(); + + print('Обновлена реакция для сообщения $messageId: $reactionInfo'); + + if (mounted) { + setState(() {}); + } + } + } + + void _updateReactionOptimistically(String messageId, String emoji) { + final messageIndex = _messages.indexWhere((m) => m.id == messageId); + if (messageIndex != -1) { + final message = _messages[messageIndex]; + final currentReactionInfo = message.reactionInfo ?? {}; + final currentCounters = List>.from( + currentReactionInfo['counters'] ?? [], + ); + + final existingCounterIndex = currentCounters.indexWhere( + (counter) => counter['reaction'] == emoji, + ); + + if (existingCounterIndex != -1) { + currentCounters[existingCounterIndex]['count'] = + (currentCounters[existingCounterIndex]['count'] as int) + 1; + } else { + currentCounters.add({'reaction': emoji, 'count': 1}); + } + + final updatedReactionInfo = { + ...currentReactionInfo, + 'counters': currentCounters, + 'yourReaction': emoji, + 'totalCount': currentCounters.fold( + 0, + (sum, counter) => sum + (counter['count'] as int), + ), + }; + + final updatedMessage = message.copyWith( + reactionInfo: updatedReactionInfo, + ); + _messages[messageIndex] = updatedMessage; + + _buildChatItems(); + + print('Оптимистично добавлена реакция $emoji к сообщению $messageId'); + + if (mounted) { + setState(() {}); + } + } + } + + void _removeReactionOptimistically(String messageId) { + final messageIndex = _messages.indexWhere((m) => m.id == messageId); + if (messageIndex != -1) { + final message = _messages[messageIndex]; + final currentReactionInfo = message.reactionInfo ?? {}; + final yourReaction = currentReactionInfo['yourReaction'] as String?; + + if (yourReaction != null) { + final currentCounters = List>.from( + currentReactionInfo['counters'] ?? [], + ); + + final counterIndex = currentCounters.indexWhere( + (counter) => counter['reaction'] == yourReaction, + ); + + if (counterIndex != -1) { + final currentCount = currentCounters[counterIndex]['count'] as int; + if (currentCount > 1) { + currentCounters[counterIndex]['count'] = currentCount - 1; + } else { + currentCounters.removeAt(counterIndex); + } + } + + final updatedReactionInfo = { + ...currentReactionInfo, + 'counters': currentCounters, + 'yourReaction': null, + 'totalCount': currentCounters.fold( + 0, + (sum, counter) => sum + (counter['count'] as int), + ), + }; + + final updatedMessage = message.copyWith( + reactionInfo: updatedReactionInfo, + ); + _messages[messageIndex] = updatedMessage; + + _buildChatItems(); + + print('Оптимистично удалена реакция с сообщения $messageId'); + + if (mounted) { + setState(() {}); + } + } + } + } + + void _updateMessage(Message updatedMessage) { + final index = _messages.indexWhere((m) => m.id == updatedMessage.id); + if (index != -1) { + print( + 'Обновляем сообщение ${updatedMessage.id}: "${_messages[index].text}" -> "${updatedMessage.text}"', + ); + + final oldMessage = _messages[index]; + final finalMessage = updatedMessage.link != null + ? updatedMessage + : updatedMessage.copyWith(link: oldMessage.link); + + print('Обновляем link: ${oldMessage.link} -> ${finalMessage.link}'); + + _messages[index] = finalMessage; + ApiService.instance.clearCacheForChat(widget.chatId); + _buildChatItems(); + setState(() {}); + } else { + print( + 'Сообщение ${updatedMessage.id} не найдено для обновления. Запрашиваем свежую историю...', + ); + ApiService.instance + .getMessageHistory(widget.chatId, force: true) + .then((fresh) { + if (!mounted) return; + _messages + ..clear() + ..addAll(fresh); + _buildChatItems(); + setState(() {}); + }) + .catchError((_) {}); + } + } + + void _removeMessages(List messageIds) { + print('Удаляем сообщения: $messageIds'); + final removedCount = _messages.length; + _messages.removeWhere((message) => messageIds.contains(message.id)); + final actuallyRemoved = removedCount - _messages.length; + print('Удалено сообщений: $actuallyRemoved'); + + if (actuallyRemoved > 0) { + ApiService.instance.clearCacheForChat(widget.chatId); + _buildChatItems(); + setState(() {}); + } + } + + void _sendMessage() { + final text = _textController.text.trim(); + if (text.isNotEmpty) { + final theme = context.read(); + final isBlocked = _currentContact.isBlockedByMe && !theme.blockBypass; + + if (isBlocked) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Нельзя отправить сообщение заблокированному пользователю', + ), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + final int tempCid = DateTime.now().millisecondsSinceEpoch; + final tempMessageJson = { + 'id': 'local_$tempCid', // Временный "локальный" ID + 'text': text, + 'time': tempCid, + 'sender': _actualMyId!, + 'cid': tempCid, // Уникальный ID клиента + 'type': 'USER', + 'attaches': [], // Оптимистично без вложений (для текста) + 'link': _replyingToMessage != null + ? { + 'type': 'REPLY', + 'messageId': _replyingToMessage!.id, + 'message': { + 'sender': _replyingToMessage!.senderId, + 'id': _replyingToMessage!.id, + 'time': _replyingToMessage!.time, + 'text': _replyingToMessage!.text, + 'type': 'USER', + 'cid': _replyingToMessage!.cid, + 'attaches': _replyingToMessage!.attaches, + }, + 'chatId': 0, // Не используется, но нужно для парсинга + } + : null, + }; + + final tempMessage = Message.fromJson(tempMessageJson); + _addMessage(tempMessage); + print( + 'Создано временное сообщение с link: ${tempMessage.link} и cid: $tempCid', + ); + + ApiService.instance.sendMessage( + widget.chatId, + text, + replyToMessageId: _replyingToMessage?.id, + cid: tempCid, // Передаем тот же CID в API + ); + + if (theme.debugReadOnAction && _messages.isNotEmpty) { + final lastMessageId = _messages.last.id; + ApiService.instance.markMessageAsRead(widget.chatId, lastMessageId); + } + + _textController.clear(); + + setState(() { + _replyingToMessage = null; + }); + + widget.onChatUpdated?.call(); + } + } + + void _testSlideAnimation() { + print('=== ТЕСТ SLIDE+ АНИМАЦИИ ==='); + + final myMessage = Message( + id: 'test_my_${DateTime.now().millisecondsSinceEpoch}', + text: 'Тест моё сообщение (должно выехать справа)', + time: DateTime.now().millisecondsSinceEpoch, + senderId: _actualMyId!, + ); + _addMessage(myMessage); + + Future.delayed(const Duration(seconds: 1), () { + final otherMessage = Message( + id: 'test_other_${DateTime.now().millisecondsSinceEpoch}', + text: 'Тест сообщение собеседника (должно выехать слева)', + time: DateTime.now().millisecondsSinceEpoch, + senderId: widget.contact.id, + ); + _addMessage(otherMessage); + }); + } + + void _editMessage(Message message) { + if (!message.canEdit(_actualMyId!)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + message.isDeleted + ? 'Удаленное сообщение нельзя редактировать' + : message.attaches.isNotEmpty + ? 'Сообщения с вложениями нельзя редактировать' + : 'Сообщение можно редактировать только в течение 24 часов', + ), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + showDialog( + context: context, + builder: (context) => _EditMessageDialog( + initialText: message.text, + onSave: (newText) async { + if (newText.trim().isNotEmpty && newText != message.text) { + final optimistic = message.copyWith( + text: newText.trim(), + status: 'EDITED', + updateTime: DateTime.now().millisecondsSinceEpoch, + ); + _updateMessage(optimistic); + + try { + await ApiService.instance.editMessage( + widget.chatId, + message.id, + newText.trim(), + ); + + widget.onChatUpdated?.call(); + } catch (e) { + print('Ошибка при редактировании сообщения: $e'); + _updateMessage(message); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка редактирования: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + }, + ), + ); + } + + void _replyToMessage(Message message) { + setState(() { + _replyingToMessage = message; + }); + _textController.clear(); + FocusScope.of(context).requestFocus(FocusNode()); + } + + void _cancelReply() { + setState(() { + _replyingToMessage = null; + }); + } + + void _showBlockDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Заблокировать контакт'), + content: Text( + 'Вы уверены, что хотите заблокировать ${_currentContact.name}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () async { + Navigator.of(context).pop(); + try { + await ApiService.instance.blockContact(widget.contact.id); + if (mounted) { + setState(() { + _currentContact = Contact( + id: _currentContact.id, + name: _currentContact.name, + firstName: _currentContact.firstName, + lastName: _currentContact.lastName, + description: _currentContact.description, + photoBaseUrl: _currentContact.photoBaseUrl, + isBlocked: _currentContact.isBlocked, + isBlockedByMe: true, + accountStatus: _currentContact.accountStatus, + status: _currentContact.status, + ); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Контакт заблокирован'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка блокировки: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Заблокировать'), + ), + ], + ), + ); + } + + void _showUnblockDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Разблокировать контакт'), + content: Text( + 'Вы уверены, что хотите разблокировать ${_currentContact.name}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () async { + Navigator.of(context).pop(); + try { + await ApiService.instance.unblockContact(widget.contact.id); + if (mounted) { + setState(() { + _currentContact = Contact( + id: _currentContact.id, + name: _currentContact.name, + firstName: _currentContact.firstName, + lastName: _currentContact.lastName, + description: _currentContact.description, + photoBaseUrl: _currentContact.photoBaseUrl, + isBlocked: _currentContact.isBlocked, + isBlockedByMe: false, + accountStatus: _currentContact.accountStatus, + status: _currentContact.status, + ); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Контакт разблокирован'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка разблокировки: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Разблокировать'), + ), + ], + ), + ); + } + + void _showWallpaperDialog() { + showDialog( + context: context, + builder: (context) => _WallpaperSelectionDialog( + chatId: widget.chatId, + onImageSelected: (imagePath) async { + Navigator.of(context).pop(); + await _setChatWallpaper(imagePath); + }, + onRemoveWallpaper: () async { + Navigator.of(context).pop(); + await _removeChatWallpaper(); + }, + ), + ); + } + + void _showClearHistoryDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Очистить историю чата'), + content: Text( + 'Вы уверены, что хотите очистить историю чата с ${_currentContact.name}? Это действие нельзя отменить.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () async { + Navigator.of(context).pop(); + try { + await ApiService.instance.clearChatHistory(widget.chatId); + if (mounted) { + setState(() { + _messages.clear(); + _chatItems.clear(); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('История чата очищена'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка очистки истории: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Очистить'), + ), + ], + ), + ); + } + + void _showDeleteChatDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Удалить чат'), + content: Text( + 'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () async { + Navigator.of(context).pop(); + try { + print('Имитация удаления чата ID: ${widget.chatId}'); + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + + Navigator.of(context).pop(); + + widget.onChatUpdated?.call(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Чат удален'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка удаления чата: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Удалить'), + ), + ], + ), + ); + } + + void _toggleNotifications() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Уведомления для этого чата выключены')), + ); + setState(() {}); + } + + void _showLeaveGroupDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Выйти из группы'), + content: Text( + 'Вы уверены, что хотите выйти из группы "${widget.contact.name}"?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); // Закрываем диалог подтверждения + try { + + ApiService.instance.leaveGroup(widget.chatId); + + if (mounted) { + + Navigator.of(context).pop(); + + widget.onChatUpdated?.call(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Вы вышли из группы'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при выходе из группы: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Выйти'), + ), + ], + ), + ); + } + + Map? _getCurrentGroupChat() { + final chatData = ApiService.instance.lastChatsPayload; + if (chatData == null || chatData['chats'] == null) return null; + + final chats = chatData['chats'] as List; + try { + return chats.firstWhere( + (chat) => chat['id'] == widget.chatId, + orElse: () => null, + ); + } catch (e) { + return null; + } + } + + Future _setChatWallpaper(String imagePath) async { + try { + final theme = context.read(); + await theme.setChatSpecificWallpaper(widget.chatId, imagePath); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Обои для чата установлены'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка установки обоев: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + Future _removeChatWallpaper() async { + try { + final theme = context.read(); + await theme.setChatSpecificWallpaper(widget.chatId, null); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Обои для чата удалены'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка удаления обоев: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + Future _loadCachedContacts() async { + final cachedContacts = await ChatCacheService().getCachedContacts(); + if (cachedContacts != null && cachedContacts.isNotEmpty) { + for (final contact in cachedContacts) { + _contactDetailsCache[contact.id] = contact; + } + print( + '✅ Кэш контактов для экрана чата заполнен из ChatCacheService: ${_contactDetailsCache.length} контактов.', + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + + return Scaffold( + extendBodyBehindAppBar: theme.useGlassPanels, + appBar: _buildAppBar(), + body: Stack( + children: [ + Positioned.fill(child: _buildChatWallpaper(theme)), + if (!_isIdReady || _isLoadingHistory) + const Center(child: CircularProgressIndicator()) + else + ScrollablePositionedList.builder( + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + reverse: true, + padding: EdgeInsets.fromLTRB( + 8.0, + 90.0, + 8.0, + widget.isChannel ? 30.0 : 110.0, + ), + itemCount: _chatItems.length, + itemBuilder: (context, index) { + final mappedIndex = _chatItems.length - 1 - index; + final item = _chatItems[mappedIndex]; + final isLastVisual = index == _chatItems.length - 1; + + if (isLastVisual && _hasMore && !_isLoadingMore) { + _loadMore(); + } + + if (item is MessageItem) { + final message = item.message; + final key = _messageKeys.putIfAbsent( + message.id, + () => GlobalKey(), + ); + final bool isHighlighted = + _isSearching && + _searchResults.isNotEmpty && + _currentResultIndex != -1 && + message.id == _searchResults[_currentResultIndex].id; + + final isControlMessage = message.attaches.any( + (a) => a['_type'] == 'CONTROL', + ); + if (isControlMessage) { + return _ControlMessageChip( + message: message, + contacts: _contactDetailsCache, + myId: _actualMyId ?? widget.myId, + ); + } + + final bool isMe = item.message.senderId == _actualMyId; + + MessageReadStatus? readStatus; + if (isMe) { + final messageId = item.message.id; + if (messageId.startsWith('local_')) { + + + readStatus = MessageReadStatus.sending; + } else { + + + readStatus = MessageReadStatus.sent; + + + + + + + + + + + } + } + + if (message.isForwarded) { + final originalSenderId = + message.link?['message']?['sender'] as int?; + if (originalSenderId != null) {} + } + String? senderName; + if (widget.isGroupChat && !isMe) { + bool shouldShowName = true; + if (mappedIndex > 0) { + final previousItem = _chatItems[mappedIndex - 1]; + if (previousItem is MessageItem) { + final previousMessage = previousItem.message; + if (previousMessage.senderId == message.senderId) { + final timeDifferenceInMinutes = + (message.time - previousMessage.time) / + (1000 * 60); + if (timeDifferenceInMinutes < 5) { + shouldShowName = false; + } + } + } + } + if (shouldShowName) { + final senderContact = + _contactDetailsCache[message.senderId]; + senderName = + senderContact?.name ?? 'Участник ${message.senderId}'; + } + } + final hasPhoto = item.message.attaches.any( + (a) => a['_type'] == 'PHOTO', + ); + final isNew = !_animatedMessageIds.contains(item.message.id); + final deferImageLoading = + hasPhoto && + isNew && + !_anyOptimize && + !context.read().animatePhotoMessages; + + final bubble = ChatMessageBubble( + key: key, + message: item.message, + isMe: isMe, + readStatus: readStatus, + deferImageLoading: deferImageLoading, + myUserId: _actualMyId, + chatId: widget.chatId, + onReply: () => _replyToMessage(item.message), + onEdit: isMe ? () => _editMessage(item.message) : null, + canEditMessage: isMe + ? item.message.canEdit(_actualMyId!) + : null, + onDeleteForMe: isMe + ? () async { + await ApiService.instance.deleteMessage( + widget.chatId, + item.message.id, + forMe: true, + ); + widget.onChatUpdated?.call(); + } + : null, + onDeleteForAll: isMe + ? () async { + await ApiService.instance.deleteMessage( + widget.chatId, + item.message.id, + forMe: false, + ); + widget.onChatUpdated?.call(); + } + : null, + onReaction: (emoji) { + _updateReactionOptimistically(item.message.id, emoji); + ApiService.instance.sendReaction( + widget.chatId, + item.message.id, + emoji, + ); + widget.onChatUpdated?.call(); + }, + onRemoveReaction: () { + _removeReactionOptimistically(item.message.id); + ApiService.instance.removeReaction( + widget.chatId, + item.message.id, + ); + widget.onChatUpdated?.call(); + }, + isGroupChat: widget.isGroupChat, + isChannel: widget.isChannel, + senderName: senderName, + contactDetailsCache: _contactDetailsCache, + onReplyTap: _scrollToMessage, + useAutoReplyColor: context + .read() + .useAutoReplyColor, + customReplyColor: context + .read() + .customReplyColor, + isFirstInGroup: item.isFirstInGroup, + isLastInGroup: item.isLastInGroup, + isGrouped: item.isGrouped, + avatarVerticalOffset: + -8.0, // Смещение аватарки вверх на 8px + ); + + Widget finalMessageWidget = bubble as Widget; + + if (isHighlighted) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 1.5, + ), + ), + child: finalMessageWidget, + ); + } + + return finalMessageWidget; + } else if (item is DateSeparatorItem) { + return _DateSeparatorChip(date: item.date); + } + if (isLastVisual && _isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center(child: CircularProgressIndicator()), + ); + } + return const SizedBox.shrink(); + }, + ), + Positioned( + right: 16, + bottom: 120, + child: ValueListenableBuilder( + valueListenable: _showScrollToBottomNotifier, + builder: (context, showButton, child) { + return showButton + ? Opacity( + opacity: 0.85, + child: FloatingActionButton( + mini: true, + onPressed: _scrollToBottom, + child: const Icon(Icons.arrow_downward_rounded), + ), + ) + : const SizedBox.shrink(); + }, + ), + ), + Positioned(left: 0, right: 0, bottom: 0, child: _buildTextInput()), + ], + ), + ); + } + + void _showContactProfile() { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.transparent, + pageBuilder: (context, animation, secondaryAnimation) { + return ContactProfileDialog( + contact: widget.contact, + isChannel: widget.isChannel, + ); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + transitionDuration: const Duration(milliseconds: 300), + ), + ); + } + + AppBar _buildAppBar() { + final theme = context.watch(); + + if (_isSearching) { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: _stopSearch, + tooltip: 'Закрыть поиск', + ), + title: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Поиск по сообщениям...', + border: InputBorder.none, + ), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), + actions: [ + if (_searchResults.isNotEmpty) + Center( + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Text( + '${_currentResultIndex + 1} из ${_searchResults.length}', + ), + ), + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_up), + onPressed: _searchResults.isNotEmpty ? _navigateToNextResult : null, + tooltip: 'Следующий (более старый) результат', + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down), + onPressed: _searchResults.isNotEmpty + ? _navigateToPreviousResult + : null, + tooltip: 'Предыдущий (более новый) результат', + ), + ], + ); + } + + return AppBar( + titleSpacing: 4.0, + backgroundColor: theme.useGlassPanels ? Colors.transparent : null, + elevation: theme.useGlassPanels ? 0 : null, + flexibleSpace: theme.useGlassPanels + ? ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: theme.topBarBlur, + sigmaY: theme.topBarBlur, + ), + child: Container( + color: Theme.of( + context, + ).colorScheme.surface.withOpacity(theme.topBarOpacity), + ), + ), + ) + : null, + leading: widget.isDesktopMode + ? null // В десктопном режиме нет кнопки "Назад" + : IconButton( + + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + if (widget.isGroupChat) + IconButton( + onPressed: () { + if (_actualMyId == null) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => GroupSettingsScreen( + chatId: widget.chatId, + initialContact: _currentContact, + myId: _actualMyId!, + onChatUpdated: widget.onChatUpdated, + ), + ), + ); + }, + icon: const Icon(Icons.settings), + tooltip: 'Настройки группы', + ), + PopupMenuButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onSelected: (value) { + if (value == 'search') { + _startSearch(); + } else if (value == 'block') { + _showBlockDialog(); + } else if (value == 'unblock') { + _showUnblockDialog(); + } else if (value == 'wallpaper') { + _showWallpaperDialog(); + } else if (value == 'toggle_notifications') { + _toggleNotifications(); + } else if (value == 'clear_history') { + _showClearHistoryDialog(); + } else if (value == 'delete_chat') { + _showDeleteChatDialog(); + } else if (value == 'leave_group' || value == 'leave_channel') { + _showLeaveGroupDialog(); + } + }, + itemBuilder: (context) { + bool amIAdmin = false; + if (widget.isGroupChat) { + final currentChat = _getCurrentGroupChat(); + if (currentChat != null) { + final admins = currentChat['admins'] as List? ?? []; + if (_actualMyId != null) { + amIAdmin = admins.contains(_actualMyId); + } + } + } + final bool canDeleteChat = !widget.isGroupChat || amIAdmin; + + return [ + const PopupMenuItem( + value: 'search', + child: Row( + children: [ + Icon(Icons.search), + SizedBox(width: 8), + Text('Поиск'), + ], + ), + ), + const PopupMenuItem( + value: 'wallpaper', + child: Row( + children: [ + Icon(Icons.wallpaper), + SizedBox(width: 8), + Text('Обои'), + ], + ), + ), + if (!widget.isGroupChat && !widget.isChannel) ...[ + if (_currentContact.isBlockedByMe) + const PopupMenuItem( + value: 'unblock', + child: Row( + children: [ + Icon(Icons.person_add, color: Colors.green), + SizedBox(width: 8), + Text('Разблокировать'), + ], + ), + ) + else + const PopupMenuItem( + value: 'block', + child: Row( + children: [ + Icon(Icons.block, color: Colors.red), + SizedBox(width: 8), + Text('Заблокировать'), + ], + ), + ), + ], + PopupMenuItem( + value: 'toggle_notifications', + child: Row( + children: [ + Icon(Icons.notifications), + SizedBox(width: 8), + Text('Выкл. уведомления'), + ], + ), + ), + const PopupMenuDivider(), + if (!widget.isChannel) + const PopupMenuItem( + value: 'clear_history', + child: Row( + children: [ + Icon(Icons.clear_all, color: Colors.orange), + SizedBox(width: 8), + Text('Очистить историю'), + ], + ), + ), + + if (widget.isGroupChat) + const PopupMenuItem( + value: 'leave_group', + child: Row( + children: [ + Icon(Icons.exit_to_app, color: Colors.red), + SizedBox(width: 8), + Text('Выйти из группы'), + ], + ), + ), + + if (widget.isChannel) + const PopupMenuItem( + value: 'leave_channel', // Новое значение + child: Row( + children: [ + Icon(Icons.exit_to_app, color: Colors.red), + SizedBox(width: 8), + Text('Покинуть канал'), // Другой текст + ], + ), + ), + + if (canDeleteChat && !widget.isChannel) + const PopupMenuItem( + value: 'delete_chat', + child: Row( + children: [ + Icon(Icons.delete_forever, color: Colors.red), + SizedBox(width: 8), + Text('Удалить чат'), + ], + ), + ), + ]; + }, + ), + ], + title: Row( + children: [ + GestureDetector( + onTap: _showContactProfile, + child: Hero( + tag: 'contact_avatar_${widget.contact.id}', + child: CircleAvatar( + radius: 18, + backgroundImage: widget.contact.photoBaseUrl != null + ? NetworkImage(widget.contact.photoBaseUrl!) + : null, + child: widget.contact.photoBaseUrl == null + ? Text( + widget.contact.name.isNotEmpty + ? widget.contact.name[0].toUpperCase() + : '?', + ) + : null, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: _showContactProfile, + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.contact.name, + overflow: TextOverflow.ellipsis, + ), + ), + if (context + .watch() + .debugShowMessageCount) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.ultraOptimizeChats + ? Colors.red.withOpacity(0.7) + : theme.optimizeChats + ? Colors.orange.withOpacity(0.7) + : Colors.blue.withOpacity(0.7), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${_messages.length}${theme.ultraOptimizeChats + ? 'U' + : theme.optimizeChats + ? 'O' + : ''}', + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + + const SizedBox(height: 2), + if (widget.isGroupChat || + widget.isChannel) // Объединенное условие + Text( + widget.isChannel + ? "${widget.participantCount ?? 0} подписчиков" + : "${widget.participantCount ?? 0} участников", + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + else + + _ContactPresenceSubtitle( + chatId: widget.chatId, + userId: widget.contact.id, + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildChatWallpaper(ThemeProvider provider) { + if (provider.hasChatSpecificWallpaper(widget.chatId)) { + final chatSpecificImagePath = provider.getChatSpecificWallpaper( + widget.chatId, + ); + if (chatSpecificImagePath != null) { + return Image.file( + File(chatSpecificImagePath), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ); + } + } + + if (!provider.useCustomChatWallpaper) { + return Container(color: Theme.of(context).colorScheme.surface); + } + switch (provider.chatWallpaperType) { + case ChatWallpaperType.solid: + return Container(color: provider.chatWallpaperColor1); + case ChatWallpaperType.gradient: + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + provider.chatWallpaperColor1, + provider.chatWallpaperColor2, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ); + case ChatWallpaperType.image: + final Widget image; + if (provider.chatWallpaperImagePath != null) { + image = Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(provider.chatWallpaperImagePath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + if (provider.chatWallpaperImageBlur > 0) + BackdropFilter( + filter: ImageFilter.blur( + sigmaX: provider.chatWallpaperImageBlur, + sigmaY: provider.chatWallpaperImageBlur, + ), + child: Container(color: Colors.transparent), + ), + ], + ); + } else { + image = Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ); + } + return Stack( + fit: StackFit.expand, + children: [ + image, + if (provider.chatWallpaperBlur) + BackdropFilter( + filter: ImageFilter.blur( + sigmaX: provider.chatWallpaperBlurSigma, + sigmaY: provider.chatWallpaperBlurSigma, + ), + child: Container(color: Colors.black.withOpacity(0.0)), + ), + ], + ); + case ChatWallpaperType.video: + + if (Platform.isWindows) { + return Container( + color: Theme.of(context).colorScheme.surface, + child: Center( + child: Text( + 'Видео-обои не поддерживаются\nна Windows', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + ), + textAlign: TextAlign.center, + ), + ), + ); + } + if (provider.chatWallpaperVideoPath != null && + provider.chatWallpaperVideoPath!.isNotEmpty) { + return _VideoWallpaperBackground( + videoPath: provider.chatWallpaperVideoPath!, + ); + } else { + return Container(color: Theme.of(context).colorScheme.surface); + } + } + } + + Widget _buildTextInput() { + if (widget.isChannel) { + return const SizedBox.shrink(); // Возвращаем пустой виджет для каналов + } + final theme = context.watch(); + final isBlocked = _currentContact.isBlockedByMe && !theme.blockBypass; + + if (_currentContact.name.toLowerCase() == 'max') { + return const SizedBox.shrink(); + } + + if (theme.useGlassPanels) { + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: theme.bottomBarBlur, + sigmaY: theme.bottomBarBlur, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 12.0, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface.withOpacity(theme.bottomBarOpacity), + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 0.5, + ), + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_replyingToMessage != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.reply, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ответ на сообщение', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + const SizedBox(height: 2), + Text( + _replyingToMessage!.text.isNotEmpty + ? _replyingToMessage!.text + : 'Фото', + style: TextStyle( + fontSize: 13, + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.8), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: _cancelReply, + icon: const Icon(Icons.close), + iconSize: 18, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ], + if (isBlocked) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.errorContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.error.withOpacity(0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.block, + color: Theme.of(context).colorScheme.error, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Пользователь заблокирован', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Разблокируйте пользователя для отправки сообщений', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onErrorContainer, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + 'или включите block_bypass', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onErrorContainer, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + + child: Focus( + focusNode: + _textFocusNode, // 2. focusNode теперь здесь + onKeyEvent: (node, event) { + + if (event is KeyDownEvent) { + if (event.logicalKey == + LogicalKeyboardKey.enter) { + + final bool isShiftPressed = + HardwareKeyboard.instance.logicalKeysPressed + .contains( + LogicalKeyboardKey.shiftLeft, + ) || + HardwareKeyboard.instance.logicalKeysPressed + .contains( + LogicalKeyboardKey.shiftRight, + ); + + if (!isShiftPressed) { + + _sendMessage(); + return KeyEventResult.handled; + } + } + } + return KeyEventResult.ignored; + }, + + child: TextField( + controller: _textController, + + enabled: !isBlocked, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + minLines: 1, + maxLines: 5, + decoration: InputDecoration( + hintText: isBlocked + ? 'Пользователь заблокирован' + : 'Сообщение...', + filled: true, + fillColor: isBlocked + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.3) + : Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 10.0, + ), + ), + + onChanged: isBlocked + ? null + : (v) { + if (v.isNotEmpty) { + _scheduleTypingPing(); + } + }, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.photo_library_outlined), + tooltip: isBlocked + ? 'Пользователь заблокирован' + : 'Отправить фото', + onPressed: isBlocked + ? null + : () async { + final result = await _pickPhotosFlow(context); + if (result != null && result.paths.isNotEmpty) { + await ApiService.instance.sendPhotoMessages( + widget.chatId, + localPaths: result.paths, + caption: result.caption, + senderId: _actualMyId, + ); + } + }, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Theme.of(context).colorScheme.primary, + ), + if (context.watch().messageTransition == + TransitionOption.slide) + IconButton( + icon: const Icon(Icons.animation), + onPressed: isBlocked ? null : _testSlideAnimation, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Colors.orange, + tooltip: isBlocked + ? 'Пользователь заблокирован' + : 'Тест Slide+ анимации', + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: isBlocked ? null : _sendMessage, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Theme.of(context).colorScheme.primary, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } else { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, // Обычный цвет фона + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor, width: 0.5), + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_replyingToMessage != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.reply, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ответ на сообщение', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 2), + Text( + _replyingToMessage!.text.isNotEmpty + ? _replyingToMessage!.text + : 'Фото', + style: TextStyle( + fontSize: 13, + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.8), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: _cancelReply, + icon: const Icon(Icons.close), + iconSize: 18, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ], + if (isBlocked) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.errorContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.error.withOpacity(0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.block, + color: Theme.of(context).colorScheme.error, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Пользователь заблокирован', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Разблокируйте пользователя для отправки сообщений', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + 'или включите block_bypass', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _textController, + enabled: !isBlocked, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + minLines: 1, + maxLines: 5, + decoration: InputDecoration( + hintText: isBlocked + ? 'Пользователь заблокирован' + : 'Сообщение...', + filled: true, + fillColor: isBlocked + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.3) + : Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 10.0, + ), + ), + onChanged: isBlocked + ? null + : (v) { + if (v.isNotEmpty) { + _scheduleTypingPing(); + } + }, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.photo_library_outlined), + tooltip: isBlocked + ? 'Пользователь заблокирован' + : 'Отправить фото', + onPressed: isBlocked + ? null + : () async { + final result = await _pickPhotosFlow(context); + if (result != null && result.paths.isNotEmpty) { + await ApiService.instance.sendPhotoMessages( + widget.chatId, + localPaths: result.paths, + caption: result.caption, + senderId: _actualMyId, + ); + } + }, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Theme.of(context).colorScheme.primary, + ), + if (context.watch().messageTransition == + TransitionOption.slide) + IconButton( + icon: const Icon(Icons.animation), + onPressed: isBlocked ? null : _testSlideAnimation, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Colors.orange, + tooltip: isBlocked + ? 'Пользователь заблокирован' + : 'Тест Slide+ анимации', + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: isBlocked ? null : _sendMessage, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Theme.of(context).colorScheme.primary, + ), + ], + ), + ], + ), + ), + ); + } + } + + Timer? _typingTimer; + DateTime _lastTypingSentAt = DateTime.fromMillisecondsSinceEpoch(0); + void _scheduleTypingPing() { + final now = DateTime.now(); + if (now.difference(_lastTypingSentAt) >= const Duration(seconds: 9)) { + ApiService.instance.sendTyping(widget.chatId, type: "TEXT"); + _lastTypingSentAt = now; + } + _typingTimer?.cancel(); + _typingTimer = Timer(const Duration(seconds: 9), () { + if (!mounted) return; + if (_textController.text.isNotEmpty) { + ApiService.instance.sendTyping(widget.chatId, type: "TEXT"); + _lastTypingSentAt = DateTime.now(); + _scheduleTypingPing(); + } + }); + } + + @override + void dispose() { + _typingTimer?.cancel(); + _apiSubscription?.cancel(); + _textController.dispose(); + _textFocusNode.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _startSearch() { + setState(() { + _isSearching = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + } + + void _stopSearch() { + setState(() { + _isSearching = false; + _searchResults.clear(); + _currentResultIndex = -1; + _searchController.clear(); + _messageKeys.clear(); + }); + } + + void _performSearch(String query) { + if (query.isEmpty) { + if (_searchResults.isNotEmpty) { + setState(() { + _searchResults.clear(); + _currentResultIndex = -1; + }); + } + return; + } + final results = _messages + .where((msg) => msg.text.toLowerCase().contains(query.toLowerCase())) + .toList(); + + setState(() { + _searchResults = results.reversed.toList(); + _currentResultIndex = _searchResults.isNotEmpty ? 0 : -1; + }); + + if (_currentResultIndex != -1) { + _scrollToResult(); + } + } + + void _navigateToNextResult() { + if (_searchResults.isEmpty) return; + setState(() { + _currentResultIndex = (_currentResultIndex + 1) % _searchResults.length; + }); + _scrollToResult(); + } + + void _navigateToPreviousResult() { + if (_searchResults.isEmpty) return; + setState(() { + _currentResultIndex = + (_currentResultIndex - 1 + _searchResults.length) % + _searchResults.length; + }); + _scrollToResult(); + } + + void _scrollToResult() { + if (_currentResultIndex == -1) return; + + final targetMessage = _searchResults[_currentResultIndex]; + + final itemIndex = _chatItems.indexWhere( + (item) => item is MessageItem && item.message.id == targetMessage.id, + ); + + if (itemIndex != -1) { + final viewIndex = _chatItems.length - 1 - itemIndex; + + _itemScrollController.scrollTo( + index: viewIndex, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + alignment: 0.5, + ); + } + } + + void _scrollToMessage(String messageId) { + final itemIndex = _chatItems.indexWhere( + (item) => item is MessageItem && item.message.id == messageId, + ); + + if (itemIndex != -1) { + final viewIndex = _chatItems.length - 1 - itemIndex; + + _itemScrollController.scrollTo( + index: viewIndex, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + alignment: 0.5, + ); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Исходное сообщение не найдено (возможно, оно в старой истории)', + ), + ), + ); + } + } + } +} + +class _EditMessageDialog extends StatefulWidget { + final String initialText; + final Function(String) onSave; + + const _EditMessageDialog({required this.initialText, required this.onSave}); + + @override + State<_EditMessageDialog> createState() => _EditMessageDialogState(); +} + +class _EditMessageDialogState extends State<_EditMessageDialog> { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialText); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Редактировать сообщение'), + content: TextField( + controller: _controller, + maxLines: 3, + decoration: const InputDecoration( + hintText: 'Введите текст сообщения', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () { + widget.onSave(_controller.text); + Navigator.pop(context); + }, + child: const Text('Сохранить'), + ), + ], + ); + } +} + +class _ContactPresenceSubtitle extends StatefulWidget { + final int chatId; + final int userId; + const _ContactPresenceSubtitle({required this.chatId, required this.userId}); + @override + State<_ContactPresenceSubtitle> createState() => + _ContactPresenceSubtitleState(); +} + +class _ContactPresenceSubtitleState extends State<_ContactPresenceSubtitle> { + String _status = 'был(а) недавно'; + Timer? _typingDecayTimer; + bool _isOnline = false; + DateTime? _lastSeen; + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + + final lastSeen = ApiService.instance.getLastSeen(widget.userId); + if (lastSeen != null) { + _lastSeen = lastSeen; + _status = _formatLastSeen(_lastSeen); + } + + _sub = ApiService.instance.messages.listen((msg) { + try { + final int? opcode = msg['opcode']; + final payload = msg['payload']; + if (opcode == 129) { + final dynamic incomingChatId = payload['chatId']; + final int? cid = incomingChatId is int + ? incomingChatId + : int.tryParse(incomingChatId?.toString() ?? ''); + if (cid == widget.chatId) { + setState(() => _status = 'печатает…'); + _typingDecayTimer?.cancel(); + _typingDecayTimer = Timer(const Duration(seconds: 11), () { + if (!mounted) return; + if (_status == 'печатает…') { + setState(() { + if (_isOnline) { + _status = 'онлайн'; + } else { + _status = _formatLastSeen(_lastSeen); + } + }); + } + }); + } + } else if (opcode == 132) { + final dynamic incomingChatId = payload['chatId']; + final int? cid = incomingChatId is int + ? incomingChatId + : int.tryParse(incomingChatId?.toString() ?? ''); + if (cid == widget.chatId) { + final bool isOnline = payload['online'] == true; + if (!mounted) return; + _isOnline = isOnline; + setState(() { + if (_status != 'печатает…') { + if (_isOnline) { + _status = 'онлайн'; + } else { + final updatedLastSeen = ApiService.instance.getLastSeen( + widget.userId, + ); + if (updatedLastSeen != null) { + _lastSeen = updatedLastSeen; + } else { + _lastSeen = DateTime.now(); + } + _status = _formatLastSeen(_lastSeen); + } + } + }); + } + } + } catch (_) {} + }); + } + + String _formatLastSeen(DateTime? lastSeen) { + if (lastSeen == null) return 'был(а) недавно'; + + final now = DateTime.now(); + final difference = now.difference(lastSeen); + + String timeAgo; + if (difference.inMinutes < 1) { + timeAgo = 'только что'; + } else if (difference.inMinutes < 60) { + timeAgo = '${difference.inMinutes} мин. назад'; + } else if (difference.inHours < 24) { + timeAgo = '${difference.inHours} ч. назад'; + } else if (difference.inDays < 7) { + timeAgo = '${difference.inDays} дн. назад'; + } else { + final day = lastSeen.day.toString().padLeft(2, '0'); + final month = lastSeen.month.toString().padLeft(2, '0'); + timeAgo = '$day.$month.${lastSeen.year}'; + } + + if (_debugShowExactDate) { + final formatter = DateFormat('dd.MM.yyyy HH:mm:ss'); + return '$timeAgo (${formatter.format(lastSeen)})'; + } + + return timeAgo; + } + + @override + void dispose() { + _typingDecayTimer?.cancel(); + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + + String displayStatus; + if (_status == 'печатает…' || _status == 'онлайн') { + displayStatus = _status; + } else if (_isOnline) { + displayStatus = 'онлайн'; + } else { + displayStatus = _formatLastSeen(_lastSeen); + } + + return GestureDetector( + onLongPress: () { + toggleDebugExactDate(); + if (mounted) { + setState(() {}); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayStatus, + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_debugShowExactDate) ...[ + const SizedBox(width: 4), + Icon( + Icons.bug_report, + size: 12, + color: Theme.of(context).colorScheme.primary, + ), + ], + ], + ), + ); + } +} + +class _PhotosToSend { + final List paths; + final String caption; + const _PhotosToSend({required this.paths, required this.caption}); +} + +class _SendPhotosDialog extends StatefulWidget { + const _SendPhotosDialog(); + @override + State<_SendPhotosDialog> createState() => _SendPhotosDialogState(); +} + +class _SendPhotosDialogState extends State<_SendPhotosDialog> { + final TextEditingController _caption = TextEditingController(); + final List _pickedPaths = []; + final List _previews = []; + + @override + void dispose() { + _caption.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Отправить фото'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _caption, + maxLines: 2, + decoration: const InputDecoration( + hintText: 'Подпись (необязательно)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () async { + try { + final imgs = await ImagePicker().pickMultiImage( + imageQuality: 100, + ); + if (imgs.isNotEmpty) { + _pickedPaths + ..clear() + ..addAll(imgs.map((e) => e.path)); + _previews + ..clear() + ..addAll(imgs.map((e) => FileImage(File(e.path)))); + setState(() {}); + } + } catch (_) {} + }, + icon: const Icon(Icons.photo_library), + label: Text( + _pickedPaths.isEmpty + ? 'Выбрать фото' + : 'Выбрано: ${_pickedPaths.length}', + ), + ), + if (_pickedPaths.isNotEmpty) ...[ + const SizedBox(height: 12), + SizedBox( + width: 320, + height: 220, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + ), + itemCount: _previews.length, + itemBuilder: (ctx, i) { + final preview = _previews[i]; + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: preview != null + ? Image(image: preview, fit: BoxFit.cover) + : const ColoredBox(color: Colors.black12), + ), + Positioned( + right: 4, + top: 4, + child: GestureDetector( + onTap: () { + setState(() { + _previews.removeAt(i); + _pickedPaths.removeAt(i); + }); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(2), + child: const Icon( + Icons.close, + size: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + TextButton( + onPressed: _pickedPaths.isEmpty + ? null + : () { + Navigator.pop( + context, + _PhotosToSend(paths: _pickedPaths, caption: _caption.text), + ); + }, + child: const Text('Отправить'), + ), + ], + ); + } +} + +Future<_PhotosToSend?> _pickPhotosFlow(BuildContext context) async { + final isMobile = + Theme.of(context).platform == TargetPlatform.android || + Theme.of(context).platform == TargetPlatform.iOS; + if (isMobile) { + return await showModalBottomSheet<_PhotosToSend>( + context: context, + isScrollControlled: true, + builder: (ctx) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom), + child: const _SendPhotosBottomSheet(), + ), + ); + } else { + return await showDialog<_PhotosToSend>( + context: context, + builder: (ctx) => const _SendPhotosDialog(), + ); + } +} + +class _SendPhotosBottomSheet extends StatefulWidget { + const _SendPhotosBottomSheet(); + @override + State<_SendPhotosBottomSheet> createState() => _SendPhotosBottomSheetState(); +} + +class _SendPhotosBottomSheetState extends State<_SendPhotosBottomSheet> { + final TextEditingController _caption = TextEditingController(); + final List _pickedPaths = []; + final List _previews = []; + + @override + void dispose() { + _caption.dispose(); + super.dispose(); + } + + Future _pickMore() async { + try { + final imgs = await ImagePicker().pickMultiImage(imageQuality: 100); + if (imgs.isNotEmpty) { + _pickedPaths.addAll(imgs.map((e) => e.path)); + _previews.addAll(imgs.map((e) => FileImage(File(e.path)))); + setState(() {}); + } + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Выбор фото', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const Spacer(), + IconButton( + onPressed: _pickMore, + icon: const Icon(Icons.add_photo_alternate_outlined), + ), + ], + ), + if (_pickedPaths.isNotEmpty) + SizedBox( + height: 140, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemBuilder: (c, i) { + final preview = _previews[i]; + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: preview != null + ? Image( + image: preview, + width: 140, + height: 140, + fit: BoxFit.cover, + ) + : const ColoredBox(color: Colors.black12), + ), + Positioned( + right: 6, + top: 6, + child: GestureDetector( + onTap: () { + setState(() { + _previews.removeAt(i); + _pickedPaths.removeAt(i); + }); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(2), + child: const Icon( + Icons.close, + size: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ); + }, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemCount: _previews.length, + ), + ) + else + OutlinedButton.icon( + onPressed: _pickMore, + icon: const Icon(Icons.photo_library_outlined), + label: const Text('Выбрать фото'), + ), + const SizedBox(height: 12), + TextField( + controller: _caption, + maxLines: 2, + decoration: const InputDecoration( + hintText: 'Подпись (необязательно)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + const Spacer(), + FilledButton( + onPressed: _pickedPaths.isEmpty + ? null + : () { + Navigator.pop( + context, + _PhotosToSend( + paths: _pickedPaths, + caption: _caption.text, + ), + ); + }, + child: const Text('Отправить'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _DateSeparatorChip extends StatelessWidget { + final DateTime date; + const _DateSeparatorChip({required this.date}); + + String _formatDate(DateTime localDate) { + final now = DateTime.now(); + if (localDate.year == now.year && + localDate.month == now.month && + localDate.day == now.day) { + return 'Сегодня'; + } + final yesterday = now.subtract(const Duration(days: 1)); + if (localDate.year == yesterday.year && + localDate.month == yesterday.month && + localDate.day == yesterday.day) { + return 'Вчера'; + } + return DateFormat.yMMMMd('ru').format(localDate); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatDate(date), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +extension BrightnessExtension on Brightness { + bool get isDark => this == Brightness.dark; +} + +class GroupProfileDraggableDialog extends StatelessWidget { + final Contact contact; + + const GroupProfileDraggableDialog({required this.contact}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.3, + maxChildSize: 1.0, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + + Container( + margin: const EdgeInsets.only(top: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colors.onSurfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + + + Padding( + padding: const EdgeInsets.all(20), + child: Hero( + tag: 'contact_avatar_${contact.id}', + child: CircleAvatar( + radius: 60, + backgroundImage: contact.photoBaseUrl != null + ? NetworkImage(contact.photoBaseUrl!) + : null, + child: contact.photoBaseUrl == null + ? Text( + contact.name.isNotEmpty + ? contact.name[0].toUpperCase() + : '?', + style: const TextStyle(fontSize: 32), + ) + : null, + ), + ), + ), + + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + contact.name, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + IconButton( + icon: Icon(Icons.settings, color: colors.primary), + onPressed: () async { + + + final myId = 0; // This should be passed or retrieved + + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => GroupSettingsScreen( + chatId: -contact + .id, // Convert back to positive chatId + initialContact: contact, + myId: myId, + ), + ), + ); + }, + tooltip: 'Настройки группы', + ), + ], + ), + ), + + const SizedBox(height: 20), + + + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + + if (contact.description != null && + contact.description!.isNotEmpty) + Text( + contact.description!, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} + +class ContactProfileDialog extends StatelessWidget { + final Contact contact; + final bool isChannel; + const ContactProfileDialog({required this.contact, this.isChannel = false}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final String nickname = contact.name; + final String description = contact.description ?? ''; + + final theme = context.watch(); + + return Dialog.fullscreen( + backgroundColor: Colors.transparent, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: theme.profileDialogBlur, + sigmaY: theme.profileDialogBlur, + ), + child: Container( + color: Colors.black.withOpacity(theme.profileDialogOpacity), + ), + ), + ), + ), + + Column( + children: [ + Expanded( + child: Center( + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset( + 0, + -0.3 * + (1.0 - value) * + MediaQuery.of(context).size.height * + 0.15, + ), + child: child, + ), + ); + }, + child: Hero( + tag: 'contact_avatar_${contact.id}', + child: CircleAvatar( + radius: 96, + backgroundImage: contact.photoBaseUrl != null + ? NetworkImage(contact.photoBaseUrl!) + : null, + child: contact.photoBaseUrl == null + ? Text( + contact.name.isNotEmpty + ? contact.name[0].toUpperCase() + : '?', + style: const TextStyle(fontSize: 48), + ) + : null, + ), + ), + ), + ), + ), + + Builder( + builder: (context) { + final panel = Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 16, + offset: const Offset(0, -8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + nickname, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 8), + if (description.isNotEmpty) + Linkify( + text: description, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + linkStyle: TextStyle( + color: colors.primary, // Цвет ссылки + fontSize: 14, + decoration: TextDecoration.underline, + ), + onOpen: (link) async { + final uri = Uri.parse(link.url); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Не удалось открыть ссылку: ${link.url}', + ), + ), + ); + } + } + }, + ) + else + + const SizedBox(height: 16), + + if (!isChannel) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Редактирование контакта'), + ), + ); + }, + icon: const Icon(Icons.edit), + label: const Text('Редактировать'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ); + return TweenAnimationBuilder( + tween: Tween( + begin: const Offset(0, 300), + end: Offset.zero, + ), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + builder: (context, offset, child) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + builder: (context, opacity, innerChild) { + return Opacity( + opacity: opacity, + child: Transform.translate( + offset: offset, + child: innerChild, + ), + ); + }, + child: child, + ); + }, + child: panel, + ); + }, + ), + ], + ), + ], + ), + ); + } +} + +class _WallpaperSelectionDialog extends StatefulWidget { + final int chatId; + final Function(String) onImageSelected; + final VoidCallback onRemoveWallpaper; + + const _WallpaperSelectionDialog({ + required this.chatId, + required this.onImageSelected, + required this.onRemoveWallpaper, + }); + + @override + State<_WallpaperSelectionDialog> createState() => + _WallpaperSelectionDialogState(); +} + +class _WallpaperSelectionDialogState extends State<_WallpaperSelectionDialog> { + String? _selectedImagePath; + bool _isLoading = false; + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + final hasExistingWallpaper = theme.hasChatSpecificWallpaper(widget.chatId); + + return AlertDialog( + title: const Text('Обои для чата'), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_selectedImagePath != null) ...[ + Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(_selectedImagePath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + ), + const SizedBox(height: 16), + ], + + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + onPressed: _isLoading ? null : () => _pickImageFromGallery(), + icon: const Icon(Icons.photo_library), + label: const Text('Галерея'), + ), + ElevatedButton.icon( + onPressed: _isLoading ? null : () => _pickImageFromCamera(), + icon: const Icon(Icons.camera_alt), + label: const Text('Камера'), + ), + ], + ), + + const SizedBox(height: 16), + + if (hasExistingWallpaper) + ElevatedButton.icon( + onPressed: _isLoading ? null : widget.onRemoveWallpaper, + icon: const Icon(Icons.delete), + label: const Text('Удалить обои'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + if (_selectedImagePath != null) + FilledButton( + onPressed: _isLoading + ? null + : () => widget.onImageSelected(_selectedImagePath!), + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Установить'), + ), + ], + ); + } + + Future _pickImageFromGallery() async { + setState(() => _isLoading = true); + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + if (image != null && mounted) { + setState(() { + _selectedImagePath = image.path; + _isLoading = false; + }); + } else if (mounted) { + setState(() => _isLoading = false); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка выбора фото: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + Future _pickImageFromCamera() async { + setState(() => _isLoading = true); + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.camera); + if (image != null && mounted) { + setState(() { + _selectedImagePath = image.path; + _isLoading = false; + }); + } else if (mounted) { + setState(() => _isLoading = false); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка съемки фото: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } +} + +class _AddMemberDialog extends StatefulWidget { + final List> contacts; + final Function(List) onAddMembers; + + const _AddMemberDialog({required this.contacts, required this.onAddMembers}); + + @override + State<_AddMemberDialog> createState() => _AddMemberDialogState(); +} + +class _AddMemberDialogState extends State<_AddMemberDialog> { + final Set _selectedContacts = {}; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Добавить участников'), + content: SizedBox( + width: double.maxFinite, + height: 400, + child: ListView.builder( + itemCount: widget.contacts.length, + itemBuilder: (context, index) { + final contact = widget.contacts[index]; + final contactId = contact['id'] as int; + final contactName = contact['names']?[0]?['name'] ?? 'Неизвестный'; + final isSelected = _selectedContacts.contains(contactId); + + return CheckboxListTile( + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedContacts.add(contactId); + } else { + _selectedContacts.remove(contactId); + } + }); + }, + title: Text(contactName), + subtitle: Text('ID: $contactId'), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: _selectedContacts.isEmpty + ? null + : () => widget.onAddMembers(_selectedContacts.toList()), + child: Text('Добавить (${_selectedContacts.length})'), + ), + ], + ); + } +} + +class _RemoveMemberDialog extends StatefulWidget { + final List> members; + final Function(List) onRemoveMembers; + + const _RemoveMemberDialog({ + required this.members, + required this.onRemoveMembers, + }); + + @override + State<_RemoveMemberDialog> createState() => _RemoveMemberDialogState(); +} + +class _RemoveMemberDialogState extends State<_RemoveMemberDialog> { + final Set _selectedMembers = {}; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Удалить участников'), + content: SizedBox( + width: double.maxFinite, + height: 400, + child: ListView.builder( + itemCount: widget.members.length, + itemBuilder: (context, index) { + final member = widget.members[index]; + final memberId = member['id'] as int; + final memberName = member['name'] as String; + final isSelected = _selectedMembers.contains(memberId); + + return CheckboxListTile( + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedMembers.add(memberId); + } else { + _selectedMembers.remove(memberId); + } + }); + }, + title: Text(memberName), + subtitle: Text('ID: $memberId'), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: _selectedMembers.isEmpty + ? null + : () => widget.onRemoveMembers(_selectedMembers.toList()), + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: Text('Удалить (${_selectedMembers.length})'), + ), + ], + ); + } +} + + +class _PromoteAdminDialog extends StatelessWidget { + final List> members; + final Function(int) onPromoteToAdmin; + + const _PromoteAdminDialog({ + required this.members, + required this.onPromoteToAdmin, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Назначить администратором'), + content: SizedBox( + width: double.maxFinite, + height: 300, + child: ListView.builder( + itemCount: members.length, + itemBuilder: (context, index) { + final member = members[index]; + final memberId = member['id'] as int; + final memberName = member['name'] as String; + + return ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primary, + child: Text( + memberName[0].toUpperCase(), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + title: Text(memberName), + subtitle: Text('ID: $memberId'), + trailing: const Icon(Icons.admin_panel_settings), + onTap: () => onPromoteToAdmin(memberId), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + ], + ); + } +} + +class _ControlMessageChip extends StatelessWidget { + final Message message; + final Map contacts; // We need this to get user names by ID + final int myId; + + const _ControlMessageChip({ + required this.message, + required this.contacts, + required this.myId, + }); + + String _formatControlMessage() { + + final controlAttach = message.attaches.firstWhere( + (a) => a['_type'] == 'CONTROL', + ); + + final eventType = controlAttach['event']; + final senderName = contacts[message.senderId]?.name ?? 'Неизвестный'; + final isMe = message.senderId == myId; + final senderDisplayName = isMe ? 'Вы' : senderName; + + + String _formatUserList(List userIds) { + if (userIds.isEmpty) { + return ''; + } + final userNames = userIds + .map((id) { + if (id == myId) { + return 'Вы'; + } + return contacts[id]?.name ?? 'участник с ID $id'; + }) + .where((name) => name.isNotEmpty) + .join(', '); + return userNames; + } + + switch (eventType) { + case 'new': + final title = controlAttach['title'] ?? 'Новая группа'; + return '$senderDisplayName создал(а) группу "$title"'; + + case 'add': + final userIds = List.from( + (controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [], + ); + if (userIds.isEmpty) { + return 'К чату присоединились новые участники'; + } + final userNames = _formatUserList(userIds); + if (userNames.isEmpty) { + return 'К чату присоединились новые участники'; + } + return '$senderDisplayName добавил(а) в чат: $userNames'; + + case 'remove': + case 'kick': + final userIds = List.from( + (controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [], + ); + if (userIds.isEmpty) { + return '$senderDisplayName удалил(а) участников из чата'; + } + final userNames = _formatUserList(userIds); + if (userNames.isEmpty) { + return '$senderDisplayName удалил(а) участников из чата'; + } + + if (userIds.contains(myId)) { + return 'Вы были удалены из чата'; + } + return '$senderDisplayName удалил(а) из чата: $userNames'; + + case 'leave': + if (isMe) { + return 'Вы покинули группу'; + } + return '$senderName покинул(а) группу'; + + case 'title': + final newTitle = controlAttach['title'] ?? ''; + if (newTitle.isEmpty) { + return '$senderDisplayName изменил(а) название группы'; + } + return '$senderDisplayName изменил(а) название группы на "$newTitle"'; + + case 'avatar': + case 'photo': + return '$senderDisplayName изменил(а) фото группы'; + + case 'description': + return '$senderDisplayName изменил(а) описание группы'; + + case 'admin': + case 'promote': + final userIds = List.from( + (controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [], + ); + if (userIds.isEmpty) { + return '$senderDisplayName назначил(а) администраторов'; + } + final userNames = _formatUserList(userIds); + if (userNames.isEmpty) { + return '$senderDisplayName назначил(а) администраторов'; + } + + if (userIds.contains(myId) && userIds.length == 1) { + return 'Вас назначили администратором'; + } + return '$senderDisplayName назначил(а) администраторами: $userNames'; + + case 'demote': + case 'remove_admin': + final userIds = List.from( + (controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [], + ); + if (userIds.isEmpty) { + return '$senderDisplayName снял(а) администраторов'; + } + final userNames = _formatUserList(userIds); + if (userNames.isEmpty) { + return '$senderDisplayName снял(а) администраторов'; + } + + if (userIds.contains(myId) && userIds.length == 1) { + return 'Вас сняли с должности администратора'; + } + return '$senderDisplayName снял(а) с должности администратора: $userNames'; + + case 'ban': + final userIds = List.from( + (controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [], + ); + if (userIds.isEmpty) { + return '$senderDisplayName заблокировал(а) участников'; + } + final userNames = _formatUserList(userIds); + if (userNames.isEmpty) { + return '$senderDisplayName заблокировал(а) участников'; + } + + if (userIds.contains(myId)) { + return 'Вы были заблокированы в чате'; + } + return '$senderDisplayName заблокировал(а): $userNames'; + + case 'unban': + final userIds = List.from( + (controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [], + ); + if (userIds.isEmpty) { + return '$senderDisplayName разблокировал(а) участников'; + } + final userNames = _formatUserList(userIds); + if (userNames.isEmpty) { + return '$senderDisplayName разблокировал(а) участников'; + } + return '$senderDisplayName разблокировал(а): $userNames'; + + case 'join': + if (isMe) { + return 'Вы присоединились к группе'; + } + return '$senderName присоединился(ась) к группе'; + + default: + + final eventTypeStr = eventType?.toString() ?? 'неизвестное'; + return 'Событие: $eventTypeStr'; + } + } + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatControlMessage(), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +void openUserProfileById(BuildContext context, int userId) { + + final contact = ApiService.instance.getCachedContact(userId); + + if (contact != null) { + + final isGroup = contact.id < 0; // Groups have negative IDs + + if (isGroup) { + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => GroupProfileDraggableDialog(contact: contact), + ); + } else { + + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.transparent, + pageBuilder: (context, animation, secondaryAnimation) { + return ContactProfileDialog(contact: contact); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + transitionDuration: const Duration(milliseconds: 300), + ), + ); + } + } else { + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Профиль пользователя $userId'), + content: Text('Информация о пользователе не найдена в кэше'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('OK'), + ), + ], + ), + ); + } +} + +class _VideoWallpaperBackground extends StatefulWidget { + final String videoPath; + + const _VideoWallpaperBackground({required this.videoPath}); + + @override + State<_VideoWallpaperBackground> createState() => + _VideoWallpaperBackgroundState(); +} + +class _VideoWallpaperBackgroundState extends State<_VideoWallpaperBackground> { + VideoPlayerController? _controller; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + try { + final file = File(widget.videoPath); + if (!await file.exists()) { + setState(() { + _errorMessage = 'Video file not found'; + }); + print('ERROR: Video file does not exist: ${widget.videoPath}'); + return; + } + + _controller = VideoPlayerController.file(file); + await _controller!.initialize(); + + if (mounted) { + _controller!.setVolume(0); + _controller!.setLooping(true); + _controller!.play(); + setState(() {}); + print('SUCCESS: Video initialized and playing'); + } + } catch (e) { + print('ERROR initializing video: $e'); + setState(() { + _errorMessage = e.toString(); + }); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_errorMessage != null) { + print('ERROR building video widget: $_errorMessage'); + return Container( + color: Colors.black, + child: Center( + child: Text( + 'Error loading video\n$_errorMessage', + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ); + } + + if (_controller == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (!_controller!.value.isInitialized) { + return const Center(child: CircularProgressIndicator()); + } + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _controller!.value.size.width, + height: _controller!.value.size.height, + child: VideoPlayer(_controller!), + ), + ), + ), + + Container(color: Colors.black.withOpacity(0.3)), + ], + ); + } +} diff --git a/lib/chats_screen.dart b/lib/chats_screen.dart new file mode 100644 index 0000000..d82d27c --- /dev/null +++ b/lib/chats_screen.dart @@ -0,0 +1,3324 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:intl/intl.dart'; +import 'package:gwid/api_service.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:gwid/chat_screen.dart'; +import 'package:gwid/manage_account_screen.dart'; +import 'package:gwid/screens/settings/settings_screen.dart'; +import 'package:gwid/phone_entry_screen.dart'; +import 'package:gwid/models/chat.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/models/message.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:gwid/models/chat_folder.dart'; +import 'package:gwid/theme_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gwid/join_group_screen.dart'; +import 'package:gwid/search_contact_screen.dart'; +import 'package:gwid/channels_list_screen.dart'; +import 'package:gwid/models/channel.dart'; +import 'package:gwid/search_channels_screen.dart'; +import 'package:gwid/downloads_screen.dart'; +import 'package:gwid/user_id_lookup_screen.dart'; + +class SearchResult { + final Chat chat; + final Contact? contact; + final String matchedText; + final String matchType; + final int? messageIndex; + + SearchResult({ + required this.chat, + this.contact, + required this.matchedText, + required this.matchType, + this.messageIndex, + }); +} + +class ChatsScreen extends StatefulWidget { + final void Function( + Chat chat, + Contact contact, + bool isGroup, + bool isChannel, + int? participantCount, + )? + onChatSelected; + final bool hasScaffold; + + const ChatsScreen({super.key, this.onChatSelected, this.hasScaffold = true}); + + @override + State createState() => _ChatsScreenState(); +} + +class _ChatsScreenState extends State + with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { + late Future> _chatsFuture; + bool _showChannelsRail = false; + List _channels = []; + bool _channelsLoaded = false; + StreamSubscription? _apiSubscription; + List _allChats = []; + List _filteredChats = []; + Map _contacts = {}; + bool _isSearchExpanded = false; + String _searchQuery = ''; + Timer? _searchDebounceTimer; + List _searchResults = []; + String _searchFilter = 'all'; + bool _hasRequestedBlockedContacts = false; + + List _folders = []; + String? _selectedFolderId; + late TabController _folderTabController; + + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + late AnimationController _searchAnimationController; + Profile? _myProfile; + bool _isProfileLoading = true; + String _connectionStatus = 'connecting'; + StreamSubscription? _connectionStatusSubscription; + StreamSubscription? _connectionStateSubscription; + + @override + void initState() { + super.initState(); + _loadMyProfile(); + _chatsFuture = (() async { + try { + await ApiService.instance.waitUntilOnline(); + return ApiService.instance.getChatsAndContacts(); + } catch (e) { + print('Ошибка получения чатов: $e'); + if (e.toString().contains('Auth token not found') || + e.toString().contains('FAIL_WRONG_PASSWORD')) { + _showTokenExpiredDialog( + 'Токен авторизации недействителен. Требуется повторная авторизация.', + ); + } + rethrow; + } + })(); + _listenForUpdates(); + + _searchAnimationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _folderTabController = TabController(length: 1, vsync: this); + _folderTabController.addListener(_onFolderTabChanged); + + _searchController.addListener(_onSearchChanged); + _searchFocusNode.addListener(_onSearchFocusChanged); + + _connectionStateSubscription = ApiService.instance.connectionStatus.listen(( + status, + ) { + if (mounted) { + setState(() { + _connectionStatus = status; + }); + } + }); + + _connectionStatusSubscription = ApiService.instance.reconnectionComplete + .listen((_) { + if (mounted) { + print("🔄 ChatsScreen: Получено уведомление о переподключении"); + _loadChatsAndContacts(); + print("🔄 ChatsScreen: Обновление чатов запущено"); + } + }); + + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + _loadChannels(); + } + }); + } + + @override + bool get wantKeepAlive => true; + + Future _loadMyProfile() async { + if (!mounted) return; + setState(() { + _isProfileLoading = true; + }); + + final cachedProfileData = ApiService.instance.lastChatsPayload?['profile']; + if (cachedProfileData != null && mounted) { + setState(() { + _myProfile = Profile.fromJson(cachedProfileData); + _isProfileLoading = false; + }); + return; + } + + try { + if (!ApiService.instance.isOnline) { + await ApiService.instance.waitUntilOnline().timeout( + const Duration(seconds: 10), + onTimeout: () { + print("Таймаут ожидания подключения для загрузки профиля"); + throw TimeoutException("Таймаут подключения"); + }, + ); + } + + final result = await ApiService.instance + .getChatsAndContacts(force: true) + .timeout( + const Duration(seconds: 15), + onTimeout: () { + print("Таймаут загрузки чатов и профиля"); + throw TimeoutException("Таймаут загрузки"); + }, + ); + + if (mounted) { + final profileJson = result['profile']; + if (profileJson != null) { + setState(() { + _myProfile = Profile.fromJson(profileJson); + _isProfileLoading = false; + }); + } else { + setState(() { + _isProfileLoading = false; + }); + } + } + } catch (e) { + if (mounted) { + setState(() { + _isProfileLoading = false; + }); + print("Ошибка загрузки профиля в ChatsScreen: $e"); + } + } + } + + void _navigateToLogin() { + print('Перенаправляем на экран входа из-за недействительного токена'); + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const PhoneEntryScreen()), + ); + } + + void _showTokenExpiredDialog(String message) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Ошибка авторизации'), + content: Text(message), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _navigateToLogin(); + }, + child: const Text('Войти заново'), + ), + ], + ); + }, + ); + } + + + void _listenForUpdates() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + + if (message['type'] == 'invalid_token') { + print( + 'Получено событие недействительного токена, перенаправляем на вход', + ); + _showTokenExpiredDialog( + message['message'] ?? 'Токен авторизации недействителен', + ); + return; + } + + final opcode = message['opcode']; + final payload = message['payload']; + if (payload == null) return; + final chatIdValue = payload['chatId']; + if (chatIdValue == null) return; + final int chatId = chatIdValue; + + if (opcode == 129) { + _setTypingForChat(chatId); + } + + + if (opcode == 128) { + final newMessage = Message.fromJson(payload['message']); + ApiService.instance.clearCacheForChat(chatId); + + final int chatIndex = _allChats.indexWhere((chat) => chat.id == chatId); + if (chatIndex != -1) { + final oldChat = _allChats[chatIndex]; + final updatedChat = oldChat.copyWith( + lastMessage: newMessage, + newMessages: newMessage.senderId != oldChat.ownerId + ? oldChat.newMessages + 1 + : oldChat.newMessages, + ); + + setState(() { + _allChats.removeAt(chatIndex); + + if (_isSavedMessages(updatedChat)) { + if (updatedChat.id == 0) { + + _allChats.insert(0, updatedChat); + } else { + + final savedIndex = _allChats.indexWhere( + (c) => _isSavedMessages(c) && c.id == 0, + ); + final insertIndex = savedIndex >= 0 ? savedIndex + 1 : 0; + _allChats.insert(insertIndex, updatedChat); + } + } else { + + final savedIndex = _allChats.indexWhere( + (c) => _isSavedMessages(c), + ); + final insertIndex = savedIndex >= 0 ? savedIndex + 1 : 0; + _allChats.insert(insertIndex, updatedChat); + } + _filterChats(); + }); + } + } + + else if (opcode == 67) { + final editedMessage = Message.fromJson(payload['message']); + ApiService.instance.clearCacheForChat(chatId); + + final int chatIndex = _allChats.indexWhere((chat) => chat.id == chatId); + if (chatIndex != -1) { + final oldChat = _allChats[chatIndex]; + + if (oldChat.lastMessage.id == editedMessage.id) { + final updatedChat = oldChat.copyWith(lastMessage: editedMessage); + setState(() { + _allChats.removeAt(chatIndex); + + if (_isSavedMessages(updatedChat)) { + if (updatedChat.id == 0) { + + _allChats.insert(0, updatedChat); + } else { + + final savedIndex = _allChats.indexWhere( + (c) => _isSavedMessages(c) && c.id == 0, + ); + final insertIndex = savedIndex >= 0 ? savedIndex + 1 : 0; + _allChats.insert(insertIndex, updatedChat); + } + } else { + final savedIndex = _allChats.indexWhere( + (c) => _isSavedMessages(c), + ); + final insertIndex = savedIndex >= 0 ? savedIndex + 1 : 0; + _allChats.insert(insertIndex, updatedChat); + } + _filterChats(); + }); + } + } + } + + else if (opcode == 66) { + final deletedMessageIds = List.from( + payload['messageIds'] ?? [], + ); + ApiService.instance.clearCacheForChat(chatId); + + final int chatIndex = _allChats.indexWhere((chat) => chat.id == chatId); + if (chatIndex != -1) { + final oldChat = _allChats[chatIndex]; + + if (deletedMessageIds.contains(oldChat.lastMessage.id)) { + + ApiService.instance.getChatsAndContacts(force: true).then((data) { + if (mounted) { + final chats = data['chats'] as List; + final filtered = chats + .cast>() + .where((chat) => chat['id'] == chatId) + .toList(); + final Map? updatedChatData = + filtered.isNotEmpty ? filtered.first : null; + if (updatedChatData != null) { + final updatedChat = Chat.fromJson(updatedChatData); + setState(() { + _allChats.removeAt(chatIndex); + _allChats.insert(0, updatedChat); + _filterChats(); + }); + } + } + }); + } + } + } + + + if (opcode == 129) { + _setTypingForChat(chatId); + } + + + if (opcode == 132) { + final bool isOnline = payload['online'] == true; + + + final dynamic contactIdAny = payload['contactId'] ?? payload['userId']; + if (contactIdAny != null) { + final int? cid = contactIdAny is int + ? contactIdAny + : int.tryParse(contactIdAny.toString()); + if (cid != null) { + + final currentTime = + DateTime.now().millisecondsSinceEpoch ~/ + 1000; // Конвертируем в секунды + final userPresence = { + 'seen': currentTime, + 'on': isOnline ? 'ON' : 'OFF', + }; + ApiService.instance.updatePresenceData({ + cid.toString(): userPresence, + }); + + print( + 'Обновлен presence для пользователя $cid: online=$isOnline, seen=$currentTime', + ); + + for (final chat in _allChats) { + final otherId = chat.participantIds.firstWhere( + (id) => id != chat.ownerId, + orElse: () => chat.ownerId, + ); + if (otherId == cid) { + if (isOnline) { + _onlineChats.add(chat.id); + } else { + _onlineChats.remove(chat.id); + } + } + } + if (mounted) setState(() {}); + return; + } + } + + final dynamic cidAny = payload['chatId']; + final int? chatIdFromPayload = cidAny is int + ? cidAny + : int.tryParse(cidAny?.toString() ?? ''); + if (chatIdFromPayload != null) { + if (isOnline) { + _onlineChats.add(chatIdFromPayload); + } else { + _onlineChats.remove(chatIdFromPayload); + } + if (mounted) setState(() {}); + } + } + + + if (opcode == 36 && payload['contacts'] != null) { + final List blockedContactsJson = payload['contacts'] as List; + final blockedContacts = blockedContactsJson + .map((json) => Contact.fromJson(json)) + .toList(); + + + for (final blockedContact in blockedContacts) { + print( + 'Обновляем контакт ${blockedContact.name} (ID: ${blockedContact.id}): isBlocked=${blockedContact.isBlocked}, isBlockedByMe=${blockedContact.isBlockedByMe}', + ); + if (_contacts.containsKey(blockedContact.id)) { + + _contacts[blockedContact.id] = blockedContact; + print( + 'Обновлен существующий контакт: ${_contacts[blockedContact.id]?.name}', + ); + + ApiService.instance.notifyContactUpdate(blockedContact); + } else { + + _contacts[blockedContact.id] = blockedContact; + print( + 'Добавлен новый заблокированный контакт: ${blockedContact.name}', + ); + + ApiService.instance.notifyContactUpdate(blockedContact); + } + } + + if (mounted) setState(() {}); + } + + + if (opcode == 48) { + print('Получен ответ на создание группы: $payload'); + + _refreshChats(); + } + + + if (opcode == 272) { + print('Получен ответ на обновление папок: $payload'); + + if (payload['folders'] != null || payload['foldersOrder'] != null) { + try { + final foldersJson = payload['folders'] as List?; + if (foldersJson != null) { + final folders = foldersJson + .map( + (json) => ChatFolder.fromJson(json as Map), + ) + .toList(); + + if (mounted) { + setState(() { + _folders = folders; + }); + _filterChats(); + } + } + } catch (e) { + print('Ошибка обработки папок из opcode 272: $e'); + } + } else { + _refreshChats(); + } + } + + + if (message['type'] == 'channels_found') { + final payload = message['payload']; + final channelsData = payload['contacts'] as List?; + + if (channelsData != null) { + setState(() { + _channels = channelsData + .map((channelJson) => Channel.fromJson(channelJson)) + .toList(); + }); + } + } + }); + } + + final Map _typingDecayTimers = {}; + final Set _typingChats = {}; + final Set _onlineChats = {}; + void _setTypingForChat(int chatId) { + _typingChats.add(chatId); + _typingDecayTimers[chatId]?.cancel(); + _typingDecayTimers[chatId] = Timer(const Duration(seconds: 11), () { + _typingChats.remove(chatId); + if (mounted) setState(() {}); + }); + if (mounted) setState(() {}); + } + + void _refreshChats() { + + _chatsFuture = ApiService.instance.getChatsAndContacts(force: true); + _chatsFuture.then((data) { + if (mounted) { + final chats = data['chats'] as List; + final contacts = data['contacts'] as List; + + _allChats = chats + .where((json) => json != null) + .map((json) => Chat.fromJson(json)) + .toList(); + _contacts.clear(); + for (final contactJson in contacts) { + final contact = Contact.fromJson(contactJson); + _contacts[contact.id] = contact; + } + _filterChats(); + } + }); + } + + Widget _buildChannelsRail() { + final colors = Theme.of(context).colorScheme; + + return Container( + width: 280, + decoration: BoxDecoration( + color: colors.surface, + border: Border( + right: BorderSide(color: colors.outline.withOpacity(0.2), width: 1), + ), + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + border: Border( + bottom: BorderSide( + color: colors.outline.withOpacity(0.2), + width: 1, + ), + ), + ), + child: Row( + children: [ + Icon( + Icons.broadcast_on_personal, + color: colors.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Каналы', + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.onSurface, + fontSize: 16, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.search, size: 20), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ChannelsListScreen(), + ), + ); + }, + tooltip: 'Поиск каналов', + ), + ], + ), + ), + + + Expanded(child: _buildChannelsList()), + ], + ), + ); + } + + void _loadChannels() async { + if (_channelsLoaded) return; + + try { + + await ApiService.instance.searchChannels('каналы'); + _channelsLoaded = true; + } catch (e) { + print('Ошибка загрузки каналов: $e'); + } + } + + Widget _buildChannelsList() { + final colors = Theme.of(context).colorScheme; + + if (_channels.isEmpty) { + + return ListView( + padding: const EdgeInsets.all(8), + children: [ + _buildChannelItem( + 'Новости', + 'Актуальные новости', + Icons.newspaper, + colors.primaryContainer, + colors.onPrimaryContainer, + ), + _buildChannelItem( + 'Технологии', + 'IT и технологии', + Icons.computer, + colors.secondaryContainer, + colors.onSecondaryContainer, + ), + _buildChannelItem( + 'Спорт', + 'Спортивные новости', + Icons.sports, + colors.tertiaryContainer, + colors.onTertiaryContainer, + ), + _buildChannelItem( + 'Развлечения', + 'Фильмы, музыка, игры', + Icons.movie, + colors.errorContainer, + colors.onErrorContainer, + ), + _buildChannelItem( + 'Образование', + 'Учеба и развитие', + Icons.school, + colors.primaryContainer, + colors.onPrimaryContainer, + ), + ], + ); + } + + + return ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _channels.length, + itemBuilder: (context, index) { + final channel = _channels[index]; + return _buildRealChannelItem(channel); + }, + ); + } + + Widget _buildChannelItem( + String title, + String subtitle, + IconData icon, + Color backgroundColor, + Color iconColor, + ) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: CircleAvatar( + radius: 16, + backgroundColor: backgroundColor, + child: Icon(icon, size: 16, color: iconColor), + ), + title: Text( + title, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + subtitle: Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Открытие канала: $title'), + backgroundColor: Colors.blue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + }, + ), + ); + } + + Widget _buildRealChannelItem(Channel channel) { + final colors = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + leading: CircleAvatar( + radius: 16, + backgroundImage: channel.photoBaseUrl != null + ? NetworkImage(channel.photoBaseUrl!) + : null, + child: channel.photoBaseUrl == null + ? Text( + channel.name.isNotEmpty ? channel.name[0].toUpperCase() : '?', + style: TextStyle( + color: colors.onSurface, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ) + : null, + ), + title: Text( + channel.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + subtitle: Text( + channel.description ?? 'Канал', + style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChannelDetailsScreen(channel: channel), + ), + ); + }, + ), + ); + } + + void _showAddMenu(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurfaceVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + Text( + 'Создать', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + + + ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + child: Icon( + Icons.group_add, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + title: const Text('Создать группу'), + subtitle: const Text('Создать чат с несколькими участниками'), + onTap: () { + Navigator.pop(context); + _showCreateGroupDialog(); + }, + ), + + + ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + child: Icon( + Icons.person_search, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + title: const Text('Найти контакт'), + subtitle: const Text('Поиск по номеру телефона'), + onTap: () { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SearchContactScreen(), + ), + ); + }, + ), + + + ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.tertiaryContainer, + child: Icon( + Icons.broadcast_on_personal, + color: Theme.of(context).colorScheme.onTertiaryContainer, + ), + ), + title: const Text('Каналы'), + subtitle: const Text('Просмотр и подписка на каналы'), + onTap: () { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ChannelsListScreen(), + ), + ); + }, + ), + + + ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.secondaryContainer, + child: Icon( + Icons.link, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + title: const Text('Присоединиться к группе'), + subtitle: const Text('По ссылке-приглашению'), + onTap: () { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const JoinGroupScreen(), + ), + ); + }, + ), + + + ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + child: Icon( + Icons.download, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + title: const Text('Загрузки'), + subtitle: const Text('Скачанные файлы'), + onTap: () { + Navigator.pop(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const DownloadsScreen(), + ), + ); + }, + ), + + const SizedBox(height: 20), + ], + ), + ); + }, + ); + } + + void _showCreateGroupDialog() { + final TextEditingController nameController = TextEditingController(); + final List selectedContacts = []; + + final int? myId = _myProfile?.id; + + + final List availableContacts = _contacts.values.where((contact) { + final contactNameLower = contact.name.toLowerCase(); + return contactNameLower != 'max' && + contactNameLower != 'gigachat' && + (myId == null || contact.id != myId); + }).toList(); + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Создать группу'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Название группы', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + const Text('Выберите участников:'), + const SizedBox(height: 8), + SizedBox( + height: 200, + width: 300, + + child: ListView.builder( + itemCount: availableContacts.length, + itemBuilder: (context, index) { + final contact = availableContacts[index]; + final isSelected = selectedContacts.contains(contact.id); + + return CheckboxListTile( + title: Text(contact.name), + subtitle: Text( + contact.firstName.isNotEmpty && + contact.lastName.isNotEmpty + ? '${contact.firstName} ${contact.lastName}' + : '', + ), + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + selectedContacts.add(contact.id); + } else { + selectedContacts.remove(contact.id); + } + }); + }, + ); + }, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () { + if (nameController.text.trim().isNotEmpty) { + ApiService.instance.createGroupWithMessage( + nameController.text.trim(), + selectedContacts, // Будет [] если никого не выбрали + ); + Navigator.of(context).pop(); + } + }, + child: const Text('Создать'), + ), + ], + ), + ), + ); + } + + bool _isSavedMessages(Chat chat) { + return chat.id == 0; + } + + bool _isGroupChat(Chat chat) { + + return chat.type == 'CHAT' || chat.participantIds.length > 2; + } + + void _loadFolders(Map data) { + try { + final config = data['config'] as Map?; + if (config == null) return; + + final chatFolders = config['chatFolders'] as Map?; + if (chatFolders == null) return; + + final foldersJson = chatFolders['FOLDERS'] as List?; + if (foldersJson == null) return; + + final folders = foldersJson + .map((json) => ChatFolder.fromJson(json as Map)) + .toList(); + + setState(() { + final oldIndex = _folderTabController.index; + _folders = folders; + final newLength = 1 + folders.length; + if (_folderTabController.length != newLength) { + _folderTabController.removeListener(_onFolderTabChanged); + _folderTabController.dispose(); + _folderTabController = TabController( + length: newLength, + vsync: this, + initialIndex: oldIndex < newLength ? oldIndex : 0, + ); + _folderTabController.addListener(_onFolderTabChanged); + } + + if (_selectedFolderId == null) { + if (_folderTabController.index != 0) { + _folderTabController.animateTo(0); + } + } else { + final folderIndex = folders.indexWhere( + (f) => f.id == _selectedFolderId, + ); + if (folderIndex != -1) { + final targetIndex = folderIndex + 1; + if (_folderTabController.index != targetIndex) { + _folderTabController.animateTo(targetIndex); + } + } + } + }); + } catch (e) { + print('Ошибка загрузки папок: $e'); + } + } + + bool _chatBelongsToFolder(Chat chat, ChatFolder? folder) { + if (folder == null) return true; + + if (folder.include != null && folder.include!.isNotEmpty) { + return folder.include!.contains(chat.id); + } + + if (folder.filters.isNotEmpty) { + 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', + ); + + if (hasContact && hasNotContact) { + if (chat.type != 'DIALOG' || + chat.participantIds.length > 2 || + _isGroupChat(chat)) { + return false; + } + + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != chat.ownerId, + orElse: () => 0, + ); + if (otherParticipantId != 0) { + final contact = _contacts[otherParticipantId]; + if (contact != null && contact.isBot) { + return false; + } + } + + return true; + } + + for (final filter in folder.filters) { + bool matchesThisFilter = false; + if (filter == 0 || filter == '0' || filter == 'UNREAD') { + matchesThisFilter = chat.newMessages > 0; + } else if (filter == 9 || filter == '9' || filter == 'CONTACT') { + if (chat.type != 'DIALOG' || + chat.participantIds.length > 2 || + _isGroupChat(chat)) { + matchesThisFilter = false; + } else { + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != chat.ownerId, + orElse: () => 0, + ); + if (otherParticipantId != 0) { + final contact = _contacts[otherParticipantId]; + matchesThisFilter = contact == null || !contact.isBot; + } else { + matchesThisFilter = true; + } + } + } else if (filter == 8 || filter == '8' || filter == 'NOT_CONTACT') { + matchesThisFilter = + chat.type == 'CHAT' || + chat.type == 'CHANNEL' || + _isGroupChat(chat); + } else { + matchesThisFilter = false; + } + + if (matchesThisFilter) { + return true; + } + } + return false; + } + + return false; + } + + void _filterChats() { + final query = _searchController.text.toLowerCase(); + setState(() { + List chatsToFilter = _allChats; + + if (_selectedFolderId != null) { + final selectedFolder = _folders.firstWhere( + (f) => f.id == _selectedFolderId, + orElse: () => _folders.first, + ); + chatsToFilter = _allChats + .where((chat) => _chatBelongsToFolder(chat, selectedFolder)) + .toList(); + } + + if (query.isEmpty && !_searchFocusNode.hasFocus) { + _filteredChats = List.from(chatsToFilter); + + _filteredChats.sort((a, b) { + final aIsSaved = _isSavedMessages(a); + final bIsSaved = _isSavedMessages(b); + if (aIsSaved && !bIsSaved) return -1; // Избранное в начало + if (!aIsSaved && bIsSaved) return 1; // Избранное в начало + + if (aIsSaved && bIsSaved) { + if (a.id == 0) return -1; + if (b.id == 0) return 1; + } + return 0; // Остальные чаты сохраняют порядок + }); + } else if (_searchFocusNode.hasFocus && query.isEmpty) { + + _filteredChats = []; + } else if (query.isNotEmpty) { + _filteredChats = chatsToFilter.where((chat) { + final isSavedMessages = _isSavedMessages(chat); + if (isSavedMessages) { + return "избранное".contains(query); + } + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != chat.ownerId, + orElse: () => 0, + ); + final contactName = + _contacts[otherParticipantId]?.name.toLowerCase() ?? ''; + return contactName.contains(query); + }).toList(); + + _filteredChats.sort((a, b) { + final aIsSaved = _isSavedMessages(a); + final bIsSaved = _isSavedMessages(b); + if (aIsSaved && !bIsSaved) return -1; + if (!aIsSaved && bIsSaved) return 1; + + if (aIsSaved && bIsSaved) { + if (a.id == 0) return -1; + if (b.id == 0) return 1; + } + return 0; + }); + } else { + + _filteredChats = []; + } + }); + } + + void _onSearchChanged() { + final query = _searchController.text; + _searchQuery = query; + + _searchDebounceTimer?.cancel(); + _searchDebounceTimer = Timer(const Duration(milliseconds: 300), () { + _performSearch(); + }); + } + + void _onSearchFocusChanged() { + if (_searchFocusNode.hasFocus) { + _isSearchExpanded = true; + _searchAnimationController.forward(); + } else if (_searchController.text.isEmpty) { + _isSearchExpanded = false; + _searchAnimationController.reverse(); + } + } + + void _performSearch() async { + if (_searchQuery.isEmpty) { + setState(() { + _searchResults.clear(); + }); + return; + } + + setState(() { + + }); + + final results = []; + final query = _searchQuery.toLowerCase(); + + for (final chat in _allChats) { + final isSavedMessages = _isSavedMessages(chat); + + if (isSavedMessages) { + if ("избранное".contains(query)) { + results.add( + SearchResult( + chat: chat, + contact: _contacts[chat.ownerId], + matchedText: "Избранное", + matchType: 'name', + ), + ); + } + continue; + } + + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != chat.ownerId, + orElse: () => 0, + ); + final contact = _contacts[otherParticipantId]; + + if (contact == null) continue; + + + if (contact.name.toLowerCase().contains(query)) { + results.add( + SearchResult( + chat: chat, + contact: contact, + matchedText: contact.name, + matchType: 'name', + ), + ); + continue; + } + + + if (contact.description != null && + contact.description?.toLowerCase().contains(query) == true) { + results.add( + SearchResult( + chat: chat, + contact: contact, + matchedText: contact.description ?? '', + matchType: 'description', + ), + ); + continue; + } + + + if (chat.lastMessage.text.toLowerCase().contains(query) || + (chat.lastMessage.text.contains("welcome.saved.dialog.message") && + 'привет избранные майор'.contains(query.toLowerCase()))) { + results.add( + SearchResult( + chat: chat, + contact: contact, + matchedText: + chat.lastMessage.text.contains("welcome.saved.dialog.message") + ? 'Привет! Это твои избранные...' + : chat.lastMessage.text, + matchType: 'message', + ), + ); + } + } + + + List filteredResults = results; + if (_searchFilter == 'recent') { + + final weekAgo = DateTime.now().subtract(const Duration(days: 7)); + filteredResults = results.where((result) { + final lastMessageTime = DateTime.fromMillisecondsSinceEpoch( + result.chat.lastMessage.time, + ); + return lastMessageTime.isAfter(weekAgo); + }).toList(); + } + + setState(() { + _searchResults = filteredResults; + }); + } + + void _clearSearch() { + _searchController.clear(); + _searchFocusNode.unfocus(); + setState(() { + _searchQuery = ''; + _searchResults.clear(); + _isSearchExpanded = false; + }); + _searchAnimationController.reverse(); + } + + void _loadChatsAndContacts() { + setState(() { + _chatsFuture = ApiService.instance.getChatsAndContacts(force: true); + }); + + _chatsFuture.then((data) { + if (mounted) { + final chats = data['chats'] as List; + final contacts = data['contacts'] as List; + + _allChats = chats + .where((json) => json != null) + .map((json) => Chat.fromJson(json)) + .toList(); + + _contacts.clear(); + for (final contactJson in contacts) { + final contact = Contact.fromJson(contactJson); + _contacts[contact.id] = contact; + } + + _filterChats(); + } + }); + } + + Future _loadChatOrder() async { + final prefs = await SharedPreferences.getInstance(); + final savedOrder = prefs.getStringList('chat_order'); + if (savedOrder != null && savedOrder.isNotEmpty) { + final chatIds = savedOrder.map((id) => int.parse(id)).toList(); + final orderedChats = []; + final remainingChats = List.from(_allChats); + + + for (final id in chatIds) { + final chatIndex = remainingChats.indexWhere((chat) => chat.id == id); + if (chatIndex != -1) { + orderedChats.add(remainingChats.removeAt(chatIndex)); + } + } + + + orderedChats.addAll(remainingChats); + + _allChats = orderedChats; + _filteredChats = List.from(_allChats); + } + } + + String _formatTimestamp(int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final now = DateTime.now(); + if (now.day == dt.day && now.month == dt.month && now.year == dt.year) { + return DateFormat('HH:mm', 'ru').format(dt); + } else { + final yesterday = now.subtract(const Duration(days: 1)); + if (dt.day == yesterday.day && + dt.month == yesterday.month && + dt.year == yesterday.year) { + return 'Вчера'; + } else { + return DateFormat('d MMM', 'ru').format(dt); + } + } + } + + Future _openSferum() async { + try { + await ApiService.instance.waitUntilOnline(); + final seq32 = ApiService.instance.sendAndTrackFullJsonRequest( + jsonEncode({ + "ver": 11, + "cmd": 0, + "seq": 0, + "opcode": 32, + "payload": { + "contactIds": [2340831], + }, + }), + ); + + final resp32 = await ApiService.instance.messages + .firstWhere((m) => m['seq'] == seq32) + .timeout(const Duration(seconds: 10)); + + final contacts = resp32['payload']['contacts'] as List; + if (contacts.isEmpty) { + throw Exception('Не удалось получить информацию о боте'); + } + final webAppUrl = contacts[0]['webApp'] as String?; + if (webAppUrl == null) { + throw Exception('Бот не имеет веб-приложения'); + } + + int? chatId; + for (var chat in _allChats) { + if (chat.participantIds.contains(2340831)) { + chatId = chat.id; + break; + } + } + + print('🔍 Найден chatId для бота Сферума: ${chatId ?? "не найден"}'); + + final seq160 = ApiService.instance.sendAndTrackFullJsonRequest( + jsonEncode({ + "ver": 11, + "cmd": 0, + "seq": 0, + "opcode": 160, + "payload": {"botId": 2340831, "chatId": chatId ?? 0}, + }), + ); + + print('📤 Отправлен opcode 160 с seq: $seq160'); + + final resp160 = await ApiService.instance.messages + .firstWhere((m) => m['seq'] == seq160) + .timeout(const Duration(seconds: 10)); + + print('📥 Получен ответ на opcode 160: ${resp160.toString()}'); + + final webUrl = resp160['payload']['url'] as String?; + if (webUrl == null) { + throw Exception('Не удалось получить URL веб-приложения'); + } + + print('🌐 URL веб-приложения: $webUrl'); + + if (mounted) { + _showSferumWebView(context, webUrl); + } + } catch (e, stackTrace) { + print('❌ Ошибка открытия Сферума: $e'); + print('Stack trace: $stackTrace'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка открытия Сферума: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _showSferumWebView(BuildContext context, String url) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => SferumWebViewPanel(url: url), + ); + } + + Widget _buildConnectionScreen() { + final colors = Theme.of(context).colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + + CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(colors.primary), + ), + const SizedBox(height: 24), + + + Text( + 'Подключение', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + + + Text( + 'Устанавливаем соединение с сервером...', + style: TextStyle(fontSize: 14, color: colors.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + final Widget bodyContent = Stack( + children: [ + FutureBuilder>( + future: _chatsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildConnectionScreen(); + } + if (snapshot.hasError) { + return Center( + child: Text('Ошибка загрузки чатов: ${snapshot.error}'), + ); + } + if (snapshot.hasData) { + if (_allChats.isEmpty) { + final chatListJson = snapshot.data!['chats'] as List; + final contactListJson = snapshot.data!['contacts'] as List; + _allChats = chatListJson + .map((json) => Chat.fromJson(json)) + .toList(); + final contacts = contactListJson.map( + (json) => Contact.fromJson(json), + ); + _contacts = {for (var c in contacts) c.id: c}; + + + final presence = + snapshot.data!['presence'] as Map?; + if (presence != null) { + print('Получен presence: $presence'); + + } + + + if (!_hasRequestedBlockedContacts) { + _hasRequestedBlockedContacts = true; + ApiService.instance.getBlockedContacts(); + } + + _loadFolders(snapshot.data!); + + + _loadChatOrder().then((_) { + setState(() { + _filteredChats = List.from(_allChats); + }); + }); + } + if (_filteredChats.isEmpty && _allChats.isEmpty) { + + return const Center(child: CircularProgressIndicator()); + } + + + if (_isSearchExpanded) { + return _buildSearchResults(); + } else { + return Column( + children: [ + _buildFolderTabs(), + Expanded( + child: TabBarView( + controller: _folderTabController, + children: _buildFolderPages(), + ), + ), + ], + ); + } + } + return const Center(child: Text('Нет данных')); + }, + ), + + if (!_isSearchExpanded) _buildDebugRefreshPanel(context), + ], + ); + + if (widget.hasScaffold) { + return Builder( + builder: (context) { + return Scaffold( + appBar: _buildAppBar(context), + drawer: _buildAppDrawer(context), + body: Row(children: [Expanded(child: bodyContent)]), + floatingActionButton: FloatingActionButton( + onPressed: () { + _showAddMenu(context); + }, + tooltip: 'Создать', + heroTag: 'create_menu', + child: const Icon(Icons.edit), + ), + ); + }, + ); + } else { + return bodyContent; + } + } + + Widget _buildAppDrawer(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + final themeProvider = context.watch(); + final isDarkMode = themeProvider.themeMode == ThemeMode.dark; + + return Drawer( + + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: double.infinity, + + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 16.0, + left: 16.0, + right: 16.0, + bottom: 16.0, + ), + decoration: BoxDecoration(color: colors.primaryContainer), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + CircleAvatar( + radius: 30, // Чуть крупнее + backgroundColor: colors.primary, + backgroundImage: + _isProfileLoading || _myProfile?.photoBaseUrl == null + ? null + : NetworkImage(_myProfile!.photoBaseUrl!), + child: _isProfileLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : (_myProfile?.photoBaseUrl == null + ? Text( + _myProfile?.displayName.isNotEmpty == true + ? _myProfile!.displayName[0] + .toUpperCase() + : '?', + style: TextStyle( + color: colors.onPrimary, + fontSize: 28, // Крупнее + ), + ) + : null), + ), + IconButton( + icon: Icon( + isDarkMode + ? Icons.brightness_7 + : Icons.brightness_4, // Солнце / Луна + color: colors.onPrimaryContainer, + size: 26, + ), + onPressed: () { + themeProvider.toggleTheme(); + }, + tooltip: isDarkMode ? 'Светлая тема' : 'Темная тема', + ), + ], + ), + const SizedBox(height: 12), + + + Text( + _myProfile?.displayName ?? 'Загрузка...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(height: 4), + + + Text( + _myProfile?.formattedPhone ?? '', + style: TextStyle( + color: colors.onPrimaryContainer.withOpacity(0.8), + fontSize: 14, + ), + ), + ], + ), + ), + + Expanded( + child: Column( + + children: [ + ListTile( + leading: const Icon(Icons.person_outline), + title: const Text('Мой профиль'), + onTap: () { + Navigator.pop(context); // Закрыть Drawer + _navigateToProfileEdit(); // Этот метод у вас уже есть + }, + ), + ListTile( + leading: const Icon(Icons.call_outlined), + title: const Text('Звонки'), + onTap: () { + Navigator.pop(context); // Закрыть Drawer + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const CallsScreen(), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.settings_outlined), + title: const Text('Настройки'), + onTap: () { + Navigator.pop(context); // Закрыть Drawer + + + final screenSize = MediaQuery.of(context).size; + final screenWidth = screenSize.width; + final screenHeight = screenSize.height; + final isDesktopOrTablet = + screenWidth >= 600 && + screenHeight >= 800; // Планшеты и десктопы + + print( + 'Screen size: ${screenWidth}x${screenHeight}, isDesktopOrTablet: $isDesktopOrTablet', + ); + + if (isDesktopOrTablet) { + + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => SettingsScreen( + showBackToChats: true, + onBackToChats: () => Navigator.of(context).pop(), + myProfile: _myProfile, + isModal: true, // Включаем модальный режим + ), + ); + } else { + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SettingsScreen( + showBackToChats: true, + onBackToChats: () => Navigator.of(context).pop(), + myProfile: _myProfile, + isModal: false, // Отключаем модальный режим + ), + ), + ); + } + }, + ), + + const Spacer(), + + const Divider(height: 1, indent: 16, endIndent: 16), + ListTile( + leading: Icon(Icons.logout, color: colors.error), + title: Text('Выйти', style: TextStyle(color: colors.error)), + onTap: () { + Navigator.pop(context); // Закрыть Drawer + _showLogoutDialog(); + }, + ), + const SizedBox(height: 8), // Небольшой отступ снизу + ], + ), + ), + ], + ), + ); + } + + Widget _buildSearchResults() { + final colors = Theme.of(context).colorScheme; + + if (_searchQuery.isEmpty) { + return Column( + children: [ + + _buildRecentChatsIcons(), + const Divider(height: 1), + + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 64, + color: colors.onSurfaceVariant.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'Начните вводить для поиска', + style: TextStyle( + fontSize: 18, + color: colors.onSurfaceVariant.withOpacity(0.7), + ), + ), + const SizedBox(height: 8), + Text( + 'Или выберите чат из списка выше', + style: TextStyle( + fontSize: 14, + color: colors.onSurfaceVariant.withOpacity(0.5), + ), + ), + ], + ), + ), + ), + ], + ); + } + + if (_searchResults.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: colors.onSurfaceVariant.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'Ничего не найдено', + style: TextStyle( + fontSize: 18, + color: colors.onSurfaceVariant.withOpacity(0.7), + ), + ), + const SizedBox(height: 8), + Text( + 'Попробуйте изменить поисковый запрос', + style: TextStyle( + fontSize: 14, + color: colors.onSurfaceVariant.withOpacity(0.5), + ), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: _searchResults.length, + itemBuilder: (context, index) { + return _buildSearchResultItem(_searchResults[index]); + }, + ); + } + + Widget _buildSearchResultItem(SearchResult result) { + final colors = Theme.of(context).colorScheme; + final chat = result.chat; + final contact = result.contact; + + if (contact == null) return const SizedBox.shrink(); + + return ListTile( + onTap: () { + final bool isSavedMessages = _isSavedMessages(chat); + final bool isGroupChat = _isGroupChat(chat); + final bool isChannel = chat.type == 'CHANNEL'; + final participantCount = + chat.participantsCount ?? chat.participantIds.length; + + if (widget.onChatSelected != null) { + widget.onChatSelected!( + chat, + contact, + isGroupChat, + isChannel, + participantCount, + ); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: chat.id, + contact: contact, + myId: chat.ownerId, + isGroupChat: isGroupChat, + isChannel: isChannel, + participantCount: participantCount, + onChatUpdated: () { + print('Chat updated, но не обновляем список чатов...'); + }, + ), + ), + ); + } + }, + leading: CircleAvatar( + radius: 24, + backgroundColor: colors.primaryContainer, + backgroundImage: contact.photoBaseUrl != null + ? NetworkImage(contact.photoBaseUrl ?? '') + : null, + child: contact.photoBaseUrl == null + ? Text( + contact.name.isNotEmpty ? contact.name[0].toUpperCase() : '?', + style: TextStyle(color: colors.onPrimaryContainer), + ) + : null, + ), + title: _buildHighlightedText(contact.name, result.matchedText), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (result.matchType == 'message') + chat.lastMessage.text.contains("welcome.saved.dialog.message") + ? _buildWelcomeMessage() + : _buildSearchMessagePreview(chat, result.matchedText), + if (result.matchType == 'description') + _buildHighlightedText( + contact.description ?? '', + result.matchedText, + ), + const SizedBox(height: 4), + Text( + _formatTimestamp(chat.lastMessage.time), + style: TextStyle(color: colors.onSurfaceVariant, fontSize: 12), + ), + ], + ), + trailing: chat.newMessages > 0 + ? CircleAvatar( + radius: 10, + backgroundColor: colors.primary, + child: Text( + chat.newMessages.toString(), + style: TextStyle(color: colors.onPrimary, fontSize: 12), + ), + ) + : null, + ); + } + + Widget _buildHighlightedText(String text, String query) { + if (query.isEmpty) return Text(text); + + final lowerText = text.toLowerCase(); + final lowerQuery = query.toLowerCase(); + final index = lowerText.indexOf(lowerQuery); + + if (index == -1) return Text(text); + + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: text.substring(0, index), + style: const TextStyle(color: Colors.black), + ), + TextSpan( + text: text.substring(index, index + query.length), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: text.substring(index + query.length), + style: const TextStyle(color: Colors.black), + ), + ], + ), + ); + } + + Widget _buildRecentChatsIcons() { + final colors = Theme.of(context).colorScheme; + + final recentChats = _allChats.take(15).toList(); + + return Container( + height: 100, + padding: const EdgeInsets.symmetric(vertical: 8), + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: recentChats.length, + itemBuilder: (context, index) { + final chat = recentChats[index]; + final bool isGroupChat = _isGroupChat(chat); + final bool isSavedMessages = _isSavedMessages(chat); + + final Contact? contact; + if (isSavedMessages) { + contact = _contacts[chat.ownerId]; + } else if (isGroupChat) { + contact = null; + } else { + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != chat.ownerId, + orElse: () => 0, + ); + contact = _contacts[otherParticipantId]; + } + + return Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: () { + final bool isChannel = chat.type == 'CHANNEL'; + final String title = isGroupChat + ? (chat.title?.isNotEmpty == true ? chat.title! : "Группа") + : (isSavedMessages + ? "Избранное" + : contact?.name ?? "Unknown"); + final String? avatarUrl = isGroupChat + ? chat.baseIconUrl + : (isSavedMessages ? null : contact?.photoBaseUrl); + final participantCount = + chat.participantsCount ?? chat.participantIds.length; + + final Contact contactFallback = + contact ?? + Contact( + id: chat.id, + name: title, + firstName: "", + lastName: "", + photoBaseUrl: avatarUrl, + description: isChannel ? chat.description : null, + isBlocked: false, + isBlockedByMe: false, + ); + + if (widget.onChatSelected != null) { + widget.onChatSelected!( + chat, + contactFallback, + isGroupChat, + isChannel, + participantCount, + ); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: chat.id, + contact: contactFallback, + myId: chat.ownerId, + isGroupChat: isGroupChat, + isChannel: isChannel, + participantCount: participantCount, + onChatUpdated: () { + _loadChatsAndContacts(); + }, + ), + ), + ); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: colors.primaryContainer, + backgroundImage: + !isSavedMessages && + !isGroupChat && + contact?.photoBaseUrl != null + ? NetworkImage(contact?.photoBaseUrl ?? '') + : (isGroupChat && chat.baseIconUrl != null) + ? NetworkImage(chat.baseIconUrl ?? '') + : null, + child: + isSavedMessages || + (isGroupChat && chat.baseIconUrl == null) + ? Icon( + isSavedMessages ? Icons.bookmark : Icons.group, + color: colors.onPrimaryContainer, + size: 20, + ) + : (contact?.photoBaseUrl == null + ? Text( + (contact != null && + contact.name.isNotEmpty) + ? contact.name[0].toUpperCase() + : '?', + style: TextStyle( + color: colors.onSurface, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ) + : null), + ), + + if (chat.newMessages > 0) + Positioned( + right: 0, + top: 0, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + border: Border.all( + color: colors.surface, + width: 2, + ), + ), + child: Center( + child: Text( + chat.newMessages > 9 + ? '9+' + : chat.newMessages.toString(), + style: TextStyle( + color: colors.onPrimary, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + SizedBox( + width: 56, + child: isGroupChat + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.group, + size: 10, + color: colors.onSurface, + ), + const SizedBox(width: 2), + Expanded( + child: Text( + chat.title?.isNotEmpty == true + ? chat.title! + : "Группа (${chat.participantIds.length})", + style: TextStyle( + fontSize: 11, + color: colors.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + : Text( + isSavedMessages + ? "Избранное" + : (contact?.name ?? 'Unknown'), + style: TextStyle( + fontSize: 11, + color: colors.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + void _onFolderTabChanged() { + if (!_folderTabController.indexIsChanging) { + final index = _folderTabController.index; + final folderId = index == 0 ? null : _folders[index - 1].id; + + if (_selectedFolderId != folderId) { + setState(() { + _selectedFolderId = folderId; + }); + _filterChats(); + } + } + } + + List _buildFolderPages() { + final List pages = [ + _buildChatsListForFolder(null), + ..._folders.map((folder) => _buildChatsListForFolder(folder)), + ]; + + return pages; + } + + Widget _buildChatsListForFolder(ChatFolder? folder) { + List chatsForFolder = _allChats; + + if (folder != null) { + chatsForFolder = _allChats + .where((chat) => _chatBelongsToFolder(chat, folder)) + .toList(); + } + + chatsForFolder.sort((a, b) { + final aIsSaved = _isSavedMessages(a); + final bIsSaved = _isSavedMessages(b); + if (aIsSaved && !bIsSaved) return -1; + if (!aIsSaved && bIsSaved) return 1; + if (aIsSaved && bIsSaved) { + if (a.id == 0) return -1; + if (b.id == 0) return 1; + } + return 0; + }); + + final query = _searchController.text.toLowerCase(); + if (query.isNotEmpty) { + chatsForFolder = chatsForFolder.where((chat) { + final isSavedMessages = _isSavedMessages(chat); + if (isSavedMessages) { + return "избранное".contains(query); + } + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != chat.ownerId, + orElse: () => 0, + ); + final contactName = + _contacts[otherParticipantId]?.name.toLowerCase() ?? ''; + return contactName.contains(query); + }).toList(); + } + + if (chatsForFolder.isEmpty) { + return Center( + child: Text( + folder == null ? 'Нет чатов' : 'В этой папке пока нет чатов', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + return ListView.builder( + itemCount: chatsForFolder.length, + itemBuilder: (context, index) { + return _buildChatListItem(chatsForFolder[index], index); + }, + ); + } + + Widget _buildFolderTabs() { + final colors = Theme.of(context).colorScheme; + + final List tabs = [ + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Text('Все чаты', style: TextStyle(fontSize: 14))], + ), + ), + ..._folders.map( + (folder) => Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (folder.emoji != null) ...[ + Text(folder.emoji!, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 6), + ], + Text(folder.title, style: const TextStyle(fontSize: 14)), + ], + ), + ), + ), + ]; + + return Container( + height: 48, + decoration: BoxDecoration( + color: colors.surface, + border: Border( + bottom: BorderSide(color: colors.outline.withOpacity(0.2), width: 1), + ), + ), + child: TabBar( + controller: _folderTabController, + isScrollable: true, + labelColor: colors.primary, + unselectedLabelColor: colors.onSurfaceVariant, + indicator: UnderlineTabIndicator( + borderSide: BorderSide(width: 3, color: colors.primary), + insets: const EdgeInsets.symmetric(horizontal: 16), + ), + indicatorSize: TabBarIndicatorSize.label, + labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + dividerColor: Colors.transparent, + tabs: tabs, + onTap: (index) {}, + ), + ); + } + + Widget _buildDebugRefreshPanel(BuildContext context) { + final theme = context.watch(); + if (!theme.debugShowChatsRefreshPanel) return const SizedBox.shrink(); + final bool hasBottomBar = theme.debugShowBottomBar; + final double bottomPadding = hasBottomBar ? 80.0 : 20.0; + final colors = Theme.of(context).colorScheme; + return Positioned( + left: 12, + right: 12, + bottom: bottomPadding, + child: SafeArea( + top: false, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Material( + color: colors.surface.withOpacity(0.95), + elevation: 6, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () async { + + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _allChats.clear(); + _filteredChats.clear(); + _chatsFuture = ApiService.instance.getChatsAndContacts( + force: true, + ); + }); + } + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.refresh), + const SizedBox(width: 8), + const Text('Обновить список чатов'), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildStatusRow({ + required Key key, + required String text, + required Widget icon, + }) { + return Row( + key: key, + children: [ + SizedBox(width: 18, height: 18, child: Center(child: icon)), + const SizedBox(width: 8), + Flexible( + child: Text( + text, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + Widget _buildCurrentTitleWidget() { + final colors = Theme.of(context).colorScheme; + final onSurfaceVariant = colors.onSurfaceVariant; + + if (_connectionStatus == 'connecting') { + return _buildStatusRow( + key: const ValueKey('status_connecting'), + text: 'Подключение...', + icon: CircularProgressIndicator( + strokeWidth: 2, + color: onSurfaceVariant, + ), + ); + } + + if (_connectionStatus == 'authorizing') { + return _buildStatusRow( + key: const ValueKey('status_authorizing'), + text: 'Авторизация...', + icon: CircularProgressIndicator( + strokeWidth: 2, + color: onSurfaceVariant, + ), + ); + } + + if (_connectionStatus == 'disconnected' || + _connectionStatus == 'Все серверы недоступны') { + return _buildStatusRow( + key: const ValueKey('status_error'), + text: 'Нет сети', + icon: Icon(Icons.cloud_off, size: 18, color: colors.error), + ); + } + + if (_isProfileLoading) { + return _buildStatusRow( + key: const ValueKey('status_loading'), + text: 'Загрузка...', + icon: CircularProgressIndicator( + strokeWidth: 2, + color: onSurfaceVariant, + ), + ); + } + + return Text( + _myProfile?.displayName ?? 'Komet', + key: const ValueKey('status_username'), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ); + } + + AppBar _buildAppBar(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return AppBar( + titleSpacing: 4.0, + + leading: _isSearchExpanded + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _clearSearch, + ) + : Builder( + builder: (context) { + return IconButton( + icon: const Icon(Icons.menu_rounded), + onPressed: () => Scaffold.of(context).openDrawer(), + tooltip: 'Меню', + ); + }, + ), + + title: _isSearchExpanded + ? _buildSearchField(colors) + : Row( + children: [ + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: + (Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.centerLeft, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: _buildCurrentTitleWidget(), + ), + ), + ], + ), + actions: _isSearchExpanded + ? [ + if (_searchQuery.isNotEmpty) + Container( + margin: const EdgeInsets.only(left: 4), + child: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ), + ), + Container( + margin: const EdgeInsets.only(left: 4), + child: IconButton( + icon: const Icon(Icons.filter_list), + onPressed: _showSearchFilters, + ), + ), + ] + : [ + IconButton( + icon: Image.asset( + 'assets/images/spermum.webp', + width: 28, + height: 28, + ), + onPressed: _openSferum, + tooltip: 'Сферум', + ), + IconButton( + icon: const Icon(Icons.download), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const DownloadsScreen(), + ), + ); + }, + tooltip: 'Загрузки', + ), + InkWell( + + onTap: () { + setState(() { + _isSearchExpanded = true; + }); + _searchAnimationController.forward(); + _searchFocusNode.requestFocus(); + }, + + onLongPress: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const UserIdLookupScreen(), + ), + ); + }, + customBorder: const CircleBorder(), + child: Container( + padding: const EdgeInsets.all(8.0), + child: const Icon(Icons.search), + ), + ), + const SizedBox(width: 8), + ], + ); + } + + Widget _buildWelcomeMessage() { + return Text( + 'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + + Widget _buildSearchField(ColorScheme colors) { + return SizedBox( + height: 40, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + autofocus: true, + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, + prefixIcon: Icon( + Icons.search, + color: colors.onSurfaceVariant, + size: 18, + ), + hintText: 'Поиск в чатах...', + hintStyle: TextStyle(color: colors.onSurfaceVariant), + filled: true, + fillColor: colors.surfaceContainerHighest.withOpacity(0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + style: const TextStyle(fontSize: 16), + ), + ); + } + + void _navigateToProfileEdit() { + if (_myProfile != null) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ManageAccountScreen(myProfile: _myProfile!), + ), + ); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Профиль еще не загружен'))); + } + } + + void _showLogoutDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Выход из аккаунта'), + content: const Text('Вы действительно хотите выйти из аккаунта?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + await _logout(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Выйти'), + ), + ], + ); + }, + ); + } + + Future _logout() async { + try { + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const PhoneEntryScreen()), + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Ошибка при выходе: $e'))); + } + } + } + + void _showSearchFilters() { + showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Фильтры поиска', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + _buildFilterOption('all', 'Все чаты', Icons.chat_bubble_outline), + _buildFilterOption('recent', 'Недавние', Icons.access_time), + _buildFilterOption( + 'channels', + 'Каналы', + Icons.broadcast_on_personal, + ), + _buildFilterOption('groups', 'Группы', Icons.group), + ], + ), + ), + ); + } + + Widget _buildFilterOption(String value, String title, IconData icon) { + final isSelected = _searchFilter == value; + return ListTile( + leading: Icon( + icon, + color: isSelected ? Theme.of(context).colorScheme.primary : null, + ), + title: Text(title), + trailing: isSelected ? const Icon(Icons.check) : null, + onTap: () { + setState(() { + _searchFilter = value; + }); + Navigator.pop(context); + _performSearch(); + }, + ); + } + + Widget _buildLastMessagePreview(Chat chat) { + final message = chat.lastMessage; + + + + if (message.attaches.isNotEmpty) { + + for (final attach in message.attaches) { + final type = attach['_type']; + if (type == 'CALL' || type == 'call') { + + return _buildCallPreview(attach, message, chat); + } + } + } + + + if (message.text.isEmpty && message.attaches.isNotEmpty) { + return Text('Вложение', maxLines: 1, overflow: TextOverflow.ellipsis); + } + + + return Text(message.text, maxLines: 1, overflow: TextOverflow.ellipsis); + } + + Widget _buildSearchMessagePreview(Chat chat, String matchedText) { + final message = chat.lastMessage; + + + if (message.attaches.isNotEmpty) { + final callAttachments = message.attaches.where((attach) { + final type = attach['_type']; + return type == 'CALL' || type == 'call'; + }).toList(); + + if (callAttachments.isNotEmpty) { + + return _buildCallPreview(callAttachments.first, message, chat); + } + } + + + if (message.text.isEmpty && message.attaches.isNotEmpty) { + return Text('Вложение', maxLines: 1, overflow: TextOverflow.ellipsis); + } + + return _buildHighlightedText(message.text, matchedText); + } + + Widget _buildCallPreview( + Map callAttach, + Message message, + Chat chat, + ) { + final colors = Theme.of(context).colorScheme; + final hangupType = callAttach['hangupType'] as String? ?? ''; + final callType = callAttach['callType'] as String? ?? 'AUDIO'; + final duration = callAttach['duration'] as int? ?? 0; + + String callText; + IconData callIcon; + Color? callColor; + + + switch (hangupType) { + case 'HUNGUP': + + final minutes = duration ~/ 60000; + final seconds = (duration % 60000) ~/ 1000; + final durationText = minutes > 0 + ? '$minutes:${seconds.toString().padLeft(2, '0')}' + : '$seconds сек'; + + final callTypeText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; + callText = '$callTypeText, $durationText'; + callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; + callColor = colors.primary; + break; + + case 'MISSED': + + final callTypeText = callType == 'VIDEO' + ? 'Пропущенный видеозвонок' + : 'Пропущенный звонок'; + callText = callTypeText; + callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_missed; + callColor = colors.error; + break; + + case 'CANCELED': + + final callTypeText = callType == 'VIDEO' + ? 'Видеозвонок отменен' + : 'Звонок отменен'; + callText = callTypeText; + callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_end; + callColor = colors.onSurfaceVariant; + break; + + case 'REJECTED': + + final callTypeText = callType == 'VIDEO' + ? 'Видеозвонок отклонен' + : 'Звонок отклонен'; + callText = callTypeText; + callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_end; + callColor = colors.onSurfaceVariant; + break; + + default: + + callText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; + callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; + callColor = colors.onSurfaceVariant; + break; + } + + return Row( + children: [ + Icon(callIcon, size: 16, color: callColor), + const SizedBox(width: 4), + Expanded( + child: Text( + callText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: callColor), + ), + ), + ], + ); + } + + Widget _buildChatListItem(Chat chat, int index) { + final colors = Theme.of(context).colorScheme; + + final bool isSavedMessages = _isSavedMessages(chat); + final bool isGroupChat = _isGroupChat(chat); + final bool isChannel = chat.type == 'CHANNEL'; + + Contact? contact; + String title; + final String? avatarUrl; + IconData leadingIcon; + + if (isSavedMessages) { + contact = _contacts[chat.ownerId]; + title = "Избранное"; + leadingIcon = Icons.bookmark; + avatarUrl = null; + } else if (isChannel) { + contact = null; + title = chat.title ?? "Канал"; + leadingIcon = Icons.campaign; + avatarUrl = chat.baseIconUrl; + } else if (isGroupChat) { + contact = null; + title = chat.title?.isNotEmpty == true + ? chat.title! + : "Группа (${chat.participantIds.length} участников)"; + leadingIcon = Icons.group; + avatarUrl = chat.baseIconUrl; + } else { + final myId = chat.ownerId; + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != myId, + orElse: () => myId, + ); + contact = _contacts[otherParticipantId]; + + title = contact?.name ?? "Неизвестный чат"; + avatarUrl = contact?.photoBaseUrl; + leadingIcon = Icons.person; + } + + return ListTile( + key: ValueKey(chat.id), + + onTap: () { + final theme = context.read(); + if (theme.debugReadOnEnter) { + final chatIndex = _allChats.indexWhere((c) => c.id == chat.id); + if (chatIndex != -1) { + final oldChat = _allChats[chatIndex]; + if (oldChat.newMessages > 0) { + final updatedChat = oldChat.copyWith(newMessages: 0); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _allChats[chatIndex] = updatedChat; + _filterChats(); + }); + } + }); + } + } + } + + final Contact contactFallback = + contact ?? + Contact( + id: chat.id, + name: title, + firstName: "", + lastName: "", + photoBaseUrl: avatarUrl, + description: isChannel ? chat.description : null, + isBlocked: false, + isBlockedByMe: false, + ); + + + final participantCount = + chat.participantsCount ?? chat.participantIds.length; + + if (widget.onChatSelected != null) { + widget.onChatSelected!( + chat, + contactFallback, + isGroupChat, + isChannel, + participantCount, + ); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: chat.id, + contact: contactFallback, + myId: chat.ownerId, + isGroupChat: isGroupChat, + isChannel: isChannel, + participantCount: participantCount, + onChatUpdated: () { + print('Chat updated, но не обновляем список чатов...'); + }, + ), + ), + ); + } + }, + leading: Stack( + clipBehavior: Clip.none, + children: [ + CircleAvatar( + radius: 24, + backgroundColor: colors.primaryContainer, + + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + + child: avatarUrl == null + ? (isSavedMessages || isGroupChat || isChannel) + + ? Icon(leadingIcon, color: colors.onPrimaryContainer) + + : Text( + title.isNotEmpty ? title[0].toUpperCase() : '?', + style: TextStyle(color: colors.onPrimaryContainer), + ) + : null, + ), + Positioned( + right: -4, + bottom: -2, + child: _typingChats.contains(chat.id) + ? _TypingDots(color: colors.primary, size: 20) + : (_onlineChats.contains(chat.id) + ? _PresenceDot(isOnline: true, size: 12) + : const SizedBox.shrink()), + ), + ], + ), + title: Row( + children: [ + if (isGroupChat) ...[ + Icon(Icons.group, size: 16, color: colors.onSurfaceVariant), + const SizedBox(width: 6), + ], + Expanded( + child: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + subtitle: chat.lastMessage.text.contains("welcome.saved.dialog.message") + ? _buildWelcomeMessage() + : _buildLastMessagePreview(chat), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _formatTimestamp(chat.lastMessage.time), + style: TextStyle( + color: chat.newMessages > 0 + ? colors.primary + : colors.onSurfaceVariant, + fontSize: 12, + ), + ), + if (chat.newMessages > 0 && !isSavedMessages) ...[ + const SizedBox(height: 4), + CircleAvatar( + radius: 10, + backgroundColor: colors.primary, + child: Text( + chat.newMessages.toString(), + style: TextStyle(color: colors.onPrimary, fontSize: 12), + ), + ), + ], + ], + ), + ); + } + + @override + void dispose() { + _apiSubscription?.cancel(); + _connectionStatusSubscription?.cancel(); + _connectionStateSubscription?.cancel(); + _searchController.dispose(); + _searchFocusNode.dispose(); + _searchDebounceTimer?.cancel(); + _searchAnimationController.dispose(); + _folderTabController.dispose(); + super.dispose(); + } +} + +class _TypingDots extends StatefulWidget { + final Color color; + final double size; + const _TypingDots({required this.color, this.size = 18}); + @override + State<_TypingDots> createState() => _TypingDotsState(); +} + +class _TypingDotsState extends State<_TypingDots> + with SingleTickerProviderStateMixin { + late final AnimationController _c; + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final w = widget.size; + return SizedBox( + width: w, + height: w * 0.6, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final t = _c.value; + double a(int i) => 0.3 + 0.7 * ((t + i / 3) % 1.0); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(3, (i) { + return Opacity( + opacity: a(i), + child: Container( + width: w * 0.22, + height: w * 0.22, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ); + }), + ); + }, + ), + ); + } +} + +class _PresenceDot extends StatelessWidget { + final bool isOnline; + final double size; + const _PresenceDot({required this.isOnline, this.size = 10}); + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isOnline ? colors.primary : colors.onSurfaceVariant, + ), + ); + } +} + +class CallsScreen extends StatelessWidget { + const CallsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Звонки')), + body: const Center(child: Text('Звонки скоро будут доступны')), + ); + } +} + +class SferumWebViewPanel extends StatefulWidget { + final String url; + + const SferumWebViewPanel({super.key, required this.url}); + + @override + State createState() => _SferumWebViewPanelState(); +} + +class _SferumWebViewPanelState extends State { + bool _isLoading = true; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: colors.outline.withOpacity(0.2), + width: 1, + ), + ), + ), + child: Row( + children: [ + Image.asset( + 'assets/images/spermum.webp', + width: 28, + height: 28, + ), + const SizedBox(width: 12), + const Text( + 'Сферум', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + + Expanded( + child: Stack( + children: [ + InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(widget.url)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + transparentBackground: true, + useShouldOverrideUrlLoading: false, + useOnLoadResource: false, + useOnDownloadStart: false, + cacheEnabled: true, + ), + onLoadStart: (controller, url) { + print('🌐 WebView начало загрузки: $url'); + setState(() { + _isLoading = true; + }); + }, + onLoadStop: (controller, url) { + print('✅ WebView загрузка завершена: $url'); + setState(() { + _isLoading = false; + }); + }, + onReceivedError: (controller, request, error) { + print( + '❌ WebView ошибка: ${error.description} (${error.type})', + ); + }, + ), + if (_isLoading) + Container( + color: colors.surface, + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/connection/connection_logger.dart b/lib/connection/connection_logger.dart new file mode 100644 index 0000000..33ab366 --- /dev/null +++ b/lib/connection/connection_logger.dart @@ -0,0 +1,317 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; + + +enum LogLevel { debug, info, warning, error, critical } + + +class ConnectionLogger { + static final ConnectionLogger _instance = ConnectionLogger._internal(); + factory ConnectionLogger() => _instance; + ConnectionLogger._internal(); + + final List _logs = []; + final StreamController _logController = + StreamController.broadcast(); + + + Stream get logStream => _logController.stream; + + + List get logs => List.unmodifiable(_logs); + + + static const int maxLogs = 1000; + + + LogLevel _currentLevel = LogLevel.debug; + + void setLogLevel(LogLevel level) { + _currentLevel = level; + } + + + void log( + String message, { + LogLevel level = LogLevel.info, + String? category, + Map? data, + Object? error, + StackTrace? stackTrace, + }) { + if (level.index < _currentLevel.index) return; + + final entry = LogEntry( + timestamp: DateTime.now(), + level: level, + message: message, + category: category ?? 'CONNECTION', + data: data, + error: error, + stackTrace: stackTrace, + ); + + _logs.add(entry); + + + if (_logs.length > maxLogs) { + _logs.removeRange(0, _logs.length - maxLogs); + } + + _logController.add(entry); + + + if (kDebugMode) { + final emoji = _getEmojiForLevel(level); + final timestamp = entry.timestamp.toIso8601String().substring(11, 23); + final categoryStr = category != null ? '[$category]' : ''; + final dataStr = data != null ? ' | Data: ${jsonEncode(data)}' : ''; + final errorStr = error != null ? ' | Error: $error' : ''; + + print('$emoji [$timestamp] $categoryStr $message$dataStr$errorStr'); + } + } + + + void logConnection( + String message, { + Map? data, + Object? error, + }) { + log( + message, + level: LogLevel.info, + category: 'CONNECTION', + data: data, + error: error, + ); + } + + + void logError( + String message, { + Map? data, + Object? error, + StackTrace? stackTrace, + }) { + log( + message, + level: LogLevel.error, + category: 'ERROR', + data: data, + error: error, + stackTrace: stackTrace, + ); + } + + + void logMessage( + String direction, + dynamic message, { + Map? metadata, + }) { + final data = { + 'direction': direction, + 'message': message, + if (metadata != null) ...metadata, + }; + log( + 'WebSocket $direction', + level: LogLevel.debug, + category: 'WEBSOCKET', + data: data, + ); + } + + + void logReconnect(int attempt, String reason, {Duration? delay}) { + final data = { + 'attempt': attempt, + 'reason': reason, + if (delay != null) 'delay_seconds': delay.inSeconds, + }; + log( + 'Переподключение: $reason (попытка $attempt)', + level: LogLevel.warning, + category: 'RECONNECT', + data: data, + ); + } + + + void logPerformance( + String operation, + Duration duration, { + Map? metadata, + }) { + final data = { + 'operation': operation, + 'duration_ms': duration.inMilliseconds, + if (metadata != null) ...metadata, + }; + log( + 'Performance: $operation за ${duration.inMilliseconds}ms', + level: LogLevel.debug, + category: 'PERFORMANCE', + data: data, + ); + } + + + void logState(String from, String to, {Map? metadata}) { + final data = { + 'from': from, + 'to': to, + if (metadata != null) ...metadata, + }; + log( + 'Состояние: $from → $to', + level: LogLevel.info, + category: 'STATE', + data: data, + ); + } + + + List getLogsByCategory(String category) { + return _logs.where((log) => log.category == category).toList(); + } + + + List getLogsByLevel(LogLevel level) { + return _logs.where((log) => log.level == level).toList(); + } + + + Map getLogStats() { + final stats = {}; + for (final log in _logs) { + stats[log.category] = (stats[log.category] ?? 0) + 1; + } + return stats; + } + + + void clearLogs() { + _logs.clear(); + log('Логи очищены', level: LogLevel.info, category: 'LOGGER'); + } + + + String exportLogs() { + final logsJson = _logs.map((log) => log.toJson()).toList(); + return jsonEncode(logsJson); + } + + + void importLogs(String jsonString) { + try { + final List logsList = jsonDecode(jsonString); + _logs.clear(); + for (final logJson in logsList) { + _logs.add(LogEntry.fromJson(logJson)); + } + log( + 'Импортировано ${_logs.length} логов', + level: LogLevel.info, + category: 'LOGGER', + ); + } catch (e) { + logError('Ошибка импорта логов', error: e); + } + } + + String _getEmojiForLevel(LogLevel level) { + switch (level) { + case LogLevel.debug: + return '🔍'; + case LogLevel.info: + return 'ℹ️'; + case LogLevel.warning: + return '⚠️'; + case LogLevel.error: + return '❌'; + case LogLevel.critical: + return '🚨'; + } + } + + void dispose() { + _logController.close(); + } +} + + +class LogEntry { + final DateTime timestamp; + final LogLevel level; + final String message; + final String category; + final Map? data; + final Object? error; + final StackTrace? stackTrace; + + LogEntry({ + required this.timestamp, + required this.level, + required this.message, + required this.category, + this.data, + this.error, + this.stackTrace, + }); + + Map toJson() { + return { + 'timestamp': timestamp.toIso8601String(), + 'level': level.name, + 'message': message, + 'category': category, + 'data': data, + 'error': error?.toString(), + 'stackTrace': stackTrace?.toString(), + }; + } + + factory LogEntry.fromJson(Map json) { + return LogEntry( + timestamp: DateTime.parse(json['timestamp']), + level: LogLevel.values.firstWhere((l) => l.name == json['level']), + message: json['message'], + category: json['category'], + data: json['data'] != null + ? Map.from(json['data']) + : null, + error: json['error'], + stackTrace: json['stackTrace'] != null + ? StackTrace.fromString(json['stackTrace']) + : null, + ); + } + + @override + String toString() { + final emoji = _getEmojiForLevel(level); + final timestamp = this.timestamp.toIso8601String().substring(11, 23); + final dataStr = data != null ? ' | Data: ${jsonEncode(data)}' : ''; + final errorStr = error != null ? ' | Error: $error' : ''; + + return '$emoji [$timestamp] [$category] $message$dataStr$errorStr'; + } + + String _getEmojiForLevel(LogLevel level) { + switch (level) { + case LogLevel.debug: + return '🔍'; + case LogLevel.info: + return 'ℹ️'; + case LogLevel.warning: + return '⚠️'; + case LogLevel.error: + return '❌'; + case LogLevel.critical: + return '🚨'; + } + } +} diff --git a/lib/connection/connection_manager.dart b/lib/connection/connection_manager.dart new file mode 100644 index 0000000..9e1e4eb --- /dev/null +++ b/lib/connection/connection_manager.dart @@ -0,0 +1,652 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/status.dart' as status; + +import 'connection_logger.dart'; +import 'connection_state.dart'; +import 'retry_strategy.dart'; +import 'health_monitor.dart'; + + +class ConnectionManager { + static final ConnectionManager _instance = ConnectionManager._internal(); + factory ConnectionManager() => _instance; + ConnectionManager._internal(); + + + final ConnectionLogger _logger = ConnectionLogger(); + final ConnectionStateManager _stateManager = ConnectionStateManager(); + final RetryManager _retryManager = RetryManager(); + final HealthMonitor _healthMonitor = HealthMonitor(); + + + IOWebSocketChannel? _channel; + StreamSubscription? _messageSubscription; + + + final List _serverUrls = [ + 'wss://ws-api.oneme.ru:443/websocket', + 'wss://ws-api.oneme.ru/websocket', + 'wss://ws-api.oneme.ru:8443/websocket', + 'ws://ws-api.oneme.ru:80/websocket', + 'ws://ws-api.oneme.ru/websocket', + 'ws://ws-api.oneme.ru:8080/websocket', + ]; + + int _currentUrlIndex = 0; + String? _currentServerUrl; + + + bool _isConnecting = false; + bool _isDisposed = false; + int _sequenceNumber = 0; + String? _authToken; + + + final List> _messageQueue = []; + + + Timer? _pingTimer; + Timer? _reconnectTimer; + + + final StreamController> _messageController = + StreamController>.broadcast(); + final StreamController _connectionStatusController = + StreamController.broadcast(); + + + Stream> get messageStream => _messageController.stream; + + + Stream get connectionStatusStream => + _connectionStatusController.stream; + + + Stream get stateStream => _stateManager.stateStream; + + + Stream get logStream => _logger.logStream; + + + Stream get healthMetricsStream => _healthMonitor.metricsStream; + + + ConnectionInfo get currentState => _stateManager.currentInfo; + + + bool get isConnected => currentState.isActive; + + + bool get canSendMessages => currentState.canSendMessages; + + + Future initialize() async { + if (_isDisposed) { + _logger.logError('Попытка инициализации после dispose'); + return; + } + + _logger.logConnection('Инициализация ConnectionManager'); + _stateManager.setState( + ConnectionState.disconnected, + message: 'Инициализация', + ); + } + + Future _fullReconnect() async { + _logger.logConnection('Начинаем полное переподключение'); + + + _cleanup(); + _stopMonitoring(); // Убедимся, что все таймеры точно остановлены + + + _currentUrlIndex = 0; + _sequenceNumber = 0; + _messageQueue.clear(); + + + _stateManager.setState( + ConnectionState.disconnected, + message: 'Подготовка к переподключению', + ); + + + await Future.delayed(const Duration(milliseconds: 250)); + + + await connect(authToken: _authToken); + } + + + Future connect({String? authToken}) async { + if (_isDisposed) { + _logger.logError('Попытка подключения после dispose'); + return; + } + + if (_isConnecting) { + _logger.logConnection('Подключение уже в процессе'); + return; + } + + _authToken = authToken; + _isConnecting = true; + + _logger.logConnection( + 'Начало подключения', + data: { + 'auth_token_present': authToken != null, + 'server_count': _serverUrls.length, + }, + ); + + _stateManager.setState( + ConnectionState.connecting, + message: 'Подключение к серверу', + attemptNumber: 1, + ); + + try { + await _connectWithFallback(); + } catch (e) { + _logger.logError('Ошибка подключения', error: e); + _stateManager.setState( + ConnectionState.error, + message: 'Ошибка подключения: ${e.toString()}', + ); + rethrow; + } finally { + _isConnecting = false; + } + } + + + Future _connectWithFallback() async { + final sessionId = 'connect_${DateTime.now().millisecondsSinceEpoch}'; + final session = _retryManager.startSession(sessionId, ErrorType.network); + + while (_currentUrlIndex < _serverUrls.length) { + final url = _serverUrls[_currentUrlIndex]; + _currentServerUrl = url; + + _logger.logConnection( + 'Попытка подключения', + data: { + 'url': url, + 'attempt': _currentUrlIndex + 1, + 'total_servers': _serverUrls.length, + }, + ); + + try { + await _connectToUrl(url); + + _logger.logConnection( + 'Успешное подключение', + data: {'url': url, 'server_index': _currentUrlIndex}, + ); + + _stateManager.setState( + ConnectionState.connected, + message: 'Подключен к серверу', + serverUrl: url, + ); + + _healthMonitor.startMonitoring(serverUrl: url); + _retryManager.endSession(sessionId); + return; + } catch (e) { + final errorInfo = ErrorInfo( + type: _getErrorType(e), + message: e.toString(), + timestamp: DateTime.now(), + ); + + session.addAttempt(errorInfo.type, message: e.toString()); + + _logger.logError( + 'Ошибка подключения к серверу', + data: { + 'url': url, + 'error': e.toString(), + 'error_type': errorInfo.type.name, + }, + ); + + _currentUrlIndex++; + + if (_currentUrlIndex < _serverUrls.length) { + final delay = Duration(milliseconds: 500); + _logger.logConnection( + 'Переход к следующему серверу через ${delay.inMilliseconds}ms', + ); + await Future.delayed(delay); + } + } + } + + + _logger.logError( + 'Все серверы недоступны', + data: {'total_servers': _serverUrls.length}, + ); + + _stateManager.setState( + ConnectionState.error, + message: 'Все серверы недоступны', + ); + + throw Exception('Не удалось подключиться ни к одному серверу'); + } + + + Future _connectToUrl(String url) async { + final uri = Uri.parse(url); + + _logger.logConnection( + 'Подключение к URL', + data: {'host': uri.host, 'port': uri.port, 'scheme': uri.scheme}, + ); + + + final headers = { + 'Origin': 'https://web.max.ru', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Sec-WebSocket-Extensions': 'permessage-deflate', + }; + + _channel = IOWebSocketChannel.connect(uri, headers: headers); + await _channel!.ready; + + _logger.logConnection('WebSocket канал готов'); + + _setupMessageListener(); + await _sendHandshake(); + _startPingTimer(); + } + + + void _setupMessageListener() { + _messageSubscription?.cancel(); + + _messageSubscription = _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDisconnection, + cancelOnError: true, + ); + + _logger.logConnection('Слушатель сообщений настроен'); + } + + + void _handleMessage(dynamic message) { + if (message == null || (message is String && message.trim().isEmpty)) { + return; + } + + try { + _logger.logMessage('IN', message); + + final decodedMessage = message is String ? jsonDecode(message) : message; + + + if (decodedMessage is Map && decodedMessage['opcode'] == 1) { + _healthMonitor.onPongReceived(); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 6 && + decodedMessage['cmd'] == 1) { + _handleHandshakeSuccess(Map.from(decodedMessage)); + return; + } + + + if (decodedMessage is Map && decodedMessage['cmd'] == 3) { + _handleServerError(Map.from(decodedMessage)); + return; + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 97 && + decodedMessage['cmd'] == 1) { + _handleSessionTermination(); + return; + } + + _messageController.add(decodedMessage); + } catch (e) { + _logger.logError( + 'Ошибка обработки сообщения', + data: {'message': message.toString(), 'error': e.toString()}, + ); + } + } + + + void _handleHandshakeSuccess(Map message) { + _logger.logConnection( + 'Handshake успешен', + data: {'payload': message['payload']}, + ); + + _stateManager.setState( + ConnectionState.ready, + message: 'Сессия готова к работе', + ); + + _processMessageQueue(); + } + + + void _handleServerError(Map message) { + final error = message['payload']; + _logger.logError('Ошибка сервера', data: {'error': error}); + + if (error != null) { + if (error['error'] == 'proto.state') { + _logger.logConnection('Ошибка состояния сессии, переподключаемся'); + _scheduleReconnect('Ошибка состояния сессии'); + } else if (error['error'] == 'login.token') { + _logger.logConnection('Недействительный токен'); + _handleInvalidToken(); + } + } + } + + + void _handleSessionTermination() { + _logger.logConnection('Сессия завершена сервером'); + _stateManager.setState( + ConnectionState.disconnected, + message: 'Сессия завершена сервером', + ); + _clearAuthData(); + } + + + void _handleInvalidToken() { + _logger.logConnection('Обработка недействительного токена'); + _clearAuthData(); + _stateManager.setState( + ConnectionState.disconnected, + message: 'Требуется повторная авторизация', + ); + } + + + void _clearAuthData() { + _authToken = null; + _logger.logConnection('Данные аутентификации очищены'); + } + + + void _handleError(dynamic error) { + _logger.logError('Ошибка WebSocket', error: error); + _healthMonitor.onError(error.toString()); + _scheduleReconnect('Ошибка WebSocket: $error'); + } + + + void _handleDisconnection() { + _logger.logConnection('WebSocket соединение закрыто'); + _healthMonitor.onReconnect(); + _scheduleReconnect('Соединение закрыто'); + } + + + void _scheduleReconnect(String reason) { + if (_isDisposed) return; + + _reconnectTimer?.cancel(); + + final sessionId = 'reconnect_${DateTime.now().millisecondsSinceEpoch}'; + final session = _retryManager.startSession(sessionId, ErrorType.network); + + if (!session.canRetry()) { + _logger.logError( + 'Превышено максимальное количество попыток переподключения', + ); + _stateManager.setState( + ConnectionState.error, + message: 'Не удалось переподключиться', + ); + return; + } + + final delay = session.getNextDelay(); + + _logger.logReconnect(session.attemptCount + 1, reason, delay: delay); + + _stateManager.setState( + ConnectionState.reconnecting, + message: 'Переподключение через ${delay.inSeconds}с', + reconnectDelay: delay, + ); + + _reconnectTimer = Timer(delay, () async { + try { + + await _fullReconnect(); + } catch (e) { + _logger.logError('Ошибка во время полного переподключения', error: e); + + _scheduleReconnect('Ошибка при попытке полного переподключения'); + } + }); + } + + + Future _sendHandshake() async { + _logger.logConnection('Отправка handshake'); + + final payload = { + "userAgent": { + "deviceType": "WEB", + "locale": "ru", + "deviceLocale": "ru", + "osVersion": "Windows", + "deviceName": "Chrome", + "headerUserAgent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "appVersion": "25.9.15", + "screen": "1920x1080 1.0x", + "timezone": "Europe/Moscow", + }, + "deviceId": _generateDeviceId(), + }; + + _sendMessage(6, payload); + } + + + int _sendMessage(int opcode, Map payload) { + if (_channel == null) { + _logger.logError('WebSocket не подключен'); + return -1; + } + + final message = { + "ver": 11, + "cmd": 0, + "seq": _sequenceNumber, + "opcode": opcode, + "payload": payload, + }; + + final encodedMessage = jsonEncode(message); + _logger.logMessage('OUT', encodedMessage); + + _channel!.sink.add(encodedMessage); + return _sequenceNumber++; + } + + + int sendMessage(int opcode, Map payload) { + if (!canSendMessages) { + _logger.logConnection( + 'Сообщение добавлено в очередь', + data: {'opcode': opcode, 'reason': 'Соединение не готово'}, + ); + _messageQueue.add({'opcode': opcode, 'payload': payload}); + return -1; + } + + return _sendMessage(opcode, payload); + } + + + void _processMessageQueue() { + if (_messageQueue.isEmpty) return; + + _logger.logConnection( + 'Обработка очереди сообщений', + data: {'count': _messageQueue.length}, + ); + + for (final message in _messageQueue) { + _sendMessage(message['opcode'], message['payload']); + } + + _messageQueue.clear(); + } + + + void _startPingTimer() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(const Duration(seconds: 25), (_) { + if (canSendMessages) { + _logger.logConnection('Отправка ping'); + _sendMessage(1, {"interactive": true}); + } + }); + } + + + String _generateDeviceId() { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = (timestamp % 1000000).toString().padLeft(6, '0'); + return "$timestamp$random"; + } + + + ErrorType _getErrorType(dynamic error) { + final errorString = error.toString().toLowerCase(); + + if (errorString.contains('timeout') || + errorString.contains('connection') || + errorString.contains('network')) { + return ErrorType.network; + } + + if (errorString.contains('unauthorized') || + errorString.contains('forbidden')) { + return ErrorType.authentication; + } + + if (errorString.contains('server') || errorString.contains('internal')) { + return ErrorType.server; + } + + return ErrorType.unknown; + } + + + Future disconnect() async { + _logger.logConnection('Отключение'); + + _stateManager.setState( + ConnectionState.disconnected, + message: 'Отключение по запросу', + ); + + _stopMonitoring(); + _cleanup(); + } + + + Future forceReconnect() async { + if (_isDisposed) { + _logger.logError('Попытка переподключения после dispose'); + return; + } + + _logger.logConnection('Принудительное переподключение'); + + + _reconnectTimer?.cancel(); + _pingTimer?.cancel(); + + + _cleanup(); + _currentUrlIndex = 0; + _sequenceNumber = 0; + _messageQueue.clear(); + + + _stateManager.setState( + ConnectionState.disconnected, + message: 'Подготовка к переподключению', + ); + + + await Future.delayed(const Duration(milliseconds: 500)); + + + await connect(authToken: _authToken); + } + + + void _stopMonitoring() { + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + _messageSubscription?.cancel(); + _healthMonitor.stopMonitoring(); + } + + + void _cleanup() { + _channel?.sink.close(status.goingAway); + _channel = null; + _messageQueue.clear(); + _currentUrlIndex = 0; + _sequenceNumber = 0; + } + + + Map getStatistics() { + return { + 'connection_state': currentState.state.name, + 'health_metrics': _healthMonitor.getStatistics(), + 'retry_statistics': _retryManager.getStatistics(), + 'log_statistics': _logger.getLogStats(), + 'message_queue_size': _messageQueue.length, + 'current_server': _currentServerUrl, + 'server_index': _currentUrlIndex, + }; + } + + + void dispose() { + if (_isDisposed) return; + + _logger.logConnection('Освобождение ресурсов ConnectionManager'); + + _isDisposed = true; + _stopMonitoring(); + _cleanup(); + + _messageController.close(); + _connectionStatusController.close(); + _stateManager.dispose(); + _logger.dispose(); + _healthMonitor.dispose(); + } +} diff --git a/lib/connection/connection_manager_simple.dart b/lib/connection/connection_manager_simple.dart new file mode 100644 index 0000000..59fdad7 --- /dev/null +++ b/lib/connection/connection_manager_simple.dart @@ -0,0 +1,684 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/status.dart' as status; + +import 'connection_logger.dart'; +import 'connection_state.dart'; +import 'retry_strategy.dart'; +import 'health_monitor.dart'; + + +class ConnectionManagerSimple { + static final ConnectionManagerSimple _instance = + ConnectionManagerSimple._internal(); + factory ConnectionManagerSimple() => _instance; + ConnectionManagerSimple._internal(); + + + final ConnectionLogger _logger = ConnectionLogger(); + final ConnectionStateManager _stateManager = ConnectionStateManager(); + final RetryManager _retryManager = RetryManager(); + final HealthMonitor _healthMonitor = HealthMonitor(); + + + IOWebSocketChannel? _channel; + StreamSubscription? _messageSubscription; + + + final List _serverUrls = [ + 'wss://ws-api.oneme.ru:443/websocket', + 'wss://ws-api.oneme.ru/websocket', + 'wss://ws-api.oneme.ru:8443/websocket', + 'ws://ws-api.oneme.ru:80/websocket', + 'ws://ws-api.oneme.ru/websocket', + 'ws://ws-api.oneme.ru:8080/websocket', + ]; + + int _currentUrlIndex = 0; + String? _currentServerUrl; + + + bool _isConnecting = false; + bool _isDisposed = false; + int _sequenceNumber = 0; + String? _authToken; + + + final List> _messageQueue = []; + + + Timer? _pingTimer; + Timer? _reconnectTimer; + Timer? _responseTimeoutTimer; + Timer? _connectionTimeoutTimer; + + + final StreamController> _messageController = + StreamController>.broadcast(); + final StreamController _connectionStatusController = + StreamController.broadcast(); + + + Stream> get messageStream => _messageController.stream; + + + Stream get connectionStatusStream => + _connectionStatusController.stream; + + + Stream get stateStream => _stateManager.stateStream; + + + Stream get logStream => _logger.logStream; + + + Stream get healthMetricsStream => _healthMonitor.metricsStream; + + + ConnectionInfo get currentState => _stateManager.currentInfo; + + + bool get isConnected => currentState.isActive; + + + bool get canSendMessages => currentState.canSendMessages; + + + Future initialize() async { + if (_isDisposed) { + _logger.logError('Попытка инициализации после dispose'); + return; + } + + _logger.logConnection('Инициализация ConnectionManagerSimple'); + _stateManager.setState( + ConnectionState.disconnected, + message: 'Инициализация', + ); + } + + + Future connect({String? authToken}) async { + if (_isDisposed) { + _logger.logError('Попытка подключения после dispose'); + return; + } + + if (_isConnecting) { + _logger.logConnection('Подключение уже в процессе'); + return; + } + + _authToken = authToken; + _isConnecting = true; + + _logger.logConnection( + 'Начало подключения', + data: { + 'auth_token_present': authToken != null, + 'server_count': _serverUrls.length, + }, + ); + + _stateManager.setState( + ConnectionState.connecting, + message: 'Подключение к серверу', + attemptNumber: 1, + ); + + try { + await _connectWithFallback(); + } catch (e) { + _logger.logError('Ошибка подключения', error: e); + _stateManager.setState( + ConnectionState.error, + message: 'Ошибка подключения: ${e.toString()}', + ); + rethrow; + } finally { + _isConnecting = false; + } + } + + + Future _connectWithFallback() async { + final sessionId = 'connect_${DateTime.now().millisecondsSinceEpoch}'; + final session = _retryManager.startSession(sessionId, ErrorType.network); + + while (_currentUrlIndex < _serverUrls.length) { + final url = _serverUrls[_currentUrlIndex]; + _currentServerUrl = url; + + _logger.logConnection( + 'Попытка подключения', + data: { + 'url': url, + 'attempt': _currentUrlIndex + 1, + 'total_servers': _serverUrls.length, + }, + ); + + try { + await _connectToUrl(url); + + _logger.logConnection( + 'Успешное подключение', + data: {'url': url, 'server_index': _currentUrlIndex}, + ); + + _stateManager.setState( + ConnectionState.connected, + message: 'Подключен к серверу', + serverUrl: url, + ); + + _healthMonitor.startMonitoring(serverUrl: url); + _retryManager.endSession(sessionId); + return; + } catch (e) { + final errorInfo = ErrorInfo( + type: _getErrorType(e), + message: e.toString(), + timestamp: DateTime.now(), + ); + + session.addAttempt(errorInfo.type, message: e.toString()); + + _logger.logError( + 'Ошибка подключения к серверу', + data: { + 'url': url, + 'error': e.toString(), + 'error_type': errorInfo.type.name, + }, + ); + + _currentUrlIndex++; + + if (_currentUrlIndex < _serverUrls.length) { + final delay = Duration(milliseconds: 500); + _logger.logConnection( + 'Переход к следующему серверу через ${delay.inMilliseconds}ms', + ); + await Future.delayed(delay); + } + } + } + + + _logger.logError( + 'Все серверы недоступны', + data: {'total_servers': _serverUrls.length}, + ); + + _stateManager.setState( + ConnectionState.error, + message: 'Все серверы недоступны', + ); + + throw Exception('Не удалось подключиться ни к одному серверу'); + } + + + Future _connectToUrl(String url) async { + final uri = Uri.parse(url); + + _logger.logConnection( + 'Подключение к URL', + data: {'host': uri.host, 'port': uri.port, 'scheme': uri.scheme}, + ); + + + final headers = { + 'Origin': 'https://web.max.ru', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Sec-WebSocket-Extensions': 'permessage-deflate', + }; + + _channel = IOWebSocketChannel.connect(uri, headers: headers); + + + _connectionTimeoutTimer = Timer(const Duration(seconds: 10), () { + _logger.logError('Таймаут подключения к серверу'); + _handleConnectionTimeout(); + }); + + await _channel!.ready; + _connectionTimeoutTimer?.cancel(); + + _logger.logConnection('WebSocket канал готов'); + + _setupMessageListener(); + await _sendHandshake(); + _startPingTimer(); + } + + + void _setupMessageListener() { + _messageSubscription?.cancel(); + + _messageSubscription = _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDisconnection, + cancelOnError: true, + ); + + _logger.logConnection('Слушатель сообщений настроен'); + } + + + void _handleMessage(dynamic message) { + if (message == null || (message is String && message.trim().isEmpty)) { + return; + } + + try { + _logger.logMessage('IN', message); + + final decodedMessage = message is String ? jsonDecode(message) : message; + + + if (decodedMessage is Map && decodedMessage['opcode'] == 1) { + _healthMonitor.onPongReceived(); + + _responseTimeoutTimer?.cancel(); + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 6 && + decodedMessage['cmd'] == 1) { + _handleHandshakeSuccess(Map.from(decodedMessage)); + return; + } + + + if (decodedMessage is Map && decodedMessage['cmd'] == 3) { + _handleServerError(Map.from(decodedMessage)); + return; + } + + + if (decodedMessage is Map && + decodedMessage['opcode'] == 97 && + decodedMessage['cmd'] == 1) { + _handleSessionTermination(); + return; + } + + _messageController.add(decodedMessage); + } catch (e) { + _logger.logError( + 'Ошибка обработки сообщения', + data: {'message': message.toString(), 'error': e.toString()}, + ); + } + } + + + void _handleHandshakeSuccess(Map message) { + _logger.logConnection( + 'Handshake успешен', + data: {'payload': message['payload']}, + ); + + + _responseTimeoutTimer?.cancel(); + + _stateManager.setState( + ConnectionState.ready, + message: 'Сессия готова к работе', + ); + + _processMessageQueue(); + } + + + void _handleServerError(Map message) { + final error = message['payload']; + _logger.logError('Ошибка сервера', data: {'error': error}); + + if (error != null) { + if (error['error'] == 'proto.state') { + _logger.logConnection('Ошибка состояния сессии, переподключаемся'); + _scheduleReconnect('Ошибка состояния сессии'); + } else if (error['error'] == 'login.token') { + _logger.logConnection('Недействительный токен'); + _handleInvalidToken(); + } + } + } + + + void _handleSessionTermination() { + _logger.logConnection('Сессия завершена сервером'); + _stateManager.setState( + ConnectionState.disconnected, + message: 'Сессия завершена сервером', + ); + _clearAuthData(); + } + + + void _handleInvalidToken() { + _logger.logConnection('Обработка недействительного токена'); + _clearAuthData(); + _stateManager.setState( + ConnectionState.disconnected, + message: 'Требуется повторная авторизация', + ); + } + + + void _handleConnectionTimeout() { + _logger.logError('Таймаут подключения к серверу'); + _stateManager.setState( + ConnectionState.error, + message: 'Таймаут подключения', + ); + _scheduleReconnect('Таймаут подключения'); + } + + + void _handleResponseTimeout() { + _logger.logError('Таймаут ответа от сервера'); + _stateManager.setState( + ConnectionState.error, + message: 'Сервер не отвечает', + ); + _scheduleReconnect('Сервер не отвечает'); + } + + + void _clearAuthData() { + _authToken = null; + _logger.logConnection('Данные аутентификации очищены'); + } + + + void _handleError(dynamic error) { + _logger.logError('Ошибка WebSocket', error: error); + _healthMonitor.onError(error.toString()); + _scheduleReconnect('Ошибка WebSocket: $error'); + } + + + void _handleDisconnection() { + _logger.logConnection('WebSocket соединение закрыто'); + _healthMonitor.onReconnect(); + _scheduleReconnect('Соединение закрыто'); + } + + + void _scheduleReconnect(String reason) { + if (_isDisposed) return; + + _reconnectTimer?.cancel(); + + final sessionId = 'reconnect_${DateTime.now().millisecondsSinceEpoch}'; + final session = _retryManager.startSession(sessionId, ErrorType.network); + + if (!session.canRetry()) { + _logger.logError( + 'Превышено максимальное количество попыток переподключения', + ); + _stateManager.setState( + ConnectionState.error, + message: 'Не удалось переподключиться', + ); + return; + } + + final delay = session.getNextDelay(); + + _logger.logReconnect(session.attemptCount + 1, reason, delay: delay); + + _stateManager.setState( + ConnectionState.reconnecting, + message: 'Переподключение через ${delay.inSeconds}с', + reconnectDelay: delay, + ); + + _reconnectTimer = Timer(delay, () async { + try { + + await _fullReconnect(); + } catch (e) { + _logger.logError('Ошибка переподключения', error: e); + } + }); + } + + + Future _fullReconnect() async { + _logger.logConnection('Начинаем полное переподключение'); + + + _cleanup(); + _currentUrlIndex = 0; + _sequenceNumber = 0; + _messageQueue.clear(); + + + _stateManager.setState( + ConnectionState.disconnected, + message: 'Подготовка к переподключению', + ); + + + await Future.delayed(const Duration(milliseconds: 500)); + + + await connect(authToken: _authToken); + } + + + Future _sendHandshake() async { + _logger.logConnection('Отправка handshake'); + + final payload = { + "userAgent": { + "deviceType": "WEB", + "locale": "ru", + "deviceLocale": "ru", + "osVersion": "Windows", + "deviceName": "Chrome", + "headerUserAgent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "appVersion": "25.9.15", + "screen": "1920x1080 1.0x", + "timezone": "Europe/Moscow", + }, + "deviceId": _generateDeviceId(), + }; + + _sendMessage(6, payload); + + + _responseTimeoutTimer = Timer(const Duration(seconds: 15), () { + _logger.logError('Таймаут ответа от сервера на handshake'); + _handleResponseTimeout(); + }); + } + + + int _sendMessage(int opcode, Map payload) { + if (_channel == null) { + _logger.logError('WebSocket не подключен'); + return -1; + } + + final message = { + "ver": 11, + "cmd": 0, + "seq": _sequenceNumber, + "opcode": opcode, + "payload": payload, + }; + + final encodedMessage = jsonEncode(message); + _logger.logMessage('OUT', encodedMessage); + + _channel!.sink.add(encodedMessage); + return _sequenceNumber++; + } + + + int sendMessage(int opcode, Map payload) { + if (!canSendMessages) { + _logger.logConnection( + 'Сообщение добавлено в очередь', + data: {'opcode': opcode, 'reason': 'Соединение не готово'}, + ); + _messageQueue.add({'opcode': opcode, 'payload': payload}); + return -1; + } + + return _sendMessage(opcode, payload); + } + + + void _processMessageQueue() { + if (_messageQueue.isEmpty) return; + + _logger.logConnection( + 'Обработка очереди сообщений', + data: {'count': _messageQueue.length}, + ); + + for (final message in _messageQueue) { + _sendMessage(message['opcode'], message['payload']); + } + + _messageQueue.clear(); + } + + + void _startPingTimer() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(const Duration(seconds: 25), (_) { + if (canSendMessages) { + _logger.logConnection('Отправка ping'); + _sendMessage(1, {"interactive": true}); + + + _responseTimeoutTimer?.cancel(); + _responseTimeoutTimer = Timer(const Duration(seconds: 10), () { + _logger.logError('Таймаут pong ответа от сервера'); + _handleResponseTimeout(); + }); + } + }); + } + + + String _generateDeviceId() { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final random = (timestamp % 1000000).toString().padLeft(6, '0'); + return "$timestamp$random"; + } + + + ErrorType _getErrorType(dynamic error) { + final errorString = error.toString().toLowerCase(); + + if (errorString.contains('timeout') || + errorString.contains('connection') || + errorString.contains('network')) { + return ErrorType.network; + } + + if (errorString.contains('unauthorized') || + errorString.contains('forbidden')) { + return ErrorType.authentication; + } + + if (errorString.contains('server') || errorString.contains('internal')) { + return ErrorType.server; + } + + return ErrorType.unknown; + } + + + Future disconnect() async { + _logger.logConnection('Отключение'); + + _stateManager.setState( + ConnectionState.disconnected, + message: 'Отключение по запросу', + ); + + _stopMonitoring(); + _cleanup(); + } + + + Future forceReconnect() async { + if (_isDisposed) { + _logger.logError('Попытка переподключения после dispose'); + return; + } + + _logger.logConnection('Принудительное переподключение'); + + + _reconnectTimer?.cancel(); + _pingTimer?.cancel(); + + + await _fullReconnect(); + } + + + void _stopMonitoring() { + _pingTimer?.cancel(); + _reconnectTimer?.cancel(); + _responseTimeoutTimer?.cancel(); + _connectionTimeoutTimer?.cancel(); + _messageSubscription?.cancel(); + _healthMonitor.stopMonitoring(); + } + + + void _cleanup() { + _channel?.sink.close(status.goingAway); + _channel = null; + _messageQueue.clear(); + _currentUrlIndex = 0; + _sequenceNumber = 0; + } + + + Map getStatistics() { + return { + 'connection_state': currentState.state.name, + 'health_metrics': _healthMonitor.getStatistics(), + 'retry_statistics': _retryManager.getStatistics(), + 'log_statistics': _logger.getLogStats(), + 'message_queue_size': _messageQueue.length, + 'current_server': _currentServerUrl, + 'server_index': _currentUrlIndex, + }; + } + + + void dispose() { + if (_isDisposed) return; + + _logger.logConnection('Освобождение ресурсов ConnectionManagerSimple'); + + _isDisposed = true; + _stopMonitoring(); + _cleanup(); + + _messageController.close(); + _connectionStatusController.close(); + _stateManager.dispose(); + _logger.dispose(); + _healthMonitor.dispose(); + } +} diff --git a/lib/connection/connection_state.dart b/lib/connection/connection_state.dart new file mode 100644 index 0000000..487df7f --- /dev/null +++ b/lib/connection/connection_state.dart @@ -0,0 +1,283 @@ +import 'dart:async'; + + +enum ConnectionState { + + disconnected, + + + connecting, + + + connected, + + + ready, + + + reconnecting, + + + error, + + + disabled, +} + + +class ConnectionInfo { + final ConnectionState state; + final DateTime timestamp; + final String? message; + final Map? metadata; + final int? attemptNumber; + final Duration? reconnectDelay; + final String? serverUrl; + final int? latency; + + ConnectionInfo({ + required this.state, + required this.timestamp, + this.message, + this.metadata, + this.attemptNumber, + this.reconnectDelay, + this.serverUrl, + this.latency, + }); + + ConnectionInfo copyWith({ + ConnectionState? state, + DateTime? timestamp, + String? message, + Map? metadata, + int? attemptNumber, + Duration? reconnectDelay, + String? serverUrl, + int? latency, + }) { + return ConnectionInfo( + state: state ?? this.state, + timestamp: timestamp ?? this.timestamp, + message: message ?? this.message, + metadata: metadata ?? this.metadata, + attemptNumber: attemptNumber ?? this.attemptNumber, + reconnectDelay: reconnectDelay ?? this.reconnectDelay, + serverUrl: serverUrl ?? this.serverUrl, + latency: latency ?? this.latency, + ); + } + + + bool get isActive => + state == ConnectionState.ready || state == ConnectionState.connected; + + + bool get canSendMessages => state == ConnectionState.ready; + + + bool get isConnecting => + state == ConnectionState.connecting || + state == ConnectionState.reconnecting; + + + bool get hasError => state == ConnectionState.error; + + + bool get isDisconnected => + state == ConnectionState.disconnected || + state == ConnectionState.disabled; + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write('ConnectionInfo(state: $state'); + if (message != null) buffer.write(', message: $message'); + if (attemptNumber != null) buffer.write(', attempt: $attemptNumber'); + if (serverUrl != null) buffer.write(', server: $serverUrl'); + if (latency != null) buffer.write(', latency: ${latency}ms'); + buffer.write(')'); + return buffer.toString(); + } +} + + +class ConnectionStateManager { + static final ConnectionStateManager _instance = + ConnectionStateManager._internal(); + factory ConnectionStateManager() => _instance; + ConnectionStateManager._internal(); + + ConnectionInfo _currentInfo = ConnectionInfo( + state: ConnectionState.disconnected, + timestamp: DateTime.now(), + ); + + final StreamController _stateController = + StreamController.broadcast(); + + + ConnectionInfo get currentInfo => _currentInfo; + + + Stream get stateStream => _stateController.stream; + + + final List _stateHistory = []; + + + List get stateHistory => List.unmodifiable(_stateHistory); + + + void setState( + ConnectionState newState, { + String? message, + Map? metadata, + int? attemptNumber, + Duration? reconnectDelay, + String? serverUrl, + int? latency, + }) { + final oldState = _currentInfo.state; + final newInfo = _currentInfo.copyWith( + state: newState, + timestamp: DateTime.now(), + message: message, + metadata: metadata, + attemptNumber: attemptNumber, + reconnectDelay: reconnectDelay, + serverUrl: serverUrl, + latency: latency, + ); + + _currentInfo = newInfo; + _addToHistory(newInfo); + _stateController.add(newInfo); + + + _logStateChange(oldState, newState, message); + } + + + void updateMetadata(Map metadata) { + final updatedInfo = _currentInfo.copyWith( + metadata: {...?(_currentInfo.metadata), ...metadata}, + ); + _currentInfo = updatedInfo; + _stateController.add(updatedInfo); + } + + + void updateReconnectDelay(Duration delay) { + final updatedInfo = _currentInfo.copyWith(reconnectDelay: delay); + _currentInfo = updatedInfo; + _stateController.add(updatedInfo); + } + + + void updateLatency(int latencyMs) { + final updatedInfo = _currentInfo.copyWith(latency: latencyMs); + _currentInfo = updatedInfo; + _stateController.add(updatedInfo); + } + + + Duration get timeInCurrentState { + return DateTime.now().difference(_currentInfo.timestamp); + } + + + int get connectionAttempts { + return _stateHistory + .where((info) => info.state == ConnectionState.connecting) + .length; + } + + + int get errorCount { + return _stateHistory + .where((info) => info.state == ConnectionState.error) + .length; + } + + + double get averageLatency { + final latencies = _stateHistory + .where((info) => info.latency != null) + .map((info) => info.latency!) + .toList(); + + if (latencies.isEmpty) return 0.0; + return latencies.reduce((a, b) => a + b) / latencies.length; + } + + + Map get stateStatistics { + final stats = {}; + for (final info in _stateHistory) { + stats[info.state] = (stats[info.state] ?? 0) + 1; + } + return stats; + } + + + List getLastStates(int count) { + final start = _stateHistory.length - count; + return _stateHistory.sublist(start < 0 ? 0 : start); + } + + + void clearHistory() { + _stateHistory.clear(); + } + + + void reset() { + setState(ConnectionState.disconnected, message: 'Состояние сброшено'); + clearHistory(); + } + + void _addToHistory(ConnectionInfo info) { + _stateHistory.add(info); + + + if (_stateHistory.length > 50) { + _stateHistory.removeAt(0); + } + } + + void _logStateChange( + ConnectionState from, + ConnectionState to, + String? message, + ) { + final fromStr = _getStateDisplayName(from); + final toStr = _getStateDisplayName(to); + final messageStr = message != null ? ' ($message)' : ''; + + print('🔄 Состояние подключения: $fromStr → $toStr$messageStr'); + } + + String _getStateDisplayName(ConnectionState state) { + switch (state) { + case ConnectionState.disconnected: + return 'Отключен'; + case ConnectionState.connecting: + return 'Подключение'; + case ConnectionState.connected: + return 'Подключен'; + case ConnectionState.ready: + return 'Готов'; + case ConnectionState.reconnecting: + return 'Переподключение'; + case ConnectionState.error: + return 'Ошибка'; + case ConnectionState.disabled: + return 'Отключен'; + } + } + + void dispose() { + _stateController.close(); + } +} diff --git a/lib/connection/health_monitor.dart b/lib/connection/health_monitor.dart new file mode 100644 index 0000000..77566c0 --- /dev/null +++ b/lib/connection/health_monitor.dart @@ -0,0 +1,331 @@ +import 'dart:async'; +import 'dart:math'; + + +class HealthMetrics { + final int latency; + final int packetLoss; + final int connectionUptime; + final int reconnects; + final int errors; + final DateTime timestamp; + final String? serverUrl; + + HealthMetrics({ + required this.latency, + required this.packetLoss, + required this.connectionUptime, + required this.reconnects, + required this.errors, + required this.timestamp, + this.serverUrl, + }); + + + int get healthScore { + int score = 100; + + + if (latency > 1000) { + score -= 30; + } else if (latency > 500) + score -= 20; + else if (latency > 200) + score -= 10; + + + if (packetLoss > 10) { + score -= 40; + } else if (packetLoss > 5) + score -= 20; + else if (packetLoss > 1) + score -= 10; + + + if (reconnects > 10) { + score -= 30; + } else if (reconnects > 5) + score -= 20; + else if (reconnects > 2) + score -= 10; + + + if (errors > 20) { + score -= 25; + } else if (errors > 10) + score -= 15; + else if (errors > 5) + score -= 10; + + return max(0, score); + } + + + ConnectionQuality get quality { + final score = healthScore; + if (score >= 90) return ConnectionQuality.excellent; + if (score >= 70) return ConnectionQuality.good; + if (score >= 50) return ConnectionQuality.fair; + if (score >= 30) return ConnectionQuality.poor; + return ConnectionQuality.critical; + } + + Map toJson() { + return { + 'latency': latency, + 'packet_loss': packetLoss, + 'connection_uptime': connectionUptime, + 'reconnects': reconnects, + 'errors': errors, + 'health_score': healthScore, + 'quality': quality.name, + 'timestamp': timestamp.toIso8601String(), + 'server_url': serverUrl, + }; + } +} + + +enum ConnectionQuality { excellent, good, fair, poor, critical } + + +class HealthMonitor { + static final HealthMonitor _instance = HealthMonitor._internal(); + factory HealthMonitor() => _instance; + HealthMonitor._internal(); + + final List _metricsHistory = []; + final StreamController _metricsController = + StreamController.broadcast(); + + Timer? _pingTimer; + Timer? _healthCheckTimer; + + int _pingCount = 0; + int _pongCount = 0; + int _reconnectCount = 0; + int _errorCount = 0; + DateTime? _connectionStartTime; + String? _currentServerUrl; + + + Stream get metricsStream => _metricsController.stream; + + + HealthMetrics? get currentMetrics => + _metricsHistory.isNotEmpty ? _metricsHistory.last : null; + + + List get metricsHistory => List.unmodifiable(_metricsHistory); + + + void startMonitoring({String? serverUrl}) { + _currentServerUrl = serverUrl; + _connectionStartTime = DateTime.now(); + _resetCounters(); + + + _pingTimer?.cancel(); + _pingTimer = Timer.periodic( + const Duration(seconds: 30), + (_) => _sendPing(), + ); + + + _healthCheckTimer?.cancel(); + _healthCheckTimer = Timer.periodic( + const Duration(seconds: 60), + (_) => _updateHealthMetrics(), + ); + + _logHealthEvent('Мониторинг здоровья начат', {'server_url': serverUrl}); + } + + + void stopMonitoring() { + _pingTimer?.cancel(); + _healthCheckTimer?.cancel(); + _logHealthEvent('Мониторинг здоровья остановлен'); + } + + + void onPongReceived() { + _pongCount++; + _logHealthEvent('Pong получен', {'pong_count': _pongCount}); + } + + + void onReconnect() { + _reconnectCount++; + _logHealthEvent('Переподключение', {'reconnect_count': _reconnectCount}); + } + + + void onError(String error) { + _errorCount++; + _logHealthEvent('Ошибка зафиксирована', { + 'error': error, + 'error_count': _errorCount, + }); + } + + + void _sendPing() { + _pingCount++; + _logHealthEvent('Ping отправлен', {'ping_count': _pingCount}); + } + + + void _updateHealthMetrics() { + final now = DateTime.now(); + final uptime = _connectionStartTime != null + ? now.difference(_connectionStartTime!).inSeconds + : 0; + + final latency = _calculateLatency(); + final packetLoss = _calculatePacketLoss(); + + final metrics = HealthMetrics( + latency: latency, + packetLoss: packetLoss, + connectionUptime: uptime, + reconnects: _reconnectCount, + errors: _errorCount, + timestamp: now, + serverUrl: _currentServerUrl, + ); + + _metricsHistory.add(metrics); + + + if (_metricsHistory.length > 100) { + _metricsHistory.removeAt(0); + } + + _metricsController.add(metrics); + + _logHealthEvent('Метрики обновлены', { + 'latency': latency, + 'packet_loss': packetLoss, + 'uptime': uptime, + 'health_score': metrics.healthScore, + 'quality': metrics.quality.name, + }); + } + + + int _calculateLatency() { + + if (_pingCount == 0) return 0; + + + final baseLatency = 50 + Random().nextInt(100); + final packetLossPenalty = _calculatePacketLoss() * 10; + + return baseLatency + packetLossPenalty; + } + + + int _calculatePacketLoss() { + if (_pingCount == 0) return 0; + + final expectedPongs = _pingCount; + final actualPongs = _pongCount; + final lostPackets = expectedPongs - actualPongs; + + return ((lostPackets / expectedPongs) * 100).round(); + } + + + HealthMetrics? getAverageMetrics({Duration? period}) { + if (_metricsHistory.isEmpty) return null; + + final cutoff = period != null + ? DateTime.now().subtract(period) + : DateTime.now().subtract(const Duration(hours: 1)); + + final recentMetrics = _metricsHistory + .where((m) => m.timestamp.isAfter(cutoff)) + .toList(); + + if (recentMetrics.isEmpty) return null; + + final avgLatency = + recentMetrics.map((m) => m.latency).reduce((a, b) => a + b) / + recentMetrics.length; + + final avgPacketLoss = + recentMetrics.map((m) => m.packetLoss).reduce((a, b) => a + b) / + recentMetrics.length; + + final totalReconnects = recentMetrics.last.reconnects; + final totalErrors = recentMetrics.last.errors; + final avgUptime = recentMetrics.last.connectionUptime; + + return HealthMetrics( + latency: avgLatency.round(), + packetLoss: avgPacketLoss.round(), + connectionUptime: avgUptime, + reconnects: totalReconnects, + errors: totalErrors, + timestamp: DateTime.now(), + serverUrl: _currentServerUrl, + ); + } + + + Map getStatistics() { + if (_metricsHistory.isEmpty) { + return { + 'total_metrics': 0, + 'average_health_score': 0, + 'current_quality': 'unknown', + }; + } + + final avgHealthScore = + _metricsHistory.map((m) => m.healthScore).reduce((a, b) => a + b) / + _metricsHistory.length; + + final qualityDistribution = {}; + for (final metrics in _metricsHistory) { + final quality = metrics.quality.name; + qualityDistribution[quality] = (qualityDistribution[quality] ?? 0) + 1; + } + + return { + 'total_metrics': _metricsHistory.length, + 'average_health_score': avgHealthScore.round(), + 'current_quality': _metricsHistory.last.quality.name, + 'quality_distribution': qualityDistribution, + 'total_reconnects': _reconnectCount, + 'total_errors': _errorCount, + 'connection_uptime': _connectionStartTime != null + ? DateTime.now().difference(_connectionStartTime!).inSeconds + : 0, + }; + } + + + void _resetCounters() { + _pingCount = 0; + _pongCount = 0; + _reconnectCount = 0; + _errorCount = 0; + } + + + void clearHistory() { + _metricsHistory.clear(); + _logHealthEvent('История метрик очищена'); + } + + void _logHealthEvent(String event, [Map? data]) { + print('🏥 HealthMonitor: $event${data != null ? ' | Data: $data' : ''}'); + } + + void dispose() { + stopMonitoring(); + _metricsController.close(); + } +} diff --git a/lib/connection/retry_strategy.dart b/lib/connection/retry_strategy.dart new file mode 100644 index 0000000..c4b10a0 --- /dev/null +++ b/lib/connection/retry_strategy.dart @@ -0,0 +1,339 @@ +import 'dart:math'; + + +enum ErrorType { + + network, + + + server, + + + authentication, + + + protocol, + + + unknown, +} + + +class ErrorInfo { + final ErrorType type; + final String message; + final int? httpStatusCode; + final DateTime timestamp; + final Map? metadata; + + ErrorInfo({ + required this.type, + required this.message, + this.httpStatusCode, + required this.timestamp, + this.metadata, + }); + + + static ErrorType getErrorTypeFromHttpStatus(int statusCode) { + if (statusCode >= 500) return ErrorType.server; + if (statusCode == 401 || statusCode == 403) return ErrorType.authentication; + if (statusCode >= 400) return ErrorType.protocol; + return ErrorType.network; + } + + + static ErrorType getErrorTypeFromMessage(String message) { + final lowerMessage = message.toLowerCase(); + + if (lowerMessage.contains('timeout') || + lowerMessage.contains('connection') || + lowerMessage.contains('network')) { + return ErrorType.network; + } + + if (lowerMessage.contains('unauthorized') || + lowerMessage.contains('forbidden') || + lowerMessage.contains('token')) { + return ErrorType.authentication; + } + + if (lowerMessage.contains('server') || lowerMessage.contains('internal')) { + return ErrorType.server; + } + + return ErrorType.unknown; + } +} + + +class RetryStrategy { + final int maxAttempts; + final Duration baseDelay; + final Duration maxDelay; + final double backoffMultiplier; + final double jitterFactor; + final Map errorConfigs; + + RetryStrategy({ + this.maxAttempts = 10, + this.baseDelay = const Duration(seconds: 1), + this.maxDelay = const Duration(minutes: 5), + this.backoffMultiplier = 2.0, + this.jitterFactor = 0.1, + Map? errorConfigs, + }) : errorConfigs = errorConfigs ?? _defaultErrorConfigs; + + static final Map _defaultErrorConfigs = { + ErrorType.network: RetryConfig( + maxAttempts: 15, + baseDelay: Duration(seconds: 2), + maxDelay: Duration(minutes: 10), + backoffMultiplier: 1.5, + ), + ErrorType.server: RetryConfig( + maxAttempts: 8, + baseDelay: Duration(seconds: 5), + maxDelay: Duration(minutes: 3), + backoffMultiplier: 2.0, + ), + ErrorType.authentication: RetryConfig( + maxAttempts: 3, + baseDelay: Duration(seconds: 1), + maxDelay: Duration(seconds: 10), + backoffMultiplier: 1.0, + ), + ErrorType.protocol: RetryConfig( + maxAttempts: 5, + baseDelay: Duration(seconds: 2), + maxDelay: Duration(minutes: 2), + backoffMultiplier: 1.5, + ), + ErrorType.unknown: RetryConfig( + maxAttempts: 5, + baseDelay: Duration(seconds: 3), + maxDelay: Duration(minutes: 5), + backoffMultiplier: 2.0, + ), + }; + + + Duration calculateDelay(int attempt, ErrorType errorType) { + final config = errorConfigs[errorType] ?? errorConfigs[ErrorType.unknown]!; + + + final exponentialDelay = + config.baseDelay * pow(config.backoffMultiplier, attempt - 1); + final cappedDelay = exponentialDelay > config.maxDelay + ? config.maxDelay + : exponentialDelay; + + + final jitter = + cappedDelay.inMilliseconds * + jitterFactor * + (Random().nextDouble() * 2 - 1); + final finalDelay = Duration( + milliseconds: (cappedDelay.inMilliseconds + jitter).round(), + ); + + return finalDelay; + } + + + bool shouldRetry(int attempt, ErrorType errorType) { + final config = errorConfigs[errorType] ?? errorConfigs[ErrorType.unknown]!; + return attempt <= config.maxAttempts; + } + + + RetryConfig getConfigForError(ErrorType errorType) { + return errorConfigs[errorType] ?? errorConfigs[ErrorType.unknown]!; + } +} + + +class RetryConfig { + final int maxAttempts; + final Duration baseDelay; + final Duration maxDelay; + final double backoffMultiplier; + + RetryConfig({ + required this.maxAttempts, + required this.baseDelay, + required this.maxDelay, + required this.backoffMultiplier, + }); +} + + +class RetryManager { + final RetryStrategy _strategy; + final Map _sessions = {}; + + RetryManager({RetryStrategy? strategy}) + : _strategy = strategy ?? RetryStrategy(); + + + RetrySession startSession(String sessionId, ErrorType initialErrorType) { + final session = RetrySession( + id: sessionId, + strategy: _strategy, + initialErrorType: initialErrorType, + ); + _sessions[sessionId] = session; + return session; + } + + + RetrySession? getSession(String sessionId) { + return _sessions[sessionId]; + } + + + void endSession(String sessionId) { + _sessions.remove(sessionId); + } + + + void clearSessions() { + _sessions.clear(); + } + + + Map getStatistics() { + final totalSessions = _sessions.length; + final activeSessions = _sessions.values.where((s) => s.isActive).length; + final successfulSessions = _sessions.values + .where((s) => s.isSuccessful) + .length; + final failedSessions = _sessions.values.where((s) => s.isFailed).length; + + return { + 'total_sessions': totalSessions, + 'active_sessions': activeSessions, + 'successful_sessions': successfulSessions, + 'failed_sessions': failedSessions, + 'success_rate': totalSessions > 0 + ? successfulSessions / totalSessions + : 0.0, + }; + } +} + + +class RetrySession { + final String id; + final RetryStrategy strategy; + final ErrorType initialErrorType; + final DateTime startTime; + final List attempts = []; + + RetrySession({ + required this.id, + required this.strategy, + required this.initialErrorType, + }) : startTime = DateTime.now(); + + + void addAttempt( + ErrorType errorType, { + String? message, + Map? metadata, + }) { + final attempt = RetryAttempt( + number: attempts.length + 1, + errorType: errorType, + timestamp: DateTime.now(), + message: message, + metadata: metadata, + ); + attempts.add(attempt); + } + + + Duration getNextDelay() { + return strategy.calculateDelay(attempts.length + 1, currentErrorType); + } + + + bool canRetry() { + return strategy.shouldRetry(attempts.length + 1, currentErrorType); + } + + + ErrorType get currentErrorType { + if (attempts.isEmpty) return initialErrorType; + return attempts.last.errorType; + } + + + int get attemptCount => attempts.length; + + + bool get isActive => !isSuccessful && !isFailed && canRetry(); + + + bool get isSuccessful => attempts.isNotEmpty && attempts.last.isSuccessful; + + + bool get isFailed => !canRetry() && !isSuccessful; + + + Duration get duration => DateTime.now().difference(startTime); + + + RetryAttempt? get lastAttempt => attempts.isNotEmpty ? attempts.last : null; + + + Map getStatistics() { + final errorTypes = attempts.map((a) => a.errorType.name).toList(); + final errorTypeCounts = {}; + for (final type in errorTypes) { + errorTypeCounts[type] = (errorTypeCounts[type] ?? 0) + 1; + } + + return { + 'session_id': id, + 'start_time': startTime.toIso8601String(), + 'duration_seconds': duration.inSeconds, + 'attempt_count': attemptCount, + 'is_active': isActive, + 'is_successful': isSuccessful, + 'is_failed': isFailed, + 'error_types': errorTypeCounts, + 'last_attempt': lastAttempt?.toJson(), + }; + } +} + + +class RetryAttempt { + final int number; + final ErrorType errorType; + final DateTime timestamp; + final String? message; + final Map? metadata; + final bool isSuccessful; + + RetryAttempt({ + required this.number, + required this.errorType, + required this.timestamp, + this.message, + this.metadata, + this.isSuccessful = false, + }); + + Map toJson() { + return { + 'number': number, + 'error_type': errorType.name, + 'timestamp': timestamp.toIso8601String(), + 'message': message, + 'metadata': metadata, + 'is_successful': isSuccessful, + }; + } +} diff --git a/lib/connection_lifecycle_manager.dart b/lib/connection_lifecycle_manager.dart new file mode 100644 index 0000000..c9a22c9 --- /dev/null +++ b/lib/connection_lifecycle_manager.dart @@ -0,0 +1,138 @@ + + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'api_service.dart'; +import 'theme_provider.dart'; + +class ConnectionLifecycleManager extends StatefulWidget { + final Widget child; + + const ConnectionLifecycleManager({super.key, required this.child}); + + @override + _ConnectionLifecycleManagerState createState() => + _ConnectionLifecycleManagerState(); +} + +class _ConnectionLifecycleManagerState extends State + with WidgetsBindingObserver, SingleTickerProviderStateMixin { + bool _isReconnecting = false; + late AnimationController _animationController; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _slideAnimation = + Tween(begin: const Offset(0, -1), end: Offset.zero).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + } + + @override + void dispose() { + _animationController.dispose(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + switch (state) { + case AppLifecycleState.resumed: + print("✅ Приложение возобновлено."); + ApiService.instance.setAppInForeground(true); + ApiService.instance.sendNavEvent('WARM_START'); + _checkAndReconnectIfNeeded(); + break; + case AppLifecycleState.paused: + print("⏸️ Приложение свернуто."); + ApiService.instance.setAppInForeground(false); + ApiService.instance.sendNavEvent('GO', screenTo: 1, screenFrom: 150); + break; + default: + break; + } + } + + Future _checkAndReconnectIfNeeded() async { + final hasToken = await ApiService.instance.hasToken(); + if (!hasToken) { + print("🔒 Токен отсутствует, переподключение не требуется"); + return; + } + + await Future.delayed(const Duration(milliseconds: 500)); + final bool actuallyConnected = ApiService.instance.isActuallyConnected; + print("🔍 Проверка соединения:"); + print(" - isOnline: ${ApiService.instance.isOnline}"); + print(" - isActuallyConnected: $actuallyConnected"); + + if (!actuallyConnected) { + print("🔌 Соединение потеряно. Запускаем переподключение..."); + if (mounted) { + setState(() { + _isReconnecting = true; + }); + _animationController.forward(); + } + + try { + await ApiService.instance.performFullReconnection(); + print("✅ Переподключение выполнено успешно"); + if (mounted) { + await _animationController.reverse(); + setState(() { + _isReconnecting = false; + }); + } + } catch (e) { + print("❌ Ошибка при переподключении: $e"); + Future.delayed(const Duration(seconds: 3), () async { + if (!ApiService.instance.isActuallyConnected) { + print("🔁 Повторная попытка переподключения..."); + try { + await ApiService.instance.performFullReconnection(); + if (mounted) { + await _animationController.reverse(); + setState(() { + _isReconnecting = false; + }); + } + } catch (e) { + print("❌ Повторная попытка не удалась: $e"); + if (mounted) { + await _animationController.reverse(); + setState(() { + _isReconnecting = false; + }); + } + } + } + }); + } + } else { + print("✅ Соединение активно, переподключение не требуется"); + } + } + + @override + Widget build(BuildContext context) { + final theme = Provider.of(context); + final accentColor = theme.accentColor; + + return Directionality( + textDirection: TextDirection.ltr, + child: Stack(children: [widget.child]), + ); + } +} diff --git a/lib/custom_request_screen.dart b/lib/custom_request_screen.dart new file mode 100644 index 0000000..3dfacc2 --- /dev/null +++ b/lib/custom_request_screen.dart @@ -0,0 +1,317 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gwid/api_service.dart'; + + +class RequestHistoryItem { + final String request; + final String response; + final DateTime timestamp; + + RequestHistoryItem({ + required this.request, + required this.response, + required this.timestamp, + }); +} + +class CustomRequestScreen extends StatefulWidget { + const CustomRequestScreen({super.key}); + + @override + State createState() => _CustomRequestScreenState(); +} + +class _CustomRequestScreenState extends State { + final _requestController = TextEditingController(); + final _scrollController = ScrollController(); + + String? _response; + String? _error; + bool _isLoading = false; + + final List _history = []; + + void _handleResponse(Map message, String originalRequest) { + const encoder = JsonEncoder.withIndent(' '); + final formattedResponse = encoder.convert(message); + + if (!mounted) return; // Убедимся, что виджет все еще существует + + setState(() { + _response = formattedResponse; + _isLoading = false; + _error = null; + + + _history.insert( + 0, + RequestHistoryItem( + request: originalRequest, // Используем переданный запрос + response: formattedResponse, + timestamp: DateTime.now(), + ), + ); + }); + } + + Future _sendRequest() async { + if (_isLoading) return; + FocusScope.of(context).unfocus(); + + final requestText = _requestController.text.isEmpty + ? '{}' + : _requestController.text; + Map requestJson; + + try { + requestJson = jsonDecode(requestText) as Map; + } catch (e) { + setState(() { + _error = 'Ошибка: Невалидный JSON в запросе.\n$e'; + }); + return; + } + + setState(() { + _isLoading = true; + _response = null; + _error = null; + }); + + StreamSubscription? subscription; + Timer? timeoutTimer; + + try { + final int sentSeq = ApiService.instance.sendAndTrackFullJsonRequest( + jsonEncode(requestJson), + ); + + + timeoutTimer = Timer(const Duration(seconds: 15), () { + subscription?.cancel(); // Прекращаем слушать стрим + if (mounted && _isLoading) { + setState(() { + _error = 'Ошибка: Превышено время ожидания ответа (15с).'; + _isLoading = false; + }); + } + }); + + + subscription = ApiService.instance.messages.listen((message) { + + if (message['seq'] == sentSeq) { + timeoutTimer?.cancel(); // Отменяем таймер + subscription?.cancel(); // Отменяем подписку + _handleResponse(message, requestText); // Обрабатываем ответ + } + }); + } catch (e) { + timeoutTimer?.cancel(); + subscription?.cancel(); + if (mounted) { + setState(() { + _error = 'Ошибка при отправке запроса: $e'; + _isLoading = false; + }); + } + } + } + + void _useHistoryItem(RequestHistoryItem item) { + + _requestController.text = item.request; + setState(() { + _response = item.response; + _error = null; + }); + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom WebSocket Request')), + body: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildRequestSection(), + const SizedBox(height: 24), + _buildResponseSection(), + const SizedBox(height: 24), + if (_history.isNotEmpty) _buildHistoryWidget(), + ], + ), + ), + ); + } + + Widget _buildRequestSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Запрос к серверу', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + TextField( + controller: _requestController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + alignLabelWithHint: true, + hintText: 'Введите полный JSON запроса...', + ), + keyboardType: TextInputType.multiline, + maxLines: 12, + style: const TextStyle(fontFamily: 'monospace', fontSize: 14.0), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _isLoading ? null : _sendRequest, + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.send), + label: Text(_isLoading ? 'Ожидание...' : 'Отправить запрос'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + minimumSize: const Size(double.infinity, 50), + ), + ), + ], + ); + } + + Widget _buildResponseSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Ответ от сервера', + style: Theme.of(context).textTheme.titleLarge, + ), + if (_response != null) + IconButton( + icon: const Icon(Icons.copy_all_outlined), + tooltip: 'Скопировать ответ', + onPressed: () { + Clipboard.setData(ClipboardData(text: _response!)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ответ скопирован в буфер обмена'), + backgroundColor: Colors.green, + ), + ); + }, + ), + ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 150), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + child: _buildResponseContent(), + ), + ], + ); + } + + Widget _buildResponseContent() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return SelectableText( + _error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontFamily: 'monospace', + ), + ); + } + if (_response != null) { + return SelectableText( + _response!, + style: const TextStyle(fontFamily: 'monospace', fontSize: 14), + ); + } + return Center( + child: Text( + 'Здесь появится ответ от сервера...', + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ); + } + + Widget _buildHistoryWidget() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('История запросов', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _history.length, + itemBuilder: (context, index) { + final item = _history[index]; + String opcode = 'N/A'; + try { + final decoded = jsonDecode(item.request); + opcode = decoded['opcode']?.toString() ?? 'N/A'; + } catch (_) {} + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar(child: Text(opcode)), + title: Text( + 'Request: ${item.request.replaceAll('\n', ' ').substring(0, (item.request.length > 50) ? 50 : item.request.length)}...', + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontFamily: 'monospace'), + ), + subtitle: Text( + '${item.timestamp.hour.toString().padLeft(2, '0')}:${item.timestamp.minute.toString().padLeft(2, '0')}:${item.timestamp.second.toString().padLeft(2, '0')}', + ), + onTap: () => _useHistoryItem(item), + ), + ); + }, + ), + ], + ); + } + + @override + void dispose() { + _requestController.dispose(); + _scrollController.dispose(); + super.dispose(); + } +} diff --git a/lib/debug_screen.dart b/lib/debug_screen.dart new file mode 100644 index 0000000..b16bc50 --- /dev/null +++ b/lib/debug_screen.dart @@ -0,0 +1,740 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:gwid/cache_management_screen.dart'; // Добавлен импорт +import 'package:provider/provider.dart'; +import 'package:gwid/theme_provider.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/phone_entry_screen.dart'; +import 'package:gwid/custom_request_screen.dart'; +import 'dart:async'; + +class DebugScreen extends StatelessWidget { + const DebugScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final theme = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Debug Settings'), + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "Performance Debug", + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.speed), + title: const Text("Показать FPS overlay"), + subtitle: const Text("Отображение FPS и производительности"), + trailing: Switch( + value: theme.debugShowPerformanceOverlay, + onChanged: (value) => + theme.setDebugShowPerformanceOverlay(value), + ), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.refresh), + title: const Text("Показать панель обновления чатов"), + subtitle: const Text( + "Debug панель для обновления списка чатов", + ), + trailing: Switch( + value: theme.debugShowChatsRefreshPanel, + onChanged: (value) => + theme.setDebugShowChatsRefreshPanel(value), + ), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.message), + title: const Text("Показать счётчик сообщений"), + subtitle: const Text("Отладочная информация о сообщениях"), + trailing: Switch( + value: theme.debugShowMessageCount, + onChanged: (value) => theme.setDebugShowMessageCount(value), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "Инструменты разработчика", + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.code), + title: const Text("Custom API Request"), + subtitle: const Text("Отправить сырой запрос на сервер"), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const CustomRequestScreen(), + ), + ); + }, + ), + ], + ), + ), + + const SizedBox(height: 16), + + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "Data Management", + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.delete_forever), + title: const Text("Очистить все данные"), + subtitle: const Text("Полная очистка кэшей и данных"), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showClearAllDataDialog(context), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.phone), + title: const Text("Показать ввод номера"), + subtitle: const Text("Открыть экран ввода номера без выхода"), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showPhoneEntryScreen(context), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.traffic), + title: const Text("Статистика трафика"), + subtitle: const Text("Просмотр использованного трафика"), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showTrafficStats(context), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.storage), + title: const Text("Использование памяти"), + subtitle: const Text("Просмотр статистики памяти"), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showMemoryUsage(context), + ), + const SizedBox(height: 8), + + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.cached), + title: const Text("Управление кэшем"), + subtitle: const Text("Настройки кэширования и статистика"), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const CacheManagementScreen(), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ); + } + + void _showClearAllDataDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Очистить все данные'), + content: const Text( + 'Это действие удалит ВСЕ данные приложения:\n\n' + '• Все кэши и сообщения\n' + '• Настройки и профиль\n' + '• Токен авторизации\n' + '• История чатов\n\n' + 'После очистки приложение будет закрыто.\n' + 'Вы уверены?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () async { + Navigator.of(context).pop(); + await _performFullDataClear(context); + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Очистить и закрыть'), + ), + ], + ), + ); + } + + Future _performFullDataClear(BuildContext context) async { + try { + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(width: 16), + Text('Очистка данных...'), + ], + ), + ), + ); + + + await ApiService.instance.clearAllData(); + + + if (context.mounted) { + Navigator.of(context).pop(); + } + + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Все данные очищены. Приложение будет закрыто.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + + + await Future.delayed(const Duration(seconds: 2)); + + + if (context.mounted) { + SystemNavigator.pop(); + } + } catch (e) { + + if (context.mounted) { + Navigator.of(context).pop(); + } + + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при очистке данных: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _showPhoneEntryScreen(BuildContext context) { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const PhoneEntryScreen())); + } + + void _showTrafficStats(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Статистика трафика'), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('📊 Статистика использования данных:'), + SizedBox(height: 16), + Text('• Отправлено сообщений: 1,247'), + Text('• Получено сообщений: 3,891'), + Text('• Загружено фото: 156 MB'), + Text('• Загружено видео: 89 MB'), + Text('• Общий трафик: 2.1 GB'), + SizedBox(height: 16), + Text('📅 За последние 30 дней'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Закрыть'), + ), + ], + ), + ); + } + + void _showMemoryUsage(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Использование памяти'), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('💾 Использование памяти:'), + SizedBox(height: 16), + Text('• Кэш сообщений: 45.2 MB'), + Text('• Кэш контактов: 12.8 MB'), + Text('• Кэш чатов: 8.3 MB'), + Text('• Медиа файлы: 156.7 MB'), + Text('• Общее использование: 223.0 MB'), + SizedBox(height: 16), + Text('📱 Доступно памяти: 2.1 GB'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Закрыть'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + ApiService.instance.clearAllCaches(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Кэш очищен'), + backgroundColor: Colors.green, + ), + ); + }, + child: const Text('Очистить кэш'), + ), + ], + ), + ); + } +} + +class _OutlinedSection extends StatelessWidget { + final Widget child; + + const _OutlinedSection({required this.child}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: colors.outline.withOpacity(0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: child, + ); + } +} + +class Session { + final String client; + final String location; + final bool current; + final int time; + final String info; + + Session({ + required this.client, + required this.location, + required this.current, + required this.time, + required this.info, + }); + + factory Session.fromJson(Map json) { + return Session( + client: json['client'] ?? '', + location: json['location'] ?? '', + current: json['current'] ?? false, + time: json['time'] ?? 0, + info: json['info'] ?? '', + ); + } +} + +class SessionsScreen extends StatefulWidget { + const SessionsScreen({super.key}); + + @override + State createState() => _SessionsScreenState(); +} + +class _SessionsScreenState extends State { + List _sessions = []; + bool _isLoading = true; + bool _isInitialLoad = true; + StreamSubscription? _apiSubscription; + + @override + void initState() { + super.initState(); + _listenToApi(); + + } + + void _loadSessions() { + + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isLoading = true; + }); + } + }); + ApiService.instance.requestSessions(); + } + + void _terminateAllSessions() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Сбросить все сессии?'), + content: const Text( + 'Все остальные сессии будут завершены. ' + 'Текущая сессия останется активной.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + child: const Text('Сбросить'), + ), + ], + ), + ); + + if (confirmed == true) { + + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isLoading = true; + }); + } + }); + + ApiService.instance.terminateAllSessions(); + + + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _loadSessions(); + } + }); + } + }); + } + } + + void _listenToApi() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (message['opcode'] == 96 && mounted) { + + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + }); + + final payload = message['payload']; + if (payload != null && payload['sessions'] != null) { + final sessionsList = payload['sessions'] as List; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _sessions = sessionsList + .map((session) => Session.fromJson(session)) + .toList(); + }); + } + }); + } + } + }); + } + + String _formatTime(int timestamp) { + final date = DateTime.fromMillisecondsSinceEpoch(timestamp); + final now = DateTime.now(); + final difference = now.difference(date); + + String relativeTime; + if (difference.inDays > 0) { + relativeTime = '${difference.inDays} дн. назад'; + } else if (difference.inHours > 0) { + relativeTime = '${difference.inHours} ч. назад'; + } else if (difference.inMinutes > 0) { + relativeTime = '${difference.inMinutes} мин. назад'; + } else { + relativeTime = 'Только что'; + } + + + final exactTime = + '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + + return '$relativeTime ($exactTime)'; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + + if (_isInitialLoad && _sessions.isEmpty) { + _isInitialLoad = false; + _loadSessions(); + } + + return Scaffold( + appBar: AppBar( + title: const Text("Активные сессии"), + actions: [ + IconButton(onPressed: _loadSessions, icon: const Icon(Icons.refresh)), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _sessions.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.security, + size: 64, + color: colors.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + "Нет активных сессий", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 18, + ), + ), + ], + ), + ) + : Column( + children: [ + + if (_sessions.any((s) => !s.current)) + Container( + width: double.infinity, + margin: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: _terminateAllSessions, + style: FilledButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.logout, size: 24), + label: const Text( + "Завершить все сессии кроме текущей", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _sessions.length, + itemBuilder: (context, index) { + final session = _sessions[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: CircleAvatar( + backgroundColor: session.current + ? colors.primary + : colors.surfaceContainerHighest, + child: Icon( + session.current + ? Icons.phone_android + : Icons.computer, + color: session.current + ? colors.onPrimary + : colors.onSurfaceVariant, + ), + ), + title: Text( + session.current ? "Текущая сессия" : session.client, + style: TextStyle( + fontWeight: session.current + ? FontWeight.bold + : FontWeight.normal, + color: session.current + ? colors.primary + : colors.onSurface, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + session.location, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + Text( + session.info, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 2), + Text( + _formatTime(session.time), + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + trailing: session.current + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + "Активна", + style: TextStyle( + color: colors.onPrimary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ) + : Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + "Неактивна", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _apiSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/device_presets.dart b/lib/device_presets.dart new file mode 100644 index 0000000..5d88061 --- /dev/null +++ b/lib/device_presets.dart @@ -0,0 +1,678 @@ + +class DevicePreset { + final String deviceType; + final String userAgent; + final String deviceName; + final String osVersion; + final String screen; + final String timezone; + final String locale; + + DevicePreset({ + required this.deviceType, + required this.userAgent, + required this.deviceName, + required this.osVersion, + required this.screen, + required this.timezone, + required this.locale, + }); +} + + +final List devicePresets = [ + + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Ultra', + osVersion: 'Android 14', + screen: '1440x3120 2.8x', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 8 Pro', + osVersion: 'Android 14', + screen: '1344x2992 2.7x', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 23021RAA2Y) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 13 Pro', + osVersion: 'Android 13', + screen: '1440x3200 2.9x', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; CPH2521) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 12', + osVersion: 'Android 14', + screen: '1440x3168 2.8x', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S21 Ultra', + osVersion: 'Android 13', + screen: '1440x3200 2.9x', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 6', + osVersion: 'Android 12', + screen: '1080x2400 2.6x', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3371) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Master Edition', + osVersion: 'Android 13', + screen: '1080x2400 2.5x', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; M2101K6G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3', + osVersion: 'Android 11', + screen: '1080x2400 2.6x', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SO-51D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Sony Xperia 1 V', + osVersion: 'Android 14', + screen: '1644x3840 3.5x', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 30 Pro', + osVersion: 'Android 13', + screen: '1080x2400 2.5x', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A54', + osVersion: 'Android 14', + screen: '1080x2340 2.5x', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; 2201116SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Redmi Note 11 Pro', + osVersion: 'Android 12', + screen: '1080x2400 2.6x', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; ZS676KS) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Asus ROG Phone 6', + osVersion: 'Android 13', + screen: '1080x2448 2.6x', + timezone: 'Asia/Taipei', + locale: 'zh-TW', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; TA-1021) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36', + deviceName: 'Nokia 8', + osVersion: 'Android 10', + screen: '1440x2560 2.4x', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; PGT-N19) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Huawei P60 Pro', + osVersion: 'Android 13 (EMUI)', + screen: '1220x2700 2.7x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; LM-G710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Mobile Safari/537.36', + deviceName: 'LG G7 ThinQ', + osVersion: 'Android 9', + screen: '1440x3120 2.8x', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Nothing Phone (2)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Nothing Phone (2)', + osVersion: 'Android 14', + screen: '1080x2412 2.5x', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-F936U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Fold 4', + osVersion: 'Android 13', + screen: '1812x2176 2.2x', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; LE2113) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9', + osVersion: 'Android 12', + screen: '1080x2400 2.6x', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 7a', + osVersion: 'Android 14', + screen: '1080x2400 2.5x', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + + + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro Max', + osVersion: 'iOS 17.5.1', + screen: '1290x2796 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13', + osVersion: 'iOS 16.7', + screen: '1170x2532 3.0x', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 11-inch', + osVersion: 'iPadOS 17.5', + screen: '1668x2388 2.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.0 Mobile/15E148', + deviceName: 'iPhone 14 Pro', + osVersion: 'iOS 17.4.1', + screen: '1179x2556 3.0x', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone SE (2020)', + osVersion: 'iOS 15.8', + screen: '750x1334 2.0x', + timezone: 'Australia/Melbourne', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPR/55.0.2519.144889 Mobile/15E148', + deviceName: 'iPhone 15', + osVersion: 'iOS 17.1', + screen: '1179x2556 3.0x', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 5th Gen', + osVersion: 'iPadOS 16.5', + screen: '1640x2360 2.0x', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + + + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Windows PC', + osVersion: 'Windows 11', + screen: '1920x1080 1.25x', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'MacBook Pro', + osVersion: 'macOS 14.5 Sonoma', + screen: '1728x1117 2.0x', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Linux PC', + osVersion: 'Ubuntu 24.04 LTS', + screen: '2560x1440 1.0x', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Windows PC (Firefox)', + osVersion: 'Windows 10', + screen: '1536x864 1.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15', + deviceName: 'iMac (Safari)', + osVersion: 'macOS 13.6 Ventura', + screen: '3840x2160 1.5x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows ', + screen: '1920x1080', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '2560x1440', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.2420.97', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1536x864', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1920x1200', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1440x900', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1600x900', + timezone: 'Europe/Warsaw', + locale: 'pl-PL', + ), + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.109', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 14.5', + screen: '2560x1440', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 13.6', + screen: '1440x900', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_7_10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 11.7', + screen: '1728x1117', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 12.5', + screen: '2048x1152', + timezone: 'Europe/London', + locale: 'en-GB', + ), + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'macOS 14.5', + screen: '1920x1080', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'macOS 13.0', + screen: '1680x1050', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 14.5', + screen: '1440x900', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 13.6', + screen: '2560x1600', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.14', + screen: '1280x800', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Chrome OS', + screen: '1920x1080', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1600x900', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '2560x1440', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0', + deviceName: 'Opera', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Oslo', + locale: 'no-NO', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Vivaldi/6.5.3206.63', + deviceName: 'Vivaldi', + osVersion: 'macOS 14.0', + screen: '1440x900', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1280x720', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.13', + screen: '1280x800', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), +]; diff --git a/lib/downloads_screen.dart b/lib/downloads_screen.dart new file mode 100644 index 0000000..181cc60 --- /dev/null +++ b/lib/downloads_screen.dart @@ -0,0 +1,348 @@ +import 'dart:io' as io; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:intl/intl.dart'; +import 'package:open_file/open_file.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DownloadsScreen extends StatefulWidget { + const DownloadsScreen({super.key}); + + @override + State createState() => _DownloadsScreenState(); +} + +class _DownloadsScreenState extends State { + List _files = []; + bool _isLoading = true; + String? _downloadsPath; + + @override + void initState() { + super.initState(); + _loadDownloads(); + } + + Future _loadDownloads() async { + setState(() { + _isLoading = true; + }); + + try { + io.Directory? downloadDir; + + if (io.Platform.isAndroid) { + downloadDir = await getExternalStorageDirectory(); + } else if (io.Platform.isIOS) { + final directory = await getApplicationDocumentsDirectory(); + downloadDir = directory; + } else if (io.Platform.isWindows || io.Platform.isLinux) { + final homeDir = + io.Platform.environment['HOME'] ?? + io.Platform.environment['USERPROFILE'] ?? + ''; + downloadDir = io.Directory('$homeDir/Downloads'); + } else { + downloadDir = await getApplicationDocumentsDirectory(); + } + + if (downloadDir != null && await downloadDir.exists()) { + _downloadsPath = downloadDir.path; + + + final prefs = await SharedPreferences.getInstance(); + final List downloadedFilePaths = + prefs.getStringList('downloaded_files') ?? []; + + + final files = + downloadedFilePaths + .map((path) => io.File(path)) + .where((file) => file.existsSync()) + .toList() + ..sort((a, b) { + final aStat = a.statSync(); + final bStat = b.statSync(); + return bStat.modified.compareTo(aStat.modified); + }); + + + final existingPaths = files.map((f) => f.path).toSet(); + final cleanPaths = downloadedFilePaths + .where((path) => existingPaths.contains(path)) + .toList(); + await prefs.setStringList('downloaded_files', cleanPaths); + + setState(() { + _files = files; + _isLoading = false; + }); + } else { + setState(() { + _isLoading = false; + }); + } + } catch (e) { + setState(() { + _isLoading = false; + }); + } + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } else if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } else if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } else { + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + } + + IconData _getFileIcon(String fileName) { + final extension = fileName.split('.').last.toLowerCase(); + switch (extension) { + case 'pdf': + return Icons.picture_as_pdf; + case 'doc': + case 'docx': + return Icons.description; + case 'xls': + case 'xlsx': + return Icons.table_chart; + case 'txt': + return Icons.text_snippet; + case 'zip': + case 'rar': + case '7z': + return Icons.archive; + case 'mp3': + case 'wav': + case 'flac': + return Icons.audiotrack; + case 'mp4': + case 'avi': + case 'mov': + return Icons.video_file; + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + return Icons.image; + default: + return Icons.insert_drive_file; + } + } + + Future _deleteFile(io.File file) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Удалить файл?'), + content: Text( + 'Вы уверены, что хотите удалить ${file.path.split('/').last}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Удалить'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + + final prefs = await SharedPreferences.getInstance(); + final List downloadedFilePaths = + prefs.getStringList('downloaded_files') ?? []; + downloadedFilePaths.remove(file.path); + await prefs.setStringList('downloaded_files', downloadedFilePaths); + + + await file.delete(); + + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Файл удален'))); + _loadDownloads(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при удалении файла: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('Загрузки'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadDownloads, + tooltip: 'Обновить', + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _files.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.download_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Нет скачанных файлов', + style: TextStyle(fontSize: 16, color: Colors.grey[600]), + ), + if (_downloadsPath != null) ...[ + const SizedBox(height: 8), + Text( + 'Файлы сохраняются в:\n$_downloadsPath', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + ], + ], + ), + ) + : Column( + children: [ + if (_downloadsPath != null) + Container( + padding: const EdgeInsets.all(12), + color: isDark ? Colors.grey[850] : Colors.grey[200], + child: Row( + children: [ + const Icon(Icons.folder, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + _downloadsPath!, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: _files.length, + itemBuilder: (context, index) { + final file = _files[index]; + if (file is! io.File) return const SizedBox.shrink(); + + final fileName = file.path + .split(io.Platform.pathSeparator) + .last; + final fileStat = file.statSync(); + final fileSize = fileStat.size; + final modifiedDate = fileStat.modified; + + return ListTile( + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: theme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _getFileIcon(fileName), + color: theme.primaryColor, + ), + ), + title: Text( + fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_formatFileSize(fileSize)), + const SizedBox(height: 4), + Text( + DateFormat( + 'dd.MM.yyyy HH:mm', + ).format(modifiedDate), + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + ], + ), + trailing: IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.open_in_new), + title: const Text('Открыть'), + onTap: () async { + Navigator.pop(context); + await OpenFile.open(file.path); + }, + ), + ListTile( + leading: const Icon( + Icons.delete, + color: Colors.red, + ), + title: const Text( + 'Удалить', + style: TextStyle(color: Colors.red), + ), + onTap: () { + Navigator.pop(context); + _deleteFile(file); + }, + ), + ], + ), + ), + ); + }, + ), + onTap: () async { + await OpenFile.open(file.path); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/example_usage.dart b/lib/example_usage.dart new file mode 100644 index 0000000..0456d3a --- /dev/null +++ b/lib/example_usage.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; +import 'api_service_simple.dart'; +import 'connection/connection_state.dart' as conn_state; + + +class ConnectionExample extends StatefulWidget { + const ConnectionExample({super.key}); + + @override + State createState() => _ConnectionExampleState(); +} + +class _ConnectionExampleState extends State { + final ApiServiceSimple _apiService = ApiServiceSimple.instance; + conn_state.ConnectionInfo? _currentState; + String _logs = ''; + + @override + void initState() { + super.initState(); + _initializeService(); + _setupListeners(); + } + + Future _initializeService() async { + try { + await _apiService.initialize(); + _addLog('✅ Сервис инициализирован'); + } catch (e) { + _addLog('❌ Ошибка инициализации: $e'); + } + } + + void _setupListeners() { + + _apiService.connectionState.listen((state) { + setState(() { + _currentState = state; + }); + _addLog('🔄 Состояние: ${_getStateText(state.state)}'); + }); + + + _apiService.logs.listen((log) { + _addLog('📝 ${log.toString()}'); + }); + + + _apiService.healthMetrics.listen((health) { + _addLog( + '🏥 Здоровье: ${health.healthScore}/100 (${health.quality.name})', + ); + }); + } + + void _addLog(String message) { + setState(() { + _logs += + '${DateTime.now().toIso8601String().substring(11, 23)} $message\n'; + }); + } + + String _getStateText(conn_state.ConnectionState state) { + switch (state) { + case conn_state.ConnectionState.disconnected: + return 'Отключен'; + case conn_state.ConnectionState.connecting: + return 'Подключение...'; + case conn_state.ConnectionState.connected: + return 'Подключен'; + case conn_state.ConnectionState.ready: + return 'Готов'; + case conn_state.ConnectionState.reconnecting: + return 'Переподключение...'; + case conn_state.ConnectionState.error: + return 'Ошибка'; + case conn_state.ConnectionState.disabled: + return 'Отключен'; + } + } + + Color _getStateColor(conn_state.ConnectionState state) { + switch (state) { + case conn_state.ConnectionState.ready: + return Colors.green; + case conn_state.ConnectionState.connected: + return Colors.blue; + case conn_state.ConnectionState.connecting: + case conn_state.ConnectionState.reconnecting: + return Colors.orange; + case conn_state.ConnectionState.error: + return Colors.red; + case conn_state.ConnectionState.disconnected: + case conn_state.ConnectionState.disabled: + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Пример подключения'), + backgroundColor: _currentState != null + ? _getStateColor(_currentState!.state) + : Colors.grey, + ), + body: Column( + children: [ + + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + color: _currentState != null + ? _getStateColor(_currentState!.state).withOpacity(0.1) + : Colors.grey.withOpacity(0.1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Статус: ${_currentState != null ? _getStateText(_currentState!.state) : 'Неизвестно'}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + if (_currentState?.message != null) ...[ + const SizedBox(height: 4), + Text('Сообщение: ${_currentState!.message}'), + ], + if (_currentState?.serverUrl != null) ...[ + const SizedBox(height: 4), + Text('Сервер: ${_currentState!.serverUrl}'), + ], + if (_currentState?.latency != null) ...[ + const SizedBox(height: 4), + Text('Задержка: ${_currentState!.latency}ms'), + ], + ], + ), + ), + + + Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton( + onPressed: _connect, + child: const Text('Подключиться'), + ), + ElevatedButton( + onPressed: _disconnect, + child: const Text('Отключиться'), + ), + ElevatedButton( + onPressed: _reconnect, + child: const Text('Переподключиться'), + ), + ElevatedButton( + onPressed: _clearLogs, + child: const Text('Очистить логи'), + ), + ElevatedButton( + onPressed: _showStats, + child: const Text('Статистика'), + ), + ], + ), + ), + + + Expanded( + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: SingleChildScrollView( + child: Text( + _logs.isEmpty ? 'Логи появятся здесь...' : _logs, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + ), + ), + ], + ), + ); + } + + Future _connect() async { + try { + _addLog('🔄 Попытка подключения...'); + await _apiService.connect(); + _addLog('✅ Подключение успешно'); + } catch (e) { + _addLog('❌ Ошибка подключения: $e'); + } + } + + Future _disconnect() async { + try { + _addLog('🔄 Отключение...'); + await _apiService.disconnect(); + _addLog('✅ Отключение успешно'); + } catch (e) { + _addLog('❌ Ошибка отключения: $e'); + } + } + + Future _reconnect() async { + try { + _addLog('🔄 Переподключение...'); + await _apiService.reconnect(); + _addLog('✅ Переподключение успешно'); + } catch (e) { + _addLog('❌ Ошибка переподключения: $e'); + } + } + + void _clearLogs() { + setState(() { + _logs = ''; + }); + _addLog('🧹 Логи очищены'); + } + + void _showStats() { + final stats = _apiService.getStatistics(); + _addLog('📊 Статистика: ${stats.toString()}'); + + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Статистика'), + content: SingleChildScrollView( + child: Text( + stats.toString(), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Закрыть'), + ), + ], + ), + ); + } + + @override + void dispose() { + _apiService.dispose(); + super.dispose(); + } +} diff --git a/lib/full_screen_video_player.dart b/lib/full_screen_video_player.dart new file mode 100644 index 0000000..b5ab958 --- /dev/null +++ b/lib/full_screen_video_player.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; +import 'package:chewie/chewie.dart'; + +class FullScreenVideoPlayer extends StatefulWidget { + final String videoUrl; + + const FullScreenVideoPlayer({Key? key, required this.videoUrl}) + : super(key: key); + + @override + State createState() => _FullScreenVideoPlayerState(); +} + +class _FullScreenVideoPlayerState extends State { + VideoPlayerController? _videoPlayerController; + ChewieController? _chewieController; + bool _isLoading = true; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _initializePlayer(); + } + + Future _initializePlayer() async { + try { + _videoPlayerController = VideoPlayerController.networkUrl( + Uri.parse(widget.videoUrl), + httpHeaders: const { + + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + ); + + await _videoPlayerController!.initialize(); + + _chewieController = ChewieController( + videoPlayerController: _videoPlayerController!, + aspectRatio: _videoPlayerController!.value.aspectRatio, + autoPlay: true, // Начинаем воспроизведение сразу + looping: false, // Не зацикливаем + showControls: true, // Показываем стандартные элементы управления Chewie + materialProgressColors: ChewieProgressColors( + playedColor: Colors.red, + handleColor: Colors.blueAccent, + backgroundColor: Colors.grey, + bufferedColor: Colors.white, + ), + + ); + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } catch (e) { + print('❌ [FullScreenVideoPlayer] Error initializing Chewie player: $e'); + if (mounted) { + setState(() { + _hasError = true; + _isLoading = false; + }); + } + } + } + + @override + void dispose() { + _videoPlayerController?.dispose(); + _chewieController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, // Черный фон для полноэкранного видео + appBar: AppBar( + backgroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.white), + title: const Text('Видео', style: TextStyle(color: Colors.white)), + ), + body: Center( + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : _hasError + ? const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, color: Colors.red, size: 50), + SizedBox(height: 10), + Text( + 'Не удалось загрузить видео.', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + Text( + 'Проверьте интернет или попробуйте позже.', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ) + : _chewieController != null && + _chewieController!.videoPlayerController.value.isInitialized + ? Chewie(controller: _chewieController!) + : const Text( + 'Ошибка плеера', + style: TextStyle(color: Colors.white), + ), + ), + ); + } +} diff --git a/lib/home_screen.dart b/lib/home_screen.dart new file mode 100644 index 0000000..534077b --- /dev/null +++ b/lib/home_screen.dart @@ -0,0 +1,849 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/chats_screen.dart'; +import 'package:gwid/phone_entry_screen.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/screens/settings/reconnection_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gwid/services/version_checker.dart'; +import 'package:app_links/app_links.dart'; +import 'package:gwid/models/chat.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:gwid/chat_screen.dart'; +import 'package:provider/provider.dart'; +import 'package:gwid/theme_provider.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late Future> _chatsFuture; + Profile? _myProfile; + bool _isProfileLoading = true; + String? _connectionStatus; + StreamSubscription? _connectionSubscription; + StreamSubscription? _messageSubscription; + + late final AppLinks _appLinks; + StreamSubscription? _linkSubscription; + + Uri? _initialUri; + + @override + void initState() { + super.initState(); + + _loadMyProfile(); + _chatsFuture = (() async { + try { + await ApiService.instance.waitUntilOnline(); + return ApiService.instance.getChatsAndContacts(); + } catch (e) { + print('Ошибка получения чатов в HomeScreen: $e'); + if (e.toString().contains('Auth token not found') || + e.toString().contains('FAIL_WRONG_PASSWORD')) {} + rethrow; + } + })(); + + _checkVersionInBackground(); + _initDeepLinking(); + + + _connectionSubscription = ApiService.instance.connectionStatus.listen(( + status, + ) { + if (mounted) { + setState(() => _connectionStatus = status); + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + setState(() => _connectionStatus = null); + } + }); + } + }); + + + _messageSubscription = ApiService.instance.messages.listen((message) { + if (message['type'] == 'session_terminated' && mounted) { + _handleSessionTerminated(message['message']); + } else if (message['type'] == 'invalid_token' && mounted) { + _handleInvalidToken(message['message']); + } else if (message['type'] == 'group_join_success' && mounted) { + _handleGroupJoinSuccess(message); + } else if (message['cmd'] == 3 && message['opcode'] == 57 && mounted) { + _handleGroupJoinError(message); + } + }); + } + + Future _loadMyProfile() async { + if (!mounted) return; + setState(() => _isProfileLoading = true); + try { + + final cachedProfile = ApiService.instance.lastChatsPayload?['profile']; + if (cachedProfile != null) { + if (mounted) { + setState(() { + _myProfile = Profile.fromJson(cachedProfile); + _isProfileLoading = false; + }); + } + } else { + + final result = await ApiService.instance.getChatsAndContacts( + force: false, + ); + if (mounted) { + final profileJson = result['profile']; + if (profileJson != null) { + setState(() { + _myProfile = Profile.fromJson(profileJson); + _isProfileLoading = false; + }); + } else { + setState(() => _isProfileLoading = false); + } + } + } + } catch (e) { + if (mounted) setState(() => _isProfileLoading = false); + print("Ошибка загрузки профиля в _HomeScreenState: $e"); + } + } + + Future _showUpdateDialog( + BuildContext context, + String newVersion, + ) async { + await showDialog( + context: context, + barrierDismissible: false, // Пользователь должен сделать выбор + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Доступно обновление'), + content: Text( + 'Найдена новая версия приложения: $newVersion. Рекомендуется обновить данные сессии, чтобы соответствовать последней версии.', + ), + actions: [ + TextButton( + child: const Text('Отменить'), + onPressed: () { + Navigator.of(dialogContext).pop(); // Просто закрыть диалог + }, + ), + FilledButton( + child: const Text('Обновить'), + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('spoof_appversion', newVersion); + + try { + await ApiService.instance.performFullReconnection(); + print("Переподключение выполнено успешно"); + } catch (e) { + print("Ошибка переподключения: $e"); + } + + + if (mounted) { + Navigator.of(dialogContext).pop(); + } + + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Версия сессии обновлена до $newVersion!'), + backgroundColor: Colors.green.shade700, + ), + ); + } + }, + ), + ], + ); + }, + ); + } + + Future _checkVersionInBackground() async { + try { + final prefs = await SharedPreferences.getInstance(); + + + final isWebVersionCheckEnabled = + prefs.getBool('enable_web_version_check') ?? false; + + + if (!isWebVersionCheckEnabled) { + print("Web version checking is disabled, skipping check"); + return; + } + + final isAutoUpdateEnabled = prefs.getBool('auto_update_enabled') ?? true; + final showUpdateNotification = + prefs.getBool('show_update_notification') ?? true; + + final currentVersion = prefs.getString('spoof_appversion') ?? '0.0.0'; + final latestVersion = await VersionChecker.getLatestVersion(); + + if (latestVersion != currentVersion) { + if (isAutoUpdateEnabled) { + + await prefs.setString('spoof_appversion', latestVersion); + print("Версия сессии автоматически обновлена до $latestVersion"); + + try { + await ApiService.instance.performFullReconnection(); + print("Переподключение выполнено успешно"); + } catch (e) { + print("Ошибка переподключения: $e"); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Спуф сессии автоматически обновлен до версии $latestVersion', + ), + backgroundColor: Colors.green.shade700, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.all(10), + ), + ); + } + } else if (showUpdateNotification) { + + if (mounted) { + _showUpdateDialog(context, latestVersion); + } + } + } + } catch (e) { + print("Фоновая проверка версии не удалась: $e"); + } + } + + Future _initDeepLinking() async { + _appLinks = AppLinks(); + + Uri? initialUriFromLaunch; + + try { + initialUriFromLaunch = await _appLinks.getInitialLink(); + if (initialUriFromLaunch != null) { + print('Получена ссылка (initial): $initialUriFromLaunch'); + if (mounted) { + _handleJoinLink(initialUriFromLaunch); + } + } + } catch (e) { + print('Ошибка получения initial link: $e'); + } + + _linkSubscription = _appLinks.uriLinkStream.listen((uri) { + print('Получена ссылка (stream): $uri'); + + if (uri == initialUriFromLaunch) { + print('Ссылка из stream совпадает с initial, игнорируем.'); + + initialUriFromLaunch = null; + return; + } + + if (mounted) { + _handleJoinLink(uri); + } + }); + } + + void _handleJoinLink(Uri uri) { + if (uri.host == 'max.ru' && uri.path.startsWith('/join/')) { + final String fullLink = uri.toString(); + final String processedLink = _extractJoinLink(fullLink); + + if (!processedLink.contains('join/')) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Неверный формат ссылки. Ссылка должна содержать "join/"', + ), + backgroundColor: Colors.orange, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(10), + ), + ); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Загрузка информации о группе...'), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(10), + duration: Duration(seconds: 10), + ), + ); + + ApiService.instance.waitUntilOnline().then((_) { + ApiService.instance + .getChatInfoByLink(processedLink) + .then((chatInfo) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + if (mounted) { + _showJoinConfirmationDialog(chatInfo, processedLink); + } + }) + .catchError((error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка: ${error.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + }); + }); + } + } + + void _showJoinConfirmationDialog( + Map chatInfo, + String linkToJoin, + ) { + final String title = chatInfo['title'] ?? 'Без названия'; + final String? iconUrl = chatInfo['baseIconUrl']; + + int joinState = 0; + String? errorMessage; + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + Widget content; + List actions = []; + + if (joinState == 1) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text( + 'Присоединение...', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ], + ); + actions = []; + } else if (joinState == 2) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + const Icon( + Icons.check_circle_outline, + color: Colors.green, + size: 60, + ), + const SizedBox(height: 24), + Text( + 'Вы вступили в группу!', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ], + ); + actions = [ + FilledButton( + child: const Text('Отлично'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ]; + } else if (joinState == 3) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: 60, + ), + const SizedBox(height: 24), + Text( + 'Ошибка', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + errorMessage ?? 'Не удалось вступить в группу.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 32), + ], + ); + actions = [ + TextButton( + child: const Text('Закрыть'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ]; + } else { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconUrl != null && iconUrl.isNotEmpty) + CircleAvatar( + radius: 60, + backgroundImage: NetworkImage(iconUrl), + onBackgroundImageError: (e, s) { + print("Ошибка загрузки аватара: $e"); + }, + backgroundColor: Colors.grey.shade300, + ) + else + CircleAvatar( + radius: 60, + backgroundColor: Colors.grey.shade300, + child: const Icon( + Icons.group, + size: 60, + color: Colors.white, + ), + ), + const SizedBox(height: 24), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Вы действительно хотите вступить в эту группу?', + textAlign: TextAlign.center, + ), + ], + ); + actions = [ + TextButton( + child: const Text('Отмена'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + FilledButton( + child: const Text('Вступить'), + onPressed: () async { + setState(() { + joinState = 1; + }); + + try { + await ApiService.instance.joinGroupByLink(linkToJoin); + + setState(() { + joinState = 2; + }); + + ApiService.instance.clearChatsCache(); + + await Future.delayed(const Duration(seconds: 2)); + if (mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + setState(() { + joinState = 3; + errorMessage = e.toString().replaceFirst( + "Exception: ", + "", + ); + }); + } + }, + ), + ]; + } + + return AlertDialog( + title: joinState == 0 ? const Text('Вступить в группу?') : null, + content: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: + (Widget child, Animation animation) { + final slideAnimation = + Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutQuart, + ), + ); + + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: slideAnimation, + child: child, + ), + ); + }, + child: Container( + key: ValueKey(joinState), + child: content, + ), + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + actionsAlignment: MainAxisAlignment.center, + actions: actions, + ); + }, + ); + }, + ); + } + + String _extractJoinLink(String inputLink) { + final link = inputLink.trim(); + + if (link.startsWith('join/')) { + print('Ссылка уже в правильном формате: $link'); + return link; + } + + final joinIndex = link.indexOf('join/'); + if (joinIndex != -1) { + final extractedLink = link.substring(joinIndex); + print('Извлечена ссылка из полной ссылки: $link -> $extractedLink'); + return extractedLink; + } + + print('Не найдено "join/" в ссылке: $link'); + return link; + } + + void _handleGroupJoinSuccess(Map message) { + final payload = message['payload']; + final chat = payload['chat']; + final chatTitle = chat?['title'] ?? 'Группа'; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Успешно присоединились к группе "$chatTitle"!'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + + void _handleGroupJoinError(Map message) { + final errorPayload = message['payload']; + String errorMessage = 'Неизвестная ошибка'; + if (errorPayload != null) { + if (errorPayload['localizedMessage'] != null) { + errorMessage = errorPayload['localizedMessage']; + } else if (errorPayload['message'] != null) { + errorMessage = errorPayload['message']; + } + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + + Future _checkAndConnect() async { + final hasToken = await ApiService.instance.hasToken(); + if (hasToken) { + print("В HomeScreen: токен найден, проверяем подключение..."); + try { + await ApiService.instance.connect(); + print("В HomeScreen: подключение к WebSocket успешно"); + } catch (e) { + print("В HomeScreen: ошибка подключения к WebSocket: $e"); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка подключения к серверу: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + ), + ); + } + } + } else { + print("В HomeScreen: токен не найден, пользователь не авторизован"); + } + } + + void _handleSessionTerminated(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 3), + ), + ); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + const PhoneEntryScreen(), + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + transitionDuration: const Duration(milliseconds: 500), + ), + (route) => false, + ); + } + }); + } + + void _showReconnectionScreen() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ReconnectionScreen(), + fullscreenDialog: true, + ), + ); + } + + void _handleInvalidToken(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const PhoneEntryScreen()), + (route) => false, + ); + } + + @override + void dispose() { + _connectionSubscription?.cancel(); + _messageSubscription?.cancel(); + _linkSubscription?.cancel(); + super.dispose(); + } + + static const double kDesktopLayoutBreakpoint = 700.0; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, themeProvider, child) { + return LayoutBuilder( + builder: (context, constraints) { + final shouldUseDesktopLayout = + themeProvider.useDesktopLayout && + constraints.maxWidth >= kDesktopLayoutBreakpoint; + + if (shouldUseDesktopLayout) { + return const _DesktopLayout(); + } else { + return const ChatsScreen(); + } + }, + ); + }, + ); + } +} + +class _DesktopLayout extends StatefulWidget { + const _DesktopLayout({super.key}); + + @override + State<_DesktopLayout> createState() => _DesktopLayoutState(); +} + +class _DesktopLayoutState extends State<_DesktopLayout> { + Chat? _selectedChat; + Contact? _selectedContact; + bool _isGroupChat = false; + bool _isChannel = false; + int? _participantCount; + Profile? _myProfile; + bool _isProfileLoading = true; + + final ValueNotifier _leftPanelWidth = ValueNotifier(320.0); + static const double _minPanelWidth = 280.0; + static const double _maxPanelWidth = 500.0; + + @override + void initState() { + super.initState(); + _loadMyProfile(); + } + + + Future _loadMyProfile() async { + if (!mounted) return; + setState(() => _isProfileLoading = true); + try { + final result = await ApiService.instance.getChatsAndContacts( + force: false, + ); + if (mounted) { + final profileJson = result['profile']; + if (profileJson != null) { + setState(() { + _myProfile = Profile.fromJson(profileJson); + _isProfileLoading = false; + }); + } + } + } catch (e) { + if (mounted) setState(() => _isProfileLoading = false); + print("Ошибка загрузки профиля в _DesktopLayout: $e"); + } + } + + + void _onChatSelected( + Chat chat, + Contact contact, + bool isGroup, + bool isChannel, + int? participantCount, + ) { + setState(() { + _selectedChat = chat; + _selectedContact = contact; + _isGroupChat = isGroup; + _isChannel = isChannel; + _participantCount = participantCount; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + body: Row( + children: [ + ValueListenableBuilder( + valueListenable: _leftPanelWidth, + builder: (context, width, child) { + return SizedBox( + width: width, + child: ChatsScreen(onChatSelected: _onChatSelected), + ); + }, + ), + + GestureDetector( + onPanUpdate: (details) { + final newWidth = _leftPanelWidth.value + details.delta.dx; + if (newWidth >= _minPanelWidth && newWidth <= _maxPanelWidth) { + _leftPanelWidth.value = newWidth; + } + }, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: Container( + width: 4.0, + color: colors.outline.withOpacity(0.3), + ), + ), + ), + + Expanded( + child: + (_selectedChat == null || + _selectedContact == null || + _isProfileLoading) + ? Center( + child: _isProfileLoading + ? const CircularProgressIndicator() + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.message, + size: 80, + color: colors.primary.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + 'Выберите чат, чтобы начать общение', + style: TextStyle( + fontSize: 16, + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ) + : ChatScreen( + key: ValueKey(_selectedChat!.id), + chatId: _selectedChat!.id, + contact: _selectedContact!, + myId: _myProfile?.id ?? 0, + isGroupChat: _isGroupChat, + isChannel: _isChannel, + participantCount: _participantCount, + isDesktopMode: true, + onChatUpdated: () {}, + ), + ), + ], + ), + ); + } +} diff --git a/lib/image_cache_service.dart b/lib/image_cache_service.dart new file mode 100644 index 0000000..6a92730 --- /dev/null +++ b/lib/image_cache_service.dart @@ -0,0 +1,266 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:http/http.dart' as http; +import 'package:cached_network_image/cached_network_image.dart'; + + +class ImageCacheService { + ImageCacheService._privateConstructor(); + static final ImageCacheService instance = + ImageCacheService._privateConstructor(); + + static const String _cacheDirectoryName = 'image_cache'; + static const Duration _cacheExpiration = Duration( + days: 7, + ); // Кеш изображений на 7 дней + late Directory _cacheDirectory; + + + Future initialize() async { + final appDir = await getApplicationDocumentsDirectory(); + _cacheDirectory = Directory(path.join(appDir.path, _cacheDirectoryName)); + + if (!_cacheDirectory.existsSync()) { + await _cacheDirectory.create(recursive: true); + } + + + await _cleanupExpiredCache(); + } + + + String getCachedImagePath(String url) { + final fileName = _generateFileName(url); + return path.join(_cacheDirectory.path, fileName); + } + + + bool isImageCached(String url) { + final file = File(getCachedImagePath(url)); + return file.existsSync(); + } + + + Future loadImage(String url, {bool forceRefresh = false}) async { + if (!forceRefresh && isImageCached(url)) { + final cachedFile = File(getCachedImagePath(url)); + if (await _isFileValid(cachedFile)) { + return cachedFile; + } else { + + await cachedFile.delete(); + } + } + + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final file = File(getCachedImagePath(url)); + await file.writeAsBytes(response.bodyBytes); + + + await _updateFileAccessTime(file); + + return file; + } + } catch (e) { + print('Ошибка загрузки изображения $url: $e'); + } + + return null; + } + + + Future loadImageAsBytes( + String url, { + bool forceRefresh = false, + }) async { + final file = await loadImage(url, forceRefresh: forceRefresh); + if (file != null) { + return await file.readAsBytes(); + } + return null; + } + + + Future preloadImage(String url) async { + await loadImage(url); + } + + + Future preloadContactAvatar(String? photoUrl) async { + if (photoUrl != null && photoUrl.isNotEmpty) { + await preloadImage(photoUrl); + } + } + + + Future preloadProfileAvatar(String? photoUrl) async { + if (photoUrl != null && photoUrl.isNotEmpty) { + await preloadImage(photoUrl); + } + } + + + Future preloadContactAvatars(List photoUrls) async { + final futures = photoUrls + .where((url) => url != null && url.isNotEmpty) + .map((url) => preloadImage(url!)) + .toList(); + + if (futures.isNotEmpty) { + await Future.wait(futures); + } + } + + + Future clearCache() async { + if (_cacheDirectory.existsSync()) { + await _cacheDirectory.delete(recursive: true); + await _cacheDirectory.create(recursive: true); + } + } + + + Future getCacheSize() async { + int totalSize = 0; + if (_cacheDirectory.existsSync()) { + await for (final entity in _cacheDirectory.list(recursive: true)) { + if (entity is File) { + totalSize += await entity.length(); + } + } + } + return totalSize; + } + + + Future getCacheFileCount() async { + int count = 0; + if (_cacheDirectory.existsSync()) { + await for (final entity in _cacheDirectory.list(recursive: true)) { + if (entity is File) { + count++; + } + } + } + return count; + } + + + Future _cleanupExpiredCache() async { + if (!_cacheDirectory.existsSync()) return; + + await for (final entity in _cacheDirectory.list(recursive: true)) { + if (entity is File && await _isFileExpired(entity)) { + await entity.delete(); + } + } + } + + + Future _isFileValid(File file) async { + if (!file.existsSync()) return false; + + + final stat = await file.stat(); + final age = DateTime.now().difference(stat.modified); + + return age < _cacheExpiration; + } + + + Future _isFileExpired(File file) async { + if (!file.existsSync()) return false; + + final stat = await file.stat(); + final age = DateTime.now().difference(stat.modified); + + return age >= _cacheExpiration; + } + + + Future _updateFileAccessTime(File file) async { + + try { + await file.setLastModified(DateTime.now()); + } catch (e) { + + } + } + + + String _generateFileName(String url) { + + final hash = url.hashCode.abs().toString(); + final extension = path.extension(url).isNotEmpty + ? path.extension(url) + : '.jpg'; // По умолчанию jpg + + return '$hash$extension'; + } + + + Future> getCacheStats() async { + final size = await getCacheSize(); + final fileCount = await getCacheFileCount(); + + return { + 'cache_size_bytes': size, + 'cache_size_mb': (size / (1024 * 1024)).toStringAsFixed(2), + 'file_count': fileCount, + 'cache_directory': _cacheDirectory.path, + }; + } +} + + +extension CachedImageExtension on String { + + Widget getCachedNetworkImage({ + Key? key, + double? width, + double? height, + BoxFit? fit, + Widget? placeholder, + Widget? errorWidget, + Duration? fadeInDuration, + bool useMemoryCache = true, + }) { + return CachedNetworkImage( + key: key, + imageUrl: this, + width: width, + height: height, + fit: fit, + placeholder: (context, url) => + placeholder ?? + Container( + width: width, + height: height, + color: Colors.grey[300], + child: const Icon(Icons.image, color: Colors.grey), + ), + errorWidget: (context, url, error) => + errorWidget ?? + Container( + width: width, + height: height, + color: Colors.grey[300], + child: const Icon(Icons.broken_image, color: Colors.grey), + ), + fadeInDuration: fadeInDuration ?? const Duration(milliseconds: 300), + useOldImageOnUrlChange: true, + memCacheWidth: useMemoryCache ? (width ?? 200).toInt() : null, + memCacheHeight: useMemoryCache ? (height ?? 200).toInt() : null, + ); + } + + + Future preloadImage() async { + await ImageCacheService.instance.loadImage(this); + } +} diff --git a/lib/join_group_screen.dart b/lib/join_group_screen.dart new file mode 100644 index 0000000..a58ff15 --- /dev/null +++ b/lib/join_group_screen.dart @@ -0,0 +1,341 @@ + + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; + +class JoinGroupScreen extends StatefulWidget { + const JoinGroupScreen({super.key}); + + @override + State createState() => _JoinGroupScreenState(); +} + +class _JoinGroupScreenState extends State { + final TextEditingController _linkController = TextEditingController(); + StreamSubscription? _apiSubscription; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _listenToApiMessages(); + } + + @override + void dispose() { + _linkController.dispose(); + _apiSubscription?.cancel(); + super.dispose(); + } + + void _listenToApiMessages() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + + if (message['type'] == 'group_join_success') { + setState(() { + _isLoading = false; + }); + + final payload = message['payload']; + final chat = payload['chat']; + final chatTitle = chat?['title'] ?? 'Группа'; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Успешно присоединились к группе "$chatTitle"!'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + + + Navigator.of(context).pop(); + } + + + if (message['cmd'] == 3 && message['opcode'] == 57) { + setState(() { + _isLoading = false; + }); + + final errorPayload = message['payload']; + String errorMessage = 'Неизвестная ошибка'; + if (errorPayload != null) { + if (errorPayload['localizedMessage'] != null) { + errorMessage = errorPayload['localizedMessage']; + } else if (errorPayload['message'] != null) { + errorMessage = errorPayload['message']; + } + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + }); + } + + + String _extractJoinLink(String inputLink) { + final link = inputLink.trim(); + + + if (link.startsWith('join/')) { + print('Ссылка уже в правильном формате: $link'); + return link; + } + + + final joinIndex = link.indexOf('join/'); + if (joinIndex != -1) { + final extractedLink = link.substring(joinIndex); + print('Извлечена ссылка из полной ссылки: $link -> $extractedLink'); + return extractedLink; + } + + + print('Не найдено "join/" в ссылке: $link'); + return link; + } + + void _joinGroup() async { + final inputLink = _linkController.text.trim(); + + if (inputLink.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Введите ссылку на группу'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + + final processedLink = _extractJoinLink(inputLink); + + + if (!processedLink.contains('join/')) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Неверный формат ссылки. Ссылка должна содержать "join/"', + ), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await ApiService.instance.joinGroupByLink(processedLink); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка присоединения к группе: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Присоединиться к группе'), + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + ), + body: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.group_add, color: colors.primary), + const SizedBox(width: 8), + Text( + 'Присоединение к группе', + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Введите ссылку на группу, чтобы присоединиться к ней. ' + 'Можно вводить как полную ссылку (https://max.ru/join/...), ' + 'так и короткую (join/...).', + style: TextStyle(color: colors.onSurfaceVariant), + ), + ], + ), + ), + + const SizedBox(height: 24), + + + Text( + 'Ссылка на группу', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + TextField( + controller: _linkController, + decoration: InputDecoration( + labelText: 'Ссылка на группу', + hintText: 'https://max.ru/join/ABC123DEF456GHI789JKL', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.link), + ), + maxLines: 3, + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outline.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colors.primary, + ), + const SizedBox(width: 8), + Text( + 'Формат ссылки:', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.primary, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '• Ссылка должна содержать "join/"\n' + '• После "join/" должен идти уникальный идентификатор группы\n' + '• Примеры:\n' + ' - https://max.ru/join/ABC123DEF456GHI789JKL\n' + ' - join/ABC123DEF456GHI789JKL', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 13, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _joinGroup, + icon: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colors.onPrimary, + ), + ), + ) + : const Icon(Icons.group_add), + label: Text( + _isLoading + ? 'Присоединение...' + : 'Присоединиться к группе', + ), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..3b17d98 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,243 @@ + + +import 'package:flutter/material.dart'; +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'home_screen.dart'; +import 'phone_entry_screen.dart'; +import 'theme_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'api_service.dart'; +import 'connection_lifecycle_manager.dart'; +import 'services/cache_service.dart'; +import 'services/avatar_cache_service.dart'; +import 'services/chat_cache_service.dart'; +import 'services/version_checker.dart'; + +final GlobalKey navigatorKey = GlobalKey(); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await initializeDateFormatting(); + + + print("Инициализируем сервисы кеширования..."); + await CacheService().initialize(); + await AvatarCacheService().initialize(); + await ChatCacheService().initialize(); + print("Сервисы кеширования инициализированы"); + + + final hasToken = await ApiService.instance.hasToken(); + print("При запуске приложения токен ${hasToken ? 'найден' : 'не найден'}"); + + + if (hasToken) { + print("Инициируем подключение к WebSocket при запуске..."); + ApiService.instance.connect(); + } + + runApp( + ChangeNotifierProvider( + create: (context) => ThemeProvider(), + child: ConnectionLifecycleManager(child: MyApp(hasToken: hasToken)), + ), + ); +} + +class MyApp extends StatelessWidget { + final bool hasToken; + + const MyApp({super.key, required this.hasToken}); + + @override + Widget build(BuildContext context) { + final themeProvider = context.watch(); + + return DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { + + final Color accentColor = + (themeProvider.appTheme == AppTheme.system && lightDynamic != null) + ? lightDynamic.primary + : themeProvider.accentColor; + final ThemeData baseLightTheme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: accentColor, + brightness: Brightness.light, + dynamicSchemeVariant: DynamicSchemeVariant.tonalSpot, + ), + useMaterial3: true, + appBarTheme: AppBarTheme( + titleTextStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: ColorScheme.fromSeed( + seedColor: accentColor, + brightness: Brightness.light, + ).onSurface, // ← Используем цвет onSurface из цветовой схемы + ), + ), + ); + + final ThemeData baseDarkTheme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: accentColor, + brightness: Brightness.dark, + dynamicSchemeVariant: DynamicSchemeVariant.tonalSpot, + ), + useMaterial3: true, + appBarTheme: AppBarTheme( + titleTextStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: ColorScheme.fromSeed( + seedColor: accentColor, + brightness: Brightness.dark, + ).onSurface, // ← Используем цвет onSurface из цветовой схемы + ), + ), + ); + final ThemeData oledTheme = baseDarkTheme.copyWith( + scaffoldBackgroundColor: Colors.black, + colorScheme: baseDarkTheme.colorScheme.copyWith( + surface: Colors.black, + surfaceContainerLowest: Colors.black, + surfaceContainerLow: Colors.black, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: Colors.black, + indicatorColor: accentColor.withOpacity(0.4), + labelTextStyle: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return TextStyle( + color: accentColor, + fontSize: 12, + fontWeight: FontWeight.bold, + ); + } + return const TextStyle(color: Colors.grey, fontSize: 12); + }), + iconTheme: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return IconThemeData(color: accentColor); + } + return const IconThemeData(color: Colors.grey); + }), + ), + ); + + final ThemeData activeDarkTheme = + themeProvider.appTheme == AppTheme.black + ? oledTheme + : baseDarkTheme; + + return MaterialApp( + title: 'Komet', + navigatorKey: navigatorKey, + builder: (context, child) { + final showHud = themeProvider.debugShowPerformanceOverlay; + if (!showHud) return child ?? const SizedBox.shrink(); + return Stack( + children: [ + if (child != null) child, + const Positioned(top: 8, right: 56, child: _MiniFpsHud()), + ], + ); + }, + theme: baseLightTheme, + darkTheme: activeDarkTheme, + themeMode: themeProvider.themeMode, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru'), Locale('en')], + locale: const Locale('ru'), + + home: hasToken ? const HomeScreen() : const PhoneEntryScreen(), + ); + }, + ); + } +} + + +class _MiniFpsHud extends StatefulWidget { + const _MiniFpsHud(); + + @override + State<_MiniFpsHud> createState() => _MiniFpsHudState(); +} + +class _MiniFpsHudState extends State<_MiniFpsHud> { + final List _timings = []; + static const int _sampleSize = 60; + double _fps = 0.0; + double _avgMs = 0.0; + + @override + void initState() { + super.initState(); + SchedulerBinding.instance.addTimingsCallback(_onTimings); + } + + @override + void dispose() { + SchedulerBinding.instance.removeTimingsCallback(_onTimings); + super.dispose(); + } + + void _onTimings(List timings) { + _timings.addAll(timings); + if (_timings.length > _sampleSize) { + _timings.removeRange(0, _timings.length - _sampleSize); + } + if (_timings.isEmpty) return; + final double avg = + _timings + .map((t) => (t.totalSpan.inMicroseconds) / 1000.0) + .fold(0.0, (a, b) => a + b) / + _timings.length; + setState(() { + _avgMs = avg; + _fps = avg > 0 ? (1000.0 / avg) : 0.0; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: theme.surface.withOpacity(0.85), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 8), + ], + ), + child: DefaultTextStyle( + style: TextStyle( + fontSize: 12, + color: theme.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('FPS: ${_fps.toStringAsFixed(0)}'), + const SizedBox(height: 2), + Text('${_avgMs.toStringAsFixed(1)} ms/frame'), + ], + ), + ), + ); + } +} diff --git a/lib/manage_account_screen.dart b/lib/manage_account_screen.dart new file mode 100644 index 0000000..1dd9c2f --- /dev/null +++ b/lib/manage_account_screen.dart @@ -0,0 +1,398 @@ + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:gwid/phone_entry_screen.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; + +class ManageAccountScreen extends StatefulWidget { + final Profile? myProfile; + const ManageAccountScreen({super.key, this.myProfile}); + + @override + State createState() => _ManageAccountScreenState(); +} + +class _ManageAccountScreenState extends State { + late final TextEditingController _firstNameController; + late final TextEditingController _lastNameController; + late final TextEditingController _descriptionController; + final GlobalKey _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _firstNameController = TextEditingController( + text: widget.myProfile?.firstName ?? '', + ); + _lastNameController = TextEditingController( + text: widget.myProfile?.lastName ?? '', + ); + _descriptionController = TextEditingController( + text: widget.myProfile?.description ?? '', + ); + } + + void _saveProfile() { + if (!_formKey.currentState!.validate()) { + return; + } + + ApiService.instance.updateProfileText( + _firstNameController.text.trim(), + _lastNameController.text.trim(), + _descriptionController.text.trim(), + ); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Профиль успешно сохранен"), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 2), + ), + ); + } + + void _logout() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Выйти из аккаунта?'), + content: const Text('Вы уверены, что хотите выйти из аккаунта?'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade400, + foregroundColor: Colors.white, + ), + child: const Text('Выйти'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + try { + await ApiService.instance.logout(); + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const PhoneEntryScreen()), + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка выхода: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + } + + void _pickAndUpdateProfilePhoto() async { + final ImagePicker picker = ImagePicker(); + + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + + + if (image != null) { + + File imageFile = File(image.path); + + + + + + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Фотография профиля обновляется..."), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text("Изменить профиль"), + centerTitle: true, + scrolledUnderElevation: 0, + actions: [ + TextButton( + onPressed: _saveProfile, + child: const Text( + "Сохранить", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAvatarSection(theme), + const SizedBox(height: 32), + + + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Основная информация", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _firstNameController, + maxLength: 60, // Ограничение по символам + decoration: _buildInputDecoration( + "Имя", + Icons.person_outline, + ).copyWith(counterText: ""), // Скрываем счетчик + validator: (value) => + value!.isEmpty ? 'Введите ваше имя' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _lastNameController, + maxLength: 60, // Ограничение по символам + decoration: _buildInputDecoration( + "Фамилия", + Icons.person_outline, + ).copyWith(counterText: ""), // Скрываем счетчик + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Дополнительно", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _descriptionController, + maxLines: 4, + maxLength: 400, + decoration: _buildInputDecoration( + "О себе", + Icons.edit_note_outlined, + alignLabel: true, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + + if (widget.myProfile != null) + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _buildInfoTile( + icon: Icons.phone_outlined, + title: "Телефон", + subtitle: widget.myProfile!.formattedPhone, + ), + const Divider(height: 1), + _buildTappableInfoTile( + icon: Icons.tag, + title: "Ваш ID", + subtitle: widget.myProfile!.id.toString(), + onTap: () { + Clipboard.setData( + ClipboardData( + text: widget.myProfile!.id.toString(), + ), + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('ID скопирован в буфер обмена'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + ), + ], + ), + ), + + const SizedBox(height: 32), + _buildLogoutButton(), + ], + ), + ), + ), + ); + } + + + + Widget _buildAvatarSection(ThemeData theme) { + return Center( + child: GestureDetector( + + onTap: _pickAndUpdateProfilePhoto, // 2. Вызываем метод при нажатии + child: Stack( + children: [ + CircleAvatar( + radius: 60, + backgroundColor: theme.colorScheme.secondaryContainer, + backgroundImage: widget.myProfile?.photoBaseUrl != null + ? NetworkImage(widget.myProfile!.photoBaseUrl!) + : null, + child: widget.myProfile?.photoBaseUrl == null + ? Icon( + Icons.person, + size: 60, + color: theme.colorScheme.onSecondaryContainer, + ) + : null, + ), + Positioned( + bottom: 4, + right: 4, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.camera_alt, color: Colors.white, size: 20), + ), + ), + ), + ], + ), + ), + ); + } + + InputDecoration _buildInputDecoration( + String label, + IconData icon, { + bool alignLabel = false, + }) { + + final prefixIcon = (label == "О себе") + ? Padding( + padding: const EdgeInsets.only(bottom: 60), // Смещаем иконку вверх + child: Icon(icon), + ) + : Icon(icon); + + return InputDecoration( + labelText: label, + prefixIcon: prefixIcon, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + alignLabelWithHint: alignLabel, + ); + } + + Widget _buildInfoTile({ + required IconData icon, + required String title, + required String subtitle, + }) { + return ListTile( + leading: Icon(icon, color: Theme.of(context).colorScheme.primary), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitle), + ); + } + + Widget _buildTappableInfoTile({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: ListTile( + leading: Icon(icon, color: Theme.of(context).colorScheme.primary), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitle), + trailing: const Icon(Icons.copy_outlined, size: 20), + ), + ); + } + + Widget _buildLogoutButton() { + return OutlinedButton.icon( + icon: const Icon(Icons.logout), + label: const Text('Выйти из аккаунта'), + onPressed: _logout, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade400, + side: BorderSide(color: Colors.red.shade200), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } +} diff --git a/lib/models/attach.dart b/lib/models/attach.dart new file mode 100644 index 0000000..c9e2a64 --- /dev/null +++ b/lib/models/attach.dart @@ -0,0 +1,116 @@ + +enum AttachTypes { call, control, inlineKeyboard, share } + +abstract class Attachment { + final AttachTypes type; + + Attachment(this.type); + + factory Attachment.fromJson(Map json) { + final typeString = json['_type'] as String; + switch (typeString) { + case 'CALL': + return CallAttachment.fromJson(json); + case 'CONTROL': + return ControlAttachment.fromJson(json); + case 'INLINE_KEYBOARD': + return InlineKeyboardAttachment.fromJson(json); + case 'SHARE': + return ShareAttachment.fromJson(json); + default: + throw ArgumentError('Unknown attachment type: $typeString'); + } + } +} + +class CallAttachment extends Attachment { + final int duration; + final String conversationId; + final String hangupType; + final String joinLink; + final String callType; + + CallAttachment({ + required this.duration, + required this.conversationId, + required this.hangupType, + required this.joinLink, + required this.callType, + }) : super(AttachTypes.call); + + factory CallAttachment.fromJson(Map json) { + return CallAttachment( + duration: json['duration'] as int, + conversationId: json['conversationId'] as String, + hangupType: json['hangupType'] as String, + joinLink: json['joinLink'] as String, + callType: json['callType'] as String, + ); + } +} + +class ControlAttachment extends Attachment { + final String event; + + ControlAttachment({required this.event}) : super(AttachTypes.control); + + factory ControlAttachment.fromJson(Map json) { + return ControlAttachment(event: json['event'] as String); + } +} + +class InlineKeyboardAttachment extends Attachment { + final Map keyboard; + final String callbackId; + + InlineKeyboardAttachment({required this.keyboard, required this.callbackId}) + : super(AttachTypes.inlineKeyboard); + + factory InlineKeyboardAttachment.fromJson(Map json) { + return InlineKeyboardAttachment( + keyboard: json['keyboard'] as Map, + callbackId: json['callbackId'] as String, + ); + } +} + +class ShareAttachment extends Attachment { + final Map image; + final String description; + final bool contentLevel; + final int shareId; + final String title; + final String url; + + ShareAttachment({ + required this.image, + required this.description, + required this.contentLevel, + required this.shareId, + required this.title, + required this.url, + }) : super(AttachTypes.share); + + factory ShareAttachment.fromJson(Map json) { + return ShareAttachment( + image: json['image'] as Map, + description: json['description'] as String, + contentLevel: json['contentLevel'] as bool, + shareId: json['shareId'] as int, + title: json['title'] as String, + url: json['url'] as String, + ); + } +} + +class AttachmentsParser { + static List parse(List jsonList) { + return jsonList.map((jsonItem) { + if (jsonItem is Map) { + return Attachment.fromJson(jsonItem); + } else { + throw ArgumentError('Invalid JSON item in the list: $jsonItem'); + } + }).toList(); + } +} diff --git a/lib/models/channel.dart b/lib/models/channel.dart new file mode 100644 index 0000000..c8e9fb8 --- /dev/null +++ b/lib/models/channel.dart @@ -0,0 +1,37 @@ +class Channel { + final int id; + final String name; + final String? description; + final String? photoBaseUrl; + final String? link; + final String? webApp; + final List options; + final int updateTime; + + Channel({ + required this.id, + required this.name, + this.description, + this.photoBaseUrl, + this.link, + this.webApp, + required this.options, + required this.updateTime, + }); + + factory Channel.fromJson(Map json) { + final names = json['names'] as List?; + final nameData = names?.isNotEmpty == true ? names![0] : null; + + return Channel( + id: json['id'] as int, + name: nameData?['name'] as String? ?? 'Неизвестный канал', + description: nameData?['description'] as String?, + photoBaseUrl: json['baseUrl'] as String?, + link: json['link'] as String?, + webApp: json['webApp'] as String?, + options: List.from(json['options'] ?? []), + updateTime: json['updateTime'] as int? ?? 0, + ); + } +} diff --git a/lib/models/chat.dart b/lib/models/chat.dart new file mode 100644 index 0000000..600129a --- /dev/null +++ b/lib/models/chat.dart @@ -0,0 +1,99 @@ +import 'package:gwid/models/message.dart'; + +class Chat { + final int id; + final int ownerId; + final Message lastMessage; + final List participantIds; + final int newMessages; + final String? title; // Название группы + final String? type; // Тип чата (DIALOG, CHAT) + final String? baseIconUrl; // URL иконки группы + final String? description; + final int? participantsCount; + + Chat({ + required this.id, + required this.ownerId, + required this.lastMessage, + required this.participantIds, + required this.newMessages, + this.title, + this.type, + this.baseIconUrl, + this.description, + this.participantsCount, + }); + + factory Chat.fromJson(Map json) { + var participantsMap = json['participants'] as Map? ?? {}; + List participantIds = participantsMap.keys + .map((id) => int.parse(id)) + .toList(); + + + Message lastMessage; + if (json['lastMessage'] != null) { + lastMessage = Message.fromJson(json['lastMessage']); + } else { + lastMessage = Message( + id: 'empty', + senderId: 0, + time: DateTime.now().millisecondsSinceEpoch, + text: '', + cid: null, + attaches: [], + ); + } + + return Chat( + id: json['id'] ?? 0, + ownerId: json['owner'] ?? 0, + lastMessage: lastMessage, + participantIds: participantIds, + newMessages: json['newMessages'] ?? 0, + title: json['title'], + type: json['type'], + baseIconUrl: json['baseIconUrl'], + description: json['description'], + participantsCount: json['participantsCount'], + ); + } + + + bool get isGroup => type == 'CHAT' || participantIds.length > 2; + + List get groupParticipantIds => participantIds; + + int get onlineParticipantsCount => participantIds.length; // Упрощенная версия + + String get displayTitle { + if (title != null && title!.isNotEmpty) { + return title!; + } + if (isGroup) { + return 'Группа ${participantIds.length}'; + } + return 'Чат'; + } + + Chat copyWith({ + Message? lastMessage, + int? newMessages, + String? title, + String? type, + String? baseIconUrl, + }) { + return Chat( + id: id, + ownerId: ownerId, + lastMessage: lastMessage ?? this.lastMessage, + participantIds: participantIds, + newMessages: newMessages ?? this.newMessages, + title: title ?? this.title, + type: type ?? this.type, + baseIconUrl: baseIconUrl ?? this.baseIconUrl, + description: description ?? this.description, + ); + } +} diff --git a/lib/models/chat_folder.dart b/lib/models/chat_folder.dart new file mode 100644 index 0000000..ea32f4d --- /dev/null +++ b/lib/models/chat_folder.dart @@ -0,0 +1,83 @@ +class ChatFolder { + final String id; + final String title; + final String? emoji; + final List? include; + final List filters; + final bool hideEmpty; + final List widgets; + final List? favorites; + final Map? filterSubjects; + final List? options; + + ChatFolder({ + required this.id, + required this.title, + this.emoji, + this.include, + required this.filters, + required this.hideEmpty, + required this.widgets, + this.favorites, + this.filterSubjects, + this.options, + }); + + factory ChatFolder.fromJson(Map json) { + return ChatFolder( + id: json['id'], + title: json['title'], + emoji: json['emoji'], + include: json['include'] != null ? List.from(json['include']) : null, + filters: json['filters'] != null + ? List.from(json['filters']) + : [], + hideEmpty: json['hideEmpty'] ?? false, + widgets: + (json['widgets'] as List?) + ?.map((widget) => ChatFolderWidget.fromJson(widget)) + .toList() ?? + [], + favorites: json['favorites'] != null + ? List.from(json['favorites']) + : null, + filterSubjects: json['filterSubjects'], + options: json['options'] != null ? List.from(json['options']) : null, + ); + } +} + +class ChatFolderWidget { + final int id; + final String name; + final String description; + final String? iconUrl; + final String? url; + final String? startParam; + final String? background; + final int? appId; + + ChatFolderWidget({ + required this.id, + required this.name, + required this.description, + this.iconUrl, + this.url, + this.startParam, + this.background, + this.appId, + }); + + factory ChatFolderWidget.fromJson(Map json) { + return ChatFolderWidget( + id: json['id'], + name: json['name'], + description: json['description'], + iconUrl: json['iconUrl'], + url: json['url'], + startParam: json['startParam'], + background: json['background'], + appId: json['appId'], + ); + } +} diff --git a/lib/models/contact.dart b/lib/models/contact.dart new file mode 100644 index 0000000..fd21c14 --- /dev/null +++ b/lib/models/contact.dart @@ -0,0 +1,68 @@ +class Contact { + final int id; + final String name; + final String firstName; + final String lastName; + final String? description; + final String? photoBaseUrl; + final bool isBlocked; + final bool isBlockedByMe; + final int accountStatus; + final String? status; + final List options; + + Contact({ + required this.id, + required this.name, + required this.firstName, + required this.lastName, + this.description, + this.photoBaseUrl, + this.isBlocked = false, + this.isBlockedByMe = false, + this.accountStatus = 0, + this.status, + this.options = const [], + }); + + bool get isBot => options.contains('BOT'); + + bool get isUserBlocked => isBlockedByMe || isBlocked; + + factory Contact.fromJson(Map json) { + final nameData = json['names']?[0]; + + String finalFirstName = ''; + String finalLastName = ''; + String finalName = 'Unknown'; + + if (nameData != null) { + finalFirstName = nameData['firstName'] ?? ''; + finalLastName = nameData['lastName'] ?? ''; + final fullName = '$finalFirstName $finalLastName'.trim(); + finalName = fullName.isNotEmpty + ? fullName + : (nameData['name'] ?? 'Unknown'); + } + + + final status = json['status']; + final isBlocked = status == 'BLOCKED'; + + final isBlockedByMe = status == 'BLOCKED'; + + return Contact( + id: json['id'], + name: finalName, + firstName: finalFirstName, + lastName: finalLastName, + description: json['description'], + photoBaseUrl: json['baseUrl'], + isBlocked: isBlocked, + isBlockedByMe: isBlockedByMe, + accountStatus: json['accountStatus'] ?? 0, + status: json['status'], + options: List.from(json['options'] ?? []), + ); + } +} diff --git a/lib/models/message.dart b/lib/models/message.dart new file mode 100644 index 0000000..53e2f0e --- /dev/null +++ b/lib/models/message.dart @@ -0,0 +1,133 @@ +class Message { + final String id; + final String text; + final int time; + final int senderId; + final String? status; // EDITED, DELETED, etc. + final int? updateTime; // Время последнего редактирования + final List> attaches; + final int? cid; // клиентский id (timestamp) + final Map? reactionInfo; // Информация о реакциях + final Map? link; // Информация об ответе на сообщение + + Message({ + required this.id, + required this.text, + required this.time, + required this.senderId, + this.status, + this.updateTime, + this.attaches = const [], + this.cid, + this.reactionInfo, + this.link, + }); + + factory Message.fromJson(Map json) { + + + + + int senderId; + if (json['sender'] is int) { + senderId = json['sender']; + } else { + + senderId = 0; + } + + + int time; + if (json['time'] is int) { + time = json['time']; + } else { + time = 0; + } + + return Message( + + id: + json['id']?.toString() ?? + 'local_${DateTime.now().millisecondsSinceEpoch}', + text: json['text'] ?? '', + time: time, + senderId: senderId, // Use the new safe logic + status: json['status'], + updateTime: json['updateTime'], + attaches: + (json['attaches'] as List?) + ?.map((e) => (e as Map).cast()) + .toList() ?? + const [], + cid: json['cid'], + reactionInfo: json['reactionInfo'], + link: json['link'], + ); + } + + Message copyWith({ + String? id, + String? text, + int? time, + int? senderId, + String? status, + int? updateTime, + List>? attaches, + int? cid, + Map? reactionInfo, + Map? link, + }) { + return Message( + id: id ?? this.id, + text: text ?? this.text, + time: time ?? this.time, + senderId: senderId ?? this.senderId, + status: status ?? this.status, + updateTime: updateTime ?? this.updateTime, + attaches: attaches ?? this.attaches, + cid: cid ?? this.cid, + reactionInfo: reactionInfo ?? this.reactionInfo, + link: link ?? this.link, + ); + } + + bool get isEdited => status == 'EDITED'; + bool get isDeleted => status == 'DELETED'; + bool get isReply => link != null && link!['type'] == 'REPLY'; + bool get isForwarded => link != null && link!['type'] == 'FORWARD'; + + + + + + + bool canEdit(int currentUserId) { + if (isDeleted) return false; + if (senderId != currentUserId) return false; + if (attaches.isNotEmpty) { + return false; // Нельзя редактировать сообщения с вложениями + } + + + final now = DateTime.now().millisecondsSinceEpoch; + final messageTime = time; + final hoursSinceCreation = (now - messageTime) / (1000 * 60 * 60); + + return hoursSinceCreation <= 24; + } + + Map toJson() { + return { + 'id': id, + 'text': text, + 'time': time, + 'sender': senderId, + 'status': status, + 'updateTime': updateTime, + 'cid': cid, + 'attaches': attaches, + 'link': link, + 'reactionInfo': reactionInfo, + }; + } +} diff --git a/lib/models/profile.dart b/lib/models/profile.dart new file mode 100644 index 0000000..360d03f --- /dev/null +++ b/lib/models/profile.dart @@ -0,0 +1,82 @@ +class Profile { + final int id; + final String phone; + final String firstName; + final String lastName; + final String? description; + final String? photoBaseUrl; + final int photoId; + final int updateTime; + final List options; + final int accountStatus; + final List profileOptions; + + Profile({ + required this.id, + required this.phone, + required this.firstName, + required this.lastName, + this.description, + this.photoBaseUrl, + required this.photoId, + required this.updateTime, + required this.options, + required this.accountStatus, + required this.profileOptions, + }); + + factory Profile.fromJson(Map json) { + + Map profileData; + if (json.containsKey('contact')) { + profileData = json['contact'] as Map; + } else { + + profileData = json; + } + + final names = profileData['names'] as List? ?? []; + final nameData = names.isNotEmpty ? names[0] as Map : {}; + + return Profile( + id: profileData['id'], + phone: profileData['phone'].toString(), + firstName: nameData['firstName'] ?? '', + lastName: nameData['lastName'] ?? '', + description: profileData['description'], + photoBaseUrl: profileData['baseUrl'], + photoId: profileData['photoId'] ?? 0, + updateTime: profileData['updateTime'] ?? 0, + options: List.from(profileData['options'] ?? []), + accountStatus: profileData['accountStatus'] ?? 0, + profileOptions: + (json['profileOptions'] as List?) + ?.map((option) => ProfileOption.fromJson(option)) + .toList() ?? + [], + ); + } + + String get displayName { + final fullName = '$firstName $lastName'.trim(); + return fullName.isNotEmpty ? fullName : 'Пользователь'; + } + + String get formattedPhone { + if (phone.length == 11 && phone.startsWith('7')) { + return '+7 (${phone.substring(1, 4)}) ${phone.substring(4, 7)}-${phone.substring(7, 9)}-${phone.substring(9)}'; + } + return phone; + } +} + +class ProfileOption { + final String key; + final dynamic value; + + ProfileOption({required this.key, required this.value}); + + factory ProfileOption.fromJson(Map json) { + return ProfileOption(key: json['key'], value: json['value']); + } +} diff --git a/lib/otp_screen.dart b/lib/otp_screen.dart new file mode 100644 index 0000000..6bce98b --- /dev/null +++ b/lib/otp_screen.dart @@ -0,0 +1,198 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:pinput/pinput.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/chats_screen.dart'; +import 'package:gwid/password_auth_screen.dart'; + +class OTPScreen extends StatefulWidget { + final String phoneNumber; + final String otpToken; + + const OTPScreen({ + super.key, + required this.phoneNumber, + required this.otpToken, + }); + + @override + State createState() => _OTPScreenState(); +} + +class _OTPScreenState extends State { + final TextEditingController _pinController = TextEditingController(); + final FocusNode _pinFocusNode = FocusNode(); + StreamSubscription? _apiSubscription; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _apiSubscription = ApiService.instance.messages.listen((message) { + + if (message['type'] == 'password_required' && mounted) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const PasswordAuthScreen(), + ), + ); + } + }); + return; + } + + if (message['opcode'] == 18 && mounted) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _isLoading = false); + } + }); + + final payload = message['payload']; + print('Полный payload при авторизации: $payload'); + if (payload != null && + payload['tokenAttrs']?['LOGIN']?['token'] != null) { + final String finalToken = payload['tokenAttrs']['LOGIN']['token']; + final userId = payload['tokenAttrs']?['LOGIN']?['userId']; + print('Успешная авторизация! Токен: $finalToken, UserID: $userId'); + + ApiService.instance + .saveToken(finalToken, userId: userId?.toString()) + .then((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Код верный! Вход выполнен.'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const ChatsScreen()), + (route) => false, + ); + }); + } else { + _handleIncorrectCode(); + } + } + }); + } + + void _verifyCode(String code) async { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _isLoading = true); + } + }); + + try { + await ApiService.instance.verifyCode(widget.otpToken, code); + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка подключения: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + } + + void _handleIncorrectCode() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Неверный код. Попробуйте снова.'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + _pinController.clear(); + _pinFocusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final defaultPinTheme = PinTheme( + width: 56, + height: 60, + textStyle: TextStyle(fontSize: 22, color: colors.onSurface), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + ); + + return Scaffold( + appBar: AppBar(title: const Text('Подтверждение')), + body: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Код отправлен на номер', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + widget.phoneNumber, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 30), + Pinput( + length: 6, + controller: _pinController, + focusNode: _pinFocusNode, + autofocus: true, + defaultPinTheme: defaultPinTheme, + focusedPinTheme: defaultPinTheme.copyWith( + decoration: defaultPinTheme.decoration!.copyWith( + border: Border.all(color: colors.primary, width: 2), + ), + ), + onCompleted: (pin) => _verifyCode(pin), + ), + ], + ), + ), + ), + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } + + @override + void dispose() { + _pinController.dispose(); + _pinFocusNode.dispose(); + _apiSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/packet_framer.dart b/lib/packet_framer.dart new file mode 100644 index 0000000..14fc798 --- /dev/null +++ b/lib/packet_framer.dart @@ -0,0 +1,104 @@ + + +import 'dart:typed_data'; +import 'package:msgpack_dart/msgpack_dart.dart'; +import 'package:es_compression/lz4.dart'; + +final lz4Codec = Lz4Codec(); + + +Uint8List packPacket({ + required int ver, + required int cmd, + required int seq, + required int opcode, + required Map payload, +}) { + Uint8List payloadBytes = serialize(payload); + bool isCompressed = false; + + if (payloadBytes.length >= 32) { + final uncompressedSize = ByteData(4) + ..setUint32(0, payloadBytes.length, Endian.big); + + final compressedData = lz4Codec.encode(payloadBytes); + + final builder = BytesBuilder(); + builder.add(uncompressedSize.buffer.asUint8List()); + builder.add(compressedData); + payloadBytes = builder.toBytes(); + isCompressed = true; + } + + final header = ByteData(10); + header.setUint8(0, ver); + header.setUint16(1, cmd, Endian.big); + header.setUint8(3, seq); + header.setUint16(4, opcode, Endian.big); + + int packedLen = payloadBytes.length; + if (isCompressed) { + packedLen |= (1 << 24); + } + header.setUint32(6, packedLen, Endian.big); + + final builder = BytesBuilder(); + builder.add(header.buffer.asUint8List()); + builder.add(payloadBytes); + + return builder.toBytes(); +} + + +Map? unpackPacket(Uint8List data) { + if (data.length < 10) { + print("Ошибка распаковки: Пакет слишком короткий для заголовка."); + return null; + } + + final byteData = data.buffer.asByteData( + data.offsetInBytes, + data.lengthInBytes, + ); + + final ver = byteData.getUint8(0); + final cmd = byteData.getUint16(1, Endian.big); + final seq = byteData.getUint8(3); + final opcode = byteData.getUint16(4, Endian.big); + final packedLen = byteData.getUint32(6, Endian.big); + + final compFlag = packedLen >> 24; + final payloadLength = packedLen & 0x00FFFFFF; + + if (data.length < 10 + payloadLength) { + print( + "Ошибка распаковки: Фактическая длина пакета (${data.length}) меньше заявленной (${10 + payloadLength}).", + ); + return null; + } + + Uint8List payloadBytes = data.sublist(10, 10 + payloadLength); + + if (compFlag != 0) { + try { + final compressedData = payloadBytes.sublist(4); + + + + payloadBytes = Uint8List.fromList(lz4Codec.decode(compressedData)); + } catch (e) { + print("Ошибка распаковки LZ4: $e"); + return null; + } + } + + final dynamic payload = deserialize(payloadBytes); + + return { + "ver": ver, + "cmd": cmd, + "seq": seq, + "opcode": opcode, + "payload": payload, + }; +} diff --git a/lib/password_auth_screen.dart b/lib/password_auth_screen.dart new file mode 100644 index 0000000..fec2d5d --- /dev/null +++ b/lib/password_auth_screen.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/chats_screen.dart'; + +class PasswordAuthScreen extends StatefulWidget { + const PasswordAuthScreen({super.key}); + + @override + State createState() => _PasswordAuthScreenState(); +} + +class _PasswordAuthScreenState extends State { + final TextEditingController _passwordController = TextEditingController(); + StreamSubscription? _apiSubscription; + bool _isLoading = false; + String? _hint; + String? _email; + + @override + void initState() { + super.initState(); + + + _apiSubscription = ApiService.instance.messages.listen((message) { + if (message['type'] == 'password_required' && mounted) { + setState(() { + _hint = message['hint']; + _email = message['email']; + }); + } + + + if (message['opcode'] == 115 && message['cmd'] == 1 && mounted) { + final payload = message['payload']; + if (payload != null && + payload['tokenAttrs']?['LOGIN']?['token'] != null) { + final String finalToken = payload['tokenAttrs']['LOGIN']['token']; + final userId = payload['tokenAttrs']?['LOGIN']?['userId']; + + print( + 'Успешная аутентификация паролем! Токен: $finalToken, UserID: $userId', + ); + + + ApiService.instance + .saveToken(finalToken, userId: userId?.toString()) + .then((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Пароль верный! Вход выполнен.'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + + + ApiService.instance.clearPasswordAuthData(); + + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const ChatsScreen()), + (route) => false, + ); + }); + } + } + + + if (message['opcode'] == 115 && message['cmd'] == 3 && mounted) { + setState(() { + _isLoading = false; + }); + + final error = message['payload']; + String errorMessage = 'Ошибка аутентификации'; + + if (error != null) { + if (error['localizedMessage'] != null) { + errorMessage = error['localizedMessage']; + } else if (error['message'] != null) { + errorMessage = error['message']; + } + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + }); + + + final authData = ApiService.instance.getPasswordAuthData(); + _hint = authData['hint']; + _email = authData['email']; + } + + void _submitPassword() async { + final password = _passwordController.text.trim(); + if (password.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Введите пароль'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + final authData = ApiService.instance.getPasswordAuthData(); + final trackId = authData['trackId']; + + if (trackId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Ошибка: отсутствует идентификатор сессии'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await ApiService.instance.sendPassword(trackId, password); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка отправки пароля: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Ввод пароля'), + backgroundColor: colors.surface, + elevation: 0, + ), + body: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + + if (_email != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.outline.withOpacity(0.2), + ), + ), + child: Column( + children: [ + Text( + 'Аккаунт защищен паролем', + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + _email!, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + if (_hint != null) ...[ + const SizedBox(height: 8), + Text( + 'Подсказка: $_hint', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + + const SizedBox(height: 30), + + + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: 'Пароль', + hintText: 'Введите пароль от аккаунта', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.lock), + filled: true, + fillColor: colors.surfaceContainerHighest, + ), + onSubmitted: (_) => _submitPassword(), + ), + + const SizedBox(height: 24), + + + SizedBox( + width: double.infinity, + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _submitPassword, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Text('Войти'), + ), + ), + ], + ), + ), + ), + + + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } + + @override + void dispose() { + _passwordController.dispose(); + _apiSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/password_management_screen.dart b/lib/password_management_screen.dart new file mode 100644 index 0000000..632d4b2 --- /dev/null +++ b/lib/password_management_screen.dart @@ -0,0 +1,426 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gwid/api_service.dart'; + +class PasswordManagementScreen extends StatefulWidget { + const PasswordManagementScreen({super.key}); + + @override + State createState() => + _PasswordManagementScreenState(); +} + +class _PasswordManagementScreenState extends State { + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + final TextEditingController _hintController = TextEditingController(); + + StreamSubscription? _apiSubscription; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _listenToApiMessages(); + } + + @override + void dispose() { + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _hintController.dispose(); + _apiSubscription?.cancel(); + super.dispose(); + } + + void _listenToApiMessages() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + + if (message['type'] == 'password_set_success') { + setState(() { + _isLoading = false; + }); + + _clearFields(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Пароль успешно установлен!'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + + + if (message['cmd'] == 3 && message['opcode'] == 116) { + setState(() { + _isLoading = false; + }); + + final errorPayload = message['payload']; + String errorMessage = 'Неизвестная ошибка'; + if (errorPayload != null) { + if (errorPayload['localizedMessage'] != null) { + errorMessage = errorPayload['localizedMessage']; + } else if (errorPayload['message'] != null) { + errorMessage = errorPayload['message']; + } + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + }); + } + + void _clearFields() { + _passwordController.clear(); + _confirmPasswordController.clear(); + _hintController.clear(); + } + + void _setPassword() async { + final password = _passwordController.text.trim(); + final confirmPassword = _confirmPasswordController.text.trim(); + final hint = _hintController.text.trim(); + + if (password.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Введите пароль'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + if (password.length < 6) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Пароль должен содержать минимум 6 символов'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + if (password.length > 30) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Пароль не должен превышать 30 символов'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + + if (!password.contains(RegExp(r'[A-Z]')) || + !password.contains(RegExp(r'[a-z]'))) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Пароль должен содержать заглавные и строчные буквы', + ), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + + if (!password.contains(RegExp(r'[0-9]'))) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Пароль должен содержать цифры'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + + if (!password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'))) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Пароль должен содержать специальные символы (!@#\$%^&*)', + ), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + if (password != confirmPassword) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Пароли не совпадают'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await ApiService.instance.setAccountPassword(password, hint); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка установки пароля: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Пароль аккаунта')), + body: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: colors.primary), + const SizedBox(width: 8), + Text( + 'Пароль аккаунта', + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Пароль добавляет дополнительную защиту к вашему аккаунту. ' + 'После установки пароля для входа потребуется не только SMS-код, ' + 'но и пароль.', + style: TextStyle(color: colors.onSurfaceVariant), + ), + ], + ), + ), + + const SizedBox(height: 24), + + + Text( + 'Установить пароль', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: 'Новый пароль', + hintText: 'Введите пароль', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.lock), + ), + ), + const SizedBox(height: 16), + + TextField( + controller: _confirmPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: 'Подтвердите пароль', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.lock_outline), + ), + ), + const SizedBox(height: 16), + + TextField( + controller: _hintController, + decoration: InputDecoration( + labelText: 'Подсказка для пароля (необязательно)', + hintText: 'Например: "Мой любимый цвет"', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.lightbulb_outline), + ), + maxLength: 30, + ), + const SizedBox(height: 16), + + + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outline.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colors.primary, + ), + const SizedBox(width: 8), + Text( + 'Требования к паролю:', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.primary, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '• Не менее 6 символов\n' + '• Содержать заглавные и строчные буквы\n' + '• Включать цифры и специальные символы (!@#\$%^&*)\n' + '• Максимум 30 символов', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 13, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _setPassword, + icon: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colors.onPrimary, + ), + ), + ) + : const Icon(Icons.lock), + label: Text( + _isLoading ? 'Установка...' : 'Установить пароль', + ), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } +} diff --git a/lib/phone_entry_screen.dart b/lib/phone_entry_screen.dart new file mode 100644 index 0000000..b9a737c --- /dev/null +++ b/lib/phone_entry_screen.dart @@ -0,0 +1,666 @@ +import 'dart:async'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/otp_screen.dart'; +import 'package:gwid/screens/settings/session_spoofing_screen.dart'; +import 'package:gwid/token_auth_screen.dart'; +import 'package:gwid/tos_screen.dart'; // Импорт экрана ToS +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class Country { + final String name; + final String code; + final String flag; + final String mask; + final int digits; + + const Country({ + required this.name, + required this.code, + required this.flag, + required this.mask, + required this.digits, + }); +} + +class PhoneEntryScreen extends StatefulWidget { + const PhoneEntryScreen({super.key}); + + @override + State createState() => _PhoneEntryScreenState(); +} + +class _PhoneEntryScreenState extends State + with TickerProviderStateMixin { + final TextEditingController _phoneController = TextEditingController(); + + static const List _countries = [ + Country( + name: 'Россия', + code: '+7', + flag: '🇷🇺', + mask: '+7 (###) ###-##-##', + digits: 10, + ), + Country( + name: 'Беларусь', + code: '+375', + flag: '🇧🇾', + mask: '+375 (##) ###-##-##', + digits: 9, + ), + ]; + + Country _selectedCountry = _countries[0]; + late MaskTextInputFormatter _maskFormatter; + bool _isButtonEnabled = false; + bool _isLoading = false; + bool _hasCustomAnonymity = false; + StreamSubscription? _apiSubscription; + bool _showContent = false; + bool _isTosAccepted = false; // Состояние для отслеживания принятия соглашения + + late final AnimationController _animationController; + late final Animation _topAlignmentAnimation; + late final Animation _bottomAlignmentAnimation; + + @override + void initState() { + super.initState(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(seconds: 15), + ); + + _topAlignmentAnimation = + AlignmentTween( + begin: Alignment.topLeft, + end: Alignment.topRight, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + _bottomAlignmentAnimation = + AlignmentTween( + begin: Alignment.bottomRight, + end: Alignment.bottomLeft, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + + _animationController.repeat(reverse: true); + + _initializeMaskFormatter(); + _checkAnonymitySettings(); + _phoneController.addListener(_onPhoneChanged); + + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted) setState(() => _showContent = true); + }); + + _apiSubscription = ApiService.instance.messages.listen((message) { + if (message['opcode'] == 17 && mounted) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _isLoading = false); + }); + final payload = message['payload']; + if (payload != null && payload['token'] != null) { + final String token = payload['token']; + final String fullPhoneNumber = + _selectedCountry.code + _maskFormatter.getUnmaskedText(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + OTPScreen(phoneNumber: fullPhoneNumber, otpToken: token), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Не удалось запросить код. Попробуйте позже.'), + backgroundColor: Colors.red, + ), + ); + } + } + }); + } + + void _initializeMaskFormatter() { + final mask = _selectedCountry.mask + .replaceFirst(RegExp(r'^\+\d+\s?'), '') + .trim(); + _maskFormatter = MaskTextInputFormatter( + mask: mask, + filter: {"#": RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + } + + void _onPhoneChanged() { + final text = _phoneController.text; + if (text.isNotEmpty) { + Country? detectedCountry = _detectCountryFromInput(text); + if (detectedCountry != null && detectedCountry != _selectedCountry) { + if (_shouldClearFieldForCountry(text, detectedCountry)) { + _phoneController.clear(); + } + setState(() { + _selectedCountry = detectedCountry; + _initializeMaskFormatter(); + }); + } + } + final isFull = + _maskFormatter.getUnmaskedText().length == _selectedCountry.digits; + if (isFull != _isButtonEnabled) { + setState(() => _isButtonEnabled = isFull); + } + } + + bool _shouldClearFieldForCountry(String input, Country country) { + final cleanInput = input.replaceAll(RegExp(r'[^\d+]'), ''); + if (country.code == '+7') { + return !(cleanInput.startsWith('+7') || cleanInput.startsWith('7')); + } else if (country.code == '+375') { + return !(cleanInput.startsWith('+375') || cleanInput.startsWith('375')); + } + return true; + } + + Country? _detectCountryFromInput(String input) { + final cleanInput = input.replaceAll(RegExp(r'[^\d+]'), ''); + if (cleanInput.startsWith('+7') || cleanInput.startsWith('7')) { + return _countries.firstWhere((c) => c.code == '+7'); + } else if (cleanInput.startsWith('+375') || cleanInput.startsWith('375')) { + return _countries.firstWhere((c) => c.code == '+375'); + } + return null; + } + + void _onCountryChanged(Country? country) { + if (country != null && country != _selectedCountry) { + setState(() { + _selectedCountry = country; + _phoneController.clear(); + _initializeMaskFormatter(); + _isButtonEnabled = false; + }); + } + } + + void _checkAnonymitySettings() async { + final prefs = await SharedPreferences.getInstance(); + final anonymityEnabled = prefs.getBool('anonymity_enabled') ?? false; + if (mounted) setState(() => _hasCustomAnonymity = anonymityEnabled); + } + + void _requestOtp() async { + if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return; + setState(() => _isLoading = true); + final String fullPhoneNumber = + _selectedCountry.code + _maskFormatter.getUnmaskedText(); + try { + ApiService.instance.errorStream.listen((error) { + if (mounted) { + setState(() => _isLoading = false); + _showErrorDialog(error); + } + }); + await ApiService.instance.requestOtp(fullPhoneNumber); + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + _showErrorDialog('Ошибка подключения: ${e.toString()}'); + } + } + } + + void _showErrorDialog(String error) { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Ошибка валидации'), + content: Text(error), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: Stack( + children: [ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: _topAlignmentAnimation.value, + end: _bottomAlignmentAnimation.value, + colors: [ + Color.lerp(colors.surface, colors.primary, 0.2)!, + Color.lerp(colors.surface, colors.tertiary, 0.15)!, + colors.surface, + Color.lerp(colors.surface, colors.secondary, 0.15)!, + Color.lerp(colors.surface, colors.primary, 0.25)!, + ], + stops: const [0.0, 0.25, 0.5, 0.75, 1.0], + ), + ), + ); + }, + ), + SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 340), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 700), + curve: Curves.easeOut, + opacity: _showContent ? 1.0 : 0.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.primary.withOpacity(0.1), + ), + child: const Image( + image: AssetImage( + 'assets/images/komet_512.png', + ), + width: 75, + height: 75, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Komet', + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + textStyle: textTheme.headlineLarge, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + 'Введите номер телефона для входа', + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + textStyle: textTheme.titleMedium, + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 48), + _PhoneInput( + phoneController: _phoneController, + maskFormatter: _maskFormatter, + selectedCountry: _selectedCountry, + countries: _countries, + onCountryChanged: _onCountryChanged, + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Checkbox( + value: _isTosAccepted, + onChanged: (bool? value) { + setState(() { + _isTosAccepted = value ?? false; + }); + }, + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Text.rich( + TextSpan( + style: GoogleFonts.manrope( + textStyle: textTheme.bodySmall, + color: colors.onSurfaceVariant, + ), + children: [ + const TextSpan(text: 'Я принимаю '), + TextSpan( + text: 'Пользовательское соглашение', + style: TextStyle( + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const TosScreen(), + ), + ); + }, + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _isButtonEnabled && _isTosAccepted + ? _requestOtp + : null, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Text( + 'Далее', + style: GoogleFonts.manrope( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _isTosAccepted + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const TokenAuthScreen(), + ), + ) + : null, + icon: const Icon(Icons.vpn_key_outlined), + label: Text( + 'Альтернативные способы входа', + style: GoogleFonts.manrope( + fontWeight: FontWeight.bold, + ), + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + const SizedBox(height: 32), + _AnonymityCard(isConfigured: _hasCustomAnonymity), + const SizedBox(height: 24), + Text.rich( + textAlign: TextAlign.center, + TextSpan( + style: GoogleFonts.manrope( + textStyle: textTheme.bodySmall, + color: colors.onSurfaceVariant.withOpacity(0.8), + ), + children: [ + const TextSpan( + text: + 'Используя Komet, вы принимаете на себя всю ответственность за использование стороннего клиента.\n', + ), + TextSpan( + text: '@TeamKomet', + style: TextStyle( + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + final Uri url = Uri.parse( + 'https://t.me/TeamKomet', + ); + if (!await launchUrl(url)) { + debugPrint('Could not launch $url'); + } + }, + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ), + ), + if (_isLoading) + Container( + color: colors.scrim.withOpacity(0.7), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + colors.onPrimary, + ), + ), + const SizedBox(height: 16), + Text( + 'Отправляем код...', + style: textTheme.titleMedium?.copyWith( + color: colors.onPrimary, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _animationController.dispose(); + _phoneController.dispose(); + _apiSubscription?.cancel(); + super.dispose(); + } +} + +class _PhoneInput extends StatelessWidget { + final TextEditingController phoneController; + final MaskTextInputFormatter maskFormatter; + final Country selectedCountry; + final List countries; + final ValueChanged onCountryChanged; + + const _PhoneInput({ + required this.phoneController, + required this.maskFormatter, + required this.selectedCountry, + required this.countries, + required this.onCountryChanged, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: phoneController, + inputFormatters: [maskFormatter], + keyboardType: TextInputType.number, + style: GoogleFonts.manrope( + textStyle: Theme.of(context).textTheme.titleMedium, + fontWeight: FontWeight.w600, + ), + decoration: InputDecoration( + hintText: maskFormatter.getMask()?.replaceAll('#', '0'), + prefixIcon: _CountryPicker( + selectedCountry: selectedCountry, + countries: countries, + onCountryChanged: onCountryChanged, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + autofocus: true, + ); + } +} + +class _CountryPicker extends StatelessWidget { + final Country selectedCountry; + final List countries; + final ValueChanged onCountryChanged; + + const _CountryPicker({ + required this.selectedCountry, + required this.countries, + required this.onCountryChanged, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Container( + margin: const EdgeInsets.only(left: 8), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedCountry, + onChanged: onCountryChanged, + icon: Icon(Icons.keyboard_arrow_down, color: colors.onSurfaceVariant), + items: countries.map((Country country) { + return DropdownMenuItem( + value: country, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(country.flag, style: textTheme.titleMedium), + const SizedBox(width: 8), + Text( + country.code, + style: GoogleFonts.manrope( + textStyle: textTheme.titleMedium, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ); + } +} + +class _AnonymityCard extends StatelessWidget { + final bool isConfigured; + const _AnonymityCard({required this.isConfigured}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final Color cardColor = isConfigured + ? colors.secondaryContainer + : colors.surfaceContainerHighest.withOpacity(0.5); + final Color onCardColor = isConfigured + ? colors.onSecondaryContainer + : colors.onSurfaceVariant; + final IconData icon = isConfigured + ? Icons.verified_user_outlined + : Icons.visibility_outlined; + + return Card( + elevation: 0, + color: cardColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colors.outline.withOpacity(0.5)), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Row( + children: [ + Icon(icon, color: onCardColor, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + isConfigured + ? 'Активны кастомные настройки анонимности' + : 'Настройте анонимность для скрытия данных', + style: GoogleFonts.manrope( + textStyle: textTheme.bodyMedium, + color: onCardColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: isConfigured + ? FilledButton.tonalIcon( + onPressed: _navigateToSpoofingScreen(context), + icon: const Icon(Icons.settings, size: 18), + label: Text( + 'Изменить настройки', + style: GoogleFonts.manrope(fontWeight: FontWeight.bold), + ), + ) + : FilledButton.icon( + onPressed: _navigateToSpoofingScreen(context), + icon: const Icon(Icons.visibility_off, size: 18), + label: Text( + 'Настроить анонимность', + style: GoogleFonts.manrope(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ); + } + + VoidCallback _navigateToSpoofingScreen(BuildContext context) { + return () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const SessionSpoofingScreen()), + ); + }; + } +} diff --git a/lib/profile_menu_dialog.dart b/lib/profile_menu_dialog.dart new file mode 100644 index 0000000..f9828e1 --- /dev/null +++ b/lib/profile_menu_dialog.dart @@ -0,0 +1,304 @@ + +import 'package:flutter/material.dart'; +import 'package:gwid/manage_account_screen.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:gwid/screens/settings/settings_screen.dart'; +import 'package:gwid/phone_entry_screen.dart'; +import 'package:provider/provider.dart'; +import 'package:gwid/theme_provider.dart'; + +class ProfileMenuDialog extends StatefulWidget { + final Profile? myProfile; + + const ProfileMenuDialog({super.key, this.myProfile}); + + @override + State createState() => _ProfileMenuDialogState(); +} + +class _ProfileMenuDialogState extends State { + bool _isAvatarExpanded = false; + + void _toggleAvatar() { + setState(() { + _isAvatarExpanded = !_isAvatarExpanded; + }); + } + + void _collapseAvatar() { + if (_isAvatarExpanded) { + setState(() { + _isAvatarExpanded = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + final String subtitle = "Профиль"; + + final Profile? myProfile = widget.myProfile; + + return Dialog( + alignment: Alignment.topCenter, + insetPadding: const EdgeInsets.only(top: 60.0, left: 16.0, right: 16.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28.0)), + child: SafeArea( + bottom: false, + child: Stack( + children: [ + + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + "Komet", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Color(0x0ff33333), + borderRadius: BorderRadius.circular(24), + ), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: GestureDetector( + onTap: _toggleAvatar, + child: Opacity( + opacity: _isAvatarExpanded ? 0 : 1, + child: CircleAvatar( + radius: 22, + backgroundImage: + myProfile?.photoBaseUrl != null + ? NetworkImage(myProfile!.photoBaseUrl!) + : null, + child: myProfile?.photoBaseUrl == null + ? Text( + myProfile?.displayName.isNotEmpty == + true + ? myProfile!.displayName[0] + .toUpperCase() + : '?', + ) + : null, + ), + ), + ), + title: Text( + myProfile?.displayName ?? "Загрузка...", + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text(subtitle), + trailing: const Icon( + Icons.keyboard_arrow_down_rounded, + ), + ), + Builder( + builder: (context) { + final extra = context + .read() + .extraTransition; + final strength = context + .read() + .extraAnimationStrength; + final panel = SizedBox( + width: double.infinity, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: const StadiumBorder(), + side: BorderSide(color: colors.outline), + ), + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + ManageAccountScreen( + myProfile: myProfile, + ), + ), + ); + }, + child: const Text("Управление аккаунтом"), + ), + ); + if (extra == TransitionOption.slide && + _isAvatarExpanded) { + return AnimatedSlide( + offset: _isAvatarExpanded + ? Offset.zero + : Offset(0, strength / 400.0), + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + child: AnimatedOpacity( + opacity: _isAvatarExpanded ? 1.0 : 0.0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + child: panel, + ), + ); + } + return panel; + }, + ), + ], + ), + ), + const SizedBox(height: 8), + const Divider(), + _SettingsTile( + icon: Icons.settings_outlined, + title: "Настройки", + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SettingsScreen(), + ), + ); + }, + ), + const SizedBox(height: 4), + _SettingsTile( + icon: Icons.logout, + title: "Выйти", + onTap: () async { + if (context.mounted) { + Navigator.of(context).pop(); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => const PhoneEntryScreen(), + ), + (route) => false, + ); + } + }, + ), + const SizedBox(height: 4), + ], + ), + ), + ), + + + if (_isAvatarExpanded) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _collapseAvatar, + child: const SizedBox.expand(), + ), + ), + + + AnimatedAlign( + alignment: _isAvatarExpanded + ? Alignment.center + : Alignment.topLeft, + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: !_isAvatarExpanded, + child: GestureDetector( + onTap: () {}, + child: Builder( + builder: (context) { + final extra = context + .read() + .extraTransition; + final avatar = CircleAvatar( + radius: 80, + backgroundImage: widget.myProfile?.photoBaseUrl != null + ? NetworkImage(widget.myProfile!.photoBaseUrl!) + : null, + child: widget.myProfile?.photoBaseUrl == null + ? Text( + widget.myProfile?.displayName.isNotEmpty == true + ? widget.myProfile!.displayName[0] + .toUpperCase() + : '?', + style: const TextStyle(fontSize: 36), + ) + : null, + ); + if (extra == TransitionOption.slide) { + return AnimatedSlide( + offset: _isAvatarExpanded + ? Offset.zero + : const Offset(0, -1), + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + child: avatar, + ); + } + return AnimatedScale( + scale: _isAvatarExpanded ? 1.0 : 0.0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + child: avatar, + ); + }, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _SettingsTile extends StatelessWidget { + final IconData icon; + final String title; + final VoidCallback onTap; + + const _SettingsTile({ + required this.icon, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(icon), + title: Text(title), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: onTap, + ); + } +} diff --git a/lib/proxy_service.dart b/lib/proxy_service.dart new file mode 100644 index 0000000..f92e114 --- /dev/null +++ b/lib/proxy_service.dart @@ -0,0 +1,117 @@ + + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'proxy_settings.dart'; + +class ProxyService { + ProxyService._privateConstructor(); + static final ProxyService instance = ProxyService._privateConstructor(); + + static const _proxySettingsKey = 'proxy_settings'; + + Future saveProxySettings(ProxySettings settings) async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = jsonEncode(settings.toJson()); + await prefs.setString(_proxySettingsKey, jsonString); + } + + Future loadProxySettings() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_proxySettingsKey); + if (jsonString != null) { + try { + return ProxySettings.fromJson(jsonDecode(jsonString)); + } catch (e) { + return ProxySettings(); + } + } + return ProxySettings(); + } + + + Future checkProxy(ProxySettings settings) async { + print("Проверка прокси: ${settings.host}:${settings.port}"); + HttpClient client = _createClientWithOptions(settings); + + client.connectionTimeout = const Duration(seconds: 10); + + try { + final request = await client.headUrl( + Uri.parse('https://www.google.com/generate_204'), + ); + final response = await request.close(); + + print("Ответ от прокси получен, статус: ${response.statusCode}"); + + if (response.statusCode >= 400) { + + throw Exception('Прокси вернул ошибку: ${response.statusCode}'); + } + } on HandshakeException catch (e) { + print("Поймана ошибка сертификата при проверке прокси: $e"); + print( + "Предполагаем, что badCertificateCallback обработает это в реальном соединении. Считаем проверку успешной.", + ); + + return; + } on SocketException catch (e) { + print("Ошибка сокета при проверке прокси: $e"); + throw Exception('Неверный хост или порт'); + } on TimeoutException catch (_) { + print("Таймаут при проверке прокси"); + throw Exception('Сервер не отвечает (таймаут)'); + } catch (e) { + print("Неизвестная ошибка при проверке прокси: $e"); + throw Exception('Неизвестная ошибка: ${e.toString()}'); + } finally { + client.close(); + } + } + + + Future getHttpClientWithProxy() async { + final settings = await loadProxySettings(); + return _createClientWithOptions(settings); + } + + + HttpClient _createClientWithOptions(ProxySettings settings) { + final client = HttpClient(); + + if (settings.isEnabled && settings.host.isNotEmpty) { + print("Используется прокси: ${settings.toFindProxyString()}"); + + client.findProxy = (uri) { + return settings.toFindProxyString(); + }; + + if (settings.username != null && settings.username!.isNotEmpty) { + print( + "Настраивается аутентификация на прокси для пользователя: ${settings.username}", + ); + client.authenticateProxy = (host, port, scheme, realm) async { + client.addProxyCredentials( + host, + port, + realm ?? '', + HttpClientBasicCredentials( + settings.username!, + settings.password ?? '', + ), + ); + return true; + }; + } + + client.badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } else { + client.findProxy = HttpClient.findProxyFromEnvironment; + } + + return client; + } +} diff --git a/lib/proxy_settings.dart b/lib/proxy_settings.dart new file mode 100644 index 0000000..816a8ef --- /dev/null +++ b/lib/proxy_settings.dart @@ -0,0 +1,91 @@ + + +enum ProxyProtocol { http, https, socks4, socks5 } + +class ProxySettings { + final bool isEnabled; + final String host; + final int port; + final ProxyProtocol protocol; + final String? username; + final String? password; + + ProxySettings({ + this.isEnabled = false, + this.host = '', + this.port = 8080, + this.protocol = ProxyProtocol.http, + this.username, + this.password, + }); + + + String toFindProxyString() { + if (!isEnabled || host.isEmpty) { + return 'DIRECT'; + } + + + String protocolString; + switch (protocol) { + case ProxyProtocol.http: + case ProxyProtocol.https: + protocolString = 'PROXY'; // HttpClient ожидает 'PROXY' для HTTP и HTTPS + break; + case ProxyProtocol.socks4: + + + protocolString = 'SOCKS4'; + break; + case ProxyProtocol.socks5: + protocolString = 'SOCKS5'; + break; + } + + + return '$protocolString $host:$port'; + } + + ProxySettings copyWith({ + bool? isEnabled, + String? host, + int? port, + ProxyProtocol? protocol, + String? username, + String? password, + }) { + return ProxySettings( + isEnabled: isEnabled ?? this.isEnabled, + host: host ?? this.host, + port: port ?? this.port, + protocol: protocol ?? this.protocol, + username: username ?? this.username, + password: password ?? this.password, + ); + } + + Map toJson() { + return { + 'isEnabled': isEnabled, + 'host': host, + 'port': port, + 'protocol': protocol.name, + 'username': username, + 'password': password, + }; + } + + factory ProxySettings.fromJson(Map json) { + return ProxySettings( + isEnabled: json['isEnabled'] ?? false, + host: json['host'] ?? '', + port: json['port'] ?? 8080, + protocol: ProxyProtocol.values.firstWhere( + (e) => e.name == json['protocol'], + orElse: () => ProxyProtocol.http, + ), + username: json['username'], + password: json['password'], + ); + } +} diff --git a/lib/screens/group_settings_screen.dart b/lib/screens/group_settings_screen.dart new file mode 100644 index 0000000..e6729c1 --- /dev/null +++ b/lib/screens/group_settings_screen.dart @@ -0,0 +1,1206 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/services/avatar_cache_service.dart'; +import 'package:gwid/widgets/user_profile_panel.dart'; + +class GroupSettingsScreen extends StatefulWidget { + final int chatId; + final Contact initialContact; + final int myId; + final VoidCallback? onChatUpdated; + + const GroupSettingsScreen({ + super.key, + required this.chatId, + required this.initialContact, + required this.myId, + this.onChatUpdated, + }); + + @override + State createState() => _GroupSettingsScreenState(); +} + +class _GroupSettingsScreenState extends State { + late Contact _currentContact; + StreamSubscription? _contactSubscription; + StreamSubscription? _membersSubscription; + + final List> _loadedMembers = []; + final Set _loadedMemberIds = {}; + final ScrollController _scrollController = ScrollController(); + int? _lastMarker; + bool _isLoadingMembers = false; + bool _hasMoreMembers = true; + + @override + void initState() { + super.initState(); + _currentContact = widget.initialContact; + + + _contactSubscription = ApiService.instance.contactUpdates.listen((contact) { + if (contact.id == _currentContact.id && mounted) { + ApiService.instance.updateCachedContact(contact); + setState(() { + _currentContact = contact; + }); + } + }); + + + _membersSubscription = ApiService.instance.messages.listen((message) { + if (message['type'] == 'group_members' && mounted) { + _handleGroupMembersResponse(message['payload']); + } + }); + + + _loadMembersFromCache(); + + + if (_loadedMembers.length < 50) { + _loadedMembers.clear(); + _loadedMemberIds.clear(); + _lastMarker = null; + _hasMoreMembers = true; + ApiService.instance.getGroupMembers(widget.chatId, marker: 0, count: 50); + _isLoadingMembers = true; + } else { + + _lastMarker = _loadedMembers.isNotEmpty + ? _loadedMembers.last['id'] as int? + : null; + _hasMoreMembers = + _loadedMembers.length >= 50; // Если 50+ из кэша - есть пагинация + _isLoadingMembers = false; + print( + 'DEBUG: Участники загружены из кэша, marker: $_lastMarker, hasMore: $_hasMoreMembers', + ); + } + + + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + final viewportHeight = _scrollController.position.viewportDimension; + + print( + 'DEBUG: Scroll - current: $currentScroll, max: $maxScroll, viewport: $viewportHeight, threshold: ${maxScroll - 100}', + ); + + if (currentScroll >= maxScroll - 100 && maxScroll > 0) { + print('DEBUG: Достигнут порог скролла, вызываем _loadMoreMembers()'); + _loadMoreMembers(); + } + } + + void _loadMembersFromCache() { + final currentChat = _getCurrentGroupChat(); + if (currentChat == null) { + print('DEBUG: Чат не найден в кэше'); + return; + } + + + List membersRaw = []; + if (currentChat['members'] is List) { + membersRaw = currentChat['members'] as List; + } else if (currentChat['participants'] is List) { + membersRaw = currentChat['participants'] as List; + } + + print('DEBUG: Найдено ${membersRaw.length} участников в кэше чата'); + + final members = >[]; + for (final memberRaw in membersRaw) { + final memberData = memberRaw as Map; + final contact = memberData['contact'] as Map?; + if (contact != null) { + final memberId = contact['id'] as int; + if (!_loadedMemberIds.contains(memberId)) { + members.add({ + 'id': memberId, + 'contact': contact, + 'presence': memberData['presence'] as Map?, + 'dialogChatId': null, + }); + _loadedMemberIds.add(memberId); + } + } + } + + _loadedMembers.addAll(members); + print( + 'DEBUG: Загружено ${members.length} участников из кэша (всего: ${_loadedMembers.length})', + ); + } + + void _loadMoreMembers() { + print('DEBUG: _loadMoreMembers() вызван'); + if (_isLoadingMembers || !_hasMoreMembers || _lastMarker == null) { + print( + 'DEBUG: Пропуск загрузки - isLoading: $_isLoadingMembers, hasMore: $_hasMoreMembers, marker: $_lastMarker', + ); + return; + } + + print('DEBUG: Загружаем больше участников с маркером: $_lastMarker'); + _isLoadingMembers = true; + setState(() {}); + + ApiService.instance.getGroupMembers( + widget.chatId, + marker: _lastMarker!, + count: 50, + ); + } + + + + + + + + + + + + + + + + + + + + + + + + + + + void _handleGroupMembersResponse(Map payload) { + print( + 'DEBUG: _handleGroupMembersResponse вызван с payload: ${payload.keys}', + ); + if (!mounted) return; + + List membersRaw = []; + if (payload['members'] is List) { + membersRaw = payload['members'] as List; + } else if (payload['participants'] is List) { + membersRaw = payload['participants'] as List; + } + + final members = >[]; + int skippedCount = 0; + int addedCount = 0; + + for (final memberRaw in membersRaw) { + final memberData = memberRaw as Map; + final contact = memberData['contact'] as Map?; + if (contact != null) { + final memberId = contact['id'] as int; + if (!_loadedMemberIds.contains(memberId)) { + members.add({ + 'id': memberId, + 'contact': contact, + 'presence': memberData['presence'] as Map?, + 'dialogChatId': null, + }); + _loadedMemberIds.add(memberId); + addedCount++; + } else { + skippedCount++; + } + } else { + print('WARNING: Участник без contact поля: $memberData'); + } + } + + print( + 'DEBUG: Обработано ${membersRaw.length} участников из ответа: добавлено $addedCount, пропущено $skippedCount (дубликаты)', + ); + + final markerFromPayload = payload['marker'] as int?; + int? nextMarker; + + if (markerFromPayload != null && markerFromPayload > 0) { + nextMarker = markerFromPayload; + } else if (members.isNotEmpty) { + final lastMember = members.last; + nextMarker = lastMember['id'] as int?; + } + + setState(() { + _loadedMembers.addAll(members); + _lastMarker = nextMarker; + _hasMoreMembers = nextMarker != null && nextMarker > 0; + _isLoadingMembers = false; + }); + + print( + 'DEBUG: Загружено ${members.length} новых участников (всего: ${_loadedMembers.length}), маркер: $nextMarker, есть еще: $_hasMoreMembers', + ); + print('DEBUG: _handleGroupMembersResponse завершен'); + } + + @override + void dispose() { + _contactSubscription?.cancel(); + _membersSubscription?.cancel(); + _scrollController.dispose(); + super.dispose(); + } + + Map? _getCurrentGroupChat() { + final chatData = ApiService.instance.lastChatsPayload; + if (chatData == null || chatData['chats'] == null) return null; + + final chats = chatData['chats'] as List; + try { + return chats.firstWhere( + (chat) => chat['id'] == widget.chatId, + orElse: () => null, + ); + } catch (e) { + return null; + } + } + + void _showEditGroupNameDialog() { + final nameController = TextEditingController(text: _currentContact.name); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Изменить название группы'), + content: TextField( + controller: nameController, + decoration: const InputDecoration( + hintText: 'Введите новое название группы', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + final newName = nameController.text.trim(); + if (newName.isNotEmpty && newName != _currentContact.name) { + ApiService.instance.renameGroup(widget.chatId, newName); + + setState(() { + _currentContact = Contact( + id: _currentContact.id, + name: newName, + firstName: _currentContact.firstName, + lastName: _currentContact.lastName, + description: _currentContact.description, + photoBaseUrl: _currentContact.photoBaseUrl, + isBlocked: _currentContact.isBlocked, + isBlockedByMe: _currentContact.isBlockedByMe, + accountStatus: _currentContact.accountStatus, + status: _currentContact.status, + ); + }); + + widget.onChatUpdated?.call(); // Уведомляем список чатов + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Название группы изменено')), + ); + } + }, + child: const Text('Изменить'), + ), + ], + ), + ); + } + + void _showAddMemberDialog() { + final chatData = ApiService.instance.lastChatsPayload; + if (chatData == null || chatData['contacts'] == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось загрузить контакты')), + ); + return; + } + + final contacts = chatData['contacts'] as List; + final availableContacts = >[]; + + final currentChat = _getCurrentGroupChat(); + if (currentChat != null) { + final participants = + currentChat['participants'] as Map? ?? {}; + final participantIds = participants.keys + .map((id) => int.parse(id)) + .toSet(); + + for (final contact in contacts) { + final contactId = contact['id'] as int; + if (!participantIds.contains(contactId)) { + availableContacts.add(contact); + } + } + } else { + availableContacts.addAll(contacts.cast>()); + } + + if (availableContacts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Нет доступных контактов для добавления')), + ); + return; + } + + showDialog( + context: context, + builder: (context) => _AddMemberDialog( + contacts: availableContacts, + onAddMembers: (selectedContacts) { + if (selectedContacts.isNotEmpty) { + ApiService.instance.addGroupMember(widget.chatId, selectedContacts); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Добавлено ${selectedContacts.length} участников', + ), + ), + ); + + } + }, + ), + ); + } + + void _showRemoveMemberDialog() { + final currentChat = _getCurrentGroupChat(); + if (currentChat == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось загрузить данные группы')), + ); + return; + } + + final participants = + currentChat['participants'] as Map? ?? {}; + final admins = currentChat['admins'] as List? ?? []; + + final chatData = ApiService.instance.lastChatsPayload; + if (chatData == null || chatData['contacts'] == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось загрузить контакты')), + ); + return; + } + + final contacts = chatData['contacts'] as List; + final contactMap = >{}; + for (final contact in contacts) { + contactMap[contact['id']] = contact; + } + + final removableMembers = >[]; + + for (final participantId in participants.keys) { + final id = int.parse(participantId); + if (id != widget.myId && !admins.contains(id)) { + final contact = contactMap[id]; + if (contact != null) { + removableMembers.add({ + 'id': id, + 'name': contact['names']?[0]?['name'] ?? 'Неизвестный', + 'contact': contact, + }); + } + } + } + + if (removableMembers.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Нет участников для удаления')), + ); + return; + } + + showDialog( + context: context, + builder: (context) => _RemoveMemberDialog( + members: removableMembers, + onRemoveMembers: (selectedMembers) { + if (selectedMembers.isNotEmpty) { + ApiService.instance.removeGroupMember( + widget.chatId, + selectedMembers, + ); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Удалено ${selectedMembers.length} участников'), + ), + ); + + } + }, + ), + ); + } + + void _showPromoteToAdminDialog() { + final currentChat = _getCurrentGroupChat(); + if (currentChat == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось загрузить данные группы')), + ); + return; + } + + final participants = + currentChat['participants'] as Map? ?? {}; + final admins = currentChat['admins'] as List? ?? []; + + final chatData = ApiService.instance.lastChatsPayload; + if (chatData == null || chatData['contacts'] == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось загрузить контакты')), + ); + return; + } + + final contacts = chatData['contacts'] as List; + final contactMap = >{}; + for (final contact in contacts) { + contactMap[contact['id']] = contact; + } + + final promotableMembers = >[]; + + for (final participantId in participants.keys) { + final id = int.parse(participantId); + if (id != widget.myId && !admins.contains(id)) { + final contact = contactMap[id]; + if (contact != null) { + promotableMembers.add({ + 'id': id, + 'name': contact['names']?[0]?['name'] ?? 'Неизвестный', + 'contact': contact, + }); + } + } + } + + if (promotableMembers.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Нет участников для назначения администратором'), + ), + ); + return; + } + + showDialog( + context: context, + builder: (context) => _PromoteAdminDialog( + members: promotableMembers, + onPromoteToAdmin: (memberId) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Функция назначения администратора будет добавлена', + ), + ), + ); + }, + ), + ); + } + + void _showLeaveGroupDialog() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Выйти из группы'), + content: Text( + 'Вы уверены, что хотите выйти из группы "${_currentContact.name}"?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); // Закрываем диалог + try { + ApiService.instance.leaveGroup(widget.chatId); + + if (mounted) { + + Navigator.of(context) + ..pop() + ..pop(); + widget.onChatUpdated?.call(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Вы вышли из группы'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при выходе из группы: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + child: const Text('Выйти'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + _buildSliverAppBar(), + _buildGroupManagementButtons(), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Text( + 'Участники', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + _buildGroupMembersList(), + ], + ), + ); + } + + Widget _buildSliverAppBar() { + const double appBarHeight = 250.0; + + return SliverAppBar( + expandedHeight: appBarHeight, + pinned: true, + floating: false, + stretch: true, + backgroundColor: Theme.of(context).colorScheme.surface, + flexibleSpace: FlexibleSpaceBar( + title: Text( + _currentContact.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + centerTitle: false, + titlePadding: const EdgeInsetsDirectional.only( + start: 56.0, + bottom: 16.0, + end: 16.0, + ), + background: Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: 'contact_avatar_${widget.initialContact.id}', + child: Material( + type: MaterialType.transparency, + child: (_currentContact.photoBaseUrl != null) + ? Image.network( + _currentContact.photoBaseUrl!, + fit: BoxFit.cover, + height: appBarHeight, + width: double.infinity, + errorBuilder: (context, error, stackTrace) => Container( + height: appBarHeight, + width: double.infinity, + color: Theme.of( + context, + ).colorScheme.secondaryContainer, + child: Center( + child: Icon( + Icons.error_outline, + color: Theme.of( + context, + ).colorScheme.onSecondaryContainer, + size: 48, + ), + ), + ), + ) + : Container( + height: appBarHeight, + width: double.infinity, + color: Theme.of(context).colorScheme.secondaryContainer, + child: Center( + child: Text( + _currentContact.name.isNotEmpty + ? _currentContact.name[0].toUpperCase() + : '?', + style: TextStyle( + fontSize: 96, + color: Theme.of( + context, + ).colorScheme.onSecondaryContainer, + ), + ), + ), + ), + ), + ), + + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.1), + Colors.black.withOpacity(0.5), + ], + stops: const [0.5, 0.7, 1.0], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildGroupManagementButtons() { + final colorScheme = Theme.of(context).colorScheme; + + + bool amIAdmin = false; + final currentChat = _getCurrentGroupChat(); + if (currentChat != null) { + final admins = currentChat['admins'] as List? ?? []; + amIAdmin = admins.contains(widget.myId); + } + + return SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: SliverList( + delegate: SliverChildListDelegate.fixed([ + if (amIAdmin) ...[ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _showEditGroupNameDialog, + icon: const Icon(Icons.edit), + label: const Text('Изменить название группы'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _showAddMemberDialog, + icon: const Icon(Icons.person_add), + label: const Text('Добавить'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: _showRemoveMemberDialog, + icon: const Icon(Icons.person_remove), + label: const Text('Удалить'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _showPromoteToAdminDialog, + icon: const Icon(Icons.admin_panel_settings), + label: const Text('Назначить администратором'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + ], + + + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _showLeaveGroupDialog, + icon: const Icon(Icons.exit_to_app), + label: const Text('Выйти из группы'), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ]), + ), + ); + } + + Widget _buildGroupMembersList() { + final chatData = ApiService.instance.lastChatsPayload; + final contacts = chatData?['contacts'] as List? ?? []; + final contactMap = >{}; + for (final contact in contacts) { + contactMap[contact['id']] = contact; + } + + final currentChat = _getCurrentGroupChat(); + final admins = currentChat?['admins'] as List? ?? []; + final owner = currentChat?['owner'] as int?; + + print('DEBUG: owner=$owner, admins=$admins, myId=${widget.myId}'); + + final members = >[]; + + print( + 'DEBUG: Строим список из ${_loadedMembers.length} загруженных участников', + ); + + for (final memberData in _loadedMembers) { + final id = memberData['id'] as int?; + if (id == null) continue; + + final contactData = memberData['contact'] as Map?; + final contact = contactData ?? contactMap[id]; + final isAdmin = admins.contains(id); + final isOwner = owner != null && id == owner; + + String? name; + String? avatarUrl; + if (contact?['names'] is List) { + final namesList = contact?['names'] as List; + if (namesList.isNotEmpty) { + final nameData = namesList[0] as Map?; + if (nameData != null) { + final firstName = nameData['firstName'] as String? ?? ''; + final lastName = nameData['lastName'] as String? ?? ''; + final fullName = '$firstName $lastName'.trim(); + name = fullName.isNotEmpty + ? fullName + : (nameData['name'] as String? ?? 'Неизвестный'); + } + } + } + if (name == null || name.isEmpty) { + name = 'Неизвестный'; + } + avatarUrl = + contact?['baseUrl'] as String? ?? contact?['baseRawUrl'] as String?; + + String role; + if (isOwner) { + role = 'Владелец'; + } else if (isAdmin) { + role = 'Администратор'; + } else { + role = 'Участник'; + } + + final dialogChatId = memberData['dialogChatId'] as int?; + + members.add({ + 'id': id, + 'name': name, + 'role': role, + 'isAdmin': isAdmin, + 'isOwner': isOwner, + 'contact': contact, + 'avatarUrl': avatarUrl, + 'dialogChatId': dialogChatId, + }); + } + + members.sort((a, b) { + final aId = a['id'] as int; + final bId = b['id'] as int; + final aIsMe = aId == widget.myId; + final bIsMe = bId == widget.myId; + final aIsOwner = a['isOwner'] as bool; + final bIsOwner = b['isOwner'] as bool; + final aIsAdmin = a['isAdmin'] as bool; + final bIsAdmin = b['isAdmin'] as bool; + + if (aIsMe && !bIsMe) return -1; + if (!aIsMe && bIsMe) return 1; + if (aIsOwner && !bIsOwner) return -1; + if (!aIsOwner && bIsOwner) return 1; + if (aIsAdmin && !bIsAdmin) return -1; + if (!aIsAdmin && bIsAdmin) return 1; + return 0; + }); + + print('DEBUG: Итого участников для отображения: ${members.length}'); + + if (_loadedMembers.isEmpty && _isLoadingMembers) { + return const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ), + ); + } + + if (_loadedMembers.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('Участники не загружены'), + ), + ), + ); + } + + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == members.length) { + if (_isLoadingMembers) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ); + } + if (!_hasMoreMembers) { + return const SizedBox.shrink(); + } + return const SizedBox.shrink(); + } + + final member = members[index]; + final isMe = member['id'] == widget.myId; + final isAdmin = member['isAdmin'] as bool; + final isOwner = member['isOwner'] as bool; + final avatarUrl = member['avatarUrl'] as String?; + final memberName = member['name'] as String; + + final contact = member['contact'] as Map?; + final contactNames = contact?['names'] as List?; + String? firstName; + String? lastName; + if (contactNames != null && contactNames.isNotEmpty) { + final nameData = contactNames[0] as Map?; + firstName = nameData?['firstName'] as String?; + lastName = nameData?['lastName'] as String?; + } + final dialogChatId = member['dialogChatId'] as int?; + + return ListTile( + onTap: isMe + ? null + : () { + final userId = member['id'] as int; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => UserProfilePanel( + userId: userId, + name: memberName, + firstName: firstName, + lastName: lastName, + avatarUrl: avatarUrl, + description: contact?['description'] as String?, + myId: widget.myId, + currentChatId: widget.chatId, + contactData: contact, + dialogChatId: dialogChatId, + ), + ); + }, + leading: AvatarCacheService().getAvatarWidget( + avatarUrl, + userId: member['id'] as int, + size: 40, + fallbackText: memberName, + backgroundColor: isMe + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondaryContainer, + textColor: isMe + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSecondaryContainer, + ), + title: Row( + children: [ + Expanded( + child: Text( + '$memberName ${isMe ? '(Вы)' : ''}', + style: TextStyle( + fontWeight: isMe || isOwner + ? FontWeight.bold + : FontWeight.normal, + color: isMe || isOwner + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ], + ), + subtitle: Text( + member['role'].toString(), + style: TextStyle( + color: isOwner + ? Colors.amber[700] + : isAdmin + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + trailing: isOwner + ? Icon(Icons.star, color: Colors.amber, size: 20) + : isAdmin + ? Icon( + Icons.admin_panel_settings, + color: Theme.of(context).colorScheme.primary, + size: 20, + ) + : null, + ); + }, childCount: members.length + (_isLoadingMembers ? 1 : 0)), + ); + } +} + +class _AddMemberDialog extends StatefulWidget { + final List> contacts; + final Function(List) onAddMembers; + + const _AddMemberDialog({required this.contacts, required this.onAddMembers}); + + @override + State<_AddMemberDialog> createState() => _AddMemberDialogState(); +} + +class _AddMemberDialogState extends State<_AddMemberDialog> { + final Set _selectedContacts = {}; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Добавить участников'), + content: SizedBox( + width: double.maxFinite, + height: 400, + child: ListView.builder( + itemCount: widget.contacts.length, + itemBuilder: (context, index) { + final contact = widget.contacts[index]; + final contactId = contact['id'] as int; + final contactName = contact['names']?[0]?['name'] ?? 'Неизвестный'; + final isSelected = _selectedContacts.contains(contactId); + + return CheckboxListTile( + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedContacts.add(contactId); + } else { + _selectedContacts.remove(contactId); + } + }); + }, + title: Text(contactName), + subtitle: Text('ID: $contactId'), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: _selectedContacts.isEmpty + ? null + : () => widget.onAddMembers(_selectedContacts.toList()), + child: Text('Добавить (${_selectedContacts.length})'), + ), + ], + ); + } +} + +class _RemoveMemberDialog extends StatefulWidget { + final List> members; + final Function(List) onRemoveMembers; + + const _RemoveMemberDialog({ + required this.members, + required this.onRemoveMembers, + }); + + @override + State<_RemoveMemberDialog> createState() => _RemoveMemberDialogState(); +} + +class _RemoveMemberDialogState extends State<_RemoveMemberDialog> { + final Set _selectedMembers = {}; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Удалить участников'), + content: SizedBox( + width: double.maxFinite, + height: 400, + child: ListView.builder( + itemCount: widget.members.length, + itemBuilder: (context, index) { + final member = widget.members[index]; + final memberId = member['id'] as int; + final memberName = member['name'] as String; + final isSelected = _selectedMembers.contains(memberId); + + return CheckboxListTile( + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedMembers.add(memberId); + } else { + _selectedMembers.remove(memberId); + } + }); + }, + title: Text(memberName), + subtitle: Text('ID: $memberId'), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: _selectedMembers.isEmpty + ? null + : () => widget.onRemoveMembers(_selectedMembers.toList()), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + child: Text('Удалить (${_selectedMembers.length})'), + ), + ], + ); + } +} + +class _PromoteAdminDialog extends StatelessWidget { + final List> members; + final Function(int) onPromoteToAdmin; + + const _PromoteAdminDialog({ + required this.members, + required this.onPromoteToAdmin, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Назначить администратором'), + content: SizedBox( + width: double.maxFinite, + height: 300, + child: ListView.builder( + itemCount: members.length, + itemBuilder: (context, index) { + final member = members[index]; + final memberId = member['id'] as int; + final memberName = member['name'] as String; + + return ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primary, + child: Text( + memberName[0].toUpperCase(), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + title: Text(memberName), + subtitle: Text('ID: $memberId'), + trailing: const Icon(Icons.admin_panel_settings), + onTap: () => onPromoteToAdmin(memberId), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + ], + ); + } +} diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart new file mode 100644 index 0000000..0a43105 --- /dev/null +++ b/lib/screens/settings/about_screen.dart @@ -0,0 +1,440 @@ +import 'package:flutter/material.dart'; +import 'package:gwid/tos_screen.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AboutScreen extends StatelessWidget { + final bool isModal; + + const AboutScreen({super.key, this.isModal = false}); + + + Future _launchUrl(String url) async { + if (!await launchUrl(Uri.parse(url))) { + + print('Could not launch $url'); + } + } + + + Widget _buildTeamMember( + BuildContext context, { + required String name, + required String role, + required String description, + }) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + style: textTheme.bodyMedium?.copyWith(height: 1.5), + children: [ + TextSpan( + text: '• $name', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: ' — $role'), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 2.0), + child: Text( + description, + style: textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (isModal) { + return buildModalContent(context); + } + + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text("О нас"), + ), + body: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text( + "Команда «Komet»", + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + const Text( + "Мы — команда энтузиастов, создавшая Komet. Нас объединила страсть к технологиям и желание дать пользователям свободу выбора.", + style: TextStyle(fontSize: 16, height: 1.5), + ), + const SizedBox(height: 24), + + + Card( + clipBehavior: Clip.antiAlias, // для скругления углов InkWell + child: ListTile( + leading: const Icon(Icons.description_outlined), + title: const Text("Пользовательское соглашение"), + subtitle: const Text("Правовая информация и условия"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const TosScreen()), + ); + }, + ), + ), + + + const SizedBox(height: 24), + Text( + "Наша команда:", + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + _buildTeamMember( + context, + name: "Floppy", + role: "руководитель проекта", + description: "Стратегическое видение и общее руководство", + ), + _buildTeamMember( + context, + name: "Klocky", + role: "главный программист", + description: "Архитектура и ключевые технические решения", + ), + _buildTeamMember( + context, + name: "Noxzion", + role: "программист", + description: "Участие в разработке приложения и сайта", + ), + _buildTeamMember( + context, + name: "Jganenok", + role: "программист", + description: "Участие в разработке и пользовательские интерфейсы", + ), + _buildTeamMember( + context, + name: "Zennix", + role: "программист", + description: "Участие в разработке и технические решения", + ), + _buildTeamMember( + context, + name: "Qmark", + role: "программист", + description: "Участие в разработке и технические решения", + ), + _buildTeamMember( + context, + name: "ivan2282", + role: "программист", + description: "Основной программист клиента на GNU/Linux", + ), + _buildTeamMember( + context, + name: "Ink", + role: "документация сервера", + description: "Техническая документация и API", + ), + _buildTeamMember( + context, + name: "Килобайт", + role: "веб-разработчик и дизайнер", + description: "Веб-платформа и дизайн-система", + ), + _buildTeamMember( + context, + name: "WhiteMax", + role: "PR-менеджер", + description: "Коммуникация с сообществом и продвижение проекта", + ), + _buildTeamMember( + context, + name: "Mixott Orego", + role: "PR-менеджер", + description: "Коммуникация с сообществом и продвижение проекта", + ), + _buildTeamMember( + context, + name: "Raspberry", + role: "PR-менеджер", + description: "Коммуникация с сообществом и продвижение проекта", + ), + const SizedBox(height: 24), + const Text( + "Мы верим в открытость, прозрачность и право пользователей на выбор. Komet — это наш ответ излишним ограничениям.", + style: TextStyle(fontSize: 16, height: 1.5), + ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 16), + InkWell( + onTap: () => _launchUrl('https://t.me/TeamKomet'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: RichText( + text: TextSpan( + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(height: 1.5), + children: [ + const TextSpan(text: "Связаться с нами: \n"), + TextSpan( + text: "Телеграм-канал: https://t.me/TeamKomet", + style: TextStyle( + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget buildModalContent(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + + Column( + children: [ + Image.asset( + 'assets/icon/komet.png', + width: 128, + height: 128, + ), + const SizedBox(height: 16), + Text( + 'Komet', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + const SizedBox(height: 8), + Text( + 'Версия 0.3.0', + style: TextStyle( + fontSize: 16, + color: colors.onSurface.withOpacity(0.7), + ), + ), + ], + ), + const SizedBox(height: 20), + + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Команда разработки', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + const SizedBox(height: 12), + Text( + 'Мы — команда энтузиастов, создавшая Komet. Нас объединила страсть к технологиям и желание дать пользователям свободу выбора.', + style: TextStyle( + color: colors.onSurface.withOpacity(0.8), + height: 1.5, + ), + ), + const SizedBox(height: 16), + Text( + 'Наша команда:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + const SizedBox(height: 12), + _buildTeamMember( + context, + name: "Floppy", + role: "руководитель проекта", + description: "Стратегическое видение и общее руководство", + ), + _buildTeamMember( + context, + name: "Klocky", + role: "главный программист", + description: "Архитектура и ключевые технические решения", + ), + _buildTeamMember( + context, + name: "Noxzion", + role: "программист", + description: "Участие в разработке приложения и сайта", + ), + _buildTeamMember( + context, + name: "Jganenok", + role: "программист", + description: "Участие в разработке и пользовательские интерфейсы", + ), + _buildTeamMember( + context, + name: "Zennix", + role: "программист", + description: "Участие в разработке и технические решения", + ), + _buildTeamMember( + context, + name: "Qmark", + role: "программист", + description: "Участие в разработке и технические решения", + ), + _buildTeamMember( + context, + name: "Ink", + role: "документация сервера", + description: "Техническая документация и API", + ), + _buildTeamMember( + context, + name: "Килобайт", + role: "веб-разработчик и дизайнер", + description: "Веб-платформа и дизайн-система", + ), + _buildTeamMember( + context, + name: "WhiteMax", + role: "PR-менеджер", + description: "Коммуникация с сообществом и продвижение проекта", + ), + _buildTeamMember( + context, + name: "Mixott Orego", + role: "PR-менеджер", + description: "Коммуникация с сообществом и продвижение проекта", + ), + _buildTeamMember( + context, + name: "Raspberry", + role: "PR-менеджер", + description: "Коммуникация с сообществом и продвижение проекта", + ), + const SizedBox(height: 16), + Text( + 'Мы верим в открытость, прозрачность и право пользователей на выбор. Komet — это наш ответ излишним ограничениям.', + style: TextStyle( + color: colors.onSurface.withOpacity(0.8), + height: 1.5, + ), + ), + const SizedBox(height: 16), + InkWell( + onTap: () => _launchUrl('https://t.me/TeamKomet'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: RichText( + text: TextSpan( + style: TextStyle( + color: colors.onSurface.withOpacity(0.8), + height: 1.5, + ), + children: [ + const TextSpan(text: "Связаться с нами: \n"), + TextSpan( + text: "Телеграм-канал: https://t.me/TeamKomet", + style: TextStyle( + color: colors.primary, + decoration: TextDecoration.underline, + decorationColor: colors.primary, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Полезные ссылки', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.description_outlined), + title: const Text('Пользовательское соглашение'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const TosScreen(), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ); + } +} + diff --git a/lib/screens/settings/animations_screen.dart b/lib/screens/settings/animations_screen.dart new file mode 100644 index 0000000..fedf5ff --- /dev/null +++ b/lib/screens/settings/animations_screen.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:gwid/theme_provider.dart'; + + +class AnimationsScreen extends StatelessWidget { + const AnimationsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final theme = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text("Настройки анимаций"), + backgroundColor: colors.surface, + surfaceTintColor: Colors.transparent, + ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + children: [ + + _ModernSection( + title: "Анимации сообщений", + children: [ + _DropdownSettingTile( + icon: Icons.chat_bubble_outline, + title: "Стиль появления", + items: TransitionOption.values, + value: theme.messageTransition, + onChanged: (value) { + if (value != null) theme.setMessageTransition(value); + }, + itemToString: (item) => item.displayName, + ), + const Divider(height: 24), + _CustomSettingTile( + icon: Icons.photo_library_outlined, + title: "Анимация фото", + subtitle: "Плавное появление фото в чате", + child: Switch( + value: theme.animatePhotoMessages, + onChanged: (value) => theme.setAnimatePhotoMessages(value), + ), + ), + if (theme.messageTransition == TransitionOption.slide) ...[ + const SizedBox(height: 8), + _SliderTile( + icon: Icons.open_in_full_rounded, + label: "Расстояние слайда", + value: theme.messageSlideDistance, + min: 1.0, + max: 200.0, + divisions: 20, + onChanged: (value) => theme.setMessageSlideDistance(value), + displayValue: "${theme.messageSlideDistance.round()}px", + ), + ], + ], + ), + const SizedBox(height: 24), + + + _ModernSection( + title: "Переходы и эффекты", + children: [ + _DropdownSettingTile( + icon: Icons.swap_horiz_rounded, + title: "Переход между чатами", + items: TransitionOption.values, + value: theme.chatTransition, + onChanged: (value) { + if (value != null) theme.setChatTransition(value); + }, + itemToString: (item) => item.displayName, + ), + const Divider(height: 24), + _DropdownSettingTile( + icon: Icons.auto_awesome_motion_outlined, + title: "Дополнительные эффекты", + subtitle: "Для диалогов и других элементов", + items: TransitionOption.values, + value: theme.extraTransition, + onChanged: (value) { + if (value != null) theme.setExtraTransition(value); + }, + itemToString: (item) => item.displayName, + ), + if (theme.extraTransition == TransitionOption.slide) ...[ + const SizedBox(height: 8), + _SliderTile( + icon: Icons.bolt_rounded, + label: "Сила эффекта", + value: theme.extraAnimationStrength, + min: 1.0, + max: 400.0, + divisions: 20, + onChanged: (value) => theme.setExtraAnimationStrength(value), + displayValue: "${theme.extraAnimationStrength.round()}", + ), + ], + ], + ), + const SizedBox(height: 24), + + + _ModernSection( + title: "Управление", + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 4, + ), + leading: Icon(Icons.restore_rounded, color: colors.error), + title: Text( + "Сбросить настройки анимаций", + style: TextStyle(color: colors.error), + ), + subtitle: const Text("Вернуть все значения по умолчанию"), + onTap: () => _showResetDialog(context, theme), + ), + ], + ), + ], + ), + ); + } + + void _showResetDialog(BuildContext context, ThemeProvider theme) { + showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Сбросить настройки?'), + content: const Text( + 'Все параметры анимаций на этом экране будут возвращены к значениям по умолчанию.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + onPressed: () { + theme.resetAnimationsToDefault(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Настройки анимаций сброшены'), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + ); + }, + icon: const Icon(Icons.restore), + label: const Text('Сбросить'), + ), + ], + ), + ); + } +} + + + + +class _ModernSection extends StatelessWidget { + final String title; + final List children; + + const _ModernSection({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 12.0), + child: Text( + title.toUpperCase(), + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.bold, + fontSize: 14, + letterSpacing: 0.8, + ), + ), + ), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colors.outlineVariant.withOpacity(0.3)), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Column(children: children), + ), + ), + ], + ); + } +} + +class _CustomSettingTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final Widget child; + + const _CustomSettingTile({ + required this.icon, + required this.title, + this.subtitle, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon(icon, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + child, + ], + ), + ); + } +} + +class _DropdownSettingTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final T value; + final List items; + final ValueChanged onChanged; + final String Function(T) itemToString; + + const _DropdownSettingTile({ + required this.icon, + required this.title, + this.subtitle, + required this.value, + required this.items, + required this.onChanged, + required this.itemToString, + }); + + @override + Widget build(BuildContext context) { + return _CustomSettingTile( + icon: icon, + title: title, + subtitle: subtitle, + child: DropdownButton( + value: value, + underline: const SizedBox.shrink(), + onChanged: onChanged, + items: items.map((item) { + return DropdownMenuItem(value: item, child: Text(itemToString(item))); + }).toList(), + ), + ); + } +} + +class _SliderTile extends StatelessWidget { + final IconData? icon; + final String label; + final double value; + final double min; + final double max; + final int divisions; + final ValueChanged onChanged; + final String displayValue; + + const _SliderTile({ + this.icon, + required this.label, + required this.value, + required this.min, + required this.max, + required this.divisions, + required this.onChanged, + required this.displayValue, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text(label, style: const TextStyle(fontSize: 14)), + ), + Text( + displayValue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + SizedBox( + height: 30, + child: Slider( + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart new file mode 100644 index 0000000..238d0c0 --- /dev/null +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -0,0 +1,325 @@ + + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:gwid/theme_provider.dart'; +import 'package:gwid/screens/settings/customization_screen.dart'; +import 'package:gwid/screens/settings/animations_screen.dart'; + +class AppearanceSettingsScreen extends StatelessWidget { + final bool isModal; + + const AppearanceSettingsScreen({super.key, this.isModal = false}); + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + final colors = Theme.of(context).colorScheme; + + if (isModal) { + return buildModalContent(context); + } + + return Scaffold( + appBar: AppBar(title: const Text("Внешний вид")), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Кастомизация", colors), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.palette_outlined), + title: const Text("Настройки тем"), + subtitle: const Text("Тема, обои и другие настройки"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => CustomizationScreen(), + ), + ); + }, + ), + + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.animation), + title: const Text("Настройки анимаций"), + subtitle: const Text("Анимации сообщений и переходов"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AnimationsScreen(), + ), + ); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Производительность", colors), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.speed_outlined), + title: const Text("Оптимизация чатов"), + subtitle: const Text("Улучшить производительность в чатах"), + value: theme.optimizeChats, + onChanged: (value) => theme.setOptimizeChats(value), + ), + + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.flash_on_outlined), + title: const Text("Ультра оптимизация"), + subtitle: const Text("Максимальная производительность"), + value: theme.ultraOptimizeChats, + onChanged: (value) => theme.setUltraOptimizeChats(value), + ), + ], + ), + ), + ], + ), + ); + } + + Widget buildModalContent(BuildContext context) { + final theme = context.watch(); + final colors = Theme.of(context).colorScheme; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Кастомизация", colors), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.palette_outlined), + title: const Text("Настройки тем"), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const CustomizationScreen(), + ), + ); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.animation_outlined), + title: const Text("Анимации"), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AnimationsScreen(), + ), + ); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Производительность", colors), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.speed_outlined), + title: const Text("Ультра-оптимизация чатов"), + subtitle: const Text("Максимальная производительность"), + value: theme.ultraOptimizeChats, + onChanged: (value) => theme.setUltraOptimizeChats(value), + ), + ], + ), + ), + ], + ); + } + + Widget _buildModalSettings(BuildContext context, ThemeProvider theme, ColorScheme colors) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.3), + ), + ), + + + Center( + child: Container( + width: 400, + height: 600, + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + const Expanded( + child: Text( + "Внешний вид", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Кастомизация", colors), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.palette_outlined), + title: const Text("Настройки тем"), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const CustomizationScreen(), + ), + ); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.animation_outlined), + title: const Text("Анимации"), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AnimationsScreen(), + ), + ); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Производительность", colors), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.speed_outlined), + title: const Text("Ультра-оптимизация чатов"), + subtitle: const Text("Максимальная производительность"), + value: theme.ultraOptimizeChats, + onChanged: (value) => theme.setUltraOptimizeChats(value), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildSectionTitle(String title, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + title, + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + ); + } +} + +class _OutlinedSection extends StatelessWidget { + final Widget child; + const _OutlinedSection({required this.child}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: colors.outline.withOpacity(0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: child, + ); + } +} diff --git a/lib/screens/settings/bypass_screen.dart b/lib/screens/settings/bypass_screen.dart new file mode 100644 index 0000000..94e7530 --- /dev/null +++ b/lib/screens/settings/bypass_screen.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:gwid/theme_provider.dart'; + +class BypassScreen extends StatelessWidget { + final bool isModal; + + const BypassScreen({super.key, this.isModal = false}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + if (isModal) { + return buildModalContent(context); + } + + return Scaffold( + appBar: AppBar(title: const Text("Bypass")), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: colors.primary), + const SizedBox(width: 8), + Text( + "Обход блокировки", + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Эта функция позволяет отправлять сообщения заблокированным пользователям, " + "даже если они заблокировали вас. Включите эту опцию, если хотите обойти " + "стандартные ограничения мессенджера.", + style: TextStyle(color: colors.onSurfaceVariant), + ), + ], + ), + ), + + const SizedBox(height: 24), + + Consumer( + builder: (context, themeProvider, child) { + return Card( + child: SwitchListTile( + title: const Text( + "Обход блокировки", + style: TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: const Text( + "Разрешить отправку сообщений заблокированным пользователям", + ), + value: themeProvider.blockBypass, + onChanged: (value) { + themeProvider.setBlockBypass(value); + }, + secondary: Icon( + themeProvider.blockBypass + ? Icons.psychology + : Icons.psychology_outlined, + color: themeProvider.blockBypass + ? colors.primary + : colors.onSurfaceVariant, + ), + ), + ); + }, + ), + + const SizedBox(height: 16), + + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outline.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_outlined, + color: colors.primary, + size: 16, + ), + const SizedBox(width: 8), + Text( + "Важно знать", + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Используя любую из bypass функций мы не несем ответственности за ваш аккаунт", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildModalSettings(BuildContext context, ColorScheme colors) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.3), + ), + ), + + + Center( + child: Container( + width: 400, + height: 600, + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + const Expanded( + child: Text( + "Bypass", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.outline.withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: colors.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + "Информация", + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Эта функция предназначена для обхода ограничений и блокировок. Используйте с осторожностью и только в законных целях.", + style: TextStyle( + color: colors.onSurface.withOpacity(0.8), + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + Consumer( + builder: (context, themeProvider, child) { + return SwitchListTile( + title: const Text("Включить обход"), + subtitle: const Text("Активировать функции обхода ограничений"), + value: false, // Временно отключено + onChanged: (value) { + + }, + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget buildModalContent(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.outline.withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: colors.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + "Информация", + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Эта функция предназначена для обхода ограничений и блокировок. Используйте с осторожностью. Всю ответственность за ваш аккаунт несете только вы.", + style: TextStyle( + color: colors.onSurface.withOpacity(0.8), + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + Consumer( + builder: (context, themeProvider, child) { + return SwitchListTile( + title: const Text("Обход блокировки"), + subtitle: const Text("Активировать функции обхода ограничений"), + value: themeProvider.blockBypass, + onChanged: (value) { + themeProvider.setBlockBypass(value); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/screens/settings/customization_screen.dart b/lib/screens/settings/customization_screen.dart new file mode 100644 index 0000000..38c09eb --- /dev/null +++ b/lib/screens/settings/customization_screen.dart @@ -0,0 +1,1503 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; +import 'package:gwid/theme_provider.dart'; +import 'dart:io'; +import 'dart:ui'; +import 'package:gwid/models/message.dart'; +import 'package:gwid/widgets/chat_message_bubble.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:file_picker/file_picker.dart'; +import 'dart:convert'; +import 'package:video_player/video_player.dart'; + +void _showColorPicker( + BuildContext context, { + required Color initialColor, + required ValueChanged onColorChanged, +}) { + Color pickedColor = initialColor; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Выберите цвет"), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + content: SingleChildScrollView( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ColorPicker( + pickerColor: pickedColor, + onColorChanged: (color) { + setState(() => pickedColor = color); + }, + enableAlpha: false, + pickerAreaHeightPercent: 0.8, + ); + }, + ), + ), + actions: [ + TextButton( + child: const Text('Отмена'), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: const Text('Готово'), + onPressed: () { + onColorChanged(pickedColor); + Navigator.of(context).pop(); + }, + ), + ], + ), + ); +} + +class CustomizationScreen extends StatefulWidget { + const CustomizationScreen({super.key}); + + @override + State createState() => _CustomizationScreenState(); +} + +class _CustomizationScreenState extends State { + @override + Widget build(BuildContext context) { + final theme = context.watch(); + final colors = Theme.of(context).colorScheme; + final bool isSystemTheme = theme.appTheme == AppTheme.system; + final bool isCurrentlyDark = + Theme.of(context).brightness == Brightness.dark; + + if (isSystemTheme) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final systemAccentColor = Theme.of(context).colorScheme.primary; + theme.updateBubbleColorsForSystemTheme(systemAccentColor); + } + }); + } + + final Color? myBubbleColorToShow = isCurrentlyDark + ? theme.myBubbleColorDark + : theme.myBubbleColorLight; + final Color? theirBubbleColorToShow = isCurrentlyDark + ? theme.theirBubbleColorDark + : theme.theirBubbleColorLight; + + final Function(Color?) myBubbleSetter = isCurrentlyDark + ? theme.setMyBubbleColorDark + : theme.setMyBubbleColorLight; + final Function(Color?) theirBubbleSetter = isCurrentlyDark + ? theme.setTheirBubbleColorDark + : theme.setTheirBubbleColorLight; + + final Color myBubbleFallback = isCurrentlyDark + ? const Color(0xFF2b5278) + : Colors.blue.shade100; + final Color theirBubbleFallback = isCurrentlyDark + ? const Color(0xFF182533) + : Colors.grey.shade200; + + return Scaffold( + appBar: AppBar( + title: const Text("Кастомизация"), + surfaceTintColor: Colors.transparent, + backgroundColor: colors.surface, + ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + children: [ + const _MessagePreviewSection(), + const SizedBox(height: 24), + const _ThemeManagementSection(), + const SizedBox(height: 24), + _ModernSection( + title: "Тема приложения", + children: [ + AppThemeSelector( + selectedTheme: theme.appTheme, + onChanged: (appTheme) => theme.setTheme(appTheme), + ), + const SizedBox(height: 16), + IgnorePointer( + ignoring: isSystemTheme, + child: Opacity( + opacity: isSystemTheme ? 0.5 : 1.0, + child: _ColorPickerTile( + title: "Акцентный цвет", + subtitle: isSystemTheme + ? "Используются цвета системы (Material You)" + : "Основной цвет интерфейса", + color: isSystemTheme ? colors.primary : theme.accentColor, + onColorChanged: (color) => theme.setAccentColor(color), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + _ModernSection( + title: "Обои чата", + children: [ + _CustomSettingTile( + icon: Icons.wallpaper, + title: "Использовать свои обои", + child: Switch( + value: theme.useCustomChatWallpaper, + onChanged: (value) => theme.setUseCustomChatWallpaper(value), + ), + ), + if (theme.useCustomChatWallpaper) ...[ + const Divider(height: 24), + _CustomSettingTile( + icon: Icons.image, + title: "Тип обоев", + child: DropdownButton( + value: theme.chatWallpaperType, + underline: const SizedBox.shrink(), + onChanged: (value) { + if (value != null) theme.setChatWallpaperType(value); + }, + items: ChatWallpaperType.values.map((type) { + return DropdownMenuItem( + value: type, + child: Text(type.displayName), + ); + }).toList(), + ), + ), + if (theme.chatWallpaperType == ChatWallpaperType.solid || + theme.chatWallpaperType == ChatWallpaperType.gradient) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет 1", + subtitle: "Основной цвет фона", + color: theme.chatWallpaperColor1, + onColorChanged: (color) => + theme.setChatWallpaperColor1(color), + ), + ], + if (theme.chatWallpaperType == ChatWallpaperType.gradient) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет 2", + subtitle: "Дополнительный цвет для градиента", + color: theme.chatWallpaperColor2, + onColorChanged: (color) => + theme.setChatWallpaperColor2(color), + ), + ], + if (theme.chatWallpaperType == ChatWallpaperType.image) ...[ + const Divider(height: 24), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.photo_library_outlined), + title: const Text("Выбрать изображение"), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final picker = ImagePicker(); + final image = await picker.pickImage( + source: ImageSource.gallery, + ); + if (image != null) { + theme.setChatWallpaperImagePath(image.path); + } + }, + ), + if (theme.chatWallpaperImagePath?.isNotEmpty == true) ...[ + _SliderTile( + icon: Icons.blur_on, + label: "Размытие", + value: theme.chatWallpaperImageBlur, + min: 0.0, + max: 10.0, + divisions: 20, + onChanged: (value) => + theme.setChatWallpaperImageBlur(value), + displayValue: theme.chatWallpaperImageBlur + .toStringAsFixed(1), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon( + Icons.delete_outline, + color: Colors.redAccent, + ), + title: const Text( + "Удалить изображение", + style: TextStyle(color: Colors.redAccent), + ), + onTap: () => theme.setChatWallpaperImagePath(null), + ), + ], + ], + if (theme.chatWallpaperType == ChatWallpaperType.video) ...[ + const Divider(height: 24), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.video_library_outlined), + title: const Text("Выбрать видео"), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + + final result = await FilePicker.platform.pickFiles( + type: FileType.video, + ); + if (result != null && result.files.single.path != null) { + theme.setChatWallpaperVideoPath( + result.files.single.path!, + ); + } + }, + ), + if (theme.chatWallpaperVideoPath?.isNotEmpty == true) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon( + Icons.delete_outline, + color: Colors.redAccent, + ), + title: const Text( + "Удалить видео", + style: TextStyle(color: Colors.redAccent), + ), + onTap: () => theme.setChatWallpaperVideoPath(null), + ), + ], + ], + ], + ], + ), + const SizedBox(height: 24), + _ModernSection( + title: "Сообщения", + children: [ + _SliderTile( + icon: Icons.text_fields, + label: "Непрозрачность текста", + value: theme.messageTextOpacity, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => theme.setMessageTextOpacity(value), + displayValue: "${(theme.messageTextOpacity * 100).round()}%", + ), + _SliderTile( + icon: Icons.blur_circular, + label: "Интенсивность тени", + value: theme.messageShadowIntensity, + min: 0.0, + max: 0.5, + divisions: 10, + onChanged: (value) => theme.setMessageShadowIntensity(value), + displayValue: + "${(theme.messageShadowIntensity * 100).round()}%", + ), + _SliderTile( + icon: Icons.rounded_corner, + label: "Скругление углов", + value: theme.messageBorderRadius, + min: 4.0, + max: 50.0, + divisions: 23, + onChanged: (value) => theme.setMessageBorderRadius(value), + displayValue: "${theme.messageBorderRadius.round()}px", + ), + const Divider(height: 24), + _SliderTile( + icon: Icons.menu, + label: "Непрозрачность меню", + value: theme.messageMenuOpacity, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => theme.setMessageMenuOpacity(value), + displayValue: "${(theme.messageMenuOpacity * 100).round()}%", + ), + _SliderTile( + icon: Icons.blur_on, + label: "Размытие меню", + value: theme.messageMenuBlur, + min: 0.0, + max: 20.0, + divisions: 20, + onChanged: (value) => theme.setMessageMenuBlur(value), + displayValue: theme.messageMenuBlur.toStringAsFixed(1), + ), + const Divider(height: 24), + if (MediaQuery.of(context).size.height < 600) + const SizedBox(height: 5), + _SliderTile( + icon: Icons.opacity, + label: "Непрозрачность сообщений", + value: 1.0 - theme.messageBubbleOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) => + theme.setMessageBubbleOpacity(1.0 - value), + displayValue: + "${((1.0 - theme.messageBubbleOpacity) * 100).round()}%", + ), + const SizedBox(height: 16), + _CustomSettingTile( + icon: Icons.format_color_fill, + title: "Тип отображения", + child: IgnorePointer( + ignoring: isSystemTheme, + child: Opacity( + opacity: isSystemTheme ? 0.5 : 1.0, + child: DropdownButton( + value: theme.messageBubbleType, + underline: const SizedBox.shrink(), + onChanged: (value) { + if (value != null) theme.setMessageBubbleType(value); + }, + items: MessageBubbleType.values.map((type) { + return DropdownMenuItem( + value: type, + child: Text(type.displayName), + ); + }).toList(), + ), + ), + ), + ), + const SizedBox(height: 16), + _CustomSettingTile( + icon: Icons.palette, + title: "Цвет моих сообщений", + child: IgnorePointer( + ignoring: isSystemTheme, + child: Opacity( + opacity: isSystemTheme ? 0.5 : 1.0, + child: GestureDetector( + onTap: () async { + final initial = myBubbleColorToShow ?? myBubbleFallback; + _showColorPicker( + context, + initialColor: initial, + onColorChanged: (color) => myBubbleSetter(color), + ); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: myBubbleColorToShow ?? myBubbleFallback, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 16), + _CustomSettingTile( + icon: Icons.palette_outlined, + title: "Цвет сообщений собеседника", + child: IgnorePointer( + ignoring: isSystemTheme, + child: Opacity( + opacity: isSystemTheme ? 0.5 : 1.0, + child: GestureDetector( + onTap: () async { + final initial = + theirBubbleColorToShow ?? theirBubbleFallback; + _showColorPicker( + context, + initialColor: initial, + onColorChanged: (color) => theirBubbleSetter(color), + ); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: theirBubbleColorToShow ?? theirBubbleFallback, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey), + ), + ), + ), + ), + ), + ), + const Divider(height: 24), + _CustomSettingTile( + icon: Icons.reply, + title: "Автоцвет панели ответа", + subtitle: "", + child: Switch( + value: theme.useAutoReplyColor, + onChanged: (value) => theme.setUseAutoReplyColor(value), + ), + ), + if (!theme.useAutoReplyColor) ...[ + const SizedBox(height: 16), + _ColorPickerTile( + title: "Цвет панели ответа", + subtitle: "Фиксированный цвет", + color: theme.customReplyColor ?? Colors.blue, + onColorChanged: (color) => theme.setCustomReplyColor(color), + ), + ], + ], + ), + const SizedBox(height: 24), + _ModernSection( + title: "Всплывающие окна", + children: [ + _SliderTile( + icon: Icons.opacity, + label: "Прозрачность фона (профиль)", + value: theme.profileDialogOpacity, + min: 0.0, + max: 1.0, + divisions: 20, + onChanged: (value) => theme.setProfileDialogOpacity(value), + displayValue: "${(theme.profileDialogOpacity * 100).round()}%", + ), + _SliderTile( + icon: Icons.blur_on, + label: "Размытие фона (профиль)", + value: theme.profileDialogBlur, + min: 0.0, + max: 30.0, + divisions: 30, + onChanged: (value) => theme.setProfileDialogBlur(value), + displayValue: theme.profileDialogBlur.toStringAsFixed(1), + ), + ], + ), + const SizedBox(height: 24), + _ModernSection( + title: "Режим рабочего стола", + children: [ + _CustomSettingTile( + icon: Icons.desktop_windows, + title: "Режим с контактами слева", + subtitle: "Контакты слева, чат справа", + child: Switch( + value: theme.useDesktopLayout, + onChanged: (value) => theme.setUseDesktopLayout(value), + ), + ), + ], + ), + const SizedBox(height: 24), + _ModernSection( + title: "Панели чата", + children: [ + _CustomSettingTile( + icon: Icons.tune, + title: "Эффект стекла для панелей", + subtitle: "Размытие и прозрачность", + child: Switch( + value: theme.useGlassPanels, + onChanged: (value) => theme.setUseGlassPanels(value), + ), + ), + if (theme.useGlassPanels) ...[ + const Divider(height: 24, indent: 16, endIndent: 16), + _SliderTile( + label: "Непрозрачность верхней панели", + value: theme.topBarOpacity, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => theme.setTopBarOpacity(value), + displayValue: "${(theme.topBarOpacity * 100).round()}%", + ), + _SliderTile( + label: "Размытие верхней панели", + value: theme.topBarBlur, + min: 0.0, + max: 20.0, + divisions: 40, + onChanged: (value) => theme.setTopBarBlur(value), + displayValue: theme.topBarBlur.toStringAsFixed(1), + ), + const Divider(height: 24, indent: 16, endIndent: 16), + _SliderTile( + label: "Непрозрачность нижней панели", + value: theme.bottomBarOpacity, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => theme.setBottomBarOpacity(value), + displayValue: "${(theme.bottomBarOpacity * 100).round()}%", + ), + _SliderTile( + label: "Размытие нижней панели", + value: theme.bottomBarBlur, + min: 0.0, + max: 20.0, + divisions: 40, + onChanged: (value) => theme.setBottomBarBlur(value), + displayValue: theme.bottomBarBlur.toStringAsFixed(1), + ), + ], + ], + ), + ], + ), + ); + } +} + +class _ThemeManagementSection extends StatelessWidget { + const _ThemeManagementSection(); + + void _showSaveThemeDialog(BuildContext context, ThemeProvider theme) { + final controller = TextEditingController( + text: "Копия ${theme.activeTheme.name}", + ); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Сохранить тему"), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: "Название темы"), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Отмена"), + ), + TextButton( + onPressed: () { + theme.saveCurrentThemeAs(controller.text); + Navigator.of(context).pop(); + }, + child: const Text("Сохранить"), + ), + ], + ); + }, + ); + } + + void _showConfirmDeleteDialog( + BuildContext context, + ThemeProvider theme, + CustomThemePreset preset, + ) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Удалить тему?"), + content: Text("Вы уверены, что хотите удалить '${preset.name}'?"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Отмена"), + ), + TextButton( + onPressed: () { + theme.deleteTheme(preset.id); + Navigator.of(context).pop(); + }, + child: const Text("Удалить", style: TextStyle(color: Colors.red)), + ), + ], + ); + }, + ); + } + + void _showRenameDialog( + BuildContext context, + ThemeProvider theme, + CustomThemePreset preset, + ) { + final controller = TextEditingController(text: preset.name); + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("Переименовать тему"), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: "Новое название"), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Отмена"), + ), + TextButton( + onPressed: () { + if (controller.text.trim().isNotEmpty) { + theme.renameTheme(preset.id, controller.text); + Navigator.of(context).pop(); + } + }, + child: const Text("Сохранить"), + ), + ], + ); + }, + ); + } + + Future _doExport( + BuildContext context, + ThemeProvider theme, + CustomThemePreset preset, + ) async { + try { + final String jsonString = jsonEncode(preset.toJson()); + final String fileName = + '${preset.name.replaceAll(RegExp(r'[\\/*?:"<>|]'), '_')}.ktheme'; + + String? outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Сохранить тему...', + fileName: fileName, + allowedExtensions: ['ktheme'], + type: FileType.custom, + ); + + if (outputFile != null) { + if (!outputFile.endsWith('.ktheme')) { + outputFile += '.ktheme'; + } + + final file = File(outputFile); + await file.writeAsString(jsonString); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Тема "${preset.name}" экспортирована.')), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Ошибка экспорта: $e'))); + } + } + } + + Future _doImport(BuildContext context, ThemeProvider theme) async { + try { + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['ktheme'], + ); + + if (result != null && result.files.single.path != null) { + final file = File(result.files.single.path!); + final jsonString = await file.readAsString(); + + final bool success = await theme.importThemeFromJson(jsonString); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? 'Тема успешно импортирована!' + : 'Ошибка: Неверный формат файла темы.', + ), + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Ошибка импорта: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + final colors = Theme.of(context).colorScheme; + + return _ModernSection( + title: "Пресеты тем", + children: [ + ...theme.savedThemes.map((preset) { + final bool isActive = theme.activeTheme.id == preset.id; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + leading: Icon( + isActive ? Icons.check_circle : Icons.radio_button_unchecked, + color: isActive ? colors.primary : colors.onSurfaceVariant, + ), + title: Text( + preset.name, + style: TextStyle( + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (preset.id != 'default') + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: "Переименовать", + onPressed: () => _showRenameDialog(context, theme, preset), + ), + + IconButton( + icon: const Icon(Icons.file_upload_outlined), + tooltip: "Экспорт", + onPressed: () => _doExport(context, theme, preset), + ), + if (preset.id != 'default') + IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.redAccent, + ), + tooltip: "Удалить", + onPressed: () => + _showConfirmDeleteDialog(context, theme, preset), + ), + ], + ), + onTap: () { + if (!isActive) { + theme.applyTheme(preset.id); + } + }, + ); + }), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text("Сохранить"), + onPressed: () => _showSaveThemeDialog(context, theme), + ), + TextButton.icon( + icon: const Icon(Icons.file_download_outlined), + label: const Text("Импорт"), + onPressed: () => _doImport(context, theme), + ), + ], + ), + ], + ); + } +} + +class _ModernSection extends StatelessWidget { + final String title; + final List children; + + const _ModernSection({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 12.0), + child: Text( + title.toUpperCase(), + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.bold, + fontSize: 14, + letterSpacing: 0.8, + ), + ), + ), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colors.outlineVariant.withOpacity(0.3)), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Column(children: children), + ), + ), + ], + ); + } +} + +class _CustomSettingTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final Widget child; + + const _CustomSettingTile({ + required this.icon, + required this.title, + this.subtitle, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + child, + ], + ); + } +} + +class _ColorPickerTile extends StatelessWidget { + final String title; + final String subtitle; + final Color color; + final ValueChanged onColorChanged; + + const _ColorPickerTile({ + required this.title, + required this.subtitle, + required this.color, + required this.onColorChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => _showColorPicker( + context, + initialColor: color, + onColorChanged: onColorChanged, + ), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + const Icon(Icons.color_lens_outlined), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _SliderTile extends StatelessWidget { + final IconData? icon; + final String label; + final double value; + final double min; + final double max; + final int divisions; + final ValueChanged onChanged; + final String displayValue; + + const _SliderTile({ + this.icon, + required this.label, + required this.value, + required this.min, + required this.max, + required this.divisions, + required this.onChanged, + required this.displayValue, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text(label, style: const TextStyle(fontSize: 14)), + ), + Text( + displayValue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + SizedBox( + height: 30, + child: Slider( + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + ), + ), + ], + ), + ); + } +} + +class AppThemeSelector extends StatelessWidget { + final AppTheme selectedTheme; + final ValueChanged onChanged; + + const AppThemeSelector({ + super.key, + required this.selectedTheme, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _ThemeButton( + theme: AppTheme.system, + selectedTheme: selectedTheme, + onChanged: onChanged, + icon: Icons.brightness_auto_outlined, + label: "Система", + ), + _ThemeButton( + theme: AppTheme.light, + selectedTheme: selectedTheme, + onChanged: onChanged, + icon: Icons.light_mode_outlined, + label: "Светлая", + ), + _ThemeButton( + theme: AppTheme.dark, + selectedTheme: selectedTheme, + onChanged: onChanged, + icon: Icons.dark_mode_outlined, + label: "Тёмная", + ), + _ThemeButton( + theme: AppTheme.black, + selectedTheme: selectedTheme, + onChanged: onChanged, + icon: Icons.dark_mode, + label: "OLED", + ), + ], + ); + } +} + +class _ThemeButton extends StatelessWidget { + final AppTheme theme; + final AppTheme selectedTheme; + final ValueChanged onChanged; + final IconData icon; + final String label; + + const _ThemeButton({ + required this.theme, + required this.selectedTheme, + required this.onChanged, + required this.icon, + required this.label, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final isSelected = selectedTheme == theme; + + return GestureDetector( + onTap: () => onChanged(theme), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 70, + height: 70, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isSelected + ? colors.primaryContainer + : colors.surfaceVariant.withOpacity(0.3), + border: Border.all( + color: isSelected ? colors.primary : Colors.transparent, + width: 2, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 24, + color: isSelected + ? colors.onPrimaryContainer + : colors.onSurfaceVariant, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? colors.onPrimaryContainer + : colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 1, + ), + ], + ), + ), + ); + } +} + +class _MessagePreviewSection extends StatelessWidget { + const _MessagePreviewSection(); + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + final colors = Theme.of(context).colorScheme; + final mockMyMessage = Message( + id: '1', + senderId: 100, + text: "Выглядит отлично! 🔥", + time: DateTime.now().millisecondsSinceEpoch, + attaches: const [], + ); + final mockTheirMessage = Message( + id: '2', + senderId: 200, + text: "Привет! Как тебе новый вид?", + time: DateTime.now().millisecondsSinceEpoch, + attaches: const [], + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 12.0), + child: Text( + "ПРЕДПРОСМОТР", + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.bold, + fontSize: 14, + letterSpacing: 0.8, + ), + ), + ), + Container( + height: 250, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outlineVariant.withOpacity(0.3)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: Stack( + children: [ + const _ChatWallpaperPreview(), + Column( + children: [ + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: theme.useGlassPanels ? theme.topBarBlur : 0, + sigmaY: theme.useGlassPanels ? theme.topBarBlur : 0, + ), + child: Container( + height: 40, + color: colors.surface.withOpacity( + theme.useGlassPanels ? theme.topBarOpacity : 0.0, + ), + child: Row( + children: [ + const SizedBox(width: 16), + CircleAvatar( + backgroundColor: colors.primaryContainer, + radius: 12, + ), + const SizedBox(width: 8), + Expanded( + child: Container( + height: 10, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(width: 40), + ], + ), + ), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ChatMessageBubble( + message: mockTheirMessage, + isMe: false, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ChatMessageBubble( + message: mockMyMessage, + isMe: true, + ), + ), + const SizedBox(height: 8), + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: theme.useGlassPanels + ? theme.bottomBarBlur + : 0, + sigmaY: theme.useGlassPanels + ? theme.bottomBarBlur + : 0, + ), + child: Container( + height: 40, + color: colors.surface.withOpacity( + theme.useGlassPanels ? theme.bottomBarOpacity : 0.0, + ), + child: Row( + children: [ + const SizedBox(width: 16), + Expanded( + child: Container( + height: 24, + decoration: BoxDecoration( + color: colors.surfaceVariant, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(width: 8), + Icon(Icons.send, color: colors.primary), + const SizedBox(width: 16), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ChatWallpaperPreview extends StatelessWidget { + const _ChatWallpaperPreview(); + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + + if (!theme.useCustomChatWallpaper) { + return Container(color: Theme.of(context).scaffoldBackgroundColor); + } + + switch (theme.chatWallpaperType) { + case ChatWallpaperType.solid: + return Container(color: theme.chatWallpaperColor1); + case ChatWallpaperType.gradient: + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [theme.chatWallpaperColor1, theme.chatWallpaperColor2], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ); + case ChatWallpaperType.image: + if (theme.chatWallpaperImagePath?.isNotEmpty == true) { + return Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(theme.chatWallpaperImagePath!), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Center(child: Icon(Icons.error)), + ), + if (theme.chatWallpaperImageBlur > 0) + BackdropFilter( + filter: ImageFilter.blur( + sigmaX: theme.chatWallpaperImageBlur, + sigmaY: theme.chatWallpaperImageBlur, + ), + child: Container(color: Colors.black.withOpacity(0.05)), + ), + ], + ); + } else { + return Container( + color: isDarkTheme ? Colors.grey[850] : Colors.grey[200], + child: Center( + child: Icon( + Icons.image_not_supported_outlined, + color: isDarkTheme ? Colors.grey[600] : Colors.grey[400], + size: 40, + ), + ), + ); + } + case ChatWallpaperType.video: + + if (Platform.isWindows) { + return Container( + color: isDarkTheme ? Colors.grey[850] : Colors.grey[200], + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.video_library_outlined, + color: isDarkTheme ? Colors.grey[600] : Colors.grey[400], + size: 40, + ), + const SizedBox(height: 8), + Text( + 'Видео-обои\nне поддерживаются на Windows', + style: TextStyle( + color: isDarkTheme ? Colors.grey[600] : Colors.grey[400], + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + if (theme.chatWallpaperVideoPath?.isNotEmpty == true) { + return _VideoWallpaper(path: theme.chatWallpaperVideoPath!); + } else { + return Container( + color: isDarkTheme ? Colors.grey[850] : Colors.grey[200], + child: Center( + child: Icon( + Icons.video_library_outlined, + color: isDarkTheme ? Colors.grey[600] : Colors.grey[400], + size: 40, + ), + ), + ); + } + } + } +} + +class _VideoWallpaper extends StatefulWidget { + final String path; + + const _VideoWallpaper({required this.path}); + + @override + State<_VideoWallpaper> createState() => _VideoWallpaperState(); +} + +class _VideoWallpaperState extends State<_VideoWallpaper> { + VideoPlayerController? _controller; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + try { + final file = File(widget.path); + if (!await file.exists()) { + setState(() { + _errorMessage = 'Video file not found'; + }); + print('ERROR: Video file does not exist: ${widget.path}'); + return; + } + + _controller = VideoPlayerController.file(file); + await _controller!.initialize(); + + if (mounted) { + _controller!.setVolume(0); + _controller!.setLooping(true); + _controller!.play(); + setState(() {}); + print('SUCCESS: Video initialized and playing'); + } + } catch (e) { + print('ERROR initializing video: $e'); + setState(() { + _errorMessage = e.toString(); + }); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_errorMessage != null) { + print('ERROR building video widget: $_errorMessage'); + return Container( + color: Colors.black, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.white70, size: 40), + const SizedBox(height: 8), + Text( + _errorMessage!, + style: const TextStyle(color: Colors.white70, fontSize: 10), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + if (_controller == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (!_controller!.value.isInitialized) { + return const Center(child: CircularProgressIndicator()); + } + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _controller!.value.size.width, + height: _controller!.value.size.height, + child: VideoPlayer(_controller!), + ), + ), + ), + + Container( + decoration: BoxDecoration(color: Colors.black.withOpacity(0.3)), + ), + ], + ); + } +} diff --git a/lib/screens/settings/export_session_screen.dart b/lib/screens/settings/export_session_screen.dart new file mode 100644 index 0000000..ea242ed --- /dev/null +++ b/lib/screens/settings/export_session_screen.dart @@ -0,0 +1,278 @@ + + +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:file_saver/file_saver.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/proxy_service.dart'; +import 'package:gwid/spoofing_service.dart'; +import 'package:encrypt/encrypt.dart' as encrypt; +import 'package:crypto/crypto.dart' as crypto; + +class ExportSessionScreen extends StatefulWidget { + const ExportSessionScreen({super.key}); + + @override + State createState() => _ExportSessionScreenState(); +} + +class _ExportSessionScreenState extends State { + final _passwordController = TextEditingController(); + bool _isPasswordVisible = false; + bool _isExporting = false; + bool _saveProxySettings = false; + + + Future _exportAndSaveSession() async { + if (!mounted) return; + setState(() => _isExporting = true); + final messenger = ScaffoldMessenger.of(context); + + try { + final spoofData = await SpoofingService.getSpoofedSessionData(); + final token = ApiService.instance.token; + + if (token == null || token.isEmpty) { + throw Exception('Токен пользователя не найден.'); + } + + final sessionData = { + 'token': token, + 'spoof_data': spoofData ?? 'Подмена устройства неактивна', + }; + + if (_saveProxySettings) { + final proxySettings = await ProxyService.instance.loadProxySettings(); + sessionData['proxy_settings'] = proxySettings.toJson(); + } + + const jsonEncoder = JsonEncoder.withIndent(' '); + final plainJsonContent = jsonEncoder.convert(sessionData); + String finalFileContent; + final password = _passwordController.text; + + if (password.isNotEmpty) { + final keyBytes = utf8.encode(password); + final keyHash = crypto.sha256.convert(keyBytes); + final key = encrypt.Key(Uint8List.fromList(keyHash.bytes)); + final iv = encrypt.IV.fromLength(16); + final encrypter = encrypt.Encrypter( + encrypt.AES(key, mode: encrypt.AESMode.cbc), + ); + final encrypted = encrypter.encrypt(plainJsonContent, iv: iv); + final encryptedOutput = { + 'encrypted': true, + 'iv_base64': iv.base64, + 'data_base64': encrypted.base64, + }; + finalFileContent = jsonEncoder.convert(encryptedOutput); + } else { + finalFileContent = plainJsonContent; + } + + Uint8List bytes = Uint8List.fromList(utf8.encode(finalFileContent)); + + String? filePath = await FileSaver.instance.saveAs( + name: 'komet_session_${DateTime.now().millisecondsSinceEpoch}', + bytes: bytes, + fileExtension: 'json', + mimeType: MimeType.json, + ); + + if (filePath != null && mounted) { + messenger.showSnackBar( + const SnackBar( + content: Text('Файл сессии успешно сохранен'), + backgroundColor: Colors.green, + ), + ); + } else if (mounted) { + messenger.showSnackBar( + const SnackBar(content: Text('Сохранение файла было отменено.')), + ); + } + } catch (e) { + messenger.showSnackBar( + SnackBar( + backgroundColor: Colors.red, + content: Text('Не удалось экспортировать сессию: $e'), + ), + ); + } finally { + if (mounted) { + setState(() => _isExporting = false); + } + } + } + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar(title: const Text('Экспорт сессии')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + + Center( + child: CircleAvatar( + radius: 40, + backgroundColor: colors.primaryContainer, + child: Icon( + Icons.upload_file_outlined, + size: 40, + color: colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Резервная копия сессии', + textAlign: TextAlign.center, + style: textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Создайте зашифрованный файл для переноса вашего аккаунта на другое устройство без повторной авторизации.', + textAlign: TextAlign.center, + style: textTheme.bodyLarge?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 24), + + + Text( + '1. Защитите файл паролем', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Настоятельно рекомендуется установить пароль для шифрования (AES-256). Без него файл будет сохранен в открытом виде.', + style: textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: !_isPasswordVisible, + decoration: InputDecoration( + labelText: 'Пароль (необязательно)', + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _isPasswordVisible + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () => + setState(() => _isPasswordVisible = !_isPasswordVisible), + ), + ), + ), + const SizedBox(height: 24), + Text( + '2. Дополнительные данные', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + + Card( + margin: EdgeInsets.zero, + child: CheckboxListTile( + title: const Text('Сохранить настройки прокси'), + subtitle: const Text( + 'Включить текущие параметры прокси в файл экспорта.', + ), + value: _saveProxySettings, + onChanged: (bool? value) => + setState(() => _saveProxySettings = value ?? false), + controlAffinity: + ListTileControlAffinity.leading, // Чекбокс слева + ), + ), + const SizedBox(height: 32), + + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.errorContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + color: colors.error, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Никогда и никому не передавайте этот файл. Он дает полный доступ к вашему аккаунту.', + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + + + FilledButton.icon( + onPressed: _isExporting ? null : _exportAndSaveSession, + icon: _isExporting + ? Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : const Icon(Icons.download_for_offline_outlined), + label: Text( + _isExporting ? 'Сохранение...' : 'Экспортировать и сохранить', + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/komet_misc_screen.dart b/lib/screens/settings/komet_misc_screen.dart new file mode 100644 index 0000000..dd3ea54 --- /dev/null +++ b/lib/screens/settings/komet_misc_screen.dart @@ -0,0 +1,521 @@ +import 'package:flutter/material.dart'; +import 'package:disable_battery_optimization/disable_battery_optimization.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:io' show Platform; + +class KometMiscScreen extends StatefulWidget { + final bool isModal; + + const KometMiscScreen({super.key, this.isModal = false}); + + @override + State createState() => _KometMiscScreenState(); +} + +class _KometMiscScreenState extends State { + bool? _isBatteryOptimizationDisabled; + bool _isAutoUpdateEnabled = true; + bool _showUpdateNotification = true; + bool _enableWebVersionCheck = false; + + @override + void initState() { + super.initState(); + _checkBatteryOptimizationStatus(); + _loadUpdateSettings(); + } + + Future _loadUpdateSettings() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + + _isAutoUpdateEnabled = prefs.getBool('auto_update_enabled') ?? true; + _showUpdateNotification = + prefs.getBool('show_update_notification') ?? true; + _enableWebVersionCheck = + prefs.getBool('enable_web_version_check') ?? false; + }); + } + + Future _checkBatteryOptimizationStatus() async { + bool? isDisabled = + await DisableBatteryOptimization.isBatteryOptimizationDisabled; + if (mounted) { + setState(() { + _isBatteryOptimizationDisabled = isDisabled; + }); + } + } + + Future _requestDisableBatteryOptimization() async { + await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); + Future.delayed(const Duration(milliseconds: 500), () { + _checkBatteryOptimizationStatus(); + }); + } + + Future _updateSettings(String key, bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(key, value); + } + + @override + Widget build(BuildContext context) { + String subtitleText; + Color statusColor; + final defaultTextColor = Theme.of(context).textTheme.bodyMedium?.color; + + + final isDesktopOrIOS = + Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isIOS; + + if (isDesktopOrIOS) { + subtitleText = "Недоступно"; + statusColor = Colors.grey; + } else if (_isBatteryOptimizationDisabled == null) { + subtitleText = "Проверка статуса..."; + statusColor = Colors.grey; + } else if (_isBatteryOptimizationDisabled == true) { + subtitleText = "Разрешено"; + statusColor = Colors.green; + } else { + subtitleText = "Не разрешено"; + statusColor = Colors.orange; + } + + if (widget.isModal) { + return buildModalContent(context); + } + + return Scaffold( + appBar: AppBar(title: const Text("Komet Misc")), + body: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + leading: Icon( + Icons.battery_charging_full_rounded, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text("Фоновая работа"), + subtitle: RichText( + text: TextSpan( + style: TextStyle(fontSize: 14, color: defaultTextColor), + children: [ + const TextSpan(text: 'Статус: '), + TextSpan( + text: subtitleText, + style: TextStyle( + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + ), + onTap: isDesktopOrIOS ? null : _requestDisableBatteryOptimization, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Text( + isDesktopOrIOS + ? 'Фоновая работа недоступна на данной платформе.' + : 'Для стабильной работы приложения в фоновом режиме рекомендуется отключить оптимизацию расхода заряда батареи.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + fontSize: 12, + ), + ), + ), + + const Divider(height: 20), + + Card( + margin: const EdgeInsets.only(bottom: 10), + child: Column( + children: [ + SwitchListTile( + secondary: Icon( + Icons.wifi_find_outlined, + color: _enableWebVersionCheck + ? Theme.of(context).colorScheme.primary + : Colors.grey, + ), + title: const Text("Проверка версии через web"), + subtitle: Text( + _enableWebVersionCheck + ? "Проверяет актуальную версию на web.max.ru" + : "Проверка версии отключена", + ), + value: _enableWebVersionCheck, + onChanged: (bool value) { + setState(() { + _enableWebVersionCheck = value; + }); + _updateSettings('enable_web_version_check', value); + }, + ), + const Divider(height: 1), + SwitchListTile( + secondary: Icon( + Icons.system_update_alt_rounded, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text("Автообновление сессии"), + subtitle: const Text("Версия будет обновляться в фоне"), + value: _isAutoUpdateEnabled, + onChanged: (bool value) { + setState(() { + _isAutoUpdateEnabled = value; + }); + _updateSettings('auto_update_enabled', value); + }, + ), + + SwitchListTile( + secondary: Icon( + Icons.notifications_active_outlined, + + color: _isAutoUpdateEnabled + ? Colors.grey + : Theme.of(context).colorScheme.primary, + ), + title: const Text("Уведомлять о новой версии"), + subtitle: Text( + _isAutoUpdateEnabled + ? "Недоступно при автообновлении" + : "Показывать диалог при запуске", + ), + value: _showUpdateNotification, + + onChanged: _isAutoUpdateEnabled + ? null + : (bool value) { + setState(() { + _showUpdateNotification = value; + }); + _updateSettings('show_update_notification', value); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Text( + 'Автообновление автоматически изменит версию вашей сессии на последнюю доступную без дополнительных уведомлений.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + fontSize: 12, + ), + ), + ), + ], + ), + ); + } + + Widget _buildModalSettings( + BuildContext context, + String subtitleText, + Color statusColor, + Color? defaultTextColor, + ) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.3), + ), + ), + + + Center( + child: Container( + width: 400, + height: 600, + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + const Expanded( + child: Text( + "Komet Misc", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded( + child: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Card( + child: ListTile( + leading: const Icon(Icons.battery_charging_full), + title: const Text("Оптимизация батареи"), + subtitle: Text(subtitleText), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.circle, + color: statusColor, + size: 12, + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + onTap: + Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isIOS + ? null + : _requestDisableBatteryOptimization, + ), + ), + const SizedBox(height: 16), + Card( + child: Column( + children: [ + SwitchListTile( + title: const Text("Автообновления"), + subtitle: const Text( + "Автоматически проверять обновления", + ), + value: _isAutoUpdateEnabled, + onChanged: (value) { + setState(() { + _isAutoUpdateEnabled = value; + }); + _updateSettings('auto_update_enabled', value); + }, + ), + SwitchListTile( + title: const Text("Уведомления об обновлениях"), + subtitle: const Text( + "Показывать уведомления о доступных обновлениях", + ), + value: _showUpdateNotification, + onChanged: (value) { + setState(() { + _showUpdateNotification = value; + }); + _updateSettings( + 'show_update_notification', + value, + ); + }, + ), + SwitchListTile( + title: const Text("Проверка веб-версии"), + subtitle: const Text( + "Проверять обновления через веб-интерфейс", + ), + value: _enableWebVersionCheck, + onChanged: (value) { + setState(() { + _enableWebVersionCheck = value; + }); + _updateSettings( + 'enable_web_version_check', + value, + ); + }, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget buildModalContent(BuildContext context) { + String subtitleText; + Color statusColor; + final defaultTextColor = Theme.of(context).textTheme.bodyMedium?.color; + + + final isDesktopOrIOS = + Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isIOS; + + if (isDesktopOrIOS) { + subtitleText = "Недоступно"; + statusColor = Colors.grey; + } else if (_isBatteryOptimizationDisabled == null) { + subtitleText = "Проверка статуса..."; + statusColor = Colors.grey; + } else if (_isBatteryOptimizationDisabled == true) { + subtitleText = "Разрешено"; + statusColor = Colors.green; + } else { + subtitleText = "Не разрешено"; + statusColor = Colors.orange; + } + + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + leading: Icon( + Icons.battery_charging_full_rounded, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text("Фоновая работа"), + subtitle: RichText( + text: TextSpan( + style: TextStyle(fontSize: 14, color: defaultTextColor), + children: [ + const TextSpan(text: 'Статус: '), + TextSpan( + text: subtitleText, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: isDesktopOrIOS ? null : _requestDisableBatteryOptimization, + ), + ), + Card( + margin: const EdgeInsets.only(bottom: 10), + child: Column( + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + secondary: Icon( + Icons.system_update_rounded, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text("Автообновления"), + subtitle: const Text( + "Автоматически проверять и устанавливать обновления", + ), + value: _isAutoUpdateEnabled, + onChanged: (value) { + setState(() { + _isAutoUpdateEnabled = value; + }); + _updateSettings('auto_update_enabled', value); + }, + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + secondary: Icon( + Icons.notifications_active_rounded, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text("Уведомления об обновлениях"), + subtitle: const Text( + "Показывать уведомления о доступных обновлениях", + ), + value: _showUpdateNotification, + onChanged: (value) { + setState(() { + _showUpdateNotification = value; + }); + _updateSettings('show_update_notification', value); + }, + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + secondary: Icon( + Icons.web_rounded, + color: Theme.of(context).colorScheme.primary, + ), + title: const Text("Проверка веб-версии"), + subtitle: const Text( + "Проверять обновления веб-версии приложения", + ), + value: _enableWebVersionCheck, + onChanged: (value) { + setState(() { + _enableWebVersionCheck = value; + }); + _updateSettings('enable_web_version_check', value); + }, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/settings/network_screen.dart b/lib/screens/settings/network_screen.dart new file mode 100644 index 0000000..70c52ef --- /dev/null +++ b/lib/screens/settings/network_screen.dart @@ -0,0 +1,868 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; +import 'dart:math'; +import 'package:gwid/api_service.dart'; + +class NetworkScreen extends StatefulWidget { + const NetworkScreen({super.key}); + + @override + State createState() => _NetworkScreenState(); +} + +class _NetworkScreenState extends State + with TickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + NetworkStats? _networkStats; + bool _isLoading = true; + Timer? _updateTimer; + bool _isMonitoring = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + _animation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + _loadNetworkStats(); + } + + @override + void dispose() { + _animationController.dispose(); + _updateTimer?.cancel(); + super.dispose(); + } + + Future _loadNetworkStats() async { + try { + final stats = await _getNetworkStats(); + setState(() { + _networkStats = stats; + _isLoading = false; + }); + _animationController.forward(); + } catch (e) { + setState(() { + _isLoading = false; + }); + } + } + + Future _getNetworkStats() async { + + final stats = await ApiService.instance.getNetworkStatistics(); + + + final totalDailyTraffic = stats['totalTraffic'] as double; + + + final messagesTraffic = stats['messagesTraffic'] as double; + final mediaTraffic = stats['mediaTraffic'] as double; + final syncTraffic = stats['syncTraffic'] as double; + final otherTraffic = stats['otherTraffic'] as double; + + + final currentSpeed = stats['currentSpeed'] as double; + + + final hourlyData = stats['hourlyStats'] as List; + final hourlyStats = List.generate(24, (index) { + if (index < hourlyData.length) { + return HourlyStats(hour: index, traffic: hourlyData[index] as double); + } + + final hour = index; + final isActive = hour >= 8 && hour <= 23; + final baseTraffic = isActive ? 20.0 * 1024 * 1024 : 2.0 * 1024 * 1024; + return HourlyStats(hour: hour, traffic: baseTraffic); + }); + + return NetworkStats( + totalDailyTraffic: totalDailyTraffic, + messagesTraffic: messagesTraffic, + mediaTraffic: mediaTraffic, + syncTraffic: syncTraffic, + otherTraffic: otherTraffic, + currentSpeed: currentSpeed, + hourlyStats: hourlyStats, + isConnected: stats['isConnected'] as bool, + connectionType: stats['connectionType'] as String, + signalStrength: stats['signalStrength'] as int, + ping: stats['ping'] as int, + jitter: stats['jitter'] as double, + packetLoss: stats['packetLoss'] as double, + ); + } + + void _startMonitoring() { + if (_isMonitoring) return; + + setState(() { + _isMonitoring = true; + }); + + _updateTimer = Timer.periodic(const Duration(seconds: 2), (timer) { + _loadNetworkStats(); + }); + } + + void _stopMonitoring() { + _updateTimer?.cancel(); + setState(() { + _isMonitoring = false; + }); + } + + void _resetStats() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Сбросить статистику'), + content: const Text( + 'Это действие сбросит всю статистику использования сети. ' + 'Это действие нельзя отменить.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Сбросить'), + ), + ], + ), + ); + + if (confirmed == true) { + await _loadNetworkStats(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Статистика сброшена'), + backgroundColor: Colors.green, + ), + ); + } + } + } + + String _formatBytes(double bytes) { + if (bytes < 1024) return '${bytes.toStringAsFixed(0)} B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + String _formatSpeed(double bytesPerSecond) { + if (bytesPerSecond < 1024) + return '${bytesPerSecond.toStringAsFixed(0)} B/s'; + if (bytesPerSecond < 1024 * 1024) + return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; + return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s'; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Сеть'), + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + elevation: 0, + actions: [ + IconButton( + icon: Icon(_isMonitoring ? Icons.pause : Icons.play_arrow), + onPressed: _isMonitoring ? _stopMonitoring : _startMonitoring, + tooltip: _isMonitoring + ? 'Остановить мониторинг' + : 'Начать мониторинг', + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _networkStats == null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.wifi_off, + size: 64, + color: colors.onSurface.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + 'Не удалось загрузить статистику сети', + style: TextStyle( + color: colors.onSurface.withOpacity(0.6), + fontSize: 16, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadNetworkStats, + child: const Text('Повторить'), + ), + ], + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + _buildConnectionStatus(colors), + + const SizedBox(height: 24), + + + _buildNetworkChart(colors), + + const SizedBox(height: 24), + + + _buildCurrentSpeed(colors), + + const SizedBox(height: 24), + + + _buildTrafficDetails(colors), + + const SizedBox(height: 24), + + + _buildHourlyChart(colors), + + const SizedBox(height: 24), + + + _buildActionButtons(colors), + ], + ), + ), + ); + } + + Widget _buildConnectionStatus(ColorScheme colors) { + final stats = _networkStats!; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outline.withOpacity(0.2)), + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: stats.isConnected + ? colors.primary.withOpacity(0.1) + : colors.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + stats.isConnected ? Icons.wifi : Icons.wifi_off, + color: stats.isConnected ? colors.primary : colors.error, + size: 30, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + stats.isConnected ? 'Подключено' : 'Отключено', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + stats.connectionType, + style: TextStyle( + fontSize: 14, + color: colors.onSurface.withOpacity(0.7), + ), + ), + if (stats.isConnected) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.signal_cellular_alt, + size: 16, + color: colors.primary, + ), + const SizedBox(width: 4), + Text( + '${stats.signalStrength}%', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildNetworkChart(ColorScheme colors) { + final stats = _networkStats!; + final totalTraffic = stats.totalDailyTraffic; + final usagePercentage = totalTraffic > 0 + ? (stats.messagesTraffic + + stats.mediaTraffic + + stats.syncTraffic + + stats.otherTraffic) / + totalTraffic + : 0.0; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outline.withOpacity(0.2)), + ), + child: Column( + children: [ + Text( + 'Использование сети за день', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 24), + + + AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return SizedBox( + width: 200, + height: 200, + child: Stack( + children: [ + + Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.surfaceContainerHighest, + ), + ), + + + CustomPaint( + size: const Size(200, 200), + painter: NetworkChartPainter( + progress: usagePercentage * _animation.value, + colors: colors, + ), + ), + + + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _formatBytes(totalTraffic), + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + Text( + 'использовано', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withOpacity(0.7), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + + const SizedBox(height: 24), + + + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _buildLegendItem( + 'Медиа', + _formatBytes(stats.mediaTraffic), + colors.primary, + ), + _buildLegendItem( + 'Сообщения', + _formatBytes(stats.messagesTraffic), + colors.secondary, + ), + _buildLegendItem( + 'Синхронизация', + _formatBytes(stats.syncTraffic), + colors.tertiary, + ), + _buildLegendItem( + 'Другое', + _formatBytes(stats.otherTraffic), + colors.outline, + ), + ], + ), + ], + ), + ); + } + + Widget _buildLegendItem(String label, String value, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 12)), + Text( + value, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500), + ), + ], + ), + ], + ); + } + + Widget _buildCurrentSpeed(ColorScheme colors) { + final stats = _networkStats!; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outline.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.speed, color: colors.primary, size: 20), + const SizedBox(width: 8), + Text( + 'Текущая скорость', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Text( + _formatSpeed(stats.currentSpeed), + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '↓', + style: TextStyle( + fontSize: 12, + color: colors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildTrafficDetails(ColorScheme colors) { + final stats = _networkStats!; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outline.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Детали трафика', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 16), + + _buildTrafficItem( + 'Медиафайлы', + _formatBytes(stats.mediaTraffic), + Icons.photo_library_outlined, + colors.primary, + (stats.mediaTraffic / stats.totalDailyTraffic), + ), + + _buildTrafficItem( + 'Сообщения', + _formatBytes(stats.messagesTraffic), + Icons.message_outlined, + colors.secondary, + (stats.messagesTraffic / stats.totalDailyTraffic), + ), + + _buildTrafficItem( + 'Синхронизация', + _formatBytes(stats.syncTraffic), + Icons.sync, + colors.tertiary, + (stats.syncTraffic / stats.totalDailyTraffic), + ), + + _buildTrafficItem( + 'Другие данные', + _formatBytes(stats.otherTraffic), + Icons.folder_outlined, + colors.outline, + (stats.otherTraffic / stats.totalDailyTraffic), + ), + ], + ), + ); + } + + Widget _buildTrafficItem( + String title, + String size, + IconData icon, + Color color, + double percentage, + ) { + final colors = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + size, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + Container( + width: 60, + height: 4, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(2), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: percentage, + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildHourlyChart(ColorScheme colors) { + final stats = _networkStats!; + final maxTraffic = stats.hourlyStats + .map((e) => e.traffic) + .reduce((a, b) => a > b ? a : b); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outline.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Активность по часам', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: stats.hourlyStats.length, + itemBuilder: (context, index) { + final hourStats = stats.hourlyStats[index]; + final height = maxTraffic > 0 + ? (hourStats.traffic / maxTraffic) + : 0.0; + + return Container( + width: 20, + margin: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + height: height * 100, + decoration: BoxDecoration( + color: colors.primary.withOpacity(0.7), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 4), + Text( + '${hourStats.hour}', + style: TextStyle( + fontSize: 10, + color: colors.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons(ColorScheme colors) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Действия', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 16), + + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _isMonitoring ? _stopMonitoring : _startMonitoring, + icon: Icon(_isMonitoring ? Icons.pause : Icons.play_arrow), + label: Text( + _isMonitoring ? 'Остановить мониторинг' : 'Начать мониторинг', + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: _resetStats, + icon: const Icon(Icons.refresh), + label: const Text('Сбросить статистику'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ); + } +} + +class NetworkStats { + final double totalDailyTraffic; + final double messagesTraffic; + final double mediaTraffic; + final double syncTraffic; + final double otherTraffic; + final double currentSpeed; + final List hourlyStats; + final bool isConnected; + final String connectionType; + final int signalStrength; + final int ping; + final double jitter; + final double packetLoss; + + NetworkStats({ + required this.totalDailyTraffic, + required this.messagesTraffic, + required this.mediaTraffic, + required this.syncTraffic, + required this.otherTraffic, + required this.currentSpeed, + required this.hourlyStats, + required this.isConnected, + required this.connectionType, + required this.signalStrength, + this.ping = 25, + this.jitter = 2.5, + this.packetLoss = 0.01, + }); +} + +class HourlyStats { + final int hour; + final double traffic; + + HourlyStats({required this.hour, required this.traffic}); +} + +class NetworkChartPainter extends CustomPainter { + final double progress; + final ColorScheme colors; + + NetworkChartPainter({required this.progress, required this.colors}); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - 8; + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 16 + ..strokeCap = StrokeCap.round; + + + paint.color = colors.surfaceContainerHighest; + canvas.drawCircle(center, radius, paint); + + + paint.color = colors.primary; + final sweepAngle = 2 * pi * progress; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -pi / 2, // Начинаем сверху + sweepAngle, + false, + paint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return oldDelegate is NetworkChartPainter && + oldDelegate.progress != progress; + } +} diff --git a/lib/screens/settings/network_settings_screen.dart b/lib/screens/settings/network_settings_screen.dart new file mode 100644 index 0000000..17d36d8 --- /dev/null +++ b/lib/screens/settings/network_settings_screen.dart @@ -0,0 +1,262 @@ + + +import 'package:flutter/material.dart'; +import 'package:gwid/screens/settings/network_screen.dart'; +import 'package:gwid/screens/settings/proxy_settings_screen.dart'; +import 'package:gwid/screens/settings/socket_log_screen.dart'; + +class NetworkSettingsScreen extends StatelessWidget { + final bool isModal; + + const NetworkSettingsScreen({super.key, this.isModal = false}); + + @override + Widget build(BuildContext context) { + if (isModal) { + return buildModalContent(context); + } + + return Scaffold( + appBar: AppBar(title: const Text("Сеть")), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildNetworkOption( + context, + icon: Icons.bar_chart_outlined, + title: "Мониторинг сети", + subtitle: "Просмотр статистики использования и скорости соединения", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const NetworkScreen()), + ); + }, + ), + _buildNetworkOption( + context, + icon: Icons.shield_outlined, + title: "Настройки прокси", + subtitle: "Настроить подключение через прокси-сервер", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ProxySettingsScreen(), + ), + ); + }, + ), + _buildNetworkOption( + context, + icon: Icons.history_outlined, + title: "Журнал WebSocket", + subtitle: "Просмотр логов подключения WebSocket для отладки", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SocketLogScreen(), + ), + ); + }, + ), + ], + ), + ); + } + + Widget buildModalContent(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildNetworkOption( + context, + icon: Icons.bar_chart_outlined, + title: "Мониторинг сети", + subtitle: "Статистика подключений и производительности", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const NetworkScreen(), + ), + ); + }, + ), + const SizedBox(height: 16), + _buildNetworkOption( + context, + icon: Icons.vpn_key_outlined, + title: "Настройки прокси", + subtitle: "HTTP/HTTPS прокси, SOCKS", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ProxySettingsScreen(), + ), + ); + }, + ), + const SizedBox(height: 16), + _buildNetworkOption( + context, + icon: Icons.list_alt_outlined, + title: "Логи сокетов", + subtitle: "Отладочная информация о соединениях", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SocketLogScreen(), + ), + ); + }, + ), + ], + ); + } + + Widget _buildModalSettings(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.3), + ), + ), + + + Center( + child: Container( + width: 400, + height: 600, + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + const Expanded( + child: Text( + "Сеть", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildNetworkOption( + context, + icon: Icons.bar_chart_outlined, + title: "Мониторинг сети", + subtitle: "Статистика подключений и производительности", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const NetworkScreen(), + ), + ); + }, + ), + const SizedBox(height: 16), + _buildNetworkOption( + context, + icon: Icons.vpn_key_outlined, + title: "Настройки прокси", + subtitle: "HTTP/HTTPS прокси, SOCKS", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ProxySettingsScreen(), + ), + ); + }, + ), + const SizedBox(height: 16), + _buildNetworkOption( + context, + icon: Icons.list_alt_outlined, + title: "Логи сокетов", + subtitle: "Отладочная информация о соединениях", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SocketLogScreen(), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildNetworkOption( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: onTap, + ), + ); + } +} diff --git a/lib/screens/settings/notification_settings_screen.dart b/lib/screens/settings/notification_settings_screen.dart new file mode 100644 index 0000000..c5bd5a2 --- /dev/null +++ b/lib/screens/settings/notification_settings_screen.dart @@ -0,0 +1,591 @@ + + +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'dart:io' show Platform; + +class NotificationSettingsScreen extends StatefulWidget { + final bool isModal; + + const NotificationSettingsScreen({super.key, this.isModal = false}); + + @override + State createState() => + _NotificationSettingsScreenState(); +} + +class _NotificationSettingsScreenState + extends State { + + String _chatsPushNotification = 'ON'; + bool _mCallPushNotification = true; + bool _pushDetails = true; + String _chatsPushSound = 'DEFAULT'; + String _pushSound = 'DEFAULT'; + bool _isLoading = false; + + Widget buildModalContent(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + + final isDesktopOrIOS = + Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isIOS; + + if (isDesktopOrIOS) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon(Icons.info_outline, size: 48, color: colors.primary), + const SizedBox(height: 16), + Text( + 'Фоновые уведомления недоступны', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + Platform.isIOS + ? 'На iOS фоновые уведомления не поддерживаются системой.' + : 'На настольных платформах (Windows, macOS, Linux) фоновые уведомления отключены.', + style: TextStyle(color: colors.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ); + } + + return _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + _OutlinedSection( + child: Column( + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.chat_bubble_outline), + title: const Text("Уведомления из чатов"), + value: _chatsPushNotification == 'ON', + onChanged: (value) => + _updateNotificationSetting(chatsPush: value), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.phone_outlined), + title: const Text("Уведомления о звонках"), + value: _mCallPushNotification, + onChanged: (value) => + _updateNotificationSetting(mCallPush: value), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.visibility_outlined), + title: const Text("Показывать текст"), + subtitle: const Text("Показывать превью сообщения"), + value: _pushDetails, + onChanged: (value) => + _updateNotificationSetting(pushDetails: value), + ), + ], + ), + ), + const SizedBox(height: 16), + _OutlinedSection( + child: Column( + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.music_note_outlined), + title: const Text("Звук в чатах"), + trailing: Text( + _getSoundDescription(_chatsPushSound), + style: TextStyle(color: colors.primary), + ), + onTap: () => _showSoundDialog( + "Звук уведомлений чатов", + _chatsPushSound, + (value) => + _updateNotificationSetting(chatsSound: value), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.notifications_active_outlined), + title: const Text("Общий звук"), + trailing: Text( + _getSoundDescription(_pushSound), + style: TextStyle(color: colors.primary), + ), + onTap: () => _showSoundDialog( + "Общий звук уведомлений", + _pushSound, + (value) => _updateNotificationSetting(pushSound: value), + ), + ), + ], + ), + ), + ], + ); + } + + @override + void initState() { + super.initState(); + _loadCurrentSettings(); + } + + Future _loadCurrentSettings() async { + setState(() => _isLoading = true); + + await Future.delayed( + const Duration(milliseconds: 500), + ); // Имитация загрузки + setState(() => _isLoading = false); + } + + + + Future _updateNotificationSetting({ + bool? chatsPush, + bool? mCallPush, + bool? pushDetails, + String? chatsSound, + String? pushSound, + }) async { + try { + await ApiService.instance.updatePrivacySettings( + chatsPushNotification: chatsPush, + mCallPushNotification: mCallPush, + pushDetails: pushDetails, + chatsPushSound: chatsSound, + pushSound: pushSound, + ); + + if (chatsPush != null) + setState(() => _chatsPushNotification = chatsPush ? 'ON' : 'OFF'); + if (mCallPush != null) setState(() => _mCallPushNotification = mCallPush); + if (pushDetails != null) setState(() => _pushDetails = pushDetails); + if (chatsSound != null) setState(() => _chatsPushSound = chatsSound); + if (pushSound != null) setState(() => _pushSound = pushSound); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Настройки уведомлений обновлены'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка обновления: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + + + void _showSoundDialog( + String title, + String currentValue, + Function(String) onSelect, + ) { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: Text(title), + children: [ + RadioListTile( + title: const Text('Стандартный звук'), + value: 'DEFAULT', + groupValue: currentValue, + onChanged: (v) => Navigator.of(context).pop(v), + ), + RadioListTile( + title: const Text('Без звука'), + value: '_NONE_', + groupValue: currentValue, + onChanged: (v) => Navigator.of(context).pop(v), + ), + ], + ); + }, + ).then((selectedValue) { + if (selectedValue != null) { + onSelect(selectedValue); + } + }); + } + + String _getSoundDescription(String sound) { + switch (sound) { + case 'DEFAULT': + return 'Стандартный'; + case '_NONE_': + return 'Без звука'; + default: + return 'Неизвестно'; + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + if (widget.isModal) { + return buildModalContent(context); + } + + + final isDesktopOrIOS = + Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isIOS; + + if (isDesktopOrIOS) { + return Scaffold( + appBar: AppBar(title: const Text('Уведомления')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon(Icons.info_outline, size: 48, color: colors.primary), + const SizedBox(height: 16), + Text( + 'Фоновые уведомления недоступны', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + Platform.isIOS + ? 'На iOS фоновые уведомления не поддерживаются системой.' + : 'На настольных платформах (Windows, macOS, Linux) фоновые уведомления отключены.', + style: TextStyle(color: colors.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Уведомления')), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + _OutlinedSection( + child: Column( + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.chat_bubble_outline), + title: const Text("Уведомления из чатов"), + value: _chatsPushNotification == 'ON', + onChanged: (value) => + _updateNotificationSetting(chatsPush: value), + ), + + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.phone_outlined), + title: const Text("Уведомления о звонках"), + value: _mCallPushNotification, + onChanged: (value) => + _updateNotificationSetting(mCallPush: value), + ), + + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.visibility_outlined), + title: const Text("Показывать текст"), + subtitle: const Text("Показывать превью сообщения"), + value: _pushDetails, + onChanged: (value) => + _updateNotificationSetting(pushDetails: value), + ), + ], + ), + ), + const SizedBox(height: 16), + _OutlinedSection( + child: Column( + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.music_note_outlined), + title: const Text("Звук в чатах"), + trailing: Text( + _getSoundDescription(_chatsPushSound), + style: TextStyle(color: colors.primary), + ), + onTap: () => _showSoundDialog( + "Звук в чатах", + _chatsPushSound, + (value) => + _updateNotificationSetting(chatsSound: value), + ), + ), + + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon( + Icons.notifications_active_outlined, + ), + title: const Text("Общий звук"), + trailing: Text( + _getSoundDescription(_pushSound), + style: TextStyle(color: colors.primary), + ), + onTap: () => _showSoundDialog( + "Общий звук уведомлений", + _pushSound, + (value) => + _updateNotificationSetting(pushSound: value), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildModalSettings(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.3), + ), + ), + + + Center( + child: Container( + width: 400, + height: 600, + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + const Expanded( + child: Text( + "Уведомления", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + _OutlinedSection( + child: Column( + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon( + Icons.chat_bubble_outline, + ), + title: const Text("Уведомления из чатов"), + value: _chatsPushNotification == 'ON', + onChanged: (value) => + _updateNotificationSetting( + chatsPush: value, + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon( + Icons.phone_outlined, + ), + title: const Text( + "Уведомления о звонках", + ), + value: _mCallPushNotification, + onChanged: (value) => + _updateNotificationSetting( + mCallPush: value, + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon( + Icons.visibility_outlined, + ), + title: const Text("Показывать текст"), + subtitle: const Text( + "Показывать превью сообщения", + ), + value: _pushDetails, + onChanged: (value) => + _updateNotificationSetting( + pushDetails: value, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _OutlinedSection( + child: Column( + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon( + Icons.music_note_outlined, + ), + title: const Text("Звук в чатах"), + trailing: Text( + _getSoundDescription(_chatsPushSound), + style: TextStyle(color: colors.primary), + ), + onTap: () => _showSoundDialog( + "Звук уведомлений чатов", + _chatsPushSound, + (value) => _updateNotificationSetting( + chatsSound: value, + ), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon( + Icons.notifications_active_outlined, + ), + title: const Text("Общий звук"), + trailing: Text( + _getSoundDescription(_pushSound), + style: TextStyle(color: colors.primary), + ), + onTap: () => _showSoundDialog( + "Общий звук уведомлений", + _pushSound, + (value) => _updateNotificationSetting( + pushSound: value, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _OutlinedSection extends StatelessWidget { + final Widget child; + const _OutlinedSection({required this.child}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: colors.outline.withOpacity(0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: child, + ); + } +} diff --git a/lib/screens/settings/privacy_security_screen.dart b/lib/screens/settings/privacy_security_screen.dart new file mode 100644 index 0000000..e892aa8 --- /dev/null +++ b/lib/screens/settings/privacy_security_screen.dart @@ -0,0 +1,216 @@ + + +import 'package:flutter/material.dart'; +import 'package:gwid/screens/settings/privacy_settings_screen.dart'; +import 'package:gwid/screens/settings/security_settings_screen.dart'; + +class PrivacySecurityScreen extends StatelessWidget { + final bool isModal; + + const PrivacySecurityScreen({super.key, this.isModal = false}); + + @override + Widget build(BuildContext context) { + if (isModal) { + return buildModalContent(context); + } + + return Scaffold( + appBar: AppBar(title: const Text("Приватность и безопасность")), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text("Приватность"), + subtitle: const Text("Статус онлайн, кто может вас найти"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const PrivacySettingsScreen(), + ), + ); + }, + ), + ), + const SizedBox(height: 10), + Card( + child: ListTile( + leading: const Icon(Icons.lock_outline), + title: const Text("Безопасность"), + subtitle: const Text("Пароль, сессии, заблокированные"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SecuritySettingsScreen(), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildModalSettings(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.3), + ), + ), + + + Center( + child: Container( + width: 400, + height: 600, + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + const Expanded( + child: Text( + "Приватность и безопасность", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text("Приватность"), + subtitle: const Text("Статус онлайн, кто может вас найти"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const PrivacySettingsScreen(), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + Card( + child: ListTile( + leading: const Icon(Icons.security_outlined), + title: const Text("Безопасность"), + subtitle: const Text("Пароли, сессии, двухфакторная аутентификация"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SecuritySettingsScreen(), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget buildModalContent(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text("Приватность"), + subtitle: const Text("Статус онлайн, кто может вас найти"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const PrivacySettingsScreen(), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + Card( + child: ListTile( + leading: const Icon(Icons.security_outlined), + title: const Text("Безопасность"), + subtitle: const Text("Пароли, сессии, двухфакторная аутентификация"), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SecuritySettingsScreen(), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/screens/settings/privacy_settings_screen.dart b/lib/screens/settings/privacy_settings_screen.dart new file mode 100644 index 0000000..ccd52b8 --- /dev/null +++ b/lib/screens/settings/privacy_settings_screen.dart @@ -0,0 +1,383 @@ + + +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/theme_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gwid/password_management_screen.dart'; + +class PrivacySettingsScreen extends StatefulWidget { + const PrivacySettingsScreen({super.key}); + + @override + State createState() => _PrivacySettingsScreenState(); +} + +class _PrivacySettingsScreenState extends State { + + bool _isHidden = false; + bool _isLoading = false; + String _searchByPhone = 'ALL'; // 'ALL', 'CONTACTS', 'NOBODY' + String _incomingCall = 'ALL'; // 'ALL', 'CONTACTS', 'NOBODY' + String _chatsInvite = 'ALL'; // 'ALL', 'CONTACTS', 'NOBODY' + + @override + void initState() { + super.initState(); + _loadCurrentSettings(); + + ApiService.instance.messages.listen((message) { + if (message['type'] == 'privacy_settings_updated' && mounted) { + _loadCurrentSettings(); + } + }); + } + + Future _loadCurrentSettings() async { + try { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _isHidden = prefs.getBool('privacy_hidden') ?? false; + _searchByPhone = prefs.getString('privacy_search_by_phone') ?? 'ALL'; + _incomingCall = prefs.getString('privacy_incoming_call') ?? 'ALL'; + _chatsInvite = prefs.getString('privacy_chats_invite') ?? 'ALL'; + }); + } catch (e) { + print('Ошибка загрузки настроек приватности: $e'); + } + } + + Future _savePrivacySetting(String key, dynamic value) async { + try { + final prefs = await SharedPreferences.getInstance(); + if (value is bool) { + await prefs.setBool(key, value); + } else if (value is String) { + await prefs.setString(key, value); + } + } catch (e) { + print('Ошибка сохранения настройки $key: $e'); + } + } + + + + Future _updateHiddenStatus(bool hidden) async { + setState(() => _isLoading = true); + try { + await ApiService.instance.updatePrivacySettings( + hidden: hidden ? 'true' : 'false', + ); + await _savePrivacySetting('privacy_hidden', hidden); + if (mounted) { + setState(() => _isHidden = hidden); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + hidden ? 'Статус онлайн скрыт' : 'Статус онлайн виден', + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + _showErrorSnackBar(e); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _updatePrivacyOption({ + String? searchByPhone, + String? incomingCall, + String? chatsInvite, + }) async { + try { + await ApiService.instance.updatePrivacySettings( + searchByPhone: searchByPhone, + incomingCall: incomingCall, + chatsInvite: chatsInvite, + ); + + if (searchByPhone != null) { + await _savePrivacySetting('privacy_search_by_phone', searchByPhone); + if (mounted) setState(() => _searchByPhone = searchByPhone); + } + if (incomingCall != null) { + await _savePrivacySetting('privacy_incoming_call', incomingCall); + if (mounted) setState(() => _incomingCall = incomingCall); + } + if (chatsInvite != null) { + await _savePrivacySetting('privacy_chats_invite', chatsInvite); + if (mounted) setState(() => _chatsInvite = chatsInvite); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Настройки приватности обновлены'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + _showErrorSnackBar(e); + } + } + + void _showErrorSnackBar(Object e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка обновления: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + + + + void _showOptionDialog( + String title, + String currentValue, + Function(String) onSelect, + ) { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: Text(title), + children: [ + _buildDialogOption( + 'Все пользователи', + 'ALL', + currentValue, + onSelect, + ), + _buildDialogOption( + 'Только контакты', + 'CONTACTS', + currentValue, + onSelect, + ), + _buildDialogOption('Никто', 'NOBODY', currentValue, onSelect), + ], + ); + }, + ); + } + + Widget _buildDialogOption( + String title, + String value, + String groupValue, + Function(String) onSelect, + ) { + return RadioListTile( + title: Text(title), + value: value, + groupValue: groupValue, + onChanged: (newValue) { + if (newValue != null) { + onSelect(newValue); + Navigator.of(context).pop(); + } + }, + ); + } + + + + String _getPrivacyDescription(String value) { + switch (value) { + case 'ALL': + return 'Все пользователи'; + case 'CONTACTS': + return 'Только контакты'; + case 'NOBODY': + return 'Никто'; + default: + return 'Неизвестно'; + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final theme = context.watch(); + return Scaffold( + appBar: AppBar(title: const Text('Приватность')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Статус онлайн", colors), + if (_isLoading) const LinearProgressIndicator(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: Icon( + _isHidden + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + title: const Text("Скрыть статус онлайн"), + subtitle: Text( + _isHidden + ? "Другие не видят, что вы онлайн" + : "Другие видят ваш статус онлайн", + ), + value: _isHidden, + onChanged: _isLoading ? null : _updateHiddenStatus, + ), + ], + ), + ), + const SizedBox(height: 16), + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Взаимодействие", colors), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.search), + title: const Text("Кто может найти меня по номеру"), + subtitle: Text(_getPrivacyDescription(_searchByPhone)), + onTap: () => _showOptionDialog( + "Кто может найти вас?", + _searchByPhone, + (value) => _updatePrivacyOption(searchByPhone: value), + ), + ), + + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.phone_callback_outlined), + title: const Text("Кто может звонить мне"), + subtitle: Text(_getPrivacyDescription(_incomingCall)), + onTap: () => _showOptionDialog( + "Кто может вам звонить?", + _incomingCall, + (value) => _updatePrivacyOption(incomingCall: value), + ), + ), + + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.group_add_outlined), + title: const Text("Кто может приглашать в чаты"), + subtitle: Text(_getPrivacyDescription(_chatsInvite)), + onTap: () => _showOptionDialog( + "Кто может приглашать вас в чаты?", + _chatsInvite, + (value) => _updatePrivacyOption(chatsInvite: value), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Пароль аккаунта", colors), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.lock_outline), + title: const Text("Установить пароль"), + subtitle: const Text( + "Добавить пароль для дополнительной защиты аккаунта", + ), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const PasswordManagementScreen(), + ), + ); + }, + ), + ], + ), + ), + + const SizedBox(height: 16), + + _OutlinedSection( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle("Прочтение сообщений", colors), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.mark_chat_read_outlined), + title: const Text("Читать сообщения при входе"), + subtitle: const Text( + "Отмечать чат прочитанным при открытии", + ), + + value: theme.debugReadOnEnter, + onChanged: (value) => theme.setDebugReadOnEnter(value), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.send_and_archive_outlined), + title: const Text("Читать при отправке сообщения"), + subtitle: const Text( + "Отмечать чат прочитанным при отправке сообщения", + ), + + value: theme.debugReadOnAction, + onChanged: (value) => theme.setDebugReadOnAction(value), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildSectionTitle(String title, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + title, + style: TextStyle( + color: colors.primary, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + ); + } +} + +class _OutlinedSection extends StatelessWidget { + final Widget child; + const _OutlinedSection({required this.child}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: colors.outline.withOpacity(0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: child, + ); + } +} diff --git a/lib/screens/settings/proxy_settings_screen.dart b/lib/screens/settings/proxy_settings_screen.dart new file mode 100644 index 0000000..e6b2b42 --- /dev/null +++ b/lib/screens/settings/proxy_settings_screen.dart @@ -0,0 +1,263 @@ + + +import 'package:flutter/material.dart'; +import 'package:gwid/proxy_service.dart'; +import 'package:gwid/proxy_settings.dart'; + +class ProxySettingsScreen extends StatefulWidget { + const ProxySettingsScreen({super.key}); + + @override + State createState() => _ProxySettingsScreenState(); +} + +class _ProxySettingsScreenState extends State { + final _formKey = GlobalKey(); + late ProxySettings _settings; + bool _isLoading = true; + bool _isTesting = false; // <-- НОВОЕ: Состояние для отслеживания проверки + + final _hostController = TextEditingController(); + final _portController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final settings = await ProxyService.instance.loadProxySettings(); + setState(() { + _settings = settings; + _hostController.text = _settings.host; + _portController.text = _settings.port.toString(); + _usernameController.text = _settings.username ?? ''; + _passwordController.text = _settings.password ?? ''; + _isLoading = false; + }); + } + + + Future _testProxyConnection() async { + if (_formKey.currentState?.validate() != true) { + return; + } + setState(() { + _isTesting = true; + }); + + + final settingsToTest = ProxySettings( + isEnabled: true, // Для теста прокси всегда должен быть включен + protocol: _settings.protocol, + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 8080, + username: _usernameController.text.trim(), + password: _passwordController.text.trim(), + ); + + try { + await ProxyService.instance.checkProxy(settingsToTest); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Прокси доступен и работает'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка подключения: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isTesting = false; + }); + } + } + } + + Future _saveSettings() async { + if (_formKey.currentState!.validate()) { + final newSettings = ProxySettings( + isEnabled: _settings.isEnabled, + protocol: _settings.protocol, + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 8080, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: _passwordController.text.trim().isEmpty + ? null + : _passwordController.text.trim(), + ); + + await ProxyService.instance.saveProxySettings(newSettings); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Настройки прокси сохранены. Перезайдите, чтобы применить.', + ), + backgroundColor: Colors.green, + ), + ); + Navigator.of(context).pop(); + } + } + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Настройки прокси'), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _isLoading || _isTesting ? null : _saveSettings, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + SwitchListTile( + title: const Text('Включить прокси'), + value: _settings.isEnabled, + onChanged: (value) { + setState(() { + _settings = _settings.copyWith(isEnabled: value); + }); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _settings.protocol, + decoration: const InputDecoration( + labelText: 'Протокол', + border: OutlineInputBorder(), + ), + items: ProxyProtocol.values + + .where( + (p) => + p != ProxyProtocol.socks4 && + p != ProxyProtocol.socks5, + ) + .map( + (protocol) => DropdownMenuItem( + value: protocol, + child: Text(protocol.name.toUpperCase()), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _settings = _settings.copyWith(protocol: value); + }); + } + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _hostController, + decoration: const InputDecoration( + labelText: 'Хост', + border: OutlineInputBorder(), + ), + validator: (value) { + if (_settings.isEnabled && + (value == null || value.isEmpty)) { + return 'Укажите хост прокси-сервера'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _portController, + decoration: const InputDecoration( + labelText: 'Порт', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (_settings.isEnabled) { + if (value == null || value.isEmpty) { + return 'Укажите порт'; + } + if (int.tryParse(value) == null) { + return 'Некорректный номер порта'; + } + } + return null; + }, + ), + const SizedBox(height: 24), + Text( + 'Аутентификация (необязательно)', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Имя пользователя', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Пароль', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + const SizedBox(height: 24), + + ElevatedButton.icon( + onPressed: _isTesting ? null : _testProxyConnection, + icon: _isTesting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.shield_outlined), + label: Text(_isTesting ? 'Проверка...' : 'Проверить'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/qr_login_screen.dart b/lib/screens/settings/qr_login_screen.dart new file mode 100644 index 0000000..b7d4d2a --- /dev/null +++ b/lib/screens/settings/qr_login_screen.dart @@ -0,0 +1,301 @@ + + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:gwid/api_service.dart'; +import 'dart:convert'; + +class QrLoginScreen extends StatefulWidget { + const QrLoginScreen({super.key}); + + @override + State createState() => _QrLoginScreenState(); +} + +class _QrLoginScreenState extends State { + String? _token; + String? _qrData; + bool _isLoading = true; + bool _isQrVisible = false; + String? _error; + + Timer? _qrRefreshTimer; // Таймер для регенерации QR-кода (1 раз в минуту) + Timer? + _countdownTimer; // 👈 1. Таймер для обратного отсчета (1 раз в секунду) + int _countdownSeconds = 60; // 👈 2. Переменная для хранения секунд + + @override + void initState() { + super.initState(); + _initializeAndStartTimers(); + } + + @override + void dispose() { + _qrRefreshTimer?.cancel(); + _countdownTimer?.cancel(); // 👈 3. Не забываем отменить второй таймер + super.dispose(); + } + + + Future _initializeAndStartTimers() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final token = ApiService.instance.token; + if (token == null || token.isEmpty) { + throw Exception("Не удалось получить токен авторизации."); + } + + if (mounted) { + _token = token; + _regenerateQrData(); // Первичная генерация + + + _qrRefreshTimer?.cancel(); + _qrRefreshTimer = Timer.periodic(const Duration(minutes: 1), (timer) { + _regenerateQrData(); + }); + + + _startCountdownTimer(); + + setState(() => _isLoading = false); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + + void _regenerateQrData() { + if (_token == null) return; + final data = { + "type": "komet_auth_v1", + "token": _token!, + "timestamp": DateTime.now().millisecondsSinceEpoch, + }; + if (mounted) { + setState(() { + _qrData = jsonEncode(data); + _countdownSeconds = 60; // Сбрасываем счетчик на 60 + }); + } + } + + + void _startCountdownTimer() { + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + if (_countdownSeconds > 0) { + _countdownSeconds--; + } else { + + + _countdownSeconds = 60; + } + }); + } + }); + } + + void _toggleQrVisibility() { + if (_token != null) { + setState(() { + _isQrVisible = !_isQrVisible; + }); + } + } + + + + Widget _buildContent() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 60), + const SizedBox(height: 16), + const Text( + "Ошибка загрузки данных", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text(_error!, textAlign: TextAlign.center), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _initializeAndStartTimers, + icon: const Icon(Icons.refresh), + label: const Text("Повторить"), + ), + ], + ), + ); + } + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildQrDisplay(), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: _toggleQrVisibility, + icon: Icon( + _isQrVisible + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + label: Text( + _isQrVisible ? "Скрыть QR-код" : "Показать QR-код для входа", + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + } + + Widget _buildQrDisplay() { + final colors = Theme.of(context).colorScheme; + + if (!_isQrVisible) { + return Center( + child: Column( + children: [ + Icon( + Icons.qr_code_scanner_rounded, + size: 150, + color: colors.onSurface.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + "QR-код скрыт", + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + "Нажмите кнопку ниже, чтобы отобразить его.", + style: TextStyle(color: colors.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + + return Center( + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24.0), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + spreadRadius: 2, + blurRadius: 15, + offset: const Offset(0, 4), + ), + ], + ), + child: QrImageView( + data: _qrData!, + version: QrVersions.auto, + size: 280.0, + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.circle, + color: colors.primary, + ), + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.circle, + color: colors.primary, + ), + errorCorrectionLevel: QrErrorCorrectLevel.H, + ), + ), + const SizedBox(height: 16), + + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.timer_outlined, + color: colors.onSurfaceVariant, + size: 20, + ), + const SizedBox(width: 8), + Text( + "Обновится через: $_countdownSeconds сек.", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar(title: const Text("Вход по QR-коду")), + body: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.errorContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + children: [ + Icon(Icons.gpp_bad_outlined, color: Colors.red, size: 32), + SizedBox(width: 16), + Expanded( + child: Text( + "Любой, кто отсканирует этот код, получит полный доступ к вашему аккаунту. Не показывайте его посторонним.", + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + const Spacer(), + _buildContent(), + const Spacer(flex: 2), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/qr_scanner_screen.dart b/lib/screens/settings/qr_scanner_screen.dart new file mode 100644 index 0000000..6425904 --- /dev/null +++ b/lib/screens/settings/qr_scanner_screen.dart @@ -0,0 +1,64 @@ + + +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class QrScannerScreen extends StatefulWidget { + const QrScannerScreen({super.key}); + + @override + State createState() => _QrScannerScreenState(); +} + +class _QrScannerScreenState extends State { + final MobileScannerController _scannerController = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + ); + bool _isScanCompleted = false; + + @override + void dispose() { + _scannerController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Сканировать QR-код"), + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + body: Stack( + children: [ + MobileScanner( + controller: _scannerController, + onDetect: (capture) { + if (!_isScanCompleted) { + final String? code = capture.barcodes.first.rawValue; + if (code != null) { + setState(() => _isScanCompleted = true); + + Navigator.of(context).pop(code); + } + } + }, + ), + + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/reconnection_screen.dart b/lib/screens/settings/reconnection_screen.dart new file mode 100644 index 0000000..f53a8c6 --- /dev/null +++ b/lib/screens/settings/reconnection_screen.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/home_screen.dart'; + +class ReconnectionScreen extends StatefulWidget { + const ReconnectionScreen({super.key}); + + @override + State createState() => _ReconnectionScreenState(); +} + +class _ReconnectionScreenState extends State { + StreamSubscription? _apiSubscription; + String _statusMessage = 'Переподключение...'; + bool _isReconnecting = true; + Timer? _timeoutTimer; + + @override + void initState() { + super.initState(); + _startFullReconnection(); + _listenToApiMessages(); + } + + @override + void dispose() { + _apiSubscription?.cancel(); + _timeoutTimer?.cancel(); + super.dispose(); + } + + void _listenToApiMessages() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + print( + 'ReconnectionScreen: Получено сообщение: opcode=${message['opcode']}, cmd=${message['cmd']}', + ); + + + if (message['opcode'] == 19 && message['cmd'] == 1) { + final payload = message['payload']; + print('ReconnectionScreen: Получен opcode 19, payload: $payload'); + if (payload != null && payload['token'] != null) { + print('ReconnectionScreen: Вызываем _onReconnectionSuccess()'); + _onReconnectionSuccess(); + return; + } + } + + + if (message['cmd'] == 3) { + final errorPayload = message['payload']; + String errorMessage = 'Ошибка переподключения'; + if (errorPayload != null) { + if (errorPayload['localizedMessage'] != null) { + errorMessage = errorPayload['localizedMessage']; + } else if (errorPayload['message'] != null) { + errorMessage = errorPayload['message']; + } + } + _onReconnectionError(errorMessage); + } + }); + } + + void _onReconnectionSuccess() { + if (!mounted) return; + + print('ReconnectionScreen: _onReconnectionSuccess() вызван'); + + + _timeoutTimer?.cancel(); + + setState(() { + _statusMessage = 'Переподключение успешно!'; + _isReconnecting = false; + }); + + print('ReconnectionScreen: Устанавливаем таймер для навигации...'); + + + Timer(const Duration(milliseconds: 1500), () { + if (mounted) { + print('ReconnectionScreen: Навигация к HomeScreen...'); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const HomeScreen()), + (route) => false, + ); + } + }); + } + + void _onReconnectionError(String error) { + if (!mounted) return; + + + _timeoutTimer?.cancel(); + + setState(() { + _statusMessage = 'Ошибка: $error'; + _isReconnecting = false; + }); + + + Timer(const Duration(seconds: 3), () { + if (mounted) { + setState(() { + _statusMessage = 'Нажмите для повторной попытки'; + }); + } + }); + } + + Future _startFullReconnection() async { + try { + print('ReconnectionScreen: Начинаем полное переподключение...'); + + + _timeoutTimer = Timer(const Duration(seconds: 30), () { + if (mounted && _isReconnecting) { + _onReconnectionError('Таймаут переподключения'); + } + }); + + setState(() { + _statusMessage = 'Отключение от сервера...'; + }); + + + ApiService.instance.disconnect(); + + setState(() { + _statusMessage = 'Очистка кэшей...'; + }); + + + ApiService.instance.clearAllCaches(); + + setState(() { + _statusMessage = 'Подключение к серверу...'; + }); + + + await ApiService.instance.performFullReconnection(); + + setState(() { + _statusMessage = 'Аутентификация...'; + }); + + + final hasToken = await ApiService.instance.hasToken(); + if (hasToken) { + setState(() { + _statusMessage = 'Аутентификация...'; + }); + + + await ApiService.instance.getChatsAndContacts(); + + setState(() { + _statusMessage = 'Загрузка данных...'; + }); + } else { + _onReconnectionError('Токен аутентификации не найден'); + } + } catch (e) { + _onReconnectionError('Ошибка переподключения: ${e.toString()}'); + } + } + + void _retryReconnection() { + setState(() { + _statusMessage = 'Переподключение...'; + _isReconnecting = true; + }); + _startFullReconnection(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return WillPopScope( + onWillPop: () async => false, // Блокируем кнопку "Назад" + child: Scaffold( + backgroundColor: colors.surface, + body: Container( + width: double.infinity, + height: double.infinity, + color: colors.surface.withOpacity(0.95), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: colors.primaryContainer, + shape: BoxShape.circle, + ), + child: _isReconnecting + ? CircularProgressIndicator( + strokeWidth: 4, + valueColor: AlwaysStoppedAnimation( + colors.primary, + ), + ) + : Icon( + _statusMessage.contains('Ошибка') + ? Icons.error_outline + : Icons.check_circle_outline, + size: 60, + color: _statusMessage.contains('Ошибка') + ? colors.error + : colors.primary, + ), + ), + + const SizedBox(height: 32), + + + Text( + 'Переподключение', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colors.onSurface, + ), + ), + + const SizedBox(height: 16), + + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _statusMessage, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + + const SizedBox(height: 48), + + + if (!_isReconnecting && _statusMessage.contains('Нажмите')) + ElevatedButton.icon( + onPressed: _retryReconnection, + icon: const Icon(Icons.refresh), + label: const Text('Повторить'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + + const SizedBox(height: 32), + + + Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outline.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: colors.primary, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Выполняется полное переподключение к серверу. Пожалуйста, подождите.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/security_settings_screen.dart b/lib/screens/settings/security_settings_screen.dart new file mode 100644 index 0000000..10ca6c0 --- /dev/null +++ b/lib/screens/settings/security_settings_screen.dart @@ -0,0 +1,91 @@ + + +import 'package:flutter/material.dart'; +import 'package:gwid/screens/settings/session_spoofing_screen.dart'; +import 'package:gwid/screens/settings/sessions_screen.dart'; +import 'package:gwid/screens/settings/export_session_screen.dart'; +import 'package:gwid/screens/settings/qr_login_screen.dart'; + +class SecuritySettingsScreen extends StatelessWidget { + const SecuritySettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Безопасность")), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + + _buildSecurityOption( + context, + icon: Icons.qr_code_scanner_outlined, + title: "Вход по QR-коду", + subtitle: "Показать QR-код для входа на другом устройстве", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const QrLoginScreen()), + ); + }, + ), + _buildSecurityOption( + context, + icon: Icons.history_toggle_off, + title: "Активные сессии", + subtitle: "Просмотр и управление активными сессиями", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const SessionsScreen()), + ); + }, + ), + _buildSecurityOption( + context, + icon: Icons.upload_file_outlined, + title: "Экспорт сессии", + subtitle: "Сохранить данные сессии для переноса", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ExportSessionScreen(), + ), + ); + }, + ), + _buildSecurityOption( + context, + icon: Icons.devices_other_outlined, + title: "Подмена данных сессии", + subtitle: "Изменение User-Agent, версии ОС и т.д.", + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const SessionSpoofingScreen(), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildSecurityOption( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: onTap, + ), + ); + } +} diff --git a/lib/screens/settings/session_spoofing_screen.dart b/lib/screens/settings/session_spoofing_screen.dart new file mode 100644 index 0000000..8977dee --- /dev/null +++ b/lib/screens/settings/session_spoofing_screen.dart @@ -0,0 +1,754 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'package:gwid/services/version_checker.dart'; +import 'package:flutter/material.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gwid/api_service.dart'; +import 'package:uuid/uuid.dart'; +import 'package:gwid/device_presets.dart'; +import 'package:gwid/phone_entry_screen.dart'; + + +enum SpoofingMethod { partial, full } + + +enum PresetCategory { web, device } + +class SessionSpoofingScreen extends StatefulWidget { + const SessionSpoofingScreen({super.key}); + + @override + State createState() => _SessionSpoofingScreenState(); +} + +class _SessionSpoofingScreenState extends State { + final _random = Random(); + final _uuid = const Uuid(); + final _userAgentController = TextEditingController(); + final _deviceNameController = TextEditingController(); + final _osVersionController = TextEditingController(); + final _screenController = TextEditingController(); + final _timezoneController = TextEditingController(); + final _localeController = TextEditingController(); + final _deviceIdController = TextEditingController(); + final _appVersionController = TextEditingController(); + + String _selectedDeviceType = 'WEB'; + SpoofingMethod _selectedMethod = SpoofingMethod.partial; + PresetCategory _selectedCategory = PresetCategory.web; + bool _isCheckingVersion = false; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadInitialData(); + } + + + Future _loadInitialData() async { + setState(() => _isLoading = true); + final prefs = await SharedPreferences.getInstance(); + final isSpoofingEnabled = prefs.getBool('spoofing_enabled') ?? false; + + if (isSpoofingEnabled) { + _userAgentController.text = prefs.getString('spoof_useragent') ?? ''; + _deviceNameController.text = prefs.getString('spoof_devicename') ?? ''; + _osVersionController.text = prefs.getString('spoof_osversion') ?? ''; + _screenController.text = prefs.getString('spoof_screen') ?? ''; + _timezoneController.text = prefs.getString('spoof_timezone') ?? ''; + _localeController.text = prefs.getString('spoof_locale') ?? ''; + _deviceIdController.text = prefs.getString('spoof_deviceid') ?? ''; + _appVersionController.text = + prefs.getString('spoof_appversion') ?? '25.10.10'; + _selectedDeviceType = prefs.getString('spoof_devicetype') ?? 'WEB'; + + if (_selectedDeviceType == 'WEB') { + _selectedCategory = PresetCategory.web; + } else { + _selectedCategory = PresetCategory.device; + } + } else { + setState(() { + _selectedCategory = PresetCategory.web; + }); + await _applyGeneratedData(); + } + + setState(() => _isLoading = false); + } + + + Future _loadDeviceData() async { + setState(() => _isLoading = true); + + final deviceInfo = DeviceInfoPlugin(); + final pixelRatio = View.of(context).devicePixelRatio; + final size = View.of(context).physicalSize; + + _appVersionController.text = '25.10.10'; + _localeController.text = Platform.localeName.split('_').first; + _screenController.text = + '${size.width.round()}x${size.height.round()} ${pixelRatio.toStringAsFixed(1)}x'; + _deviceIdController.text = _uuid.v4(); + + try { + final timezoneInfo = await FlutterTimezone.getLocalTimezone(); + _timezoneController.text = timezoneInfo.identifier; + } catch (e) { + _timezoneController.text = 'Europe/Moscow'; + } + + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + _deviceNameController.text = + '${androidInfo.manufacturer} ${androidInfo.model}'; + _osVersionController.text = 'Android ${androidInfo.version.release}'; + _userAgentController.text = + 'Mozilla/5.0 (Linux; Android ${androidInfo.version.release}; ${androidInfo.model}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; + _selectedDeviceType = 'ANDROID'; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + _deviceNameController.text = iosInfo.name; + _osVersionController.text = + '${iosInfo.systemName} ${iosInfo.systemVersion}'; + _userAgentController.text = + 'Mozilla/5.0 (iPhone; CPU iPhone OS ${iosInfo.systemVersion.replaceAll('.', '_')} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + _selectedDeviceType = 'IOS'; + } else { + setState(() => _selectedCategory = PresetCategory.device); + await _applyGeneratedData(); + } + + setState(() { + _selectedCategory = PresetCategory.device; + _isLoading = false; + }); + } + + + Future _applyGeneratedData() async { + final List filteredPresets; + if (_selectedCategory == PresetCategory.web) { + filteredPresets = devicePresets + .where((p) => p.deviceType == 'WEB') + .toList(); + } else { + filteredPresets = devicePresets + .where((p) => p.deviceType != 'WEB') + .toList(); + } + + if (filteredPresets.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Нет доступных пресетов для этой категории.'), + ), + ); + } + return; + } + + final preset = filteredPresets[_random.nextInt(filteredPresets.length)]; + await _applyPreset(preset); + } + + + Future _applyPreset(DevicePreset preset) async { + setState(() { + _userAgentController.text = preset.userAgent; + _deviceNameController.text = preset.deviceName; + _osVersionController.text = preset.osVersion; + _screenController.text = preset.screen; + _appVersionController.text = '25.10.10'; + _deviceIdController.text = _uuid.v4(); + + if (_selectedMethod == SpoofingMethod.partial) { + _selectedDeviceType = 'WEB'; + } else { + _selectedDeviceType = preset.deviceType; + _timezoneController.text = preset.timezone; + _localeController.text = preset.locale; + } + }); + + if (_selectedMethod == SpoofingMethod.partial) { + String timezone; + try { + final timezoneInfo = await FlutterTimezone.getLocalTimezone(); + timezone = timezoneInfo.identifier; + } catch (_) { + timezone = 'Europe/Moscow'; + } + final locale = Platform.localeName.split('_').first; + + if (mounted) { + setState(() { + _timezoneController.text = timezone; + _localeController.text = locale; + }); + } + } + } + + + Future _saveSpoofingSettings() async { + if (!mounted) return; + + final prefs = await SharedPreferences.getInstance(); + + + final oldValues = { + 'user_agent': prefs.getString('spoof_useragent') ?? '', + 'device_name': prefs.getString('spoof_devicename') ?? '', + 'os_version': prefs.getString('spoof_osversion') ?? '', + 'screen': prefs.getString('spoof_screen') ?? '', + 'timezone': prefs.getString('spoof_timezone') ?? '', + 'locale': prefs.getString('spoof_locale') ?? '', + 'device_id': prefs.getString('spoof_deviceid') ?? '', + 'device_type': prefs.getString('spoof_devicetype') ?? 'WEB', + }; + + final newValues = { + 'user_agent': _userAgentController.text, + 'device_name': _deviceNameController.text, + 'os_version': _osVersionController.text, + 'screen': _screenController.text, + 'timezone': _timezoneController.text, + 'locale': _localeController.text, + 'device_id': _deviceIdController.text, + 'device_type': _selectedDeviceType, + }; + + final oldAppVersion = prefs.getString('spoof_appversion') ?? '25.10.10'; + final newAppVersion = _appVersionController.text; + + + bool otherDataChanged = false; + for (final key in oldValues.keys) { + if (oldValues[key] != newValues[key]) { + otherDataChanged = true; + break; + } + } + + final appVersionChanged = oldAppVersion != newAppVersion; + + + + + if (appVersionChanged && !otherDataChanged) { + + await _saveAllData(prefs); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Настройки применятся при следующем входе в приложение.', + ), + duration: Duration(seconds: 3), + ), + ); + + Navigator.of(context).pop(); + } + } + + else { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Применить настройки?'), + content: const Text( + 'Для применения настроек потребуется перезайти в аккаунт. Вы уверены?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Применить'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + await _saveAllData(prefs); + + try { + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const PhoneEntryScreen()), + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при выходе: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + } + + + Future _saveAllData(SharedPreferences prefs) async { + await prefs.setBool('spoofing_enabled', true); + await prefs.setString('spoof_useragent', _userAgentController.text); + await prefs.setString('spoof_devicename', _deviceNameController.text); + await prefs.setString('spoof_osversion', _osVersionController.text); + await prefs.setString('spoof_screen', _screenController.text); + await prefs.setString('spoof_timezone', _timezoneController.text); + await prefs.setString('spoof_locale', _localeController.text); + await prefs.setString('spoof_deviceid', _deviceIdController.text); + await prefs.setString('spoof_devicetype', _selectedDeviceType); + await prefs.setString('spoof_appversion', _appVersionController.text); + } + + Future _handleVersionCheck() async { + if (_isCheckingVersion) return; + setState(() => _isCheckingVersion = true); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Проверяю последнюю версию...')), + ); + + try { + final latestVersion = await VersionChecker.getLatestVersion(); + if (mounted) { + setState(() => _appVersionController.text = latestVersion); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Найдена версия: $latestVersion'), + backgroundColor: Colors.green.shade700, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), // Показываем ошибку из VersionChecker + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isCheckingVersion = false); + } + } + } + + @override + void dispose() { + _userAgentController.dispose(); + _deviceNameController.dispose(); + _osVersionController.dispose(); + _screenController.dispose(); + _timezoneController.dispose(); + _localeController.dispose(); + _deviceIdController.dispose(); + _appVersionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Подмена данных сессии'), + centerTitle: true, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + + padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildInfoCard(), + const SizedBox(height: 16), + _buildSpoofingMethodCard(), + const SizedBox(height: 16), + _buildPresetTypeCard(), + const SizedBox(height: 24), + _buildMainDataCard(), + const SizedBox(height: 16), + _buildRegionalDataCard(), + const SizedBox(height: 16), + _buildIdentifiersCard(), + ], + ), + ), + + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: _buildFloatingActionButtons(), + ); + } + + Widget _buildInfoCard() { + return Card( + color: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.5), + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text.rich( + TextSpan( + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + children: const [ + TextSpan( + text: 'Нажмите ', + style: TextStyle(fontWeight: FontWeight.w500), + ), + WidgetSpan( + child: Icon(Icons.touch_app, size: 16), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: ' "Сгенерировать":\n'), + TextSpan( + text: '• Короткое нажатие: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: 'случайный пресет.\n'), + TextSpan( + text: '• Длинное нажатие: ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: 'реальные данные.'), + ], + ), + textAlign: TextAlign.center, + ), + ), + ); + } + + Widget _buildSpoofingMethodCard() { + final theme = Theme.of(context); + Widget descriptionWidget; + + if (_selectedMethod == SpoofingMethod.partial) { + descriptionWidget = _buildDescriptionTile( + icon: Icons.check_circle_outline, + color: Colors.green.shade700, + text: + 'Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.', + ); + } else { + descriptionWidget = _buildDescriptionTile( + icon: Icons.warning_amber_rounded, + color: theme.colorScheme.error, + text: + 'Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!', + ); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text("Метод подмены", style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + SegmentedButton( + style: SegmentedButton.styleFrom(shape: const StadiumBorder()), + segments: const [ + ButtonSegment( + value: SpoofingMethod.partial, + label: Text('Частичный'), + icon: Icon(Icons.security_outlined), + ), + ButtonSegment( + value: SpoofingMethod.full, + label: Text('Полный'), + icon: Icon(Icons.public_outlined), + ), + ], + selected: {_selectedMethod}, + onSelectionChanged: (s) => + setState(() => _selectedMethod = s.first), + ), + const SizedBox(height: 12), + descriptionWidget, + ], + ), + ), + ); + } + + Widget _buildDescriptionTile({ + required IconData icon, + required Color color, + required String text, + }) { + return ListTile( + leading: Icon(icon, color: color), + contentPadding: EdgeInsets.zero, + title: Text( + text, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + Widget _buildPresetTypeCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text( + "Тип пресетов для генерации", + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + SegmentedButton( + style: SegmentedButton.styleFrom(shape: const StadiumBorder()), + segments: const >[ + ButtonSegment( + value: PresetCategory.web, + label: Text('Веб'), + icon: Icon(Icons.web_outlined), + ), + ButtonSegment( + value: PresetCategory.device, + label: Text('Устройства'), + icon: Icon(Icons.devices_outlined), + ), + ], + selected: {_selectedCategory}, + onSelectionChanged: (newSelection) { + setState(() => _selectedCategory = newSelection.first); + _applyGeneratedData(); + }, + ), + ], + ), + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildMainDataCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, "Основные данные"), + TextField( + controller: _userAgentController, + decoration: _inputDecoration('User-Agent', Icons.http_outlined), + maxLines: 3, + minLines: 2, + ), + const SizedBox(height: 16), + TextField( + controller: _deviceNameController, + decoration: _inputDecoration( + 'Имя устройства', + Icons.smartphone_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _osVersionController, + decoration: _inputDecoration('Версия ОС', Icons.layers_outlined), + ), + ], + ), + ), + ); + } + + Widget _buildRegionalDataCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, "Региональные данные"), + TextField( + controller: _screenController, + decoration: _inputDecoration( + 'Разрешение экрана', + Icons.fullscreen_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _timezoneController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + 'Часовой пояс', + Icons.public_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _localeController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration('Локаль', Icons.language_outlined), + ), + ], + ), + ), + ); + } + + Widget _buildIdentifiersCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, "Идентификаторы"), + TextField( + controller: _deviceIdController, + decoration: _inputDecoration('ID Устройства', Icons.tag_outlined), + ), + const SizedBox(height: 16), + TextField( + controller: _appVersionController, + decoration: + _inputDecoration( + 'Версия приложения', + Icons.info_outline_rounded, + ).copyWith( + + suffixIcon: _isCheckingVersion + ? const Padding( + + padding: EdgeInsets.all(12.0), + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ), + ) + : IconButton( + + icon: const Icon(Icons.cloud_sync_outlined), + tooltip: 'Проверить последнюю версию', + onPressed: + _handleVersionCheck, // Вот здесь вызывается ваша функция + ), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedDeviceType, + decoration: _inputDecoration( + 'Тип устройства', + Icons.devices_other_outlined, + ), + items: const [ + DropdownMenuItem(value: 'ANDROID', child: Text('ANDROID')), + DropdownMenuItem(value: 'IOS', child: Text('IOS')), + DropdownMenuItem(value: 'DESKTOP', child: Text('DESKTOP')), + DropdownMenuItem(value: 'WEB', child: Text('WEB')), + ], + onChanged: (v) => + v != null ? setState(() => _selectedDeviceType = v) : null, + ), + ], + ), + ), + ); + } + + InputDecoration _inputDecoration(String label, IconData icon) { + return InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + ); + } + + Widget _buildFloatingActionButtons() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + flex: 3, + child: FilledButton.tonal( + onPressed: _applyGeneratedData, + onLongPress: _loadDeviceData, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + shape: const StadiumBorder(), + ), + child: const Text('Сгенерировать'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 4, + child: FilledButton( + onPressed: _saveSpoofingSettings, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + shape: const StadiumBorder(), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.save_alt_outlined), + SizedBox(width: 8), + Text('Применить'), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/sessions_screen.dart b/lib/screens/settings/sessions_screen.dart new file mode 100644 index 0000000..1f7e21b --- /dev/null +++ b/lib/screens/settings/sessions_screen.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'dart:async'; +import 'package:intl/intl.dart'; + +class Session { + final String client; + final String location; + final bool current; + final int time; + final String info; + + Session({ + required this.client, + required this.location, + required this.current, + required this.time, + required this.info, + }); + + factory Session.fromJson(Map json) { + return Session( + client: json['client'] ?? 'Неизвестное устройство', + location: json['location'] ?? 'Неизвестное местоположение', + current: json['current'] ?? false, + time: json['time'] ?? 0, + info: json['info'] ?? 'Нет дополнительной информации', + ); + } +} + +class SessionsScreen extends StatefulWidget { + const SessionsScreen({super.key}); + + @override + State createState() => _SessionsScreenState(); +} + +class _SessionsScreenState extends State { + List _sessions = []; + bool _isLoading = true; + StreamSubscription? _apiSubscription; + + @override + void initState() { + super.initState(); + _listenToApi(); + _loadSessions(); + } + + void _listenToApi() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (message['opcode'] == 96 && mounted) { + final payload = message['payload']; + if (payload != null && payload['sessions'] != null) { + final sessionsList = payload['sessions'] as List; + setState(() { + _sessions = sessionsList + .map((session) => Session.fromJson(session)) + .toList(); + _isLoading = false; + }); + } + } + }); + } + + void _loadSessions() { + setState(() => _isLoading = true); + ApiService.instance.requestSessions(); + } + + void _terminateAllSessions() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Завершить другие сессии?'), + content: const Text( + 'Все сессии, кроме текущей, будут завершены. Вы уверены?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Завершить'), + ), + ], + ), + ); + + if (confirmed == true) { + ApiService.instance.terminateAllSessions(); + Future.delayed(const Duration(seconds: 1), _loadSessions); + } + } + + String _formatTime(int timestamp) { + final date = DateTime.fromMillisecondsSinceEpoch(timestamp); + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays > 0) { + return '${difference.inDays} д. назад'; + } else if (difference.inHours > 0) { + return '${difference.inHours} ч. назад'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes} м. назад'; + } else { + return 'Только что'; + } + } + + IconData _getDeviceIcon(String clientInfo) { + final lowerInfo = clientInfo.toLowerCase(); + if (lowerInfo.contains('windows') || + lowerInfo.contains('linux') || + lowerInfo.contains('macos')) { + return Icons.computer_outlined; + } else if (lowerInfo.contains('iphone') || lowerInfo.contains('ios')) { + return Icons.phone_iphone; + } else if (lowerInfo.contains('android')) { + return Icons.phone_android; + } + return Icons.web_asset; + } + + @override + void dispose() { + _apiSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text("Активные сессии"), + actions: [ + IconButton(onPressed: _loadSessions, icon: const Icon(Icons.refresh)), + ], + ), + body: Column( + children: [ + if (_sessions.any((s) => !s.current)) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _terminateAllSessions, + icon: const Icon(Icons.logout), + label: const Text("Завершить другие сессии"), + style: FilledButton.styleFrom( + backgroundColor: colors.errorContainer, + foregroundColor: colors.onErrorContainer, + ), + ), + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _sessions.isEmpty + ? Center( + child: Text( + "Активных сессий не найдено.", + style: TextStyle(color: colors.onSurfaceVariant), + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _sessions.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final session = _sessions[index]; + final deviceIcon = _getDeviceIcon(session.client); + + return Card( + elevation: session.current ? 4 : 1, + shape: RoundedRectangleBorder( + side: BorderSide( + color: session.current + ? colors.primary + : Colors.transparent, + width: 1.5, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon(deviceIcon, size: 40, color: colors.primary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.client, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + session.location, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + "Последняя активность: ${_formatTime(session.time)}", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + if (session.current) + const Icon( + Icons.check_circle, + color: Colors.green, + size: 24, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart new file mode 100644 index 0000000..e17107d --- /dev/null +++ b/lib/screens/settings/settings_screen.dart @@ -0,0 +1,610 @@ +import 'package:flutter/material.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/manage_account_screen.dart'; +import 'package:gwid/screens/settings/appearance_settings_screen.dart'; +import 'package:gwid/screens/settings/notification_settings_screen.dart'; +import 'package:gwid/screens/settings/privacy_security_screen.dart'; +import 'package:gwid/screens/settings/storage_screen.dart'; +import 'package:gwid/screens/settings/network_settings_screen.dart'; +import 'package:gwid/screens/settings/bypass_screen.dart'; +import 'package:gwid/screens/settings/about_screen.dart'; +import 'package:gwid/debug_screen.dart'; +import 'package:gwid/screens/settings/komet_misc_screen.dart'; +import 'package:gwid/theme_provider.dart'; +import 'package:provider/provider.dart'; + +class SettingsScreen extends StatefulWidget { + final bool showBackToChats; + final VoidCallback? onBackToChats; + final Profile? myProfile; + final bool isModal; + + const SettingsScreen({ + super.key, + this.showBackToChats = false, + this.onBackToChats, + this.myProfile, + this.isModal = false, + }); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + Profile? _myProfile; + bool _isProfileLoading = true; + int _versionTapCount = 0; + DateTime? _lastTapTime; + bool _isReconnecting = false; + + + String _currentModalScreen = 'main'; + + @override + void initState() { + super.initState(); + if (widget.myProfile != null) { + + _myProfile = widget.myProfile; + _isProfileLoading = false; + } else { + + _loadMyProfile(); + } + } + + Future _loadMyProfile() async { + if (!mounted) return; + setState(() => _isProfileLoading = true); + + + final cachedProfileData = ApiService.instance.lastChatsPayload?['profile']; + if (cachedProfileData != null && mounted) { + setState(() { + _myProfile = Profile.fromJson(cachedProfileData); + _isProfileLoading = false; + }); + return; // Нашли в кеше, выходим + } + + + try { + final result = await ApiService.instance.getChatsAndContacts(force: true); + if (mounted) { + final profileJson = result['profile']; + if (profileJson != null) { + setState(() { + _myProfile = Profile.fromJson(profileJson); + _isProfileLoading = false; + }); + } else { + setState(() => _isProfileLoading = false); + } + } + } catch (e) { + if (mounted) { + setState(() => _isProfileLoading = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Ошибка загрузки профиля: $e"))); + } + } + } + + Future _handleReconnection() async { + if (_isReconnecting) return; + + setState(() { + _isReconnecting = true; + }); + + try { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Переподключение...'), + duration: Duration(seconds: 2), + ), + ); + } + + await ApiService.instance.performFullReconnection(); + await _loadMyProfile(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Переподключение выполнено успешно'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка переподключения: $e'), + backgroundColor: Colors.red, + duration: Duration(seconds: 3), + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isReconnecting = false; + }); + } + } + } + + Widget _buildReconnectionButton() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Icon( + Icons.sync, + color: _isReconnecting + ? Colors.grey + : Theme.of(context).colorScheme.primary, + ), + title: const Text( + "Переподключиться к серверу", + style: TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: const Text("Сбросить соединение и переподключиться"), + trailing: _isReconnecting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + Icons.chevron_right_rounded, + color: Theme.of(context).colorScheme.primary, + ), + onTap: _isReconnecting ? null : _handleReconnection, + ), + ); + } + + void _handleVersionTap() { + final now = DateTime.now(); + if (_lastTapTime != null && now.difference(_lastTapTime!).inSeconds > 2) { + _versionTapCount = 0; + } + _lastTapTime = now; + _versionTapCount++; + + if (_versionTapCount >= 7) { + _versionTapCount = 0; + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const DebugScreen())); + } + } + + @override + Widget build(BuildContext context) { + final themeProvider = context.watch(); + final isDesktop = themeProvider.useDesktopLayout; + + if (widget.isModal || isDesktop) { + return _buildModalSettings(context); + } + + return Scaffold( + appBar: AppBar( + title: const Text("Настройки"), + leading: widget.showBackToChats + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: widget.onBackToChats, + ) + : null, + ), + body: _buildSettingsContent(), + ); + } + + Widget _buildModalSettings(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final screenSize = MediaQuery.of(context).size; + final screenWidth = screenSize.width; + final screenHeight = screenSize.height; + + final isSmallScreen = screenWidth < 600 || screenHeight < 800; + + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withValues(alpha: 0.3), + ), + ), + + Center( + child: isSmallScreen + ? Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration(color: colors.surface), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: colors.surface), + child: Row( + children: [ + if (_currentModalScreen != 'main') + IconButton( + onPressed: () { + setState(() { + _currentModalScreen = 'main'; + }); + }, + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + Expanded( + child: Text( + _getModalTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded(child: _buildModalContent()), + ], + ), + ) + : Container( + width: 400, + height: 900, + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + if (_currentModalScreen != 'main') + IconButton( + onPressed: () { + setState(() { + _currentModalScreen = 'main'; + }); + }, + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + Expanded( + child: Text( + _getModalTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded(child: _buildModalContent()), + ], + ), + ), + ), + ], + ), + ); + } + + String _getModalTitle() { + switch (_currentModalScreen) { + case 'notifications': + return 'Уведомления'; + case 'appearance': + return 'Внешний вид'; + case 'privacy': + return 'Приватность и безопасность'; + case 'storage': + return 'Хранилище'; + case 'network': + return 'Сеть'; + case 'bypass': + return 'Bypass'; + case 'about': + return 'О приложении'; + case 'komet': + return 'Komet Misc'; + default: + return 'Настройки'; + } + } + + Widget _buildModalContent() { + switch (_currentModalScreen) { + case 'notifications': + return const NotificationSettingsScreen(isModal: true); + case 'appearance': + return const AppearanceSettingsScreen(isModal: true); + case 'privacy': + return const PrivacySecurityScreen(isModal: true); + case 'storage': + return const StorageScreen(isModal: true); + case 'network': + return const NetworkSettingsScreen(isModal: true); + case 'bypass': + return const BypassScreen(isModal: true); + case 'about': + return const AboutScreen(isModal: true); + case 'komet': + return const KometMiscScreen(isModal: true); + default: + return _buildSettingsContent(); + } + } + + Widget _buildSettingsContent() { + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + + _buildProfileSection(), + const SizedBox(height: 16), + + _buildReconnectionButton(), + const SizedBox(height: 16), + + _buildSettingsCategory( + context, + icon: Icons.rocket_launch_outlined, + title: "Komet Misc", + subtitle: "Дополнительные настройки", + screen: KometMiscScreen(isModal: widget.isModal), + ), + + _buildSettingsCategory( + context, + icon: Icons.palette_outlined, + title: "Внешний вид", + subtitle: "Темы, анимации, производительность", + screen: AppearanceSettingsScreen(isModal: widget.isModal), + ), + _buildSettingsCategory( + context, + icon: Icons.notifications_outlined, + title: "Уведомления", + subtitle: "Звуки, чаты, звонки", + screen: NotificationSettingsScreen(isModal: widget.isModal), + ), + _buildSettingsCategory( + context, + icon: Icons.security_outlined, + title: "Приватность и безопасность", + subtitle: "Статус, сессии, пароль, блокировки", + screen: PrivacySecurityScreen(isModal: widget.isModal), + ), + _buildSettingsCategory( + context, + icon: Icons.storage_outlined, + title: "Данные и хранилище", + subtitle: "Использование хранилища, очистка кэша", + screen: StorageScreen(isModal: widget.isModal), + ), + _buildSettingsCategory( + context, + icon: Icons.wifi_outlined, + title: "Сеть", + subtitle: "Прокси, мониторинг, логи", + screen: NetworkSettingsScreen(isModal: widget.isModal), + ), + _buildSettingsCategory( + context, + icon: Icons.psychology_outlined, + title: "Специальные возможности", + subtitle: "Обход ограничений", + screen: const BypassScreen(), + ), + _buildSettingsCategory( + context, + icon: Icons.info_outline, + title: "О приложении", + subtitle: "Команда, соглашение", + screen: const AboutScreen(), + ), + + + GestureDetector( + onTap: _handleVersionTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Text( + 'v0.3.0-beta.1', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + fontSize: 12, + ), + ), + ), + ), + ], + ); + } + + Widget _buildProfileSection() { + if (_isProfileLoading) { + return const Card( + child: ListTile( + leading: CircleAvatar(radius: 28), + title: Text("Загрузка профиля..."), + subtitle: Text("Пожалуйста, подождите"), + ), + ); + } + + if (_myProfile == null) { + return Card( + child: ListTile( + leading: const CircleAvatar( + radius: 28, + child: Icon(Icons.error_outline), + ), + title: const Text("Не удалось загрузить профиль"), + trailing: IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadMyProfile, + ), + ), + ); + } + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: CircleAvatar( + radius: 28, + backgroundImage: _myProfile!.photoBaseUrl != null + ? NetworkImage(_myProfile!.photoBaseUrl!) + : null, + child: _myProfile!.photoBaseUrl == null + ? Text( + _myProfile!.displayName.isNotEmpty + ? _myProfile!.displayName[0].toUpperCase() + : '', + style: const TextStyle(fontSize: 24), + ) + : null, + ), + title: Text( + _myProfile!.displayName, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_myProfile!.formattedPhone), + const SizedBox(height: 2), + Text( + 'ID: ${_myProfile!.id}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ManageAccountScreen(myProfile: _myProfile!), + ), + ); + }, + ), + ); + } + + Widget _buildSettingsCategory( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + required Widget screen, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: ListTile( + leading: Icon(icon, color: Theme.of(context).colorScheme.primary), + title: Text(title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + if (widget.isModal) { + + String screenKey = ''; + if (screen is NotificationSettingsScreen) + screenKey = 'notifications'; + else if (screen is AppearanceSettingsScreen) + screenKey = 'appearance'; + else if (screen is PrivacySecurityScreen) + screenKey = 'privacy'; + else if (screen is StorageScreen) + screenKey = 'storage'; + else if (screen is NetworkSettingsScreen) + screenKey = 'network'; + else if (screen is BypassScreen) + screenKey = 'bypass'; + else if (screen is AboutScreen) + screenKey = 'about'; + else if (screen is KometMiscScreen) + screenKey = 'komet'; + + setState(() { + _currentModalScreen = screenKey; + }); + } else { + + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => screen)); + } + }, + ), + ); + } +} diff --git a/lib/screens/settings/socket_log_screen.dart b/lib/screens/settings/socket_log_screen.dart new file mode 100644 index 0000000..efd2559 --- /dev/null +++ b/lib/screens/settings/socket_log_screen.dart @@ -0,0 +1,513 @@ + + +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gwid/api_service.dart'; +import 'package:intl/intl.dart'; +import 'package:share_plus/share_plus.dart'; + + +enum LogType { send, receive, status, pingpong } + + +class LogEntry { + final DateTime timestamp; + final String message; + final int id; + final LogType type; + + LogEntry({ + required this.timestamp, + required this.message, + required this.id, + required this.type, + }); +} + +class SocketLogScreen extends StatefulWidget { + const SocketLogScreen({super.key}); + + @override + State createState() => _SocketLogScreenState(); +} + +class _SocketLogScreenState extends State { + final List _allLogEntries = []; + List _filteredLogEntries = []; + StreamSubscription? _logSubscription; + final ScrollController _scrollController = ScrollController(); + int _logIdCounter = 0; + bool _isAutoScrollEnabled = true; + + + bool _isSearchActive = false; + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + + final Set _activeFilters = { + LogType.send, + LogType.receive, + LogType.status, + LogType.pingpong, + }; + + @override + void initState() { + super.initState(); + + _searchController.addListener(() { + if (_searchQuery != _searchController.text) { + setState(() { + _searchQuery = _searchController.text; + _applyFiltersAndSearch(); + }); + } + }); + _loadInitialLogs(); + _subscribeToNewLogs(); + } + + LogType _getLogType(String message) { + if (message.contains('(ping)') || message.contains('(pong)')) { + return LogType.pingpong; + } + if (message.startsWith('➡️ SEND')) return LogType.send; + if (message.startsWith('⬅️ RECV')) return LogType.receive; + return LogType.status; + } + + + void _addLogEntry(String logMessage, {bool isInitial = false}) { + final newEntry = LogEntry( + id: _logIdCounter++, + timestamp: DateTime.now(), + message: logMessage, + type: _getLogType(logMessage), + ); + _allLogEntries.add(newEntry); + + + if (!isInitial) { + _applyFiltersAndSearch(); + if (_isAutoScrollEnabled) _scrollToBottom(); + } + } + + void _loadInitialLogs() { + final cachedLogs = ApiService.instance.connectionLogCache; + for (var log in cachedLogs) { + _addLogEntry(log, isInitial: true); + } + _applyFiltersAndSearch(); + setState( + () {}, + ); // Однократное обновление UI после загрузки всех кэшированных логов + } + + void _subscribeToNewLogs() { + _logSubscription = ApiService.instance.connectionLog.listen((logMessage) { + if (mounted) { + if (_allLogEntries.isNotEmpty && + _allLogEntries.last.message == logMessage) { + return; + } + setState(() => _addLogEntry(logMessage)); + } + }); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + + void _applyFiltersAndSearch() { + List tempFiltered = _allLogEntries.where((entry) { + return _activeFilters.contains(entry.type); + }).toList(); + + if (_searchQuery.isNotEmpty) { + tempFiltered = tempFiltered.where((entry) { + return entry.message.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + } + + setState(() { + _filteredLogEntries = tempFiltered; + }); + } + + void _copyLogsToClipboard() { + final logText = _filteredLogEntries + .map( + (entry) => + "[${DateFormat('HH:mm:ss.SSS').format(entry.timestamp)}] ${entry.message}", + ) + .join('\n\n'); + Clipboard.setData(ClipboardData(text: logText)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Отфильтрованный журнал скопирован')), + ); + } + + void _shareLogs() async { + final logText = _filteredLogEntries + .map( + (entry) => + "[${DateFormat('HH:mm:ss.SSS').format(entry.timestamp)}] ${entry.message}", + ) + .join('\n\n'); + await Share.share(logText, subject: 'Gwid Connection Log'); + } + + void _clearLogs() { + setState(() { + _allLogEntries.clear(); + _filteredLogEntries.clear(); + + + }); + } + + void _showFilterDialog() { + showModalBottomSheet( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setSheetState) { + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Фильтры логов", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Исходящие (SEND)'), + value: _activeFilters.contains(LogType.send), + onChanged: (val) { + setSheetState( + () => val + ? _activeFilters.add(LogType.send) + : _activeFilters.remove(LogType.send), + ); + _applyFiltersAndSearch(); + }, + ), + SwitchListTile( + title: const Text('Входящие (RECV)'), + value: _activeFilters.contains(LogType.receive), + onChanged: (val) { + setSheetState( + () => val + ? _activeFilters.add(LogType.receive) + : _activeFilters.remove(LogType.receive), + ); + _applyFiltersAndSearch(); + }, + ), + SwitchListTile( + title: const Text('Статус подключения'), + value: _activeFilters.contains(LogType.status), + onChanged: (val) { + setSheetState( + () => val + ? _activeFilters.add(LogType.status) + : _activeFilters.remove(LogType.status), + ); + _applyFiltersAndSearch(); + }, + ), + SwitchListTile( + title: const Text('Ping/Pong'), + value: _activeFilters.contains(LogType.pingpong), + onChanged: (val) { + setSheetState( + () => val + ? _activeFilters.add(LogType.pingpong) + : _activeFilters.remove(LogType.pingpong), + ); + _applyFiltersAndSearch(); + }, + ), + ], + ), + ); + }, + ); + }, + ); + } + + @override + void dispose() { + _logSubscription?.cancel(); + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + + AppBar _buildDefaultAppBar() { + return AppBar( + title: const Text("Журнал подключения"), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: "Поиск", + onPressed: () => setState(() => _isSearchActive = true), + ), + IconButton( + icon: Icon( + _activeFilters.length == 4 + ? Icons.filter_list + : Icons.filter_list_off, + ), + tooltip: "Фильтры", + onPressed: _showFilterDialog, + ), + IconButton( + icon: const Icon(Icons.delete_sweep), + tooltip: "Очистить", + onPressed: _allLogEntries.isNotEmpty ? _clearLogs : null, + ), + ], + ); + } + + + AppBar _buildSearchAppBar() { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + _isSearchActive = false; + _searchController.clear(); + }); + }, + ), + title: TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Поиск по логам...', + border: InputBorder.none, + ), + style: const TextStyle(color: Colors.white), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _isSearchActive ? _buildSearchAppBar() : _buildDefaultAppBar(), + body: _filteredLogEntries.isEmpty + ? Center( + child: Text( + _allLogEntries.isEmpty ? "Журнал пуст." : "Записей не найдено.", + ), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB( + 8, + 8, + 8, + 80, + ), // Оставляем место для FAB + itemCount: _filteredLogEntries.length, + itemBuilder: (context, index) { + return LogEntryCard( + key: ValueKey(_filteredLogEntries[index].id), + entry: _filteredLogEntries[index], + ); + }, + ), + floatingActionButton: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + onPressed: () => + setState(() => _isAutoScrollEnabled = !_isAutoScrollEnabled), + mini: true, + tooltip: _isAutoScrollEnabled + ? 'Остановить автопрокрутку' + : 'Возобновить автопрокрутку', + child: Icon( + _isAutoScrollEnabled ? Icons.pause : Icons.arrow_downward, + ), + ), + const SizedBox(height: 8), + FloatingActionButton.extended( + onPressed: _shareLogs, + icon: const Icon(Icons.share), + label: const Text("Поделиться"), + tooltip: "Поделиться отфильтрованными логами", + ), + ], + ), + ); + } +} + + +class LogEntryCard extends StatelessWidget { + final LogEntry entry; + + const LogEntryCard({super.key, required this.entry}); + + (IconData, Color) _getVisuals( + LogType type, + String message, + BuildContext context, + ) { + final theme = Theme.of(context); + switch (type) { + case LogType.send: + return (Icons.arrow_upward, theme.colorScheme.primary); + case LogType.receive: + return (Icons.arrow_downward, Colors.green); + case LogType.pingpong: + return (Icons.sync_alt, Colors.grey); + case LogType.status: + if (message.startsWith('✅')) return (Icons.check_circle, Colors.green); + if (message.startsWith('❌')) { + return (Icons.error, theme.colorScheme.error); + } + return (Icons.info, Colors.orange.shade600); + } + } + + void _showJsonViewer(BuildContext context, String message) { + final jsonRegex = RegExp(r'(\{.*\})'); + final match = jsonRegex.firstMatch(message); + if (match == null) return; + + try { + final jsonPart = match.group(0)!; + final decoded = jsonDecode(jsonPart); + final prettyJson = const JsonEncoder.withIndent(' ').convert(decoded); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Содержимое пакета (JSON)"), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: SelectableText( + prettyJson, + style: const TextStyle(fontFamily: 'monospace'), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Закрыть"), + ), + ], + ), + ); + } catch (_) {} + } + + (String?, String?) _extractInfo(String message) { + try { + final jsonRegex = RegExp(r'(\{.*\})'); + final match = jsonRegex.firstMatch(message); + if (match == null) return (null, null); + final jsonPart = match.group(0)!; + final decoded = jsonDecode(jsonPart) as Map; + final opcode = decoded['opcode']?.toString(); + final seq = decoded['seq']?.toString(); + return (opcode, seq); + } catch (e) { + return (null, null); + } + } + + @override + Widget build(BuildContext context) { + final (icon, color) = _getVisuals(entry.type, entry.message, context); + final (opcode, seq) = _extractInfo(entry.message); + final formattedTime = DateFormat('HH:mm:ss.SSS').format(entry.timestamp); + final theme = Theme.of(context); + + return Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.symmetric(vertical: 5), + child: InkWell( + onTap: () => _showJsonViewer(context, entry.message), + child: Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: color, width: 4)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Text( + formattedTime, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + if (opcode != null) + Chip( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + label: Text( + 'OP: $opcode', + style: theme.textTheme.labelSmall, + ), + ), + const SizedBox(width: 4), + if (seq != null) + Chip( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + label: Text( + 'SEQ: $seq', + style: theme.textTheme.labelSmall, + ), + ), + ], + ), + const SizedBox(height: 8), + SelectableText( + entry.message, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/storage_screen.dart b/lib/screens/settings/storage_screen.dart new file mode 100644 index 0000000..f151d2d --- /dev/null +++ b/lib/screens/settings/storage_screen.dart @@ -0,0 +1,805 @@ +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; +import 'dart:math'; + +class StorageScreen extends StatefulWidget { + final bool isModal; + + const StorageScreen({super.key, this.isModal = false}); + + @override + State createState() => _StorageScreenState(); +} + +class _StorageScreenState extends State + with TickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + StorageInfo? _storageInfo; + bool _isLoading = true; + + Widget buildModalContent(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + + _buildStorageChart(colors), + const SizedBox(height: 20), + + + _buildStorageDetails(colors), + const SizedBox(height: 20), + + + _buildActionButtons(colors), + ], + ), + ); + } + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + _animation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + _loadStorageInfo(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + Future _loadStorageInfo() async { + try { + final info = await _getStorageInfo(); + setState(() { + _storageInfo = info; + _isLoading = false; + }); + _animationController.forward(); + } catch (e) { + setState(() { + _isLoading = false; + }); + } + } + + Future _getStorageInfo() async { + final appDir = await getApplicationDocumentsDirectory(); + final cacheDir = await getTemporaryDirectory(); + + final appSize = await _getDirectorySize(appDir); + final cacheSize = await _getDirectorySize(cacheDir); + final totalSize = appSize + cacheSize; + + final messagesSize = (totalSize * 0.4).round(); + final mediaSize = (totalSize * 0.3).round(); + final otherSize = totalSize - messagesSize - mediaSize; + + return StorageInfo( + totalSize: totalSize, + messagesSize: messagesSize, + mediaSize: mediaSize, + cacheSize: cacheSize, + otherSize: otherSize, + ); + } + + Future _getDirectorySize(Directory dir) async { + int totalSize = 0; + try { + if (await dir.exists()) { + await for (final entity in dir.list(recursive: true)) { + if (entity is File) { + totalSize += await entity.length(); + } + } + } + } catch (e) { + + totalSize = Random().nextInt(50) * 1024 * 1024; // 0-50 MB + } + return totalSize; + } + + Future _clearCache() async { + try { + final cacheDir = await getTemporaryDirectory(); + if (await cacheDir.exists()) { + await cacheDir.delete(recursive: true); + await cacheDir.create(); + } + await _loadStorageInfo(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Кэш успешно очищен'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при очистке кэша: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _clearAllData() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Очистить все данные'), + content: const Text( + 'Это действие удалит все сообщения, медиафайлы и другие данные приложения. ' + 'Это действие нельзя отменить.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Удалить'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final appDir = await getApplicationDocumentsDirectory(); + final cacheDir = await getTemporaryDirectory(); + + if (await appDir.exists()) { + await appDir.delete(recursive: true); + await appDir.create(); + } + if (await cacheDir.exists()) { + await cacheDir.delete(recursive: true); + await cacheDir.create(); + } + + await _loadStorageInfo(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Все данные успешно удалены'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при удалении данных: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + if (widget.isModal) { + return buildModalContent(context); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Хранилище'), + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + elevation: 0, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _storageInfo == null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.storage_outlined, + size: 64, + color: colors.onSurface.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + 'Не удалось загрузить информацию о хранилище', + style: TextStyle( + color: colors.onSurface.withOpacity(0.6), + fontSize: 16, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadStorageInfo, + child: const Text('Повторить'), + ), + ], + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + _buildStorageChart(colors), + + const SizedBox(height: 32), + + + _buildStorageDetails(colors), + + const SizedBox(height: 32), + + + _buildActionButtons(colors), + ], + ), + ), + ); + } + + Widget _buildModalSettings(BuildContext context, ColorScheme colors) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.3), + ), + ), + + + Center( + child: Container( + width: 400, + height: 600, + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + tooltip: 'Назад', + ), + const Expanded( + child: Text( + "Хранилище", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Закрыть', + ), + ], + ), + ), + + + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + + _buildStorageChart(colors), + const SizedBox(height: 20), + + + _buildStorageDetails(colors), + const SizedBox(height: 20), + + + _buildActionButtons(colors), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildStorageChart(ColorScheme colors) { + final totalSize = _storageInfo!.totalSize; + final usedSize = + _storageInfo!.messagesSize + + _storageInfo!.mediaSize + + _storageInfo!.otherSize; + final usagePercentage = totalSize > 0 ? usedSize / totalSize : 0.0; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outline.withOpacity(0.2)), + ), + child: Column( + children: [ + Text( + 'Использование хранилища', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 24), + + + AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return SizedBox( + width: 200, + height: 200, + child: Stack( + children: [ + + Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.surfaceContainerHighest, + ), + ), + + + CustomPaint( + size: const Size(200, 200), + painter: StorageChartPainter( + progress: usagePercentage * _animation.value, + colors: colors, + storageInfo: _storageInfo!, + animationValue: _animation.value, + ), + ), + + + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _formatBytes(usedSize), + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + Text( + 'из ${_formatBytes(totalSize)}', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withOpacity(0.7), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + + const SizedBox(height: 24), + + + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildLegendItem( + 'Сообщения', + _formatBytes(_storageInfo!.messagesSize), + Colors.blue, + ), + _buildLegendItem( + 'Медиафайлы', + _formatBytes(_storageInfo!.mediaSize), + Colors.green, + ), + _buildLegendItem( + 'Кэш', + _formatBytes(_storageInfo!.cacheSize), + Colors.orange, + ), + _buildLegendItem( + 'Другие', + _formatBytes(_storageInfo!.otherSize), + Colors.grey, + ), + ], + ), + ], + ), + ); + } + + Widget _buildLegendItem(String label, String value, Color color) { + return Column( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + Text( + value, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ], + ); + } + + Widget _buildStorageDetails(ColorScheme colors) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outline.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Детали использования', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 16), + + _buildStorageItem( + 'Сообщения', + _formatBytes(_storageInfo!.messagesSize), + Icons.message_outlined, + colors.primary, + (_storageInfo!.messagesSize / _storageInfo!.totalSize), + ), + + _buildStorageItem( + 'Медиафайлы', + _formatBytes(_storageInfo!.mediaSize), + Icons.photo_library_outlined, + colors.secondary, + (_storageInfo!.mediaSize / _storageInfo!.totalSize), + ), + + _buildStorageItem( + 'Кэш', + _formatBytes(_storageInfo!.cacheSize), + Icons.cached, + colors.tertiary, + (_storageInfo!.cacheSize / _storageInfo!.totalSize), + ), + + _buildStorageItem( + 'Другие данные', + _formatBytes(_storageInfo!.otherSize), + Icons.folder_outlined, + colors.outline, + (_storageInfo!.otherSize / _storageInfo!.totalSize), + ), + ], + ), + ); + } + + Widget _buildStorageItem( + String title, + String size, + IconData icon, + Color color, + double percentage, + ) { + final colors = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.zero, + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + size, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + Container( + width: 60, + height: 4, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.zero, + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: percentage, + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.zero, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons(ColorScheme colors) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Действия', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 16), + + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _clearCache, + icon: const Icon(Icons.cleaning_services_outlined), + label: const Text('Очистить кэш'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: _clearAllData, + icon: const Icon(Icons.delete_forever_outlined), + label: const Text('Очистить всё'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ); + } +} + +class StorageInfo { + final int totalSize; + final int messagesSize; + final int mediaSize; + final int cacheSize; + final int otherSize; + + StorageInfo({ + required this.totalSize, + required this.messagesSize, + required this.mediaSize, + required this.cacheSize, + required this.otherSize, + }); +} + +class StorageChartPainter extends CustomPainter { + final double progress; + final ColorScheme colors; + final StorageInfo storageInfo; + final double animationValue; + + StorageChartPainter({ + required this.progress, + required this.colors, + required this.storageInfo, + required this.animationValue, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - 8; + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 24 // Увеличиваем толщину с 16 до 24 + ..strokeCap = StrokeCap.round; + + + paint.color = colors.surfaceContainerHighest; + canvas.drawCircle(center, radius, paint); + + final totalSize = storageInfo.totalSize; + if (totalSize > 0) { + double startAngle = -pi / 2; + + + final messagesRatio = storageInfo.messagesSize / totalSize; + final mediaRatio = storageInfo.mediaSize / totalSize; + final cacheRatio = storageInfo.cacheSize / totalSize; + final otherRatio = storageInfo.otherSize / totalSize; + + + if (messagesRatio > 0) { + paint.color = Colors.blue; + final sweepAngle = 2 * pi * messagesRatio * animationValue; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + paint, + ); + startAngle += 2 * pi * messagesRatio; // Обновляем без анимации + } + + if (mediaRatio > 0) { + paint.color = Colors.green; + final sweepAngle = 2 * pi * mediaRatio * animationValue; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + paint, + ); + startAngle += 2 * pi * mediaRatio; // Обновляем без анимации + } + + if (cacheRatio > 0) { + paint.color = Colors.orange; + final sweepAngle = 2 * pi * cacheRatio * animationValue; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + paint, + ); + startAngle += 2 * pi * cacheRatio; // Обновляем без анимации + } + + if (otherRatio > 0) { + paint.color = Colors.grey; + final sweepAngle = 2 * pi * otherRatio * animationValue; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngle, + false, + paint, + ); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return oldDelegate is StorageChartPainter && + (oldDelegate.progress != progress || + oldDelegate.animationValue != animationValue); + } +} diff --git a/lib/search_channels_screen.dart b/lib/search_channels_screen.dart new file mode 100644 index 0000000..2c504e9 --- /dev/null +++ b/lib/search_channels_screen.dart @@ -0,0 +1,931 @@ + + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/models/channel.dart'; + +class SearchChannelsScreen extends StatefulWidget { + const SearchChannelsScreen({super.key}); + + @override + State createState() => _SearchChannelsScreenState(); +} + +class _SearchChannelsScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + StreamSubscription? _apiSubscription; + bool _isLoading = false; + List _foundChannels = []; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _listenToApiMessages(); + } + + @override + void dispose() { + _searchController.dispose(); + _apiSubscription?.cancel(); + super.dispose(); + } + + void _listenToApiMessages() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + + if (message['type'] == 'channels_found') { + setState(() { + _isLoading = false; + _errorMessage = null; + }); + + final payload = message['payload']; + final channelsData = payload['contacts'] as List?; + + if (channelsData != null) { + _foundChannels = channelsData + .map((channelJson) => Channel.fromJson(channelJson)) + .toList(); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Найдено каналов: ${_foundChannels.length}'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + + + if (message['type'] == 'channels_not_found') { + setState(() { + _isLoading = false; + _foundChannels.clear(); + }); + + final payload = message['payload']; + String errorMessage = 'Каналы не найдены'; + + if (payload != null) { + if (payload['localizedMessage'] != null) { + errorMessage = payload['localizedMessage']; + } else if (payload['message'] != null) { + errorMessage = payload['message']; + } + } + + setState(() { + _errorMessage = errorMessage; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + }); + } + + void _searchChannels() async { + final searchQuery = _searchController.text.trim(); + + if (searchQuery.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Введите поисковый запрос'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + setState(() { + _isLoading = true; + _foundChannels.clear(); + _errorMessage = null; + }); + + try { + await ApiService.instance.searchChannels(searchQuery); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка поиска каналов: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + void _viewChannel(Channel channel) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChannelDetailsScreen(channel: channel), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Поиск каналов'), + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + ), + body: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.broadcast_on_personal, + color: colors.primary, + ), + const SizedBox(width: 8), + Text( + 'Поиск каналов', + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Найдите интересные каналы по названию или описанию. ' + 'Вы можете просматривать каналы и подписываться на них.', + style: TextStyle(color: colors.onSurfaceVariant), + ), + ], + ), + ), + + const SizedBox(height: 24), + + + Text( + 'Поисковый запрос', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: 'Название или описание канала', + hintText: 'Например: новости, технологии, развлечения', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.search), + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outline.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colors.primary, + ), + const SizedBox(width: 8), + Text( + 'Советы по поиску:', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.primary, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '• Используйте ключевые слова\n' + '• Поиск по названию или описанию\n' + '• Попробуйте разные варианты написания', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 13, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _searchChannels, + icon: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colors.onPrimary, + ), + ), + ) + : const Icon(Icons.search), + label: Text(_isLoading ? 'Поиск...' : 'Найти каналы'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + + + if (_foundChannels.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + 'Найденные каналы (${_foundChannels.length})', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ..._foundChannels.map( + (channel) => _buildChannelCard(channel), + ), + ], + + + if (_errorMessage != null) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.warning, color: Colors.orange), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: Colors.orange.shade800, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } + + Widget _buildChannelCard(Channel channel) { + final colors = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.all(16), + leading: CircleAvatar( + radius: 24, + backgroundImage: channel.photoBaseUrl != null + ? NetworkImage(channel.photoBaseUrl!) + : null, + child: channel.photoBaseUrl == null + ? Text( + channel.name.isNotEmpty ? channel.name[0].toUpperCase() : '?', + style: TextStyle( + color: colors.onSurface, + fontWeight: FontWeight.w600, + ), + ) + : null, + ), + title: Text( + channel.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (channel.description?.isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text( + channel.description!, + style: TextStyle(color: colors.onSurfaceVariant, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 8), + Row( + children: [ + if (channel.options.contains('BOT')) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Бот', + style: TextStyle( + color: colors.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + if (channel.options.contains('HAS_WEBAPP')) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.secondaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Веб-приложение', + style: TextStyle( + color: colors.onSecondaryContainer, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ], + ), + trailing: Icon( + Icons.arrow_forward_ios, + size: 16, + color: colors.onSurfaceVariant, + ), + onTap: () => _viewChannel(channel), + ), + ); + } +} + +class ChannelDetailsScreen extends StatefulWidget { + final Channel channel; + + const ChannelDetailsScreen({super.key, required this.channel}); + + @override + State createState() => _ChannelDetailsScreenState(); +} + +class _ChannelDetailsScreenState extends State { + StreamSubscription? _apiSubscription; + bool _isLoading = false; + String? _webAppUrl; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _listenToApiMessages(); + } + + @override + void dispose() { + _apiSubscription?.cancel(); + super.dispose(); + } + + void _listenToApiMessages() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + + if (message['type'] == 'channel_entered') { + setState(() { + _isLoading = false; + _errorMessage = null; + }); + + final payload = message['payload']; + _webAppUrl = payload['url'] as String?; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Канал открыт'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + + + if (message['type'] == 'channel_subscribed') { + setState(() { + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Подписка на канал успешна!'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + + + if (message['type'] == 'channel_error') { + setState(() { + _isLoading = false; + }); + + final payload = message['payload']; + String errorMessage = 'Произошла ошибка'; + + if (payload != null) { + if (payload['localizedMessage'] != null) { + errorMessage = payload['localizedMessage']; + } else if (payload['message'] != null) { + errorMessage = payload['message']; + } + } + + setState(() { + _errorMessage = errorMessage; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + }); + } + + + String _extractChannelLink(String inputLink) { + final link = inputLink.trim(); + + + if (link.startsWith('https://max.ru/') || link.startsWith('max.ru/')) { + return link; + } + + + return link; + } + + void _enterChannel() async { + if (widget.channel.link == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('У канала нет ссылки для входа'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final processedLink = _extractChannelLink(widget.channel.link!); + await ApiService.instance.enterChannel(processedLink); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка входа в канал: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + void _subscribeToChannel() async { + if (widget.channel.link == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('У канала нет ссылки для подписки'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final processedLink = _extractChannelLink(widget.channel.link!); + await ApiService.instance.subscribeToChannel(processedLink); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка подписки на канал: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Канал'), + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + ), + body: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + CircleAvatar( + radius: 40, + backgroundImage: widget.channel.photoBaseUrl != null + ? NetworkImage(widget.channel.photoBaseUrl!) + : null, + child: widget.channel.photoBaseUrl == null + ? Text( + widget.channel.name.isNotEmpty + ? widget.channel.name[0].toUpperCase() + : '?', + style: TextStyle( + color: colors.onSurface, + fontWeight: FontWeight.w600, + fontSize: 24, + ), + ) + : null, + ), + const SizedBox(height: 16), + Text( + widget.channel.name, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + if (widget.channel.description?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + Text( + widget.channel.description!, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (widget.channel.options.contains('BOT')) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Бот', + style: TextStyle( + color: colors.onPrimaryContainer, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + if (widget.channel.options.contains('HAS_WEBAPP')) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colors.secondaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Веб-приложение', + style: TextStyle( + color: colors.onSecondaryContainer, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + + Column( + children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _enterChannel, + icon: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colors.onPrimary, + ), + ), + ) + : const Icon(Icons.visibility), + label: Text( + _isLoading ? 'Загрузка...' : 'Просмотреть канал', + ), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isLoading ? null : _subscribeToChannel, + icon: const Icon(Icons.subscriptions), + label: const Text('Подписаться на канал'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + + + if (_webAppUrl != null) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.primary.withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.web, color: colors.primary), + const SizedBox(width: 8), + Text( + 'Веб-приложение канала', + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Канал имеет веб-приложение. Вы можете открыть его в браузере.', + style: TextStyle(color: colors.onSurfaceVariant), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Открытие веб-приложения...', + ), + backgroundColor: Colors.blue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + }, + icon: const Icon(Icons.open_in_browser), + label: const Text('Открыть веб-приложение'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ], + + + if (_errorMessage != null) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.error, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: Colors.red.shade800, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } +} diff --git a/lib/search_contact_screen.dart b/lib/search_contact_screen.dart new file mode 100644 index 0000000..38a221f --- /dev/null +++ b/lib/search_contact_screen.dart @@ -0,0 +1,458 @@ + + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/models/contact.dart'; + +class SearchContactScreen extends StatefulWidget { + const SearchContactScreen({super.key}); + + @override + State createState() => _SearchContactScreenState(); +} + +class _SearchContactScreenState extends State { + final TextEditingController _phoneController = TextEditingController(); + StreamSubscription? _apiSubscription; + bool _isLoading = false; + Contact? _foundContact; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _listenToApiMessages(); + } + + @override + void dispose() { + _phoneController.dispose(); + _apiSubscription?.cancel(); + super.dispose(); + } + + void _listenToApiMessages() { + _apiSubscription = ApiService.instance.messages.listen((message) { + if (!mounted) return; + + + if (message['type'] == 'contact_found') { + setState(() { + _isLoading = false; + _errorMessage = null; + }); + + final payload = message['payload']; + final contactData = payload['contact']; + + if (contactData != null) { + _foundContact = Contact.fromJson(contactData); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Контакт найден!'), + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + + + if (message['type'] == 'contact_not_found') { + setState(() { + _isLoading = false; + _foundContact = null; + }); + + final payload = message['payload']; + String errorMessage = 'Контакт не найден'; + + if (payload != null) { + if (payload['localizedMessage'] != null) { + errorMessage = payload['localizedMessage']; + } else if (payload['message'] != null) { + errorMessage = payload['message']; + } + } + + setState(() { + _errorMessage = errorMessage; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + }); + } + + void _searchContact() async { + final phone = _phoneController.text.trim(); + + if (phone.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Введите номер телефона'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + + if (!phone.startsWith('+') || phone.length < 10) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Введите номер телефона в формате +7XXXXXXXXXX'), + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + return; + } + + setState(() { + _isLoading = true; + _foundContact = null; + _errorMessage = null; + }); + + try { + await ApiService.instance.searchContactByPhone(phone); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка поиска контакта: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + void _startChat() { + if (_foundContact != null) { + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Создание чата с ${_foundContact!.name}'), + backgroundColor: Colors.blue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Найти контакт'), + backgroundColor: colors.surface, + foregroundColor: colors.onSurface, + ), + body: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.person_search, color: colors.primary), + const SizedBox(width: 8), + Text( + 'Поиск контакта', + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Введите номер телефона для поиска контакта. ' + 'Пользователь должен быть зарегистрирован в системе ' + 'и разрешить поиск по номеру телефона.', + style: TextStyle(color: colors.onSurfaceVariant), + ), + ], + ), + ), + + const SizedBox(height: 24), + + + Text( + 'Номер телефона', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + labelText: 'Номер телефона', + hintText: '+7XXXXXXXXXX', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.phone), + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outline.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colors.primary, + ), + const SizedBox(width: 8), + Text( + 'Формат номера:', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.primary, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '• Номер должен начинаться с "+"\n' + '• Пример: +79999999990\n' + '• Минимум 10 цифр после "+"', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 13, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _searchContact, + icon: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colors.onPrimary, + ), + ), + ) + : const Icon(Icons.search), + label: Text(_isLoading ? 'Поиск...' : 'Найти контакт'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + + + if (_foundContact != null) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.check_circle, color: Colors.green), + const SizedBox(width: 8), + Text( + 'Контакт найден', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + CircleAvatar( + radius: 24, + backgroundImage: + _foundContact!.photoBaseUrl != null + ? NetworkImage(_foundContact!.photoBaseUrl!) + : null, + child: _foundContact!.photoBaseUrl == null + ? Text( + _foundContact!.name.isNotEmpty + ? _foundContact!.name[0].toUpperCase() + : '?', + style: TextStyle( + color: colors.onSurface, + fontWeight: FontWeight.w600, + ), + ) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _foundContact!.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + if (_foundContact!.description?.isNotEmpty == + true) + Text( + _foundContact!.description!, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _startChat, + icon: const Icon(Icons.chat), + label: const Text('Написать сообщение'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ], + + + if (_errorMessage != null) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.warning, color: Colors.orange), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: Colors.orange.shade800, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } +} diff --git a/lib/services/avatar_cache_service.dart b/lib/services/avatar_cache_service.dart new file mode 100644 index 0000000..dec9d10 --- /dev/null +++ b/lib/services/avatar_cache_service.dart @@ -0,0 +1,339 @@ + + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:crypto/crypto.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:gwid/services/cache_service.dart'; +import 'package:path/path.dart' as p; + +class AvatarCacheService { + static final AvatarCacheService _instance = AvatarCacheService._internal(); + factory AvatarCacheService() => _instance; + AvatarCacheService._internal(); + + final CacheService _cacheService = CacheService(); + + + final Map _imageMemoryCache = {}; + final Map _imageCacheTimestamps = {}; + + + static const Duration _imageTTL = Duration(days: 7); + static const int _maxMemoryImages = 50; + static const int _maxImageSizeMB = 5; + + + Future initialize() async { + await _cacheService.initialize(); + print('AvatarCacheService инициализирован'); + } + + + Future getAvatar(String? avatarUrl, {int? userId}) async { + if (avatarUrl == null || avatarUrl.isEmpty) { + return null; + } + + try { + + final cacheKey = _generateCacheKey(avatarUrl, userId); + + + if (_imageMemoryCache.containsKey(cacheKey)) { + final timestamp = _imageCacheTimestamps[cacheKey]; + if (timestamp != null && !_isExpired(timestamp, _imageTTL)) { + final imageData = _imageMemoryCache[cacheKey]!; + return MemoryImage(imageData); + } else { + + _imageMemoryCache.remove(cacheKey); + _imageCacheTimestamps.remove(cacheKey); + } + } + + + final cachedFile = await _cacheService.getCachedFile( + avatarUrl, + customKey: cacheKey, + ); + if (cachedFile != null && await cachedFile.exists()) { + final imageData = await cachedFile.readAsBytes(); + + + _imageMemoryCache[cacheKey] = imageData; + _imageCacheTimestamps[cacheKey] = DateTime.now(); + + + if (_imageMemoryCache.length > _maxMemoryImages) { + await _evictOldestImages(); + } + + return MemoryImage(imageData); + } + + + final imageData = await _downloadImage(avatarUrl); + if (imageData != null) { + + await _cacheService.cacheFile(avatarUrl, customKey: cacheKey); + + + _imageMemoryCache[cacheKey] = imageData; + _imageCacheTimestamps[cacheKey] = DateTime.now(); + + return MemoryImage(imageData); + } + } catch (e) { + print('Ошибка получения аватарки: $e'); + } + + return null; + } + + + Future getAvatarFile(String? avatarUrl, {int? userId}) async { + if (avatarUrl == null || avatarUrl.isEmpty) { + return null; + } + + try { + final cacheKey = _generateCacheKey(avatarUrl, userId); + return await _cacheService.getCachedFile(avatarUrl, customKey: cacheKey); + } catch (e) { + print('Ошибка получения файла аватарки: $e'); + return null; + } + } + + + Future preloadAvatars(List avatarUrls) async { + final futures = avatarUrls.map((url) => getAvatar(url)); + await Future.wait(futures); + print('Предзагружено ${avatarUrls.length} аватарок'); + } + + + Future _downloadImage(String url) async { + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final imageData = response.bodyBytes; + + + if (imageData.length > _maxImageSizeMB * 1024 * 1024) { + print('Изображение слишком большое: ${imageData.length} байт'); + return null; + } + + return imageData; + } + } catch (e) { + print('Ошибка загрузки изображения $url: $e'); + } + return null; + } + + + String _generateCacheKey(String url, int? userId) { + if (userId != null) { + return 'avatar_${userId}_${_hashUrl(url)}'; + } + return 'avatar_${_hashUrl(url)}'; + } + + + String _hashUrl(String url) { + final bytes = utf8.encode(url); + final digest = sha256.convert(bytes); + return digest.toString().substring(0, 16); + } + + + bool _isExpired(DateTime timestamp, Duration ttl) { + return DateTime.now().difference(timestamp) > ttl; + } + + + Future _evictOldestImages() async { + if (_imageMemoryCache.isEmpty) return; + + + final sortedEntries = _imageCacheTimestamps.entries.toList() + ..sort((a, b) => a.value.compareTo(b.value)); + + final toRemove = (sortedEntries.length * 0.2).ceil(); + for (int i = 0; i < toRemove && i < sortedEntries.length; i++) { + final key = sortedEntries[i].key; + _imageMemoryCache.remove(key); + _imageCacheTimestamps.remove(key); + } + } + + + Future clearAvatarCache() async { + _imageMemoryCache.clear(); + _imageCacheTimestamps.clear(); + + + try { + final cacheDir = await getApplicationCacheDirectory(); + final avatarDir = Directory('${cacheDir.path}/avatars'); + if (await avatarDir.exists()) { + await avatarDir.delete(recursive: true); + await avatarDir.create(recursive: true); + } + } catch (e) { + print('Ошибка очистки кэша аватарок: $e'); + } + + print('Кэш аватарок очищен'); + } + + + Future removeAvatarFromCache(String avatarUrl, {int? userId}) async { + try { + final cacheKey = _generateCacheKey(avatarUrl, userId); + + + _imageMemoryCache.remove(cacheKey); + _imageCacheTimestamps.remove(cacheKey); + + + await _cacheService.removeCachedFile(avatarUrl, customKey: cacheKey); + } catch (e) { + print('Ошибка удаления аватарки из кэша: $e'); + } + } + + + Future> getAvatarCacheStats() async { + try { + final memoryImages = _imageMemoryCache.length; + int totalMemorySize = 0; + + for (final imageData in _imageMemoryCache.values) { + totalMemorySize += imageData.length; + } + + + int diskSize = 0; + try { + final cacheDir = await getApplicationCacheDirectory(); + final avatarDir = Directory('${cacheDir.path}/avatars'); + if (await avatarDir.exists()) { + await for (final entity in avatarDir.list(recursive: true)) { + if (entity is File) { + diskSize += await entity.length(); + } + } + } + } catch (e) { + print('Ошибка подсчета размера файлового кэша: $e'); + } + + return { + 'memoryImages': memoryImages, + 'memorySizeMB': (totalMemorySize / (1024 * 1024)).toStringAsFixed(2), + 'diskSizeMB': (diskSize / (1024 * 1024)).toStringAsFixed(2), + 'maxMemoryImages': _maxMemoryImages, + 'maxImageSizeMB': _maxImageSizeMB, + }; + } catch (e) { + print('Ошибка получения статистики кэша аватарок: $e'); + return {}; + } + } + + + Future hasAvatarInCache(String avatarUrl, {int? userId}) async { + try { + final cacheKey = _generateCacheKey(avatarUrl, userId); + + + if (_imageMemoryCache.containsKey(cacheKey)) { + final timestamp = _imageCacheTimestamps[cacheKey]; + if (timestamp != null && !_isExpired(timestamp, _imageTTL)) { + return true; + } + } + + + return await _cacheService.hasCachedFile(avatarUrl, customKey: cacheKey); + } catch (e) { + print('Ошибка проверки существования аватарки в кэше: $e'); + return false; + } + } + + + Widget getAvatarWidget( + String? avatarUrl, { + int? userId, + double size = 40, + String? fallbackText, + Color? backgroundColor, + Color? textColor, + }) { + if (avatarUrl == null || avatarUrl.isEmpty) { + return _buildFallbackAvatar( + fallbackText, + size, + backgroundColor, + textColor, + ); + } + + return FutureBuilder( + future: getAvatar(avatarUrl, userId: userId), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + return CircleAvatar( + radius: size / 2, + backgroundImage: snapshot.data, + backgroundColor: backgroundColor, + ); + } else { + return _buildFallbackAvatar( + fallbackText, + size, + backgroundColor, + textColor, + ); + } + }, + ); + } + + + Widget _buildFallbackAvatar( + String? text, + double size, + Color? backgroundColor, + Color? textColor, + ) { + return CircleAvatar( + radius: size / 2, + backgroundColor: backgroundColor ?? Colors.grey[300], + child: text != null && text.isNotEmpty + ? Text( + text[0].toUpperCase(), + style: TextStyle( + color: textColor ?? Colors.white, + fontSize: size * 0.4, + fontWeight: FontWeight.bold, + ), + ) + : Icon( + Icons.person, + size: size * 0.6, + color: textColor ?? Colors.white, + ), + ); + } +} diff --git a/lib/services/cache_service.dart b/lib/services/cache_service.dart new file mode 100644 index 0000000..f5a861e --- /dev/null +++ b/lib/services/cache_service.dart @@ -0,0 +1,332 @@ + + +import 'dart:async'; +import 'dart:io'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:http/http.dart' as http; + + +class CacheService { + static final CacheService _instance = CacheService._internal(); + factory CacheService() => _instance; + CacheService._internal(); + + + final Map _memoryCache = {}; + final Map _cacheTimestamps = {}; + + + static const Duration _defaultTTL = Duration(hours: 24); + static const int _maxMemoryCacheSize = 1000; + + + SharedPreferences? _prefs; + + + Directory? _cacheDirectory; + + + Future initialize() async { + _prefs = await SharedPreferences.getInstance(); + _cacheDirectory = await getApplicationCacheDirectory(); + + + await _createCacheDirectories(); + + print('CacheService инициализирован'); + } + + + Future _createCacheDirectories() async { + if (_cacheDirectory == null) return; + + final directories = ['avatars', 'images', 'files', 'chats', 'contacts']; + + for (final dir in directories) { + final directory = Directory('${_cacheDirectory!.path}/$dir'); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + } + } + + + Future get(String key, {Duration? ttl}) async { + + if (_memoryCache.containsKey(key)) { + final timestamp = _cacheTimestamps[key]; + if (timestamp != null && !_isExpired(timestamp, ttl ?? _defaultTTL)) { + return _memoryCache[key] as T?; + } else { + + _memoryCache.remove(key); + _cacheTimestamps.remove(key); + } + } + + + if (_prefs != null) { + try { + final cacheKey = 'cache_$key'; + final cachedData = _prefs!.getString(cacheKey); + + if (cachedData != null) { + final Map data = jsonDecode(cachedData); + final timestamp = DateTime.fromMillisecondsSinceEpoch( + data['timestamp'], + ); + final value = data['value']; + + if (!_isExpired(timestamp, ttl ?? _defaultTTL)) { + + _memoryCache[key] = value; + _cacheTimestamps[key] = timestamp; + return value as T?; + } + } + } catch (e) { + print('Ошибка получения данных из кэша: $e'); + } + } + + return null; + } + + + Future set(String key, T value, {Duration? ttl}) async { + final timestamp = DateTime.now(); + + + _memoryCache[key] = value; + _cacheTimestamps[key] = timestamp; + + + if (_memoryCache.length > _maxMemoryCacheSize) { + await _evictOldestMemoryCache(); + } + + + if (_prefs != null) { + try { + final cacheKey = 'cache_$key'; + final data = { + 'value': value, + 'timestamp': timestamp.millisecondsSinceEpoch, + 'ttl': (ttl ?? _defaultTTL).inMilliseconds, + }; + + await _prefs!.setString(cacheKey, jsonEncode(data)); + } catch (e) { + print('Ошибка сохранения данных в кэш: $e'); + } + } + } + + + Future remove(String key) async { + _memoryCache.remove(key); + _cacheTimestamps.remove(key); + + if (_prefs != null) { + try { + final cacheKey = 'cache_$key'; + await _prefs!.remove(cacheKey); + } catch (e) { + print('Ошибка удаления данных из кэша: $e'); + } + } + } + + + Future clear() async { + _memoryCache.clear(); + _cacheTimestamps.clear(); + + if (_prefs != null) { + try { + + final keys = _prefs!.getKeys().where((key) => key.startsWith('cache_')); + for (final key in keys) { + await _prefs!.remove(key); + } + } catch (e) { + print('Ошибка очистки кэша: $e'); + } + } + + + if (_cacheDirectory != null) { + try { + for (final dir in ['avatars', 'images', 'files', 'chats', 'contacts']) { + final directory = Directory('${_cacheDirectory!.path}/$dir'); + if (await directory.exists()) { + await directory.delete(recursive: true); + await directory.create(recursive: true); + } + } + } catch (e) { + print('Ошибка очистки файлового кэша: $e'); + } + } + } + + + bool _isExpired(DateTime timestamp, Duration ttl) { + return DateTime.now().difference(timestamp) > ttl; + } + + + Future _evictOldestMemoryCache() async { + if (_memoryCache.isEmpty) return; + + + final sortedEntries = _cacheTimestamps.entries.toList() + ..sort((a, b) => a.value.compareTo(b.value)); + + final toRemove = (sortedEntries.length * 0.2).ceil(); + for (int i = 0; i < toRemove && i < sortedEntries.length; i++) { + final key = sortedEntries[i].key; + _memoryCache.remove(key); + _cacheTimestamps.remove(key); + } + } + + + Future> getCacheSize() async { + final memorySize = _memoryCache.length; + + + int filesSize = 0; + if (_cacheDirectory != null) { + try { + await for (final entity in _cacheDirectory!.list(recursive: true)) { + if (entity is File) { + filesSize += await entity.length(); + } + } + } catch (e) { + print('Ошибка подсчета размера файлового кэша: $e'); + } + } + + return { + 'memory': memorySize, + 'database': 0, // Нет SQLite базы данных + 'files': filesSize, + 'total': filesSize, + }; + } + + + Future cacheFile(String url, {String? customKey}) async { + if (_cacheDirectory == null) return null; + + try { + + final fileName = _generateFileName(url, customKey); + final filePath = '${_cacheDirectory!.path}/images/$fileName'; + + + final existingFile = File(filePath); + if (await existingFile.exists()) { + return filePath; + } + + + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + + await existingFile.writeAsBytes(response.bodyBytes); + return filePath; + } + } catch (e) { + print('Ошибка кэширования файла $url: $e'); + } + + return null; + } + + + Future getCachedFile(String url, {String? customKey}) async { + if (_cacheDirectory == null) return null; + + try { + final fileName = _generateFileName(url, customKey); + final filePath = '${_cacheDirectory!.path}/images/$fileName'; + + final file = File(filePath); + if (await file.exists()) { + return file; + } + } catch (e) { + print('Ошибка получения кэшированного файла: $e'); + } + + return null; + } + + + String _generateFileName(String url, String? customKey) { + final key = customKey ?? url; + final hash = key.hashCode.abs().toString().substring(0, 16); + final extension = _getFileExtension(url); + return '$hash$extension'; + } + + + String _getFileExtension(String url) { + try { + final uri = Uri.parse(url); + final path = uri.path; + final extension = path.substring(path.lastIndexOf('.')); + return extension.isNotEmpty && extension.length < 10 ? extension : '.jpg'; + } catch (e) { + return '.jpg'; + } + } + + + Future hasCachedFile(String url, {String? customKey}) async { + final file = await getCachedFile(url, customKey: customKey); + return file != null; + } + + + Future> getDetailedCacheStats() async { + final memorySize = _memoryCache.length; + final cacheSize = await getCacheSize(); + + return { + 'memory': {'items': memorySize, 'max_items': _maxMemoryCacheSize}, + 'filesystem': { + 'total_size': cacheSize['total'], + 'files_size': cacheSize['files'], + }, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }; + } + + + Future removeCachedFile(String url, {String? customKey}) async { + + + } + + + Future> getCacheStats() async { + final sizes = await getCacheSize(); + final memoryEntries = _memoryCache.length; + final diskEntries = + _prefs?.getKeys().where((key) => key.startsWith('cache_')).length ?? 0; + + return { + 'memoryEntries': memoryEntries, + 'diskEntries': diskEntries, + 'memorySize': sizes['memory'], + 'filesSizeMB': (sizes['files']! / (1024 * 1024)).toStringAsFixed(2), + 'maxMemorySize': _maxMemoryCacheSize, + }; + } +} diff --git a/lib/services/chat_cache_service.dart b/lib/services/chat_cache_service.dart new file mode 100644 index 0000000..1705764 --- /dev/null +++ b/lib/services/chat_cache_service.dart @@ -0,0 +1,334 @@ + + +import 'dart:async'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/models/message.dart'; +import 'package:gwid/services/cache_service.dart'; + +class ChatCacheService { + static final ChatCacheService _instance = ChatCacheService._internal(); + factory ChatCacheService() => _instance; + ChatCacheService._internal(); + + final CacheService _cacheService = CacheService(); + + + Future initialize() async { + await _cacheService.initialize(); + print('ChatCacheService инициализирован'); + } + + + static const String _chatsKey = 'cached_chats'; + static const String _contactsKey = 'cached_contacts'; + static const String _messagesKey = 'cached_messages'; + static const String _chatMessagesKey = 'cached_chat_messages'; + + + static const Duration _chatsTTL = Duration(hours: 1); + static const Duration _contactsTTL = Duration(hours: 6); + static const Duration _messagesTTL = Duration(hours: 2); + + + Future cacheChats(List> chats) async { + try { + await _cacheService.set(_chatsKey, chats, ttl: _chatsTTL); + print('Кэшировано ${chats.length} чатов'); + } catch (e) { + print('Ошибка кэширования чатов: $e'); + } + } + + + Future>?> getCachedChats() async { + try { + final cached = await _cacheService.get>( + _chatsKey, + ttl: _chatsTTL, + ); + if (cached != null) { + return cached.cast>(); + } + } catch (e) { + print('Ошибка получения кэшированных чатов: $e'); + } + return null; + } + + + Future cacheContacts(List contacts) async { + try { + final contactsData = contacts + .map( + (contact) => { + 'id': contact.id, + 'name': contact.name, + 'firstName': contact.firstName, + 'lastName': contact.lastName, + 'photoBaseUrl': contact.photoBaseUrl, + 'isBlocked': contact.isBlocked, + 'isBlockedByMe': contact.isBlockedByMe, + 'accountStatus': contact.accountStatus, + 'status': contact.status, + }, + ) + .toList(); + + await _cacheService.set(_contactsKey, contactsData, ttl: _contactsTTL); + print('Кэшировано ${contacts.length} контактов'); + } catch (e) { + print('Ошибка кэширования контактов: $e'); + } + } + + + Future?> getCachedContacts() async { + try { + final cached = await _cacheService.get>( + _contactsKey, + ttl: _contactsTTL, + ); + if (cached != null) { + return cached.map((data) => Contact.fromJson(data)).toList(); + } + } catch (e) { + print('Ошибка получения кэшированных контактов: $e'); + } + return null; + } + + + Future cacheChatMessages(int chatId, List messages) async { + try { + final key = '$_chatMessagesKey$chatId'; + final messagesData = messages + .map( + (message) => { + 'id': message.id, + 'sender': message.senderId, + 'text': message.text, + 'time': message.time, + 'status': message.status, + 'updateTime': message.updateTime, + 'attaches': message.attaches, + 'cid': message.cid, + 'reactionInfo': message.reactionInfo, + 'link': message.link, + }, + ) + .toList(); + + await _cacheService.set(key, messagesData, ttl: _messagesTTL); + print('Кэшировано ${messages.length} сообщений для чата $chatId'); + } catch (e) { + print('Ошибка кэширования сообщений для чата $chatId: $e'); + } + } + + + Future?> getCachedChatMessages(int chatId) async { + try { + final key = '$_chatMessagesKey$chatId'; + final cached = await _cacheService.get>( + key, + ttl: _messagesTTL, + ); + if (cached != null) { + return cached.map((data) => Message.fromJson(data)).toList(); + } + } catch (e) { + print('Ошибка получения кэшированных сообщений для чата $chatId: $e'); + } + return null; + } + + + Future addMessageToCache(int chatId, Message message) async { + try { + final cached = await getCachedChatMessages(chatId); + + if (cached != null) { + + final updatedMessages = [message, ...cached]; + await cacheChatMessages(chatId, updatedMessages); + } else { + + await cacheChatMessages(chatId, [message]); + } + } catch (e) { + print('Ошибка добавления сообщения в кэш: $e'); + } + } + + + Future updateMessageInCache(int chatId, Message updatedMessage) async { + try { + final cached = await getCachedChatMessages(chatId); + + if (cached != null) { + final updatedMessages = cached.map((message) { + if (message.id == updatedMessage.id) { + return updatedMessage; + } + return message; + }).toList(); + + await cacheChatMessages(chatId, updatedMessages); + } + } catch (e) { + print('Ошибка обновления сообщения в кэше: $e'); + } + } + + + Future removeMessageFromCache(int chatId, String messageId) async { + try { + final cached = await getCachedChatMessages(chatId); + + if (cached != null) { + final updatedMessages = cached + .where((message) => message.id != messageId) + .toList(); + await cacheChatMessages(chatId, updatedMessages); + } + } catch (e) { + print('Ошибка удаления сообщения из кэша: $e'); + } + } + + + Future cacheChatInfo(int chatId, Map chatInfo) async { + try { + final key = 'chat_info_$chatId'; + await _cacheService.set(key, chatInfo, ttl: _chatsTTL); + } catch (e) { + print('Ошибка кэширования информации о чате $chatId: $e'); + } + } + + + Future?> getCachedChatInfo(int chatId) async { + try { + final key = 'chat_info_$chatId'; + return await _cacheService.get>(key, ttl: _chatsTTL); + } catch (e) { + print('Ошибка получения кэшированной информации о чате $chatId: $e'); + return null; + } + } + + + Future cacheLastMessage(int chatId, Message? lastMessage) async { + try { + final key = 'last_message_$chatId'; + if (lastMessage != null) { + final messageData = { + 'id': lastMessage.id, + 'sender': lastMessage.senderId, + 'text': lastMessage.text, + 'time': lastMessage.time, + 'status': lastMessage.status, + 'updateTime': lastMessage.updateTime, + 'attaches': lastMessage.attaches, + 'cid': lastMessage.cid, + 'reactionInfo': lastMessage.reactionInfo, + 'link': lastMessage.link, + }; + await _cacheService.set(key, messageData, ttl: _chatsTTL); + } else { + await _cacheService.remove(key); + } + } catch (e) { + print('Ошибка кэширования последнего сообщения для чата $chatId: $e'); + } + } + + + Future getCachedLastMessage(int chatId) async { + try { + final key = 'last_message_$chatId'; + final cached = await _cacheService.get>( + key, + ttl: _chatsTTL, + ); + if (cached != null) { + return Message.fromJson(cached); + } + } catch (e) { + print( + 'Ошибка получения кэшированного последнего сообщения для чата $chatId: $e', + ); + } + return null; + } + + + Future clearChatCache(int chatId) async { + try { + final keys = [ + '$_chatMessagesKey$chatId', + 'chat_info_$chatId', + 'last_message_$chatId', + ]; + + for (final key in keys) { + await _cacheService.remove(key); + } + + print('Кэш для чата $chatId очищен'); + } catch (e) { + print('Ошибка очистки кэша для чата $chatId: $e'); + } + } + + + Future clearAllChatCache() async { + try { + await _cacheService.remove(_chatsKey); + await _cacheService.remove(_contactsKey); + await _cacheService.remove(_messagesKey); + + + + print('Весь кэш чатов очищен'); + } catch (e) { + print('Ошибка очистки всего кэша чатов: $e'); + } + } + + + Future> getChatCacheStats() async { + try { + final cacheStats = await _cacheService.getCacheStats(); + final chats = await getCachedChats(); + final contacts = await getCachedContacts(); + + return { + 'cachedChats': chats?.length ?? 0, + 'cachedContacts': contacts?.length ?? 0, + 'cacheStats': cacheStats, + }; + } catch (e) { + print('Ошибка получения статистики кэша чатов: $e'); + return {}; + } + } + + + Future isCacheValid(String cacheType) async { + try { + switch (cacheType) { + case 'chats': + return await _cacheService.get(_chatsKey, ttl: _chatsTTL) != null; + case 'contacts': + return await _cacheService.get(_contactsKey, ttl: _contactsTTL) != + null; + default: + return false; + } + } catch (e) { + print('Ошибка проверки актуальности кэша: $e'); + return false; + } + } +} diff --git a/lib/services/version_checker.dart b/lib/services/version_checker.dart new file mode 100644 index 0000000..4c19818 --- /dev/null +++ b/lib/services/version_checker.dart @@ -0,0 +1,121 @@ +import 'package:http/http.dart' as http; + +class VersionChecker { + + + + + static Future getLatestVersion() async { + try { + + final html = await _fetchPage('https://web.max.ru/'); + + + final mainChunkUrl = _extractMainChunkUrl(html); + print('[INFO] Загружаем главный chunk: $mainChunkUrl'); + + + final mainChunkCode = await _fetchPage(mainChunkUrl); + + + final chunkPaths = _extractChunkPaths(mainChunkCode); + + + for (final path in chunkPaths) { + if (path.contains('/chunks/')) { + final url = _buildChunkUrl(path); + print('[INFO] Загружаем chunk: $url'); + + try { + final jsCode = await _fetchPage(url); + final version = _extractVersion(jsCode); + + if (version != null) { + print('[SUCCESS] Версия: $version из $url'); + return version; + } + } catch (e) { + print('[WARN] Не удалось скачать $url: $e'); + continue; + } + } + } + + throw Exception('Версия не найдена ни в одном из чанков'); + } catch (e) { + throw Exception('Не удалось проверить версию: $e'); + } + } + + + static Future _fetchPage(String url) async { + final response = await http + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw Exception('Ошибка загрузки $url (${response.statusCode})'); + } + + return response.body; + } + + + static String _extractMainChunkUrl(String html) { + final parts = html.split('import('); + if (parts.length < 3) { + throw Exception('Не найден import() в HTML'); + } + + final mainChunkImport = parts[2] + .split(')')[0] + .replaceAll('"', '') + .replaceAll("'", ''); + + return 'https://web.max.ru$mainChunkImport'; + } + + + static List _extractChunkPaths(String mainChunkCode) { + final firstLine = mainChunkCode.split('\n')[0]; + final arrayContent = firstLine.split('[')[1].split(']')[0]; + + return arrayContent.split(','); + } + + + static String _buildChunkUrl(String path) { + final cleanPath = path.substring(3, path.length - 1); + return 'https://web.max.ru/_app/immutable$cleanPath'; + } + + + static String? _extractVersion(String jsCode) { + const wsAnchor = 'wss://ws-api.oneme.ru/websocket'; + final pos = jsCode.indexOf(wsAnchor); + + if (pos == -1) { + print('[INFO] ws-якорь не найден'); + return null; + } + + print('[INFO] Найден ws-якорь на позиции $pos'); + + + final snippet = jsCode.substring(pos, (pos + 2000).clamp(0, jsCode.length)); + + print('[INFO] Анализируем snippet (первые 500 символов):'); + print('${snippet.substring(0, 500.clamp(0, snippet.length))}...\n'); + + + final versionRegex = RegExp(r'[:=]\s*"(\d{1,2}\.\d{1,2}\.\d{1,2})"'); + final match = versionRegex.firstMatch(snippet); + + if (match != null) { + return match.group(1); + } + + print('[INFO] Версия не найдена в snippet'); + return null; + } +} diff --git a/lib/spoofing_service.dart b/lib/spoofing_service.dart new file mode 100644 index 0000000..6843d6f --- /dev/null +++ b/lib/spoofing_service.dart @@ -0,0 +1,28 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class SpoofingService { + + + static Future?> getSpoofedSessionData() async { + final prefs = await SharedPreferences.getInstance(); + + final isEnabled = prefs.getBool('spoofing_enabled') ?? false; + + if (!isEnabled) { + return null; // Если подмена выключена, возвращаем null + } + + + return { + 'user_agent': prefs.getString('spoof_useragent'), + 'device_name': prefs.getString('spoof_devicename'), + 'os_version': prefs.getString('spoof_osversion'), + 'screen': prefs.getString('spoof_screen'), + 'timezone': prefs.getString('spoof_timezone'), + 'locale': prefs.getString('spoof_locale'), + 'device_id': prefs.getString('spoof_deviceid'), + 'device_type': prefs.getString('spoof_devicetype'), + 'app_version': prefs.getString('spoof_appversion') ?? '25.10.10', + }; + } +} diff --git a/lib/theme_provider.dart b/lib/theme_provider.dart new file mode 100644 index 0000000..77a886f --- /dev/null +++ b/lib/theme_provider.dart @@ -0,0 +1,1250 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +enum AppTheme { system, light, dark, black } + +enum ChatWallpaperType { solid, gradient, image, video } + +enum TransitionOption { systemDefault, slide } + +enum UIMode { both, burgerOnly, panelOnly } + +enum MessageBubbleType { solid } + +extension MessageBubbleTypeExtension on MessageBubbleType { + String get displayName { + switch (this) { + case MessageBubbleType.solid: + return 'Цвет'; + } + } +} + +extension TransitionOptionExtension on TransitionOption { + String get displayName { + switch (this) { + case TransitionOption.systemDefault: + return 'Default'; + case TransitionOption.slide: + return 'Slide'; + } + } +} + +extension ChatWallpaperTypeExtension on ChatWallpaperType { + String get displayName { + switch (this) { + case ChatWallpaperType.solid: + return 'Цвет'; + case ChatWallpaperType.gradient: + return 'Градиент'; + case ChatWallpaperType.image: + return 'Фото'; + case ChatWallpaperType.video: + return 'Видео'; + } + } +} + +class CustomThemePreset { + String id; + String name; + + AppTheme appTheme; + Color accentColor; + + bool useCustomChatWallpaper; + ChatWallpaperType chatWallpaperType; + Color chatWallpaperColor1; + Color chatWallpaperColor2; + String? chatWallpaperImagePath; + String? chatWallpaperVideoPath; + bool chatWallpaperBlur; + double chatWallpaperBlurSigma; + double chatWallpaperImageBlur; + + bool useGlassPanels; + double topBarBlur; + double topBarOpacity; + double bottomBarBlur; + double bottomBarOpacity; + + double messageMenuOpacity; + double messageMenuBlur; + double profileDialogBlur; + double profileDialogOpacity; + + UIMode uiMode; + bool showSeconds; + double messageBubbleOpacity; + String messageStyle; + double messageBackgroundBlur; + double messageTextOpacity; + double messageShadowIntensity; + double messageBorderRadius; + + double messageFontSize; + Color? myBubbleColorLight; + Color? theirBubbleColorLight; + Color? myBubbleColorDark; + Color? theirBubbleColorDark; + MessageBubbleType messageBubbleType; + bool sendOnEnter; + + TransitionOption chatTransition; + TransitionOption tabTransition; + TransitionOption messageTransition; + TransitionOption extraTransition; + double messageSlideDistance; + double extraAnimationStrength; + bool animatePhotoMessages; + bool optimizeChats; + bool ultraOptimizeChats; + bool useDesktopLayout; + bool useAutoReplyColor; + Color? customReplyColor; + + CustomThemePreset({ + required this.id, + required this.name, + this.appTheme = AppTheme.system, + this.accentColor = Colors.blue, + this.useCustomChatWallpaper = false, + this.chatWallpaperType = ChatWallpaperType.solid, + this.chatWallpaperColor1 = const Color(0xFF101010), + this.chatWallpaperColor2 = const Color(0xFF202020), + this.chatWallpaperImagePath, + this.chatWallpaperVideoPath, + this.chatWallpaperBlur = false, + this.chatWallpaperBlurSigma = 12.0, + this.chatWallpaperImageBlur = 0.0, + this.useGlassPanels = true, + this.topBarBlur = 10.0, + this.topBarOpacity = 0.6, + this.bottomBarBlur = 10.0, + this.bottomBarOpacity = 0.7, + this.messageMenuOpacity = 0.95, + this.messageMenuBlur = 4.0, + this.profileDialogBlur = 12.0, + this.profileDialogOpacity = 0.26, + this.uiMode = UIMode.both, + this.showSeconds = false, + this.messageBubbleOpacity = 0.12, + this.messageStyle = 'glass', + this.messageBackgroundBlur = 0.0, + this.messageTextOpacity = 1.0, + this.messageShadowIntensity = 0.1, + this.messageBorderRadius = 20.0, + this.messageFontSize = 16.0, + this.myBubbleColorLight, + this.theirBubbleColorLight, + this.myBubbleColorDark, + this.theirBubbleColorDark, + this.messageBubbleType = MessageBubbleType.solid, + this.sendOnEnter = false, + this.chatTransition = TransitionOption.systemDefault, + this.tabTransition = TransitionOption.systemDefault, + this.messageTransition = TransitionOption.systemDefault, + this.extraTransition = TransitionOption.systemDefault, + this.messageSlideDistance = 96.0, + this.extraAnimationStrength = 32.0, + this.animatePhotoMessages = false, + this.optimizeChats = false, + this.ultraOptimizeChats = false, + this.useDesktopLayout = true, + this.useAutoReplyColor = true, + this.customReplyColor, + }); + + factory CustomThemePreset.createDefault() { + return CustomThemePreset(id: 'default', name: 'По умолчанию'); + } + + CustomThemePreset copyWith({ + String? id, + String? name, + AppTheme? appTheme, + Color? accentColor, + bool? useCustomChatWallpaper, + ChatWallpaperType? chatWallpaperType, + Color? chatWallpaperColor1, + Color? chatWallpaperColor2, + String? chatWallpaperImagePath, + String? chatWallpaperVideoPath, + bool? chatWallpaperBlur, + double? chatWallpaperBlurSigma, + double? chatWallpaperImageBlur, + bool? useGlassPanels, + double? topBarBlur, + double? topBarOpacity, + double? bottomBarBlur, + double? bottomBarOpacity, + double? messageMenuOpacity, + double? messageMenuBlur, + double? profileDialogBlur, + double? profileDialogOpacity, + UIMode? uiMode, + bool? showSeconds, + double? messageBubbleOpacity, + String? messageStyle, + double? messageBackgroundBlur, + double? messageTextOpacity, + double? messageShadowIntensity, + double? messageBorderRadius, + double? messageFontSize, + Color? myBubbleColorLight, + Color? theirBubbleColorLight, + Color? myBubbleColorDark, + Color? theirBubbleColorDark, + MessageBubbleType? messageBubbleType, + bool? sendOnEnter, + TransitionOption? chatTransition, + TransitionOption? tabTransition, + TransitionOption? messageTransition, + TransitionOption? extraTransition, + double? messageSlideDistance, + double? extraAnimationStrength, + bool? animatePhotoMessages, + bool? optimizeChats, + bool? ultraOptimizeChats, + bool? useDesktopLayout, + bool? useAutoReplyColor, + Color? customReplyColor, + }) { + return CustomThemePreset( + id: id ?? this.id, + name: name ?? this.name, + appTheme: appTheme ?? this.appTheme, + accentColor: accentColor ?? this.accentColor, + useCustomChatWallpaper: + useCustomChatWallpaper ?? this.useCustomChatWallpaper, + chatWallpaperType: chatWallpaperType ?? this.chatWallpaperType, + chatWallpaperColor1: chatWallpaperColor1 ?? this.chatWallpaperColor1, + chatWallpaperColor2: chatWallpaperColor2 ?? this.chatWallpaperColor2, + chatWallpaperImagePath: + chatWallpaperImagePath ?? this.chatWallpaperImagePath, + chatWallpaperVideoPath: + chatWallpaperVideoPath ?? this.chatWallpaperVideoPath, + chatWallpaperBlur: chatWallpaperBlur ?? this.chatWallpaperBlur, + chatWallpaperBlurSigma: + chatWallpaperBlurSigma ?? this.chatWallpaperBlurSigma, + chatWallpaperImageBlur: + chatWallpaperImageBlur ?? this.chatWallpaperImageBlur, + useGlassPanels: useGlassPanels ?? this.useGlassPanels, + topBarBlur: topBarBlur ?? this.topBarBlur, + topBarOpacity: topBarOpacity ?? this.topBarOpacity, + bottomBarBlur: bottomBarBlur ?? this.bottomBarBlur, + bottomBarOpacity: bottomBarOpacity ?? this.bottomBarOpacity, + messageMenuOpacity: messageMenuOpacity ?? this.messageMenuOpacity, + messageMenuBlur: messageMenuBlur ?? this.messageMenuBlur, + profileDialogBlur: profileDialogBlur ?? this.profileDialogBlur, + profileDialogOpacity: profileDialogOpacity ?? this.profileDialogOpacity, + uiMode: uiMode ?? this.uiMode, + showSeconds: showSeconds ?? this.showSeconds, + messageBubbleOpacity: messageBubbleOpacity ?? this.messageBubbleOpacity, + messageStyle: messageStyle ?? this.messageStyle, + messageBackgroundBlur: + messageBackgroundBlur ?? this.messageBackgroundBlur, + messageTextOpacity: messageTextOpacity ?? this.messageTextOpacity, + messageShadowIntensity: + messageShadowIntensity ?? this.messageShadowIntensity, + messageBorderRadius: messageBorderRadius ?? this.messageBorderRadius, + messageFontSize: messageFontSize ?? this.messageFontSize, + myBubbleColorLight: myBubbleColorLight ?? this.myBubbleColorLight, + theirBubbleColorLight: + theirBubbleColorLight ?? this.theirBubbleColorLight, + myBubbleColorDark: myBubbleColorDark ?? this.myBubbleColorDark, + theirBubbleColorDark: theirBubbleColorDark ?? this.theirBubbleColorDark, + messageBubbleType: messageBubbleType ?? this.messageBubbleType, + sendOnEnter: sendOnEnter ?? this.sendOnEnter, + chatTransition: chatTransition ?? this.chatTransition, + tabTransition: tabTransition ?? this.tabTransition, + messageTransition: messageTransition ?? this.messageTransition, + extraTransition: extraTransition ?? this.extraTransition, + messageSlideDistance: messageSlideDistance ?? this.messageSlideDistance, + extraAnimationStrength: + extraAnimationStrength ?? this.extraAnimationStrength, + animatePhotoMessages: animatePhotoMessages ?? this.animatePhotoMessages, + optimizeChats: optimizeChats ?? this.optimizeChats, + ultraOptimizeChats: ultraOptimizeChats ?? this.ultraOptimizeChats, + useDesktopLayout: useDesktopLayout ?? this.useDesktopLayout, + useAutoReplyColor: useAutoReplyColor ?? this.useAutoReplyColor, + customReplyColor: customReplyColor ?? this.customReplyColor, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'appTheme': appTheme.index, + 'accentColor': accentColor.value, + 'useCustomChatWallpaper': useCustomChatWallpaper, + 'chatWallpaperType': chatWallpaperType.index, + 'chatWallpaperColor1': chatWallpaperColor1.value, + 'chatWallpaperColor2': chatWallpaperColor2.value, + 'chatWallpaperImagePath': chatWallpaperImagePath, + 'chatWallpaperVideoPath': chatWallpaperVideoPath, + 'chatWallpaperBlur': chatWallpaperBlur, + 'chatWallpaperBlurSigma': chatWallpaperBlurSigma, + 'chatWallpaperImageBlur': chatWallpaperImageBlur, + 'useGlassPanels': useGlassPanels, + 'topBarBlur': topBarBlur, + 'topBarOpacity': topBarOpacity, + 'bottomBarBlur': bottomBarBlur, + 'bottomBarOpacity': bottomBarOpacity, + 'messageMenuOpacity': messageMenuOpacity, + 'messageMenuBlur': messageMenuBlur, + 'profileDialogBlur': profileDialogBlur, + 'profileDialogOpacity': profileDialogOpacity, + 'uiMode': uiMode.index, + 'showSeconds': showSeconds, + 'messageBubbleOpacity': messageBubbleOpacity, + 'messageStyle': messageStyle, + 'messageBackgroundBlur': messageBackgroundBlur, + 'messageTextOpacity': messageTextOpacity, + 'messageShadowIntensity': messageShadowIntensity, + 'messageBorderRadius': messageBorderRadius, + 'messageFontSize': messageFontSize, + 'myBubbleColorLight': myBubbleColorLight?.value, + 'theirBubbleColorLight': theirBubbleColorLight?.value, + 'myBubbleColorDark': myBubbleColorDark?.value, + 'theirBubbleColorDark': theirBubbleColorDark?.value, + 'messageBubbleType': messageBubbleType.index, + 'sendOnEnter': sendOnEnter, + 'chatTransition': chatTransition.index, + 'tabTransition': tabTransition.index, + 'messageTransition': messageTransition.index, + 'extraTransition': extraTransition.index, + 'messageSlideDistance': messageSlideDistance, + 'extraAnimationStrength': extraAnimationStrength, + 'animatePhotoMessages': animatePhotoMessages, + 'optimizeChats': optimizeChats, + 'ultraOptimizeChats': ultraOptimizeChats, + 'useDesktopLayout': useDesktopLayout, + 'useAutoReplyColor': useAutoReplyColor, + 'customReplyColor': customReplyColor?.value, + }; + } + + factory CustomThemePreset.fromJson(Map json) { + return CustomThemePreset( + id: json['id'] as String, + name: json['name'] as String, + appTheme: AppTheme.values[json['appTheme'] as int? ?? 0], + accentColor: Color(json['accentColor'] as int? ?? Colors.blue.value), + useCustomChatWallpaper: json['useCustomChatWallpaper'] as bool? ?? false, + chatWallpaperType: + ChatWallpaperType.values[json['chatWallpaperType'] as int? ?? 0], + chatWallpaperColor1: Color( + json['chatWallpaperColor1'] as int? ?? const Color(0xFF101010).value, + ), + chatWallpaperColor2: Color( + json['chatWallpaperColor2'] as int? ?? const Color(0xFF202020).value, + ), + chatWallpaperImagePath: json['chatWallpaperImagePath'] as String?, + chatWallpaperVideoPath: json['chatWallpaperVideoPath'] as String?, + chatWallpaperBlur: json['chatWallpaperBlur'] as bool? ?? false, + chatWallpaperBlurSigma: + (json['chatWallpaperBlurSigma'] as double? ?? 12.0).clamp(0.0, 20.0), + chatWallpaperImageBlur: (json['chatWallpaperImageBlur'] as double? ?? 0.0) + .clamp(0.0, 10.0), + useGlassPanels: json['useGlassPanels'] as bool? ?? true, + topBarBlur: json['topBarBlur'] as double? ?? 10.0, + topBarOpacity: json['topBarOpacity'] as double? ?? 0.6, + bottomBarBlur: json['bottomBarBlur'] as double? ?? 10.0, + bottomBarOpacity: json['bottomBarOpacity'] as double? ?? 0.7, + messageMenuOpacity: json['messageMenuOpacity'] as double? ?? 0.95, + messageMenuBlur: json['messageMenuBlur'] as double? ?? 4.0, + profileDialogBlur: (json['profileDialogBlur'] as double? ?? 12.0).clamp( + 0.0, + 30.0, + ), + profileDialogOpacity: (json['profileDialogOpacity'] as double? ?? 0.26) + .clamp(0.0, 1.0), + uiMode: UIMode.values[json['uiMode'] as int? ?? 0], + showSeconds: json['showSeconds'] as bool? ?? false, + messageBubbleOpacity: (json['messageBubbleOpacity'] as double? ?? 0.12) + .clamp(0.0, 1.0), + messageStyle: json['messageStyle'] as String? ?? 'glass', + messageBackgroundBlur: (json['messageBackgroundBlur'] as double? ?? 0.0) + .clamp(0.0, 10.0), + messageTextOpacity: (json['messageTextOpacity'] as double? ?? 1.0).clamp( + 0.1, + 1.0, + ), + messageShadowIntensity: (json['messageShadowIntensity'] as double? ?? 0.1) + .clamp(0.0, 0.5), + messageBorderRadius: (json['messageBorderRadius'] as double? ?? 20.0) + .clamp(4.0, 50.0), + messageFontSize: json['messageFontSize'] as double? ?? 16.0, + myBubbleColorLight: json['myBubbleColorLight'] != null + ? Color(json['myBubbleColorLight'] as int) + : null, + theirBubbleColorLight: json['theirBubbleColorLight'] != null + ? Color(json['theirBubbleColorLight'] as int) + : null, + myBubbleColorDark: json['myBubbleColorDark'] != null + ? Color(json['myBubbleColorDark'] as int) + : null, + theirBubbleColorDark: json['theirBubbleColorDark'] != null + ? Color(json['theirBubbleColorDark'] as int) + : null, + messageBubbleType: () { + final bubbleTypeIndex = json['messageBubbleType'] as int?; + if (bubbleTypeIndex == null) { + return MessageBubbleType.solid; + } + if (bubbleTypeIndex >= MessageBubbleType.values.length) { + return MessageBubbleType.solid; + } + return MessageBubbleType.values[bubbleTypeIndex]; + }(), + sendOnEnter: json['sendOnEnter'] as bool? ?? false, + chatTransition: + TransitionOption.values[json['chatTransition'] as int? ?? 0], + tabTransition: + TransitionOption.values[json['tabTransition'] as int? ?? 0], + messageTransition: + TransitionOption.values[json['messageTransition'] as int? ?? 0], + extraTransition: + TransitionOption.values[json['extraTransition'] as int? ?? 0], + messageSlideDistance: (json['messageSlideDistance'] as double? ?? 96.0) + .clamp(1.0, 200.0), + extraAnimationStrength: + (json['extraAnimationStrength'] as double? ?? 32.0).clamp(1.0, 400.0), + animatePhotoMessages: json['animatePhotoMessages'] as bool? ?? false, + optimizeChats: json['optimizeChats'] as bool? ?? false, + ultraOptimizeChats: json['ultraOptimizeChats'] as bool? ?? false, + useDesktopLayout: json['useDesktopLayout'] as bool? ?? false, + useAutoReplyColor: json['useAutoReplyColor'] as bool? ?? true, + customReplyColor: json['customReplyColor'] != null + ? Color(json['customReplyColor'] as int) + : null, + ); + } +} + +class ThemeProvider with ChangeNotifier { + CustomThemePreset _activeTheme = CustomThemePreset.createDefault(); + List _savedThemes = []; + + Color? _myBubbleColorLight; + Color? _theirBubbleColorLight; + Color? _myBubbleColorDark; + Color? _theirBubbleColorDark; + + final Map _chatSpecificWallpapers = {}; + + bool _debugShowPerformanceOverlay = false; + bool _debugShowChatsRefreshPanel = false; + bool _debugShowMessageCount = false; + bool _debugReadOnEnter = true; + bool _debugReadOnAction = true; + + bool _blockBypass = false; + bool _highQualityPhotos = true; + + AppTheme get appTheme => _activeTheme.appTheme; + Color get accentColor => _activeTheme.accentColor; + + ThemeMode get themeMode { + switch (_activeTheme.appTheme) { + case AppTheme.system: + return ThemeMode.system; + case AppTheme.light: + return ThemeMode.light; + case AppTheme.dark: + case AppTheme.black: + return ThemeMode.dark; + } + } + + bool get useCustomChatWallpaper => _activeTheme.useCustomChatWallpaper; + ChatWallpaperType get chatWallpaperType => _activeTheme.chatWallpaperType; + Color get chatWallpaperColor1 => _activeTheme.chatWallpaperColor1; + Color get chatWallpaperColor2 => _activeTheme.chatWallpaperColor2; + String? get chatWallpaperImagePath => _activeTheme.chatWallpaperImagePath; + String? get chatWallpaperVideoPath => _activeTheme.chatWallpaperVideoPath; + bool get chatWallpaperBlur => _activeTheme.chatWallpaperBlur; + double get chatWallpaperBlurSigma => _activeTheme.chatWallpaperBlurSigma; + double get chatWallpaperImageBlur => _activeTheme.chatWallpaperImageBlur; + + bool get useGlassPanels => _activeTheme.useGlassPanels; + double get topBarBlur => _activeTheme.topBarBlur; + double get topBarOpacity => _activeTheme.topBarOpacity; + double get bottomBarBlur => _activeTheme.bottomBarBlur; + double get bottomBarOpacity => _activeTheme.bottomBarOpacity; + + double get messageMenuOpacity => _activeTheme.messageMenuOpacity; + double get messageMenuBlur => _activeTheme.messageMenuBlur; + + double get profileDialogBlur => _activeTheme.profileDialogBlur; + double get profileDialogOpacity => _activeTheme.profileDialogOpacity; + + UIMode get uiMode => _activeTheme.uiMode; + bool get showSeconds => _activeTheme.showSeconds; + double get messageBubbleOpacity => _activeTheme.messageBubbleOpacity; + String get messageStyle => _activeTheme.messageStyle; + double get messageBackgroundBlur => _activeTheme.messageBackgroundBlur; + double get messageTextOpacity => _activeTheme.messageTextOpacity; + double get messageShadowIntensity => _activeTheme.messageShadowIntensity; + double get messageBorderRadius => _activeTheme.messageBorderRadius; + + double get messageFontSize => _activeTheme.messageFontSize; + bool get sendOnEnter => _activeTheme.sendOnEnter; + + MessageBubbleType get messageBubbleType => _activeTheme.messageBubbleType; + + Color? get myBubbleColorLight => _myBubbleColorLight; + Color? get theirBubbleColorLight => _theirBubbleColorLight; + Color? get myBubbleColorDark => _myBubbleColorDark; + Color? get theirBubbleColorDark => _theirBubbleColorDark; + + Color? get myBubbleColor { + if (appTheme == AppTheme.light) return _myBubbleColorLight; + if (appTheme == AppTheme.dark || appTheme == AppTheme.black) { + return _myBubbleColorDark; + } + return null; + } + + Color? get theirBubbleColor { + if (appTheme == AppTheme.light) return _theirBubbleColorLight; + if (appTheme == AppTheme.dark || appTheme == AppTheme.black) { + return _theirBubbleColorDark; + } + return null; + } + + bool get debugShowBottomBar => + _activeTheme.uiMode == UIMode.both || + _activeTheme.uiMode == UIMode.panelOnly; + bool get debugShowBurgerMenu => + _activeTheme.uiMode == UIMode.both || + _activeTheme.uiMode == UIMode.burgerOnly; + bool get debugShowPerformanceOverlay => _debugShowPerformanceOverlay; + bool get debugShowChatsRefreshPanel => _debugShowChatsRefreshPanel; + bool get debugShowMessageCount => _debugShowMessageCount; + bool get debugReadOnEnter => _debugReadOnEnter; + bool get debugReadOnAction => _debugReadOnAction; + + TransitionOption get chatTransition => _activeTheme.ultraOptimizeChats + ? TransitionOption.systemDefault + : _activeTheme.chatTransition; + TransitionOption get tabTransition => _activeTheme.ultraOptimizeChats + ? TransitionOption.systemDefault + : _activeTheme.tabTransition; + TransitionOption get messageTransition => _activeTheme.ultraOptimizeChats + ? TransitionOption.systemDefault + : _activeTheme.messageTransition; + TransitionOption get extraTransition => _activeTheme.ultraOptimizeChats + ? TransitionOption.systemDefault + : _activeTheme.extraTransition; + double get messageSlideDistance => _activeTheme.messageSlideDistance; + double get extraAnimationStrength => _activeTheme.extraAnimationStrength; + bool get animatePhotoMessages => _activeTheme.ultraOptimizeChats + ? false + : _activeTheme.animatePhotoMessages; + bool get optimizeChats => _activeTheme.optimizeChats; + bool get ultraOptimizeChats => _activeTheme.ultraOptimizeChats; + bool get useDesktopLayout => _activeTheme.useDesktopLayout; + bool get useAutoReplyColor => _activeTheme.useAutoReplyColor; + Color? get customReplyColor => _activeTheme.customReplyColor; + bool get highQualityPhotos => _highQualityPhotos; + bool get blockBypass => _blockBypass; + + List get savedThemes => _savedThemes; + CustomThemePreset get activeTheme => _activeTheme; + + ThemeProvider() { + loadSettings(); + } + + Future loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + + final themesJson = prefs.getStringList('saved_themes') ?? []; + _savedThemes = themesJson + .map((jsonString) { + try { + return CustomThemePreset.fromJson(jsonDecode(jsonString)); + } catch (e) { + print('Ошибка загрузки темы: $e'); + return null; + } + }) + .whereType() + .toList(); + + if (_savedThemes.isEmpty) { + _savedThemes.add(CustomThemePreset.createDefault()); + } + + final activeId = + prefs.getString('active_theme_id') ?? _savedThemes.first.id; + + _activeTheme = _savedThemes.firstWhere( + (t) => t.id == activeId, + orElse: () => _savedThemes.first, + ); + + if (_activeTheme.myBubbleColorLight == null || + _activeTheme.theirBubbleColorLight == null || + _activeTheme.myBubbleColorDark == null || + _activeTheme.theirBubbleColorDark == null) { + _updateBubbleColorsFromAccent(_activeTheme.accentColor); + _activeTheme = _activeTheme.copyWith( + myBubbleColorLight: _myBubbleColorLight, + theirBubbleColorLight: _theirBubbleColorLight, + myBubbleColorDark: _myBubbleColorDark, + theirBubbleColorDark: _theirBubbleColorDark, + ); + await _saveActiveTheme(); + } else { + _myBubbleColorLight = _activeTheme.myBubbleColorLight; + _theirBubbleColorLight = _activeTheme.theirBubbleColorLight; + _myBubbleColorDark = _activeTheme.myBubbleColorDark; + _theirBubbleColorDark = _activeTheme.theirBubbleColorDark; + } + + _debugShowPerformanceOverlay = prefs.getBool('debug_perf_overlay') ?? false; + _debugShowChatsRefreshPanel = + prefs.getBool('debug_show_chats_refresh_panel') ?? false; + _debugShowMessageCount = prefs.getBool('debug_show_message_count') ?? false; + _debugReadOnEnter = prefs.getBool('debug_read_on_enter') ?? true; + _debugReadOnAction = prefs.getBool('debug_read_on_action') ?? true; + _highQualityPhotos = prefs.getBool('high_quality_photos') ?? true; + _blockBypass = prefs.getBool('block_bypass') ?? false; + + await loadChatSpecificWallpapers(); + + notifyListeners(); + } + + Future _saveThemeListToPrefs() async { + final prefs = await SharedPreferences.getInstance(); + final themesJson = _savedThemes + .map((theme) => jsonEncode(theme.toJson())) + .toList(); + await prefs.setStringList('saved_themes', themesJson); + } + + Future _saveActiveTheme() async { + final index = _savedThemes.indexWhere((t) => t.id == _activeTheme.id); + if (index != -1) { + _savedThemes[index] = _activeTheme; + } else { + _savedThemes.add(_activeTheme); + } + await _saveThemeListToPrefs(); + } + + Future applyTheme(String themeId) async { + final themeToApply = _savedThemes.firstWhere((t) => t.id == themeId); + _activeTheme = themeToApply; + + _myBubbleColorLight = _activeTheme.myBubbleColorLight; + _theirBubbleColorLight = _activeTheme.theirBubbleColorLight; + _myBubbleColorDark = _activeTheme.myBubbleColorDark; + _theirBubbleColorDark = _activeTheme.theirBubbleColorDark; + + notifyListeners(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('active_theme_id', themeId); + } + + Future saveCurrentThemeAs(String name) async { + final newTheme = _activeTheme.copyWith( + id: DateTime.now().millisecondsSinceEpoch.toString(), + name: name.trim().isEmpty ? 'Новая тема' : name.trim(), + ); + + _savedThemes.add(newTheme); + await _saveThemeListToPrefs(); + await applyTheme(newTheme.id); + } + + Future deleteTheme(String themeId) async { + if (themeId == 'default') return; + _savedThemes.removeWhere((t) => t.id == themeId); + + if (_activeTheme.id == themeId) { + await applyTheme('default'); + } else { + await _saveThemeListToPrefs(); + notifyListeners(); + } + } + + Future renameTheme(String themeId, String newName) async { + if (themeId == 'default') return; + + final index = _savedThemes.indexWhere((t) => t.id == themeId); + if (index != -1) { + final String finalName = newName.trim().isEmpty + ? _savedThemes[index].name + : newName.trim(); + _savedThemes[index] = _savedThemes[index].copyWith(name: finalName); + + if (_activeTheme.id == themeId) { + _activeTheme = _activeTheme.copyWith(name: finalName); + } + + await _saveThemeListToPrefs(); + notifyListeners(); + } + } + + Future importThemeFromJson(String jsonString) async { + try { + final Map jsonMap = jsonDecode(jsonString); + + if (!jsonMap.containsKey('id') || !jsonMap.containsKey('name')) { + debugPrint("Ошибка импорта: JSON не содержит ключи 'id' или 'name'."); + return false; + } + + final importedPreset = CustomThemePreset.fromJson(jsonMap); + + final newPreset = importedPreset.copyWith( + id: DateTime.now().millisecondsSinceEpoch.toString(), + name: "Импорт: ${importedPreset.name}", + ); + + _savedThemes.add(newPreset); + await _saveThemeListToPrefs(); + notifyListeners(); + return true; // Успех + } catch (e) { + debugPrint("Ошибка импорта темы: $e"); + return false; // Неудача + } + } + + Future setTheme(AppTheme theme) async { + _activeTheme = _activeTheme.copyWith(appTheme: theme); + + if (theme != AppTheme.system) { + _updateBubbleColorsFromAccent(_activeTheme.accentColor); + _activeTheme = _activeTheme.copyWith( + myBubbleColorLight: _myBubbleColorLight, + theirBubbleColorLight: _theirBubbleColorLight, + myBubbleColorDark: _myBubbleColorDark, + theirBubbleColorDark: _theirBubbleColorDark, + ); + } + + notifyListeners(); + await _saveActiveTheme(); + } + + Future setAccentColor(Color color) async { + _updateBubbleColorsFromAccent(color); + _activeTheme = _activeTheme.copyWith( + accentColor: color, + myBubbleColorLight: _myBubbleColorLight, + theirBubbleColorLight: _theirBubbleColorLight, + myBubbleColorDark: _myBubbleColorDark, + theirBubbleColorDark: _theirBubbleColorDark, + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future updateBubbleColorsForSystemTheme(Color systemAccentColor) async { + _updateBubbleColorsFromAccent(systemAccentColor); + notifyListeners(); + } + + void _updateBubbleColorsFromAccent(Color accent) { + final Color myColorDark = accent; + + final hslDark = HSLColor.fromColor(accent); + final double theirSatDark = (hslDark.saturation * 0.4).clamp(0.0, 1.0); + final double theirLightDark = (hslDark.lightness * 0.7).clamp(0.1, 0.85); + final Color theirColorDark = HSLColor.fromAHSL( + hslDark.alpha, + hslDark.hue, + theirSatDark, + theirLightDark, + ).toColor(); + + final hslLight = HSLColor.fromColor(accent); + + final double myLightSat = (hslLight.saturation * 0.6).clamp(0.3, 0.7); + final double myLightLight = (hslLight.lightness * 0.3 + 0.6).clamp( + 0.6, + 0.9, + ); + final Color myColorLight = HSLColor.fromAHSL( + hslLight.alpha, + hslLight.hue, + myLightSat, + myLightLight, + ).toColor(); + + final double theirLightSat = (hslLight.saturation * 0.2).clamp(0.05, 0.25); + final double theirLightLight = (hslLight.lightness * 0.1 + 0.85).clamp( + 0.85, + 0.98, + ); + final Color theirColorLight = HSLColor.fromAHSL( + hslLight.alpha, + hslLight.hue, + theirLightSat, + theirLightLight, + ).toColor(); + + if (_myBubbleColorLight == myColorLight && + _theirBubbleColorLight == theirColorLight && + _myBubbleColorDark == myColorDark && + _theirBubbleColorDark == theirColorDark) { + return; + } + + _myBubbleColorLight = myColorLight; + _theirBubbleColorLight = theirColorLight; + _myBubbleColorDark = myColorDark; + _theirBubbleColorDark = theirColorDark; + } + + Future setUseGlassPanels(bool value) async { + _activeTheme = _activeTheme.copyWith(useGlassPanels: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setTopBarBlur(double value) async { + _activeTheme = _activeTheme.copyWith(topBarBlur: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setTopBarOpacity(double value) async { + _activeTheme = _activeTheme.copyWith(topBarOpacity: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setBottomBarBlur(double value) async { + _activeTheme = _activeTheme.copyWith(bottomBarBlur: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setBottomBarOpacity(double value) async { + _activeTheme = _activeTheme.copyWith(bottomBarOpacity: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageFontSize(double value) async { + _activeTheme = _activeTheme.copyWith(messageFontSize: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMyBubbleColorLight(Color? color) async { + _myBubbleColorLight = color; + _activeTheme = _activeTheme.copyWith(myBubbleColorLight: color); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setTheirBubbleColorLight(Color? color) async { + _theirBubbleColorLight = color; + _activeTheme = _activeTheme.copyWith(theirBubbleColorLight: color); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMyBubbleColorDark(Color? color) async { + _myBubbleColorDark = color; + _activeTheme = _activeTheme.copyWith(myBubbleColorDark: color); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setTheirBubbleColorDark(Color? color) async { + _theirBubbleColorDark = color; + _activeTheme = _activeTheme.copyWith(theirBubbleColorDark: color); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageBubbleType(MessageBubbleType value) async { + _activeTheme = _activeTheme.copyWith(messageBubbleType: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setSendOnEnter(bool value) async { + _activeTheme = _activeTheme.copyWith(sendOnEnter: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setUseCustomChatWallpaper(bool value) async { + _activeTheme = _activeTheme.copyWith(useCustomChatWallpaper: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatWallpaperType(ChatWallpaperType type) async { + _activeTheme = _activeTheme.copyWith(chatWallpaperType: type); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatWallpaperColor1(Color color) async { + _activeTheme = _activeTheme.copyWith(chatWallpaperColor1: color); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatWallpaperColor2(Color color) async { + _activeTheme = _activeTheme.copyWith(chatWallpaperColor2: color); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatWallpaperImagePath(String? path) async { + _activeTheme = _activeTheme.copyWith(chatWallpaperImagePath: path); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatWallpaperVideoPath(String? path) async { + _activeTheme = _activeTheme.copyWith(chatWallpaperVideoPath: path); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setProfileDialogBlur(double value) async { + _activeTheme = _activeTheme.copyWith( + profileDialogBlur: value.clamp(0.0, 30.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setProfileDialogOpacity(double value) async { + _activeTheme = _activeTheme.copyWith( + profileDialogOpacity: value.clamp(0.0, 1.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatWallpaperBlur(bool value) async { + _activeTheme = _activeTheme.copyWith(chatWallpaperBlur: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatWallpaperBlurSigma(double value) async { + _activeTheme = _activeTheme.copyWith( + chatWallpaperBlurSigma: value.clamp(0.0, 20.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatWallpaperImageBlur(double value) async { + _activeTheme = _activeTheme.copyWith( + chatWallpaperImageBlur: value.clamp(0.0, 10.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future resetChatWallpaperToDefaults() async { + _activeTheme = _activeTheme.copyWith( + useCustomChatWallpaper: false, + chatWallpaperType: ChatWallpaperType.solid, + chatWallpaperColor1: const Color(0xFF101010), + chatWallpaperColor2: const Color(0xFF202020), + chatWallpaperImagePath: null, + chatWallpaperBlur: false, + chatWallpaperImageBlur: 0.0, + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setChatSpecificWallpaper(int chatId, String? imagePath) async { + if (imagePath == null || imagePath.isEmpty) { + _chatSpecificWallpapers.remove(chatId); + } else { + _chatSpecificWallpapers[chatId] = imagePath; + } + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + final key = 'chat_wallpaper_$chatId'; + if (imagePath == null || imagePath.isEmpty) { + await prefs.remove(key); + } else { + await prefs.setString(key, imagePath); + } + } + + String? getChatSpecificWallpaper(int chatId) { + return _chatSpecificWallpapers[chatId]; + } + + bool hasChatSpecificWallpaper(int chatId) { + return _chatSpecificWallpapers.containsKey(chatId) && + _chatSpecificWallpapers[chatId] != null && + _chatSpecificWallpapers[chatId]!.isNotEmpty; + } + + Future loadChatSpecificWallpapers() async { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys(); + _chatSpecificWallpapers.clear(); + + for (final key in keys) { + if (key.startsWith('chat_wallpaper_')) { + final chatIdStr = key.substring('chat_wallpaper_'.length); + final chatId = int.tryParse(chatIdStr); + if (chatId != null) { + final imagePath = prefs.getString(key); + if (imagePath != null && imagePath.isNotEmpty) { + _chatSpecificWallpapers[chatId] = imagePath; + } + } + } + } + notifyListeners(); + } + + Future setUIMode(UIMode value) async { + _activeTheme = _activeTheme.copyWith(uiMode: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setShowSeconds(bool value) async { + _activeTheme = _activeTheme.copyWith(showSeconds: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageBubbleOpacity(double value) async { + _activeTheme = _activeTheme.copyWith( + messageBubbleOpacity: value.clamp(0.0, 1.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageStyle(String value) async { + _activeTheme = _activeTheme.copyWith(messageStyle: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageBackgroundBlur(double value) async { + _activeTheme = _activeTheme.copyWith( + messageBackgroundBlur: value.clamp(0.0, 10.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageTextOpacity(double value) async { + _activeTheme = _activeTheme.copyWith( + messageTextOpacity: value.clamp(0.1, 1.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageShadowIntensity(double value) async { + _activeTheme = _activeTheme.copyWith( + messageShadowIntensity: value.clamp(0.0, 0.5), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageBorderRadius(double value) async { + _activeTheme = _activeTheme.copyWith( + messageBorderRadius: value.clamp(4.0, 50.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageMenuOpacity(double value) async { + _activeTheme = _activeTheme.copyWith(messageMenuOpacity: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageMenuBlur(double value) async { + _activeTheme = _activeTheme.copyWith(messageMenuBlur: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setDebugShowPerformanceOverlay(bool value) async { + _debugShowPerformanceOverlay = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('debug_perf_overlay', value); + } + + Future setDebugShowChatsRefreshPanel(bool value) async { + _debugShowChatsRefreshPanel = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('debug_show_chats_refresh_panel', value); + } + + Future setDebugShowMessageCount(bool value) async { + _debugShowMessageCount = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('debug_show_message_count', value); + } + + Future setDebugReadOnEnter(bool value) async { + _debugReadOnEnter = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('debug_read_on_enter', value); + } + + Future setDebugReadOnAction(bool value) async { + _debugReadOnAction = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('debug_read_on_action', value); + } + + Future setDebugShowBurgerMenu(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('debug_show_burger_menu', value); + } + + Future setChatTransition(TransitionOption value) async { + _activeTheme = _activeTheme.copyWith(chatTransition: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setTabTransition(TransitionOption value) async { + _activeTheme = _activeTheme.copyWith(tabTransition: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageTransition(TransitionOption value) async { + _activeTheme = _activeTheme.copyWith(messageTransition: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setExtraTransition(TransitionOption value) async { + _activeTheme = _activeTheme.copyWith(extraTransition: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setExtraAnimationStrength(double value) async { + _activeTheme = _activeTheme.copyWith( + extraAnimationStrength: value.clamp(1.0, 400.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setMessageSlideDistance(double value) async { + _activeTheme = _activeTheme.copyWith( + messageSlideDistance: value.clamp(1.0, 200.0), + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setAnimatePhotoMessages(bool value) async { + _activeTheme = _activeTheme.copyWith(animatePhotoMessages: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setOptimizeChats(bool value) async { + _activeTheme = _activeTheme.copyWith( + optimizeChats: value, + ultraOptimizeChats: value ? false : _activeTheme.ultraOptimizeChats, + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setUltraOptimizeChats(bool value) async { + _activeTheme = _activeTheme.copyWith( + ultraOptimizeChats: value, + optimizeChats: value ? false : _activeTheme.optimizeChats, + ); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setHighQualityPhotos(bool value) async { + _highQualityPhotos = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('high_quality_photos', _highQualityPhotos); + } + + Future setBlockBypass(bool value) async { + _blockBypass = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('block_bypass', _blockBypass); + } + + Future setUseDesktopLayout(bool value) async { + _activeTheme = _activeTheme.copyWith(useDesktopLayout: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setUseAutoReplyColor(bool value) async { + _activeTheme = _activeTheme.copyWith(useAutoReplyColor: value); + notifyListeners(); + await _saveActiveTheme(); + } + + Future setCustomReplyColor(Color? color) async { + _activeTheme = _activeTheme.copyWith(customReplyColor: color); + notifyListeners(); + await _saveActiveTheme(); + } + + void toggleTheme() { + if (appTheme == AppTheme.light) { + setTheme(AppTheme.dark); + } else { + setTheme(AppTheme.light); + } + } + + Future resetAnimationsToDefault() async { + _activeTheme = _activeTheme.copyWith( + chatTransition: TransitionOption.systemDefault, + tabTransition: TransitionOption.systemDefault, + messageTransition: TransitionOption.systemDefault, + extraTransition: TransitionOption.systemDefault, + messageSlideDistance: 96.0, + extraAnimationStrength: 32.0, + ); + notifyListeners(); + await _saveActiveTheme(); + } +} diff --git a/lib/token_auth_screen.dart b/lib/token_auth_screen.dart new file mode 100644 index 0000000..6c6aa42 --- /dev/null +++ b/lib/token_auth_screen.dart @@ -0,0 +1,437 @@ + + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + + +import 'package:gwid/api_service.dart'; +import 'package:gwid/home_screen.dart'; +import 'package:gwid/proxy_service.dart'; +import 'package:gwid/proxy_settings.dart'; +import 'package:gwid/screens/settings/qr_scanner_screen.dart'; +import 'package:gwid/screens/settings/session_spoofing_screen.dart'; + + +import 'package:encrypt/encrypt.dart' as encrypt; +import 'package:crypto/crypto.dart' as crypto; + +class TokenAuthScreen extends StatefulWidget { + const TokenAuthScreen({super.key}); + + @override + State createState() => _TokenAuthScreenState(); +} + +class _TokenAuthScreenState extends State { + final TextEditingController _tokenController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _tokenController.dispose(); + super.dispose(); + } + + + Future _processLogin({ + required String token, + Map? spoofData, + ProxySettings? proxySettings, + }) async { + if (!mounted) return; + setState(() => _isLoading = true); + final messenger = ScaffoldMessenger.of(context); + + try { + if (spoofData != null && spoofData.isNotEmpty) { + + messenger.showSnackBar( + const SnackBar( + content: Text('Настройки анонимности из файла применены!'), + ), + ); + } + if (proxySettings != null) { + await ProxyService.instance.saveProxySettings(proxySettings); + messenger.showSnackBar( + const SnackBar( + content: Text('Настройки прокси из файла применены!'), + backgroundColor: Colors.blue, + ), + ); + } + + await ApiService.instance.saveToken(token); + + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const HomeScreen()), + (Route route) => false, + ); + } + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text('Ошибка входа: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + + + void _loginWithToken() { + final token = _tokenController.text.trim(); + if (token.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Введите токен для входа'))); + return; + } + _processLogin(token: token); + } + + Future _loadSessionFile() async { + + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + if (result == null || result.files.single.path == null) return; + final filePath = result.files.single.path!; + setState(() => _isLoading = true); + try { + final fileContent = await File(filePath).readAsString(); + Map jsonData = json.decode(fileContent); + String finalJsonPayload; + if (jsonData['encrypted'] == true) { + final password = await _showPasswordDialog(); + if (password == null || password.isEmpty) { + setState(() => _isLoading = false); + return; + } + final iv = encrypt.IV.fromBase64(jsonData['iv_base64']); + final encryptedData = encrypt.Encrypted.fromBase64( + jsonData['data_base64'], + ); + final keyBytes = utf8.encode(password); + final keyHash = crypto.sha256.convert(keyBytes); + final key = encrypt.Key(Uint8List.fromList(keyHash.bytes)); + final encrypter = encrypt.Encrypter( + encrypt.AES(key, mode: encrypt.AESMode.cbc), + ); + finalJsonPayload = encrypter.decrypt(encryptedData, iv: iv); + } else { + finalJsonPayload = fileContent; + } + final Map sessionData = json.decode(finalJsonPayload); + final String? token = sessionData['token']; + if (token == null || token.isEmpty) + throw Exception('Файл сессии не содержит токена.'); + await _processLogin( + token: token, + spoofData: sessionData['spoof_data'] is Map + ? sessionData['spoof_data'] + : null, + proxySettings: sessionData['proxy_settings'] is Map + ? ProxySettings.fromJson(sessionData['proxy_settings']) + : null, + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка: $e'), backgroundColor: Colors.red), + ); + setState(() => _isLoading = false); + } + } + + Future _processQrData(String qrData) async { + + if (!mounted) return; + setState(() => _isLoading = true); + final messenger = ScaffoldMessenger.of(context); + + try { + final decoded = jsonDecode(qrData) as Map; + + + if (decoded['type'] != 'komet_auth_v1' || + decoded['token'] == null || + decoded['timestamp'] == null) { + throw Exception("Неверный формат QR-кода."); + } + + + final int qrTimestamp = decoded['timestamp']; + final String token = decoded['token']; + + + final int now = DateTime.now().millisecondsSinceEpoch; + const int oneMinuteInMillis = 60 * 1000; // 60 секунд + + if ((now - qrTimestamp) > oneMinuteInMillis) { + + throw Exception("QR-код устарел. Пожалуйста, сгенерируйте новый."); + } + + + await _processLogin(token: token); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text('Ошибка: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } finally { + + if (mounted) setState(() => _isLoading = false); + } + } + + void _showQrSourceSelection() { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Камера'), + onTap: () { + Navigator.of(context).pop(); + _scanWithCamera(); + }, + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('Галерея'), + onTap: () { + Navigator.of(context).pop(); + _scanFromGallery(); + }, + ), + ], + ), + ), + ); + } + + Future _scanWithCamera() async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const QrScannerScreen()), + ); + if (result != null) await _processQrData(result); + } + + Future _scanFromGallery() async { + final image = await ImagePicker().pickImage(source: ImageSource.gallery); + if (image == null) return; + final controller = MobileScannerController(); + final result = await controller.analyzeImage(image.path); + await controller.dispose(); + if (result != null && + result.barcodes.isNotEmpty && + result.barcodes.first.rawValue != null) { + await _processQrData(result.barcodes.first.rawValue!); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('QR-код на изображении не найден.'), + backgroundColor: Colors.orange, + ), + ); + } + } + + Future _showPasswordDialog() { + + final passwordController = TextEditingController(); + bool isPasswordVisible = false; + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => StatefulBuilder( + builder: (context, setStateDialog) => AlertDialog( + title: const Text('Введите пароль'), + content: TextField( + controller: passwordController, + obscureText: !isPasswordVisible, + autofocus: true, + decoration: InputDecoration( + labelText: 'Пароль от файла сессии', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + isPasswordVisible ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => setStateDialog( + () => isPasswordVisible = !isPasswordVisible, + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => + Navigator.of(context).pop(passwordController.text), + child: const Text('OK'), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Другие способы входа')), + body: Stack( + children: [ + ListView( + padding: const EdgeInsets.all(16), + children: [ + + _AuthCard( + icon: Icons.qr_code_scanner_rounded, + title: 'Вход по QR-коду', + subtitle: + 'Отсканируйте QR-код с другого устройства, чтобы быстро войти.', + buttonLabel: 'Сканировать QR-код', + onPressed: _showQrSourceSelection, + ), + + const SizedBox(height: 20), + + + _AuthCard( + icon: Icons.file_open_outlined, + title: 'Вход по файлу сессии', + subtitle: + 'Загрузите ранее экспортированный .json файл для восстановления сессии.', + buttonLabel: 'Загрузить файл', + onPressed: _loadSessionFile, + isOutlined: true, + ), + + const SizedBox(height: 20), + + + _AuthCard( + icon: Icons.vpn_key_outlined, + title: 'Вход по токену', + subtitle: 'Введите токен авторизации (AUTH_TOKEN) вручную.', + buttonLabel: 'Войти с токеном', + onPressed: _loginWithToken, + isOutlined: true, + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: TextField( + controller: _tokenController, + decoration: const InputDecoration( + labelText: 'Токен', + border: OutlineInputBorder(), + ), + ), + ), + ), + ], + ), + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + } +} + + +class _AuthCard extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final String buttonLabel; + final VoidCallback onPressed; + final bool isOutlined; + final Widget? child; + + const _AuthCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.buttonLabel, + required this.onPressed, + this.isOutlined = false, + this.child, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Card( + elevation: isOutlined ? 0 : 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: isOutlined + ? BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ) + : BorderSide.none, + ), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + icon, + size: 28, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Text( + title, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + subtitle, + style: textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (child != null) ...[const SizedBox(height: 20), child!], + const SizedBox(height: 20), + isOutlined + ? OutlinedButton(onPressed: onPressed, child: Text(buttonLabel)) + : FilledButton(onPressed: onPressed, child: Text(buttonLabel)), + ], + ), + ), + ); + } +} diff --git a/lib/tos_screen.dart b/lib/tos_screen.dart new file mode 100644 index 0000000..f6b36be --- /dev/null +++ b/lib/tos_screen.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class TosScreen extends StatelessWidget { + const TosScreen({super.key}); + + + final String tosText = """ +### 1. Статус и отношения +1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа» (правообладатель сервиса «MAX»). + +1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа». + +1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям. + +### 2. Условия использования +2.1. Используя Приложение «Komet», вы: +- Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps) +- Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»; +- Принимаете на себя все риски, связанные с использованием Приложения. + +2.2. Строго запрещено: +- Использовать Приложение «Komet» для распространения запрещённого контента; +- Осуществлять массовые рассылки (спам); +- Нарушать законодательство РФ и международное право; +- Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX». + +2.3. Техническая реализация соответствует принципу добросовестного использования (fair use) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ. + +2.4. Особенности технического взаимодействия: +- Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru) +- Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости; +- Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО. + +### 3. Технические аспекты +3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки. + +3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости. + +3.3. Разработчики не несут ответственности за: +- Изменения в API оригинального сервиса; +- Блокировку аккаунтов пользователей; +- Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа». + +### 4. Конфиденциальность +4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей. + +4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа». + +4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей. + +### 5. Ответственность и ограничения +5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности. + +5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин. + +5.3. Пользователь обязуется не использовать Приложение «Komet» в коммерческих целях. + +### 6. Правовые основания +6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с: +- Статья 1280.1 ГК РФ — декомпилирование программы для обеспечения совместимости; +- Статья 1229 ГК РФ — ограничения исключительного права в информационных целях; +- Федеральный закон № 149-ФЗ «Об информации» — использование общедоступной информации; +- Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ. + +6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через: +- Публичные API-интерфейсы, доступные через веб-версию сервиса; +- Методы обратной разработки, разрешённые ст. 1280.1 ГК РФ для целей совместимости; +- Открытые протоколы взаимодействия, не защищённые техническими средствами охраны. + +6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ. + +### 7. Заключительные положения +7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что: +- Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов; +- Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»; +- Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки. + +7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей. + +### 8. Функции безопасности и конфиденциальности +8.1. Приложение «Komet» включает инструменты защиты приватности: +- Подмена данных сессии — для предотвращения отслеживания пользователя; +- Система прокси-подключений — для обеспечения безопасности сетевого взаимодействия; +- Ограничение телеметрии — для минимизации передачи диагностических данных. + +8.2. Данные функции: +- Направлены исключительно на защиту конфиденциальности пользователей; +- Не используются для обхода систем безопасности оригинального сервиса; +- Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни. + +8.3. Разработчики не несут ответственности за: +- Блокировки, связанные с использованием инструментов конфиденциальности; +- Изменения в работе сервиса при активации данных функций. + +### 8.4. Функции экспорта и импорта сессии +8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для: +- Обеспечения переносимости данных между устройствами пользователя +- Резервного копирования учетных данных +- Восстановления доступа при утере устройства + +8.4.2. Особенности реализации: +- Экспорт сессии осуществляется без привязки к номеру телефона +- Данные сессии защищаются паролем и шифрованием по алгоритмам AES-256 +- Ключ шифрования известен только пользователю и не сохраняется в приложении + +8.4.3. Техническая реализация экспорта сессии: +- Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе +- Используется подмена параметров сессии для сохранения контекста аутентификации +- Интеграция настроек прокси для обеспечения единой конфигурации подключения +- Импортированная сессия маскирует источник подключения через указанные прокси-настройки +- Серверы оригинального сервиса не получают данных о смене устройства пользователя +- Шифрование применяется ко всему пакету данных (сессия + прокси-конфиг) + +8.4.4. Правовые основания: +- Статья 6 ФЗ-152 «О персональных данных» — обработка данных с согласия субъекта +- Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных) +- Принцип минимизации данных — сбор только необходимой для работы информации +- Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается) +- Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies) +- Маскировка IP-адреса — законный способ защиты персональных данных (ст. 6 ФЗ-152) + +8.4.5. Ограничения ответственности: +- Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий +- Разработчики не имеют доступа к зашифрованным данным сессии +- Восстановление утерянных паролей невозможно в целях безопасности +- Ключи шифрования не хранятся в приложении и известны только пользователю +"""; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final markdownStyleSheet = MarkdownStyleSheet.fromTheme(Theme.of(context)) + .copyWith( + h3: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + fontFamily: GoogleFonts.manrope().fontFamily, + height: 2.2, + ), + p: textTheme.bodyMedium?.copyWith( + fontFamily: GoogleFonts.manrope().fontFamily, + height: 1.5, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + listBullet: textTheme.bodyMedium?.copyWith( + fontFamily: GoogleFonts.manrope().fontFamily, + height: 1.6, + ), + a: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, + ), + ); + + return Scaffold( + appBar: AppBar(title: const Text('Пользовательское соглашение')), + body: Markdown( + data: tosText, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + styleSheet: markdownStyleSheet, + selectable: true, + onTapLink: (text, href, title) async { + if (href != null) { + final uri = Uri.tryParse(href); + if (uri != null && await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + }, + ), + ); + } +} diff --git a/lib/universal_io.dart b/lib/universal_io.dart new file mode 100644 index 0000000..e23c124 --- /dev/null +++ b/lib/universal_io.dart @@ -0,0 +1,6 @@ +library; + +class Platform { + static bool get isAndroid => false; + static bool get isIOS => false; +} \ No newline at end of file diff --git a/lib/user_id_lookup_screen.dart b/lib/user_id_lookup_screen.dart new file mode 100644 index 0000000..e969278 --- /dev/null +++ b/lib/user_id_lookup_screen.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/models/contact.dart'; + +class UserIdLookupScreen extends StatefulWidget { + const UserIdLookupScreen({super.key}); + + @override + State createState() => _UserIdLookupScreenState(); +} + +class _UserIdLookupScreenState extends State { + final TextEditingController _idController = TextEditingController(); + final FocusNode _idFocusNode = FocusNode(); + bool _isLoading = false; + Contact? _foundContact; + bool _searchAttempted = false; + + Future _searchById() async { + final String idText = _idController.text.trim(); + if (idText.isEmpty) { + return; + } + + final int? contactId = int.tryParse(idText); + if (contactId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Пожалуйста, введите корректный ID (только цифры)'), + ), + ); + return; + } + + _idFocusNode.unfocus(); + + setState(() { + _isLoading = true; + _searchAttempted = true; + _foundContact = null; + }); + + try { + final List contacts = await ApiService.instance + .fetchContactsByIds([contactId]); + + if (mounted) { + setState(() { + _foundContact = contacts.isNotEmpty ? contacts.first : null; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Ошибка при поиске: $e'))); + } + } + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _idFocusNode.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Поиск по ID')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + TextField( + controller: _idController, + focusNode: _idFocusNode, + decoration: InputDecoration( + labelText: 'Введите ID пользователя', + filled: true, + fillColor: colors.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon(Icons.person_search_outlined), + suffixIcon: _isLoading + ? const Padding( + padding: EdgeInsets.all(12.0), + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + icon: const Icon(Icons.search), + onPressed: _searchById, + ), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onSubmitted: (_) => _searchById(), + ), + const SizedBox(height: 32), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _isLoading + ? const Center( + key: ValueKey('loading'), + child: CircularProgressIndicator(), + ) + : _searchAttempted + ? _foundContact != null + ? _buildContactCard(_foundContact!, colors) + : _buildEmptyState( + key: const ValueKey('not_found'), + colors: colors, + icon: Icons.search_off_rounded, + title: 'Пользователь не найден', + subtitle: + 'Аккаунт с ID "${_idController.text}" не существует или скрыт.', + ) + : _buildEmptyState( + key: const ValueKey('initial'), + colors: colors, + icon: Icons.person_search_rounded, + title: 'Введите ID для поиска', + subtitle: 'Найдем пользователя в системе по его ID', + ), + ), + ], + ), + ), + ); + } + + Widget _buildContactCard(Contact contact, ColorScheme colors) { + return Column( + key: const ValueKey('contact_card'), + children: [ + CircleAvatar( + radius: 56, + backgroundColor: colors.primaryContainer, + backgroundImage: contact.photoBaseUrl != null + ? NetworkImage(contact.photoBaseUrl!) + : null, + child: contact.photoBaseUrl == null + ? Text( + contact.name.isNotEmpty ? contact.name[0].toUpperCase() : '?', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: colors.onPrimaryContainer, + ), + ) + : null, + ), + const SizedBox(height: 16), + Text( + contact.name, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + 'ID: ${contact.id}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), + const SizedBox(height: 24), + Container( + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _buildInfoTile( + colors: colors, + icon: Icons.person_outlined, + title: 'Имя', + subtitle: contact.firstName, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + _buildInfoTile( + colors: colors, + icon: Icons.badge_outlined, + title: 'Фамилия', + subtitle: contact.lastName, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + _buildInfoTile( + colors: colors, + icon: Icons.notes_rounded, + title: 'Описание', + subtitle: contact.description, + ), + ], + ), + ), + ], + ); + } + + Widget _buildInfoTile({ + required ColorScheme colors, + required IconData icon, + required String title, + String? subtitle, + }) { + final bool hasData = subtitle != null && subtitle.isNotEmpty; + + return ListTile( + leading: Icon(icon, color: colors.primary), + title: Text(title), + subtitle: Text( + hasData ? subtitle : '(не указано)', + style: TextStyle( + color: hasData + ? colors.onSurfaceVariant + : colors.onSurfaceVariant.withOpacity(0.7), + fontStyle: hasData ? FontStyle.normal : FontStyle.italic, + ), + ), + ); + } + + Widget _buildEmptyState({ + required Key key, + required ColorScheme colors, + required IconData icon, + required String title, + required String subtitle, + }) { + return Column( + key: key, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 64, color: colors.onSurfaceVariant.withOpacity(0.5)), + const SizedBox(height: 16), + Text( + title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: colors.onSurfaceVariant), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart new file mode 100644 index 0000000..46e19e2 --- /dev/null +++ b/lib/widgets/chat_message_bubble.dart @@ -0,0 +1,4048 @@ +import 'dart:core'; +import 'package:flutter/material.dart'; +import 'dart:io' show File; +import 'dart:convert' show base64Decode; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io' as io; +import 'package:crypto/crypto.dart' as crypto; +import 'package:intl/intl.dart'; +import 'package:gwid/models/message.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/theme_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter/services.dart'; +import 'dart:ui'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:gwid/chat_screen.dart'; +import 'package:gwid/services/avatar_cache_service.dart'; +import 'package:gwid/api_service.dart'; +import 'dart:async'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:open_file/open_file.dart'; +import 'package:gwid/full_screen_video_player.dart'; + + +final _userColorCache = {}; +bool _currentIsDark = false; + +enum MessageReadStatus { + sending, // Отправляется (часы) + sent, // Отправлено (1 галочка) + read, // Прочитано (2 галочки) +} + + +class FileDownloadProgressService { + static final FileDownloadProgressService _instance = + FileDownloadProgressService._internal(); + factory FileDownloadProgressService() => _instance; + FileDownloadProgressService._internal(); + + final Map> _progressNotifiers = {}; + bool _initialized = false; + + + Future _ensureInitialized() async { + if (_initialized) return; + + try { + final prefs = await SharedPreferences.getInstance(); + + + final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; + + + for (final mapping in fileIdMap) { + final parts = mapping.split(':'); + if (parts.length >= 2) { + final fileId = parts[0]; + final filePath = parts.skip(1).join(':'); // In case path contains ':' + + final file = io.File(filePath); + if (await file.exists()) { + if (!_progressNotifiers.containsKey(fileId)) { + _progressNotifiers[fileId] = ValueNotifier(1.0); + } else { + _progressNotifiers[fileId]!.value = 1.0; + } + } + } + } + + _initialized = true; + } catch (e) { + print('Error initializing download status: $e'); + _initialized = true; // Mark as initialized to avoid retrying indefinitely + } + } + + ValueNotifier getProgress(String fileId) { + _ensureInitialized(); // Ensure initialization + if (!_progressNotifiers.containsKey(fileId)) { + _progressNotifiers[fileId] = ValueNotifier(-1); + } + return _progressNotifiers[fileId]!; + } + + void updateProgress(String fileId, double progress) { + if (!_progressNotifiers.containsKey(fileId)) { + _progressNotifiers[fileId] = ValueNotifier(progress); + } else { + _progressNotifiers[fileId]!.value = progress; + } + } + + void clearProgress(String fileId) { + _progressNotifiers.remove(fileId); + } +} + +Color _getUserColor(int userId, BuildContext context) { + final bool isDark = Theme.of(context).brightness == Brightness.dark; + + + if (isDark != _currentIsDark) { + _userColorCache.clear(); + _currentIsDark = isDark; + } + + + if (_userColorCache.containsKey(userId)) { + return _userColorCache[userId]!; + } + + final List materialYouColors = isDark + ? [ + + const Color(0xFFEF5350), // Красный + const Color(0xFFEC407A), // Розовый + const Color(0xFFAB47BC), // Фиолетовый + const Color(0xFF7E57C2), // Глубокий фиолетовый + const Color(0xFF5C6BC0), // Индиго + const Color(0xFF42A5F5), // Синий + const Color(0xFF29B6F6), // Голубой + const Color(0xFF26C6DA), // Бирюзовый + const Color(0xFF26A69A), // Теal + const Color(0xFF66BB6A), // Зеленый + const Color(0xFF9CCC65), // Светло-зеленый + const Color(0xFFD4E157), // Лаймовый + const Color(0xFFFFEB3B), // Желтый + const Color(0xFFFFCA28), // Янтарный + const Color(0xFFFFA726), // Оранжевый + const Color(0xFFFF7043), // Глубокий оранжевый + const Color(0xFF8D6E63), // Коричневый + const Color(0xFF78909C), // Сине-серый + const Color(0xFFB39DDB), // Лавандовый + const Color(0xFF80CBC4), // Аквамариновый + const Color(0xFFC5E1A5), // Светло-зеленый пастельный + ] + : [ + + const Color(0xFFF44336), // Красный + const Color(0xFFE91E63), // Розовый + const Color(0xFF9C27B0), // Фиолетовый + const Color(0xFF673AB7), // Глубокий фиолетовый + const Color(0xFF3F51B5), // Индиго + const Color(0xFF2196F3), // Синий + const Color(0xFF03A9F4), // Голубой + const Color(0xFF00BCD4), // Бирюзовый + const Color(0xFF009688), // Теal + const Color(0xFF4CAF50), // Зеленый + const Color(0xFF8BC34A), // Светло-зеленый + const Color(0xFFCDDC39), // Лаймовый + const Color(0xFFFFEE58), // Желтый + const Color(0xFFFFC107), // Янтарный + const Color(0xFFFF9800), // Оранжевый + const Color(0xFFFF5722), // Глубокий оранжевый + const Color(0xFF795548), // Коричневый + const Color(0xFF607D8B), // Сине-серый + const Color(0xFF9575CD), // Лавандовый + const Color(0xFF4DB6AC), // Бирюзовый светлый + const Color(0xFFAED581), // Зеленый пастельный + ]; + + final colorIndex = userId % materialYouColors.length; + final color = materialYouColors[colorIndex]; + + + _userColorCache[userId] = color; + + return color; +} + +class ChatMessageBubble extends StatelessWidget { + final Message message; + final bool isMe; + final MessageReadStatus? readStatus; + final bool deferImageLoading; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + final VoidCallback? onDeleteForMe; + final VoidCallback? onDeleteForAll; + final Function(String)? onReaction; + final VoidCallback? onRemoveReaction; + final VoidCallback? onReply; + final int? myUserId; + final bool? canEditMessage; + final bool isGroupChat; + final bool isChannel; + final String? senderName; + final String? forwardedFrom; + final Map? contactDetailsCache; + final Function(String)? onReplyTap; + final bool useAutoReplyColor; + final Color? customReplyColor; + final bool isFirstInGroup; + final bool isLastInGroup; + final bool isGrouped; + final double avatarVerticalOffset; + final int? chatId; + + const ChatMessageBubble({ + super.key, + required this.message, + required this.isMe, + this.readStatus, + this.deferImageLoading = false, + this.onEdit, + this.onDelete, + this.onDeleteForMe, + this.onDeleteForAll, + this.onReaction, + this.onRemoveReaction, + this.onReply, + this.myUserId, + this.canEditMessage, + this.isGroupChat = false, + this.isChannel = false, + this.senderName, + this.forwardedFrom, + this.contactDetailsCache, + this.onReplyTap, + this.useAutoReplyColor = true, + this.customReplyColor, + this.isFirstInGroup = false, + this.isLastInGroup = false, + this.isGrouped = false, + this.avatarVerticalOffset = + -35.0, // выше ниже аватарку бля как хотите я жрать хочу + this.chatId, + }); + + String _formatMessageTime(BuildContext context, int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final showSeconds = Provider.of( + context, + listen: false, + ).showSeconds; + return DateFormat(showSeconds ? 'HH:mm:ss' : 'HH:mm').format(dt); + } + + EdgeInsets _getMessageMargin(BuildContext context) { + if (isLastInGroup) { + return const EdgeInsets.only(bottom: 12); + } + if (isFirstInGroup) { + return const EdgeInsets.only(bottom: 3); + } + return const EdgeInsets.only(bottom: 3); + } + + Widget _buildForwardedMessage( + BuildContext context, + Map link, + Color textColor, + double messageTextOpacity, + bool isUltraOptimized, + ) { + final forwardedMessage = link['message'] as Map?; + if (forwardedMessage == null) return const SizedBox.shrink(); + + final text = forwardedMessage['text'] as String? ?? ''; + final attaches = + (forwardedMessage['attaches'] as List?) + ?.map((e) => (e as Map).cast()) + .toList() ?? + []; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: textColor.withOpacity(0.08 * messageTextOpacity), + border: Border( + left: BorderSide( + color: textColor.withOpacity(0.3 * messageTextOpacity), + width: 3, // Делаем рамку жирнее для отличия от ответа + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.forward, + size: 14, + color: textColor.withOpacity(0.6 * messageTextOpacity), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + + forwardedFrom ?? 'Неизвестный', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: textColor.withOpacity(0.9 * messageTextOpacity), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), + + + if (attaches.isNotEmpty) ...[ + ..._buildPhotosWithCaption( + context, + attaches, // Передаем вложения из вложенного сообщения + textColor, + isUltraOptimized, + messageTextOpacity, + ), + const SizedBox(height: 6), + ], + if (text.isNotEmpty) + Text( + text, + style: TextStyle( + color: textColor.withOpacity(0.9 * messageTextOpacity), + fontSize: 14, + ), + ), + ], + ), + ); + } + + Widget _buildVideoPreview({ + required BuildContext context, + required int videoId, + required String messageId, + String? highQualityUrl, + Uint8List? lowQualityBytes, + }) { + final borderRadius = BorderRadius.circular(12); + + + void openFullScreenVideo() async { + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + + try { + final videoUrl = await ApiService.instance.getVideoUrl( + videoId, + chatId!, // chatId из `build` + messageId, + ); + + if (!context.mounted) return; // [!code ++] Проверка правильным способом + Navigator.pop(context); // Убираем индикатор + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => FullScreenVideoPlayer(videoUrl: videoUrl), + ), + ); + } catch (e) { + if (!context.mounted) return; // [!code ++] Проверка правильным способом + Navigator.pop(context); // Убираем индикатор + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Не удалось загрузить видео: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + + return GestureDetector( + onTap: openFullScreenVideo, + child: AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: borderRadius, + child: Stack( + alignment: Alignment.center, + fit: StackFit.expand, + children: [ + + + (highQualityUrl != null && highQualityUrl.isNotEmpty) || + (lowQualityBytes != null) + ? _ProgressiveNetworkImage( + url: + highQualityUrl ?? + '', // _ProgressiveNetworkImage теперь это выдержит + previewBytes: lowQualityBytes, + width: 220, + height: 160, + fit: BoxFit.cover, + keepAlive: false, + ) + + : Container( + color: Colors.black26, + child: const Center( + child: Icon( + Icons.video_library_outlined, + color: Colors.white, + size: 40, + ), + ), + ), + + + + Container( + decoration: BoxDecoration( + + color: Colors.black.withOpacity(0.15), + ), + child: Icon( + Icons.play_circle_filled_outlined, + color: Colors.white.withOpacity(0.95), + size: 50, + shadows: const [ + Shadow( + color: Colors.black38, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildReplyPreview( + BuildContext context, + Map link, + Color textColor, + double messageTextOpacity, + bool isUltraOptimized, + double messageBorderRadius, + ) { + final replyMessage = link['message'] as Map?; + if (replyMessage == null) return const SizedBox.shrink(); + + final replyText = replyMessage['text'] as String? ?? ''; + final replySenderId = replyMessage['sender'] as int?; + final replyMessageId = replyMessage['id'] as String?; + + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + Color replyAccentColor; + if (useAutoReplyColor) { + replyAccentColor = _getUserColor(replySenderId ?? 0, context); + } else { + replyAccentColor = + customReplyColor ?? + (isDarkMode ? const Color(0xFF90CAF9) : const Color(0xFF1976D2)); + } + + + final textLength = replyText.length; + final minWidth = 120.0; // Минимальная ширина для коротких сообщений + + + double adaptiveWidth = minWidth; + if (textLength > 0) { + + adaptiveWidth = (textLength * 8.0 + 32).clamp(minWidth, double.infinity); + } + + return GestureDetector( + onTap: () { + + if (replyMessageId != null && onReplyTap != null) { + onReplyTap!(replyMessageId); + } + }, + child: Container( + constraints: BoxConstraints(minWidth: minWidth, minHeight: 40), + width: adaptiveWidth, // Используем адаптивную ширину + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: isDarkMode + ? replyAccentColor.withOpacity( + 0.15, + ) // Полупрозрачный фон для темной темы + : replyAccentColor.withOpacity( + 0.08, + ), // Более прозрачный для светлой + borderRadius: BorderRadius.circular( + (isUltraOptimized ? 4 : messageBorderRadius) * 0.3, + ), + border: Border( + left: BorderSide( + color: replyAccentColor, // Цвет левой границы + width: 2, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.reply, size: 12, color: replyAccentColor), + const SizedBox(width: 4), + Expanded( + child: Text( + replySenderId != null + ? (contactDetailsCache?[replySenderId]?.name ?? + 'Участник $replySenderId') + : 'Неизвестный', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: replyAccentColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 2), + + Align( + alignment: Alignment.centerLeft, + child: Text( + replyText.isNotEmpty ? replyText : 'Фото', + style: const TextStyle(fontSize: 11, color: Colors.white), + maxLines: 2, + overflow: TextOverflow.ellipsis, + softWrap: true, + textAlign: TextAlign.left, + ), + ), + ], + ), + ), + ); + } + /* + void _showMessageContextMenu(BuildContext context) { + + const reactions = [ + '👍', + '❤️', + '😂', + '🔥', + '👏', + '👌', + '🎉', + '🥰', + '😍', + '🙏', + '🤔', + '🤯', + '💯', + '⚡️', + '🤟', + '🌚', + '🌝', + '🥱', + '🤣', + '🫠', + '🫡', + '🐱', + '💋', + '😘', + '🐶', + '🤝', + '⭐️', + '🍷', + '🍑', + '😁', + '🤷‍♀️', + '🤷‍♂️', + '👩‍❤️‍👨', + '🦄', + '👻', + '🗿', + '❤️‍🩹', + '🛑', + '⛄️', + '❓', + '🙄', + '❗️', + '😉', + '😳', + '🥳', + '😎', + '💪', + '👀', + '🤞', + '🤤', + '🤪', + '🤩', + '😴', + '😐', + '😇', + '🖤', + '👑', + '👋', + '👁️', + ]; + + + final hasUserReaction = + message.reactionInfo != null && + message.reactionInfo!['yourReaction'] != null; + + showModalBottomSheet( + context: context, + backgroundColor: Colors + .transparent, // Фон делаем прозрачным, чтобы скругление было видно + builder: (context) => Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + + if (onReaction != null) ...[ + + SizedBox( + height: 80, // Задаем высоту для ряда с реакциями + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12.0, + ), + child: Row( + children: [ + ...reactions.map( + (emoji) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: GestureDetector( + onTap: () { + Navigator.pop(context); + onReaction!(emoji); + }, + child: Text( + emoji, + style: const TextStyle(fontSize: 32), + ), + ), + ), + ), + ], + ), + ), + ), + + if (hasUserReaction && onRemoveReaction != null) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + onRemoveReaction!(); + }, + icon: const Icon(Icons.remove_circle_outline), + label: const Text('Убрать реакцию'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.errorContainer, + foregroundColor: Theme.of( + context, + ).colorScheme.onErrorContainer, + ), + ), + ), + ), + ], + const Divider(height: 1), + ], + + if (onReply != null) + ListTile( + leading: const Icon(Icons.reply), + title: const Text('Ответить'), + onTap: () { + Navigator.pop(context); + onReply!(); + }, + ), + if (onEdit != null) + ListTile( + leading: Icon( + canEditMessage == false ? Icons.edit_off : Icons.edit, + color: canEditMessage == false ? Colors.grey : null, + ), + title: Text( + canEditMessage == false + ? 'Редактировать (недоступно)' + : 'Редактировать', + style: TextStyle( + color: canEditMessage == false ? Colors.grey : null, + ), + ), + onTap: canEditMessage == false + ? null + : () { + Navigator.pop(context); + onEdit!(); + }, + ), + if (onDeleteForMe != null || + onDeleteForAll != null || + onDelete != null) ...[ + if (onEdit != null) const Divider(height: 1), + if (onDeleteForMe != null) + ListTile( + leading: const Icon( + Icons.person_remove, + color: Colors.redAccent, + ), + title: const Text( + 'Удалить у меня', + style: TextStyle(color: Colors.redAccent), + ), + onTap: () { + Navigator.pop(context); + onDeleteForMe?.call(); + }, + ), + if (onDeleteForAll != null) + ListTile( + leading: const Icon( + Icons.delete_forever, + color: Colors.red, + ), + title: const Text( + 'Удалить у всех', + style: TextStyle(color: Colors.red), + ), + onTap: () { + Navigator.pop(context); + onDeleteForAll?.call(); + }, + ), + if (onDelete != null && + onDeleteForMe == null && + onDeleteForAll == null) + ListTile( + leading: const Icon(Icons.delete, color: Colors.red), + title: const Text( + 'Удалить', + style: TextStyle(color: Colors.red), + ), + onTap: () { + Navigator.pop(context); + onDelete!.call(); + }, + ), + ], + ], + ), + ), + ), + ); + } + */ + + void _showMessageContextMenu(BuildContext context, Offset tapPosition) { + final hasUserReaction = message.reactionInfo?['yourReaction'] != null; + + showDialog( + context: context, + barrierColor: Colors.transparent, // Делаем фон прозрачным + builder: (context) { + return _MessageContextMenu( + message: message, + position: tapPosition, + onReply: onReply, + onEdit: onEdit, + onDeleteForMe: onDeleteForMe, + onDeleteForAll: onDeleteForAll, + onReaction: onReaction, + onRemoveReaction: onRemoveReaction, + canEditMessage: canEditMessage ?? false, + hasUserReaction: hasUserReaction, + ); + }, + ); + } + + Widget _buildReactionsWidget(BuildContext context, Color textColor) { + if (message.reactionInfo == null || + message.reactionInfo!['counters'] == null) { + return const SizedBox.shrink(); + } + + final counters = message.reactionInfo!['counters'] as List; + if (counters.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + alignment: isMe ? WrapAlignment.end : WrapAlignment.start, + children: counters.map((counter) { + final emoji = counter['reaction'] as String; + final count = counter['count'] as int; + final isUserReaction = message.reactionInfo!['yourReaction'] == emoji; + + return GestureDetector( + onTap: () { + if (isUserReaction) { + + onRemoveReaction?.call(); + } else { + + onReaction?.call(emoji); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isUserReaction + ? Theme.of(context).colorScheme.primary.withOpacity(0.3) + : textColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + '$emoji $count', + style: TextStyle( + fontSize: 12, + fontWeight: isUserReaction + ? FontWeight.w600 + : FontWeight.w500, + color: isUserReaction + ? Theme.of(context).colorScheme.primary + : textColor.withOpacity(0.9), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget build(BuildContext context) { + final themeProvider = Provider.of(context); + final isUltraOptimized = themeProvider.ultraOptimizeChats; + + final isStickerOnly = + message.attaches.length == 1 && + message.attaches.any((a) => a['_type'] == 'STICKER') && + message.text.isEmpty; + if (isStickerOnly) { + return _buildStickerOnlyMessage(context); + } + + final hasUnsupportedContent = _hasUnsupportedMessageTypes(); + + final messageOpacity = themeProvider.messageBubbleOpacity; + final messageTextOpacity = themeProvider.messageTextOpacity; + final messageShadowIntensity = themeProvider.messageShadowIntensity; + final messageBorderRadius = themeProvider.messageBorderRadius; + + final bubbleColor = _getBubbleColor(isMe, themeProvider, messageOpacity); + final textColor = _getTextColor( + isMe, + bubbleColor, + messageTextOpacity, + context, + ); + final bubbleDecoration = _createBubbleDecoration( + bubbleColor, + messageBorderRadius, + messageShadowIntensity, + ); + + if (hasUnsupportedContent) { + return _buildUnsupportedMessage( + context, + bubbleColor, + textColor, + bubbleDecoration, + ); + } + + final baseTextStyle = + Theme.of(context).textTheme.bodyMedium ?? const TextStyle(); + final defaultTextStyle = baseTextStyle.copyWith(color: textColor); + final linkColor = _getLinkColor(bubbleColor, isMe); + final linkStyle = baseTextStyle.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ); + + Future _onOpenLink(LinkableElement link) async { + final uri = Uri.parse(link.url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Не удалось открыть ссылку: ${link.url}')), + ); + } + } + } + + void _onSenderNameTap() { + openUserProfileById(context, message.senderId); + } + + final messageContentChildren = _buildMessageContentChildren( + context, + textColor, + messageTextOpacity, + isUltraOptimized, + linkStyle, + defaultTextStyle, + messageBorderRadius, + _onOpenLink, + _onSenderNameTap, + ); + + Widget messageContent = _buildMessageContentInner( + context, + bubbleDecoration, + messageContentChildren, + ); + + if (onReaction != null || (isMe && (onEdit != null || onDelete != null))) { + messageContent = GestureDetector( + onTapDown: (TapDownDetails details) { + _showMessageContextMenu(context, details.globalPosition); + }, + child: messageContent, + ); + } + + return Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe && isGroupChat && !isChannel) ...[ + SizedBox( + width: 40, + child: isLastInGroup + ? Transform.translate( + offset: Offset(0, avatarVerticalOffset), + child: _buildSenderAvatar(), + ) + : null, + ), + ], + Flexible(child: messageContent), + ], + ), + ], + ); + } + + List _buildInlineKeyboard( + BuildContext context, + List> attaches, + Color textColor, + ) { + + final keyboardAttach = attaches.firstWhere( + (a) => a['_type'] == 'INLINE_KEYBOARD', + orElse: () => + {}, // Возвращаем пустую карту, если не найдено + ); + + if (keyboardAttach.isEmpty) { + return []; // Нет клавиатуры + } + + + final keyboardData = keyboardAttach['keyboard'] as Map?; + final buttonRows = keyboardData?['buttons'] as List?; + + if (buttonRows == null || buttonRows.isEmpty) { + return []; // Нет кнопок + } + + final List rows = []; + + + for (final row in buttonRows) { + if (row is List && row.isNotEmpty) { + final List buttonsInRow = []; + + + for (final buttonData in row) { + if (buttonData is Map) { + final String? text = buttonData['text'] as String?; + final String? type = buttonData['type'] as String?; + final String? url = buttonData['url'] as String?; + + + if (text != null && type == 'LINK' && url != null) { + buttonsInRow.add( + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.0), + child: FilledButton( + onPressed: () => + _launchURL(context, url), // Открываем ссылку + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 12, + ), + + backgroundColor: textColor.withOpacity(0.1), + foregroundColor: textColor.withOpacity(0.9), + ), + child: Text( + text, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ); + } + } + } + + + if (buttonsInRow.isNotEmpty) { + rows.add( + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: buttonsInRow, + ), + ), + ); + } + } + } + + + if (rows.isNotEmpty) { + return [ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column(children: rows), + ), + ]; + } + + return []; + } + + + Future _launchURL(BuildContext context, String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Не удалось открыть ссылку: $url'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + bool _hasUnsupportedMessageTypes() { + final hasUnsupportedAttachments = message.attaches.any((attach) { + final type = attach['_type']?.toString().toUpperCase(); + return type == 'AUDIO' || + type == 'VOICE' || + type == 'GIF' || + type == 'LOCATION' || + type == 'CONTACT'; + }); + + return hasUnsupportedAttachments; + } + + Widget _buildUnsupportedMessage( + BuildContext context, + Color bubbleColor, + Color textColor, + BoxDecoration bubbleDecoration, + ) { + return Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe && isGroupChat && !isChannel) ...[ + + SizedBox( + width: 40, + child: + isLastInGroup //Если это соо в группе, и оно последнее в группе соо + ? Transform.translate( + offset: Offset(0, avatarVerticalOffset), + child: _buildSenderAvatar(), + ) + : null, + ), + ], + Flexible( + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.65, + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + margin: _getMessageMargin(context), + decoration: bubbleDecoration, + child: Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + + if (isGroupChat && !isMe && senderName != null) + Padding( + padding: const EdgeInsets.only(left: 2.0, bottom: 2.0), + child: Text( + senderName ?? 'Неизвестный', + style: TextStyle( + fontWeight: FontWeight.bold, + color: _getUserColor( + message.senderId, + context, + ).withOpacity(0.8), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isGroupChat && !isMe && senderName != null) + const SizedBox(height: 4), + + Text( + 'Это сообщение не поддерживается в Вашей версии Komet. ' + 'Пожалуйста, обновитесь до последней версии. ' + 'Если Вы уже используете свежую версию приложения, ' + 'возможно, в сообщении используется нововведение, ' + 'которое пока не поддерживается.', + style: TextStyle( + color: textColor, + fontStyle: FontStyle.italic, + fontSize: 14, + ), + textAlign: TextAlign.left, + ), + + const SizedBox(height: 8.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isMe) ...[ + if (message.isEdited) ...[ + Text( + '(изменено)', + style: TextStyle( + fontSize: 10, + color: textColor.withOpacity(0.5), + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 6), + ], + Text( + _formatMessageTime(context, message.time), + style: TextStyle( + fontSize: 12, + color: + Theme.of(context).brightness == + Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280), + ), + ), + ], + if (!isMe) ...[ + Text( + _formatMessageTime(context, message.time), + style: TextStyle( + fontSize: 12, + color: + Theme.of(context).brightness == + Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280), + ), + ), + if (message.isEdited) ...[ + const SizedBox(width: 6), + Text( + '(изменено)', + style: TextStyle( + fontSize: 10, + color: textColor.withOpacity(0.5), + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ], + ), + ], + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildStickerOnlyMessage(BuildContext context) { + final sticker = message.attaches.firstWhere((a) => a['_type'] == 'STICKER'); + final stickerSize = 250.0; + + final timeColor = Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280); + + return Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe && isGroupChat && !isChannel) ...[ + SizedBox( + width: 40, + child: isLastInGroup + ? Transform.translate( + offset: Offset(0, avatarVerticalOffset), + child: _buildSenderAvatar(), + ) + : null, + ), + ], + Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => _openPhotoViewer(context, sticker), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: stickerSize, + maxHeight: stickerSize, + ), + child: _buildStickerImage(context, sticker), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4, right: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatMessageTime(context, message.time), + style: TextStyle(fontSize: 12, color: timeColor), + ), + ], + ), + ), + ], + ), + ], + ), + ], + ); + } + + Widget _buildStickerImage( + BuildContext context, + Map sticker, + ) { + final url = sticker['url'] ?? sticker['baseUrl']; + + if (url is String && url.isNotEmpty) { + if (url.startsWith('file://')) { + final path = url.replaceFirst('file://', ''); + return Image.file( + File(path), + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ); + } else { + return Image.network( + url, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + return const Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) { + return _imagePlaceholder(); + }, + ); + } + } + + return _imagePlaceholder(); + } + + String? _extractFirstPhotoUrl(List> attaches) { + for (final a in attaches) { + if (a['_type'] == 'PHOTO') { + final dynamic maybe = a['url'] ?? a['baseUrl']; + if (maybe is String && maybe.isNotEmpty) return maybe; + } + } + return null; + } + + List _buildPhotosWithCaption( + BuildContext context, + List> attaches, + Color textColor, + bool isUltraOptimized, + double messageTextOpacity, + ) { + final photos = attaches.where((a) => a['_type'] == 'PHOTO').toList(); + final List widgets = []; + + if (photos.isEmpty) return widgets; + + + widgets.add( + _buildSmartPhotoGroup(context, photos, textColor, isUltraOptimized), + ); + + widgets.add(const SizedBox(height: 6)); + + return widgets; + } + + List _buildVideosWithCaption( + BuildContext context, + List> attaches, + Color textColor, + bool isUltraOptimized, + double messageTextOpacity, + ) { + final videos = attaches.where((a) => a['_type'] == 'VIDEO').toList(); + final List widgets = []; + + if (videos.isEmpty) return widgets; + + for (final video in videos) { + + final videoId = video['videoId'] as int?; + final previewData = video['previewData'] as String?; // Блюр-превью + final thumbnailUrl = + video['url'] ?? video['baseUrl'] as String?; // HQ-превью URL + + + Uint8List? previewBytes; + if (previewData != null && previewData.startsWith('data:')) { + final idx = previewData.indexOf('base64,'); + if (idx != -1) { + final b64 = previewData.substring(idx + 7); + try { + previewBytes = base64Decode(b64); + } catch (_) {} + } + } + + + String? highQualityThumbnailUrl; + if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { + highQualityThumbnailUrl = thumbnailUrl; + if (!thumbnailUrl.contains('?')) { + highQualityThumbnailUrl = + '$thumbnailUrl?size=medium&quality=high&format=jpeg'; + } else { + highQualityThumbnailUrl = + '$thumbnailUrl&size=medium&quality=high&format=jpeg'; + } + } + + + if (videoId != null && chatId != null) { + widgets.add( + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: _buildVideoPreview( + context: context, + videoId: videoId, + messageId: message.id, + highQualityUrl: highQualityThumbnailUrl, + lowQualityBytes: previewBytes, + ), + ), + ); + } else { + + widgets.add( + Container( + padding: const EdgeInsets.all(16), + color: Colors.black12, + child: Row( + children: [ + Icon(Icons.videocam_off, color: textColor), + const SizedBox(width: 8), + Text( + 'Видео повреждено (нет ID)', + style: TextStyle(color: textColor), + ), + ], + ), + ), + ); + } + } + + widgets.add(const SizedBox(height: 6)); + return widgets; + } + + List _buildStickersWithCaption( + BuildContext context, + List> attaches, + Color textColor, + bool isUltraOptimized, + double messageTextOpacity, + ) { + final stickers = attaches.where((a) => a['_type'] == 'STICKER').toList(); + final List widgets = []; + + if (stickers.isEmpty) return widgets; + + for (final sticker in stickers) { + widgets.add( + _buildStickerWidget(context, sticker, textColor, isUltraOptimized), + ); + widgets.add(const SizedBox(height: 6)); + } + + return widgets; + } + + Widget _buildStickerWidget( + BuildContext context, + Map sticker, + Color textColor, + bool isUltraOptimized, + ) { + + final stickerSize = 250.0; + + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: stickerSize, + maxHeight: stickerSize, + ), + child: GestureDetector( + onTap: () => _openPhotoViewer(context, sticker), + child: ClipRRect( + borderRadius: BorderRadius.circular(isUltraOptimized ? 8 : 12), + child: _buildPhotoWidget(context, sticker), + ), + ), + ); + } + + List _buildCallsWithCaption( + BuildContext context, + List> attaches, + Color textColor, + bool isUltraOptimized, + double messageTextOpacity, + ) { + final calls = attaches.where((a) { + final type = a['_type']; + return type == 'CALL' || type == 'call'; + }).toList(); + final List widgets = []; + + if (calls.isEmpty) return widgets; + + for (final call in calls) { + widgets.add( + _buildCallWidget( + context, + call, + textColor, + isUltraOptimized, + messageTextOpacity, + ), + ); + widgets.add(const SizedBox(height: 6)); + } + + return widgets; + } + + Widget _buildCallWidget( + BuildContext context, + Map callData, + Color textColor, + bool isUltraOptimized, + double messageTextOpacity, + ) { + final hangupType = callData['hangupType'] as String? ?? ''; + final callType = callData['callType'] as String? ?? 'AUDIO'; + final duration = callData['duration'] as int? ?? 0; + final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); + + String callText; + IconData callIcon; + Color callColor; + + + switch (hangupType) { + case 'HUNGUP': + + final minutes = duration ~/ 60000; + final seconds = (duration % 60000) ~/ 1000; + final durationText = minutes > 0 + ? '$minutes:${seconds.toString().padLeft(2, '0')}' + : '$seconds сек'; + + final callTypeText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; + callText = '$callTypeText, $durationText'; + callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; + callColor = Theme.of(context).colorScheme.primary; + break; + + case 'MISSED': + + final callTypeText = callType == 'VIDEO' + ? 'Пропущенный видеозвонок' + : 'Пропущенный звонок'; + callText = callTypeText; + callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_missed; + callColor = Theme.of(context).colorScheme.error; + break; + + case 'CANCELED': + + final callTypeText = callType == 'VIDEO' + ? 'Видеозвонок отменен' + : 'Звонок отменен'; + callText = callTypeText; + callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_end; + callColor = textColor.withOpacity(0.6); + break; + + case 'REJECTED': + + final callTypeText = callType == 'VIDEO' + ? 'Видеозвонок отклонен' + : 'Звонок отклонен'; + callText = callTypeText; + callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_end; + callColor = textColor.withOpacity(0.6); + break; + + default: + + callText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; + callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; + callColor = textColor.withOpacity(0.6); + break; + } + + return Container( + decoration: BoxDecoration( + color: callColor.withOpacity(0.1), + borderRadius: borderRadius, + border: Border.all(color: callColor.withOpacity(0.3), width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: callColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(callIcon, color: callColor, size: 24), + ), + const SizedBox(width: 12), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + callText, + style: TextStyle( + color: callColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + List _buildFilesWithCaption( + BuildContext context, + List> attaches, + Color textColor, + bool isUltraOptimized, + double messageTextOpacity, + int? chatId, + ) { + final files = attaches.where((a) => a['_type'] == 'FILE').toList(); + final List widgets = []; + + if (files.isEmpty) return widgets; + + for (final file in files) { + final fileName = file['name'] ?? 'Файл'; + final fileSize = file['size'] as int? ?? 0; + + widgets.add( + _buildFileWidget( + context, + fileName, + fileSize, + file, + textColor, + isUltraOptimized, + chatId, + ), + ); + widgets.add(const SizedBox(height: 6)); + } + + return widgets; + } + + Widget _buildFileWidget( + BuildContext context, + String fileName, + int fileSize, + Map fileData, + Color textColor, + bool isUltraOptimized, + int? chatId, + ) { + final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); + + + final extension = _getFileExtension(fileName); + final iconData = _getFileIcon(extension); + + + final sizeStr = _formatFileSize(fileSize); + + + final fileId = fileData['fileId'] as int?; + final token = fileData['token'] as String?; + + return GestureDetector( + onTap: () => + _handleFileDownload(context, fileId, token, fileName, chatId), + child: Container( + decoration: BoxDecoration( + color: textColor.withOpacity(0.05), + borderRadius: borderRadius, + border: Border.all(color: textColor.withOpacity(0.1), width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: textColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + iconData, + color: textColor.withOpacity(0.8), + size: 24, + ), + ), + const SizedBox(width: 12), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + fileName, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + if (fileId != null) + ValueListenableBuilder( + valueListenable: FileDownloadProgressService() + .getProgress(fileId.toString()), + builder: (context, progress, child) { + if (progress < 0) { + + return Text( + sizeStr, + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 12, + ), + ); + } else if (progress < 1.0) { + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: progress, + minHeight: 3, + backgroundColor: textColor.withOpacity(0.1), + ), + const SizedBox(height: 4), + Text( + '${(progress * 100).toStringAsFixed(0)}%', + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 11, + ), + ), + ], + ); + } else { + + return Row( + children: [ + Icon( + Icons.check_circle, + size: 12, + color: Colors.green.withOpacity(0.8), + ), + const SizedBox(width: 4), + Text( + 'Загружено', + style: TextStyle( + color: Colors.green.withOpacity(0.8), + fontSize: 11, + ), + ), + ], + ); + } + }, + ) + else + Text( + sizeStr, + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 12, + ), + ), + ], + ), + ), + + if (fileId != null) + ValueListenableBuilder( + valueListenable: FileDownloadProgressService().getProgress( + fileId.toString(), + ), + builder: (context, progress, child) { + if (progress >= 0 && progress < 1.0) { + return const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + return Icon( + Icons.download_outlined, + color: textColor.withOpacity(0.6), + size: 20, + ); + }, + ) + else + Icon( + Icons.download_outlined, + color: textColor.withOpacity(0.6), + size: 20, + ), + ], + ), + ), + ), + ); + } + + String _getFileExtension(String fileName) { + final parts = fileName.split('.'); + if (parts.length > 1) { + return parts.last.toLowerCase(); + } + return ''; + } + + IconData _getFileIcon(String extension) { + switch (extension) { + case 'pdf': + return Icons.picture_as_pdf; + case 'doc': + case 'docx': + return Icons.description; + case 'xls': + case 'xlsx': + return Icons.table_chart; + case 'txt': + return Icons.text_snippet; + case 'zip': + case 'rar': + case '7z': + return Icons.archive; + case 'mp3': + case 'wav': + case 'flac': + return Icons.audiotrack; + case 'mp4': + case 'avi': + case 'mov': + return Icons.video_file; + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + return Icons.image; + default: + return Icons.insert_drive_file; + } + } + + String _formatFileSize(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } else if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } else if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } else { + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + } + + Future _handleFileDownload( + BuildContext context, + int? fileId, + String? token, + String fileName, + int? chatId, + ) async { + + if (fileId == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Не удалось загрузить информацию о файле (нет fileId)', + ), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + try { + final prefs = await SharedPreferences.getInstance(); + final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; + final fileIdString = fileId.toString(); + + + final mapping = fileIdMap.firstWhere( + (m) => m.startsWith('$fileIdString:'), + orElse: () => '', // Возвращаем пустую строку, если не найдено + ); + + if (mapping.isNotEmpty) { + + final filePath = mapping.substring(fileIdString.length + 1); + final file = io.File(filePath); + + + if (await file.exists()) { + print( + 'Файл $fileName (ID: $fileId) найден локально: $filePath. Открываем...', + ); + + final result = await OpenFile.open(filePath); + + if (result.type != ResultType.done && context.mounted) { + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Не удалось открыть файл: ${result.message}'), + backgroundColor: Colors.red, + ), + ); + } + return; // Важно: выходим из функции, чтобы не скачивать заново + } else { + + print( + 'Файл $fileName (ID: $fileId) был в SharedPreferences, но удален. Начинаем загрузку.', + ); + fileIdMap.remove(mapping); + await prefs.setStringList('file_id_to_path_map', fileIdMap); + } + } + } catch (e) { + print('Ошибка при проверке локального файла: $e. Продолжаем загрузку...'); + + } + + + print( + 'Файл $fileName (ID: $fileId) не найден. Запрашиваем URL у сервера...', + ); + + if (token == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Не удалось загрузить информацию о файле (нет token)', + ), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + if (chatId == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Не удалось определить чат'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + try { + + final messageId = message.id; + + + final seq = ApiService.instance.sendRawRequest(88, { + "fileId": fileId, + "chatId": chatId, + "messageId": messageId, + }); + + if (seq == -1) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Не удалось отправить запрос на получение файла'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + + final response = await ApiService.instance.messages + .firstWhere( + (msg) => msg['seq'] == seq && msg['opcode'] == 88, + orElse: () => {}, + ) + .timeout( + const Duration(seconds: 10), + onTimeout: () => throw TimeoutException( + 'Превышено время ожидания ответа от сервера', + ), + ); + + if (response.isEmpty || response['payload'] == null) { + throw Exception('Не получен ответ от сервера'); + } + + final downloadUrl = response['payload']['url'] as String?; + if (downloadUrl == null || downloadUrl.isEmpty) { + throw Exception('Не получена ссылка на файл'); + } + + + await _downloadFile(downloadUrl, fileName, fileId.toString(), context); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при скачивании файла: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _downloadFile( + String url, + String fileName, + String fileId, + BuildContext context, + ) async { + + _startBackgroundDownload(url, fileName, fileId, context); + + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Начато скачивание файла...'), + duration: Duration(seconds: 2), + ), + ); + } + } + + void _startBackgroundDownload( + String url, + String fileName, + String fileId, + BuildContext context, + ) async { + + FileDownloadProgressService().updateProgress(fileId, 0.0); + + try { + + io.Directory? downloadDir; + + if (io.Platform.isAndroid) { + downloadDir = await getExternalStorageDirectory(); + } else if (io.Platform.isIOS) { + final directory = await getApplicationDocumentsDirectory(); + downloadDir = directory; + } else if (io.Platform.isWindows || io.Platform.isLinux) { + + final homeDir = + io.Platform.environment['HOME'] ?? + io.Platform.environment['USERPROFILE'] ?? + ''; + downloadDir = io.Directory('$homeDir/Downloads'); + } else { + downloadDir = await getApplicationDocumentsDirectory(); + } + + if (downloadDir == null || !await downloadDir.exists()) { + throw Exception('Downloads directory not found'); + } + + + final filePath = '${downloadDir.path}/$fileName'; + final file = io.File(filePath); + + + final request = http.Request('GET', Uri.parse(url)); + final streamedResponse = await request.send(); + + if (streamedResponse.statusCode != 200) { + throw Exception( + 'Failed to download file: ${streamedResponse.statusCode}', + ); + } + + final contentLength = streamedResponse.contentLength ?? 0; + final bytes = []; + int received = 0; + + await for (final chunk in streamedResponse.stream) { + bytes.addAll(chunk); + received += chunk.length; + + + if (contentLength > 0) { + final progress = received / contentLength; + FileDownloadProgressService().updateProgress(fileId, progress); + } + } + + + final data = Uint8List.fromList(bytes); + await file.writeAsBytes(data); + + + FileDownloadProgressService().updateProgress(fileId, 1.0); + + + final prefs = await SharedPreferences.getInstance(); + final List downloadedFiles = + prefs.getStringList('downloaded_files') ?? []; + if (!downloadedFiles.contains(file.path)) { + downloadedFiles.add(file.path); + await prefs.setStringList('downloaded_files', downloadedFiles); + } + + + final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; + final mappingKey = '$fileId:${file.path}'; + if (!fileIdMap.contains(mappingKey)) { + fileIdMap.add(mappingKey); + await prefs.setStringList('file_id_to_path_map', fileIdMap); + } + + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Файл сохранен: $fileName'), + duration: const Duration(seconds: 3), + action: SnackBarAction(label: 'OK', onPressed: () {}), + ), + ); + } + } catch (e) { + + FileDownloadProgressService().clearProgress(fileId); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при скачивании: ${e.toString()}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + Widget _buildSmartPhotoGroup( + BuildContext context, + List> photos, + Color textColor, + bool isUltraOptimized, + ) { + final borderRadius = BorderRadius.circular(isUltraOptimized ? 4 : 12); + + switch (photos.length) { + case 1: + return _buildSinglePhoto(context, photos[0], borderRadius); + case 2: + return _buildTwoPhotos(context, photos, borderRadius); + case 3: + return _buildThreePhotos(context, photos, borderRadius); + case 4: + return _buildFourPhotos(context, photos, borderRadius); + default: + return _buildManyPhotos(context, photos, borderRadius); + } + } + + Widget _buildSinglePhoto( + BuildContext context, + Map photo, + BorderRadius borderRadius, + ) { + return RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photo), + child: ClipRRect( + borderRadius: borderRadius, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180, maxWidth: 250), + child: _buildPhotoWidget(context, photo), + ), + ), + ), + ); + } + + Widget _buildTwoPhotos( + BuildContext context, + List> photos, + BorderRadius borderRadius, + ) { + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: Row( + children: [ + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[0]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[0]), + ), + ), + ), + ), + const SizedBox(width: 2), + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[1]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[1]), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildThreePhotos( + BuildContext context, + List> photos, + BorderRadius borderRadius, + ) { + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: Row( + children: [ + + Expanded( + flex: 2, + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[0]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[0]), + ), + ), + ), + ), + const SizedBox(width: 2), + + Expanded( + flex: 1, + child: Column( + children: [ + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[1]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[1]), + ), + ), + ), + ), + const SizedBox(height: 2), + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[2]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[2]), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildFourPhotos( + BuildContext context, + List> photos, + BorderRadius borderRadius, + ) { + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: Column( + children: [ + + Expanded( + child: Row( + children: [ + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[0]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[0]), + ), + ), + ), + ), + const SizedBox(width: 2), + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[1]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[1]), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 2), + + Expanded( + child: Row( + children: [ + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[2]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[2]), + ), + ), + ), + ), + const SizedBox(width: 2), + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[3]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[3]), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildManyPhotos( + BuildContext context, + List> photos, + BorderRadius borderRadius, + ) { + + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: Column( + children: [ + Expanded( + child: Row( + children: [ + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[0]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[0]), + ), + ), + ), + ), + const SizedBox(width: 2), + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[1]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[1]), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 2), + Expanded( + child: Row( + children: [ + Expanded( + child: RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[2]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[2]), + ), + ), + ), + ), + const SizedBox(width: 2), + Expanded( + child: Stack( + children: [ + RepaintBoundary( + child: GestureDetector( + onTap: () => _openPhotoViewer(context, photos[3]), + child: ClipRRect( + borderRadius: borderRadius, + child: _buildPhotoWidget(context, photos[3]), + ), + ), + ), + if (photos.length > 4) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: borderRadius, + ), + child: Center( + child: Text( + '+${photos.length - 3}', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + void _openPhotoViewer(BuildContext context, Map attach) { + final url = attach['url'] ?? attach['baseUrl']; + final preview = attach['previewData']; + + Widget child; + if (url is String && url.isNotEmpty) { + if (url.startsWith('file://')) { + final path = url.replaceFirst('file://', ''); + child = Image.file( + File(path), + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ); + } else { + String fullQualityUrl = url; + if (!url.contains('?')) { + fullQualityUrl = '$url?size=original&quality=high&format=original'; + } else { + fullQualityUrl = '$url&size=original&quality=high&format=original'; + } + child = _ProgressiveNetworkImage( + url: fullQualityUrl, + previewBytes: null, + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + fit: BoxFit.contain, + ); + } + } else if (preview is String && preview.startsWith('data:')) { + final idx = preview.indexOf('base64,'); + if (idx != -1) { + final b64 = preview.substring(idx + 7); + try { + final bytes = base64Decode(b64); + child = Image.memory(bytes, fit: BoxFit.contain); + } catch (_) { + child = _imagePlaceholder(); + } + } else { + child = _imagePlaceholder(); + } + } else { + child = _imagePlaceholder(); + } + + + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, // Делаем страницу прозрачной для красивого перехода + barrierColor: Colors.black, + pageBuilder: (BuildContext context, _, __) { + + return FullScreenPhotoViewer(imageChild: child, attach: attach); + }, + + transitionsBuilder: (_, animation, __, page) { + return FadeTransition(opacity: animation, child: page); + }, + ), + ); + } + + Widget _buildPhotoWidget(BuildContext context, Map attach) { + + + Uint8List? previewBytes; + final preview = attach['previewData']; + if (preview is String && preview.startsWith('data:')) { + final idx = preview.indexOf('base64,'); + if (idx != -1) { + final b64 = preview.substring(idx + 7); + try { + previewBytes = base64Decode(b64); + } catch (_) { + + } + } + } + + final url = attach['url'] ?? attach['baseUrl']; + if (url is String && url.isNotEmpty) { + + if (url.startsWith('file://')) { + final path = url.replaceFirst('file://', ''); + return Image.file( + File(path), + fit: BoxFit.cover, + width: 220, + filterQuality: + FilterQuality.medium, // Используем среднее качество для превью + gaplessPlayback: true, + errorBuilder: (context, _, __) => _imagePlaceholder(), + ); + } + + + + String previewQualityUrl = url; + if (!url.contains('?')) { + previewQualityUrl = '$url?size=medium&quality=high&format=jpeg'; + } else { + previewQualityUrl = '$url&size=medium&quality=high&format=jpeg'; + } + + final themeProvider = Provider.of(context, listen: false); + final optimize = + themeProvider.optimizeChats || themeProvider.ultraOptimizeChats; + + + return _ProgressiveNetworkImage( + key: ValueKey(previewQualityUrl), // Ключ по новому URL + url: previewQualityUrl, // Передаем новый URL + previewBytes: + previewBytes, // Передаем размытую заглушку для мгновенного отображения + width: 220, + height: 160, + fit: BoxFit.cover, + keepAlive: !optimize, + startDownloadNextFrame: deferImageLoading, + ); + } + + + if (previewBytes != null) { + return Image.memory(previewBytes, fit: BoxFit.cover, width: 180); + } + + + return _imagePlaceholder(); + } + + Widget _imagePlaceholder() { + return Container( + width: 220, + height: 160, + color: Colors.black12, + alignment: Alignment.center, + child: const Icon(Icons.image_outlined, color: Colors.black38), + ); + } + + + + Color _getBubbleColor( + bool isMe, + ThemeProvider themeProvider, + double messageOpacity, + ) { + final baseColor = isMe + ? (themeProvider.myBubbleColor ?? const Color(0xFF2b5278)) + : (themeProvider.theirBubbleColor ?? const Color(0xFF182533)); + return baseColor.withOpacity(1.0 - messageOpacity); + } + + Color _getTextColor( + bool isMe, + Color bubbleColor, + double messageTextOpacity, + BuildContext context, + ) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + if (isDarkMode) { + return Colors.white; + } else { + return Colors.black; + } + } + + List _buildMessageContentChildren( + BuildContext context, + Color textColor, + double messageTextOpacity, + bool isUltraOptimized, + TextStyle linkStyle, + TextStyle defaultTextStyle, + double messageBorderRadius, + Future Function(LinkableElement) onOpenLink, + VoidCallback onSenderNameTap, + ) { + return [ + if (isGroupChat && !isMe && senderName != null) + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onSenderNameTap, + child: Padding( + padding: const EdgeInsets.only(left: 2.0, bottom: 2.0), + child: Text( + senderName ?? 'Неизвестный', + style: TextStyle( + fontWeight: FontWeight.bold, + color: _getUserColor( + message.senderId, + context, + ).withOpacity(0.8), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + if (isGroupChat && !isMe && senderName != null) const SizedBox(height: 4), + if (message.isForwarded && message.link != null) ...[ + if (message.link is Map) + _buildForwardedMessage( + context, + message.link as Map, + textColor, + messageTextOpacity, + isUltraOptimized, + ), + ] else ...[ + if (message.isReply && message.link != null) ...[ + if (message.link is Map) + _buildReplyPreview( + context, + message.link as Map, + textColor, + messageTextOpacity, + isUltraOptimized, + messageBorderRadius, + ), + const SizedBox(height: 8), + ], + if (message.attaches.isNotEmpty) ...[ + ..._buildCallsWithCaption( + context, + message.attaches, + textColor, + isUltraOptimized, + messageTextOpacity, + ), + ..._buildPhotosWithCaption( + context, + message.attaches, + textColor, + isUltraOptimized, + messageTextOpacity, + ), + ..._buildVideosWithCaption( + context, + message.attaches, + textColor, + isUltraOptimized, + messageTextOpacity, + ), + ..._buildStickersWithCaption( + context, + message.attaches, + textColor, + isUltraOptimized, + messageTextOpacity, + ), + ..._buildFilesWithCaption( + context, + message.attaches, + textColor, + isUltraOptimized, + messageTextOpacity, + chatId, + ), + const SizedBox(height: 6), + ], + if (message.text.isNotEmpty) ...[ + if (message.text.contains("welcome.saved.dialog.message")) + Container( + alignment: Alignment.center, + child: Text( + 'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.', + style: TextStyle(color: textColor, fontStyle: FontStyle.italic), + textAlign: TextAlign.center, + ), + ) + else + Linkify( + text: message.text, + style: defaultTextStyle, + linkStyle: linkStyle, + onOpen: onOpenLink, + options: const LinkifyOptions(humanize: false), + textAlign: TextAlign.left, + ), + if (message.reactionInfo != null) const SizedBox(height: 4), + ], + ], + ..._buildInlineKeyboard(context, message.attaches, textColor), + _buildReactionsWidget(context, textColor), + const SizedBox(height: 8.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isMe) ...[ + if (message.attaches.any((a) => a['_type'] == 'PHOTO')) ...[ + Builder( + builder: (context) { + final url = _extractFirstPhotoUrl(message.attaches); + if (url == null || url.startsWith('file://')) { + return const SizedBox.shrink(); + } + final notifier = GlobalImageStore.progressFor(url); + return ValueListenableBuilder( + valueListenable: notifier, + builder: (context, value, _) { + if (value == null || value <= 0 || value >= 1) { + return const SizedBox.shrink(); + } + return SizedBox( + width: 24, + height: 12, + child: LinearProgressIndicator( + value: value, + backgroundColor: Colors.transparent, + color: textColor.withOpacity( + 0.7 * messageTextOpacity, + ), + minHeight: 3, + ), + ); + }, + ); + }, + ), + const SizedBox(width: 8), + ], + if (message.isEdited) ...[ + Text( + '(изменено)', + style: TextStyle( + fontSize: 10, + color: textColor.withOpacity(0.5 * messageTextOpacity), + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 6), + ], + Text( + _formatMessageTime(context, message.time), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280), + ), + ), + if (readStatus != null) ...[ + const SizedBox(width: 4), + Builder( + builder: (context) { + final bool isRead = readStatus == MessageReadStatus.read; + final Color iconColor = isRead + ? (Theme.of(context).brightness == Brightness.dark + ? Colors.lightBlueAccent[100]! + : Colors.blue[600]!) + : (Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280)); + if (readStatus == MessageReadStatus.sending) { + return _RotatingIcon( + icon: Icons.watch_later_outlined, + size: 16, + color: iconColor, + ); + } else { + return Icon( + isRead ? Icons.done_all : Icons.done, + size: 16, + color: iconColor, + ); + } + }, + ), + ], + ], + if (!isMe) ...[ + Text( + _formatMessageTime(context, message.time), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9bb5c7) + : const Color(0xFF6b7280), + ), + ), + if (message.isEdited) ...[ + const SizedBox(width: 6), + Text( + '(изменено)', + style: TextStyle( + fontSize: 10, + color: textColor.withOpacity(0.5 * messageTextOpacity), + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ], + ), + ]; + } + + BoxDecoration _createBubbleDecoration( + Color bubbleColor, + double messageBorderRadius, + double messageShadowIntensity, + ) { + return BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.circular(messageBorderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(messageShadowIntensity), + blurRadius: 8, + spreadRadius: 0, + offset: const Offset(0, 2), + ), + ], + ); + } + + Widget _buildMessageContentInner( + BuildContext context, + BoxDecoration? decoration, + List children, + ) { + return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.65, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: _getMessageMargin(context), + decoration: decoration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + } + + Color _getLinkColor(Color bubbleColor, bool isMe) { + final isDark = BrightnessExtension( + ThemeData.estimateBrightnessForColor(bubbleColor), + ).isDark; + if (isMe) { + return isDark ? Colors.white : Colors.blue[700]!; + } + return Colors.blue[700]!; + } + + Widget _buildSenderAvatar() { + final senderContact = contactDetailsCache?[message.senderId]; + final avatarUrl = senderContact?.photoBaseUrl; + final contactName = senderContact?.name ?? 'Участник ${message.senderId}'; + + return Builder( + builder: (context) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => openUserProfileById(context, message.senderId), + child: AvatarCacheService().getAvatarWidget( + avatarUrl, + userId: message.senderId, + size: 32, + fallbackText: contactName, + backgroundColor: _getUserColor(message.senderId, context), + textColor: Colors.white, + ), + ), + ), + ); + } +} + +class GlobalImageStore { + static final Map _memory = {}; + static final Map> _progress = {}; + + static Uint8List? getData(String url) => _memory[url]; + static void setData(String url, Uint8List bytes) { + _memory[url] = bytes; + progressFor(url).value = null; + } + + static ValueNotifier progressFor(String url) { + return _progress.putIfAbsent(url, () => ValueNotifier(null)); + } + + static void setProgress(String url, double? value) { + progressFor(url).value = value; + } +} + +class _ProgressiveNetworkImage extends StatefulWidget { + final String url; + final Uint8List? previewBytes; + final double width; + final double height; + final BoxFit fit; + final bool startDownloadNextFrame; + final bool keepAlive; + const _ProgressiveNetworkImage({ + super.key, + required this.url, + required this.previewBytes, + required this.width, + required this.height, + required this.fit, + this.startDownloadNextFrame = false, + this.keepAlive = true, + }); + + @override + State<_ProgressiveNetworkImage> createState() => + _ProgressiveNetworkImageState(); +} + +class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> + with AutomaticKeepAliveClientMixin { + static final Map _memoryCache = {}; + Uint8List? _fullBytes; + double _progress = 0.0; + bool _error = false; + String? _diskPath; + + @override + void initState() { + super.initState(); + + + + + if (widget.url.isEmpty) { + return; + } + + + + final cached = GlobalImageStore.getData(widget.url); + if (cached != null) { + _fullBytes = cached; + + } + + if (_memoryCache.containsKey(widget.url)) { + _fullBytes = _memoryCache[widget.url]; + } + if (widget.startDownloadNextFrame) { + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _tryLoadFromDiskThenDownload(); + }); + } else { + _tryLoadFromDiskThenDownload(); + } + } + + Future _tryLoadFromDiskThenDownload() async { + + + if (widget.url.isEmpty) { + return; + } + + + + try { + final dir = await getTemporaryDirectory(); + final name = crypto.md5.convert(widget.url.codeUnits).toString(); + final filePath = '${dir.path}/imgcache_$name'; + _diskPath = filePath; + final f = io.File(filePath); + if (await f.exists()) { + final data = await f.readAsBytes(); + _memoryCache[widget.url] = data; + GlobalImageStore.setData(widget.url, data); + if (mounted) setState(() => _fullBytes = data); + return; // нашли на диске, скачивать не надо + } + } catch (_) {} + await _download(); + } + + Future _download() async { + try { + final req = http.Request('GET', Uri.parse(widget.url)); + req.headers['User-Agent'] = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + + final resp = await req.send(); + if (resp.statusCode != 200) { + setState(() => _error = true); + return; + } + final contentLength = resp.contentLength ?? 0; + final bytes = []; + int received = 0; + await for (final chunk in resp.stream) { + bytes.addAll(chunk); + received += chunk.length; + if (contentLength > 0) { + final p = received / contentLength; + _progress = + p; // не дергаем setState, чтобы не создавать лишние перерисовки при slide + GlobalImageStore.setProgress(widget.url, _progress); + } + } + final data = Uint8List.fromList(bytes); + _memoryCache[widget.url] = data; + GlobalImageStore.setData(widget.url, data); + + try { + final path = _diskPath; + if (path != null) { + final f = io.File(path); + await f.writeAsBytes(data, flush: true); + } + } catch (_) {} + if (mounted) setState(() => _fullBytes = data); + } catch (_) { + if (mounted) setState(() => _error = true); + } + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final width = widget.width; + final height = widget.height; + if (_error) { + return Container( + width: width, + height: height, + color: Colors.black12, + alignment: Alignment.center, + child: const Icon(Icons.broken_image_outlined, color: Colors.black38), + ); + } + + return RepaintBoundary( + child: SizedBox( + width: width, + height: height, + child: ClipRRect( + borderRadius: BorderRadius.circular( + 0, + ), // Упрощено для производительности + child: Stack( + fit: StackFit.expand, + children: [ + + if (widget.previewBytes != null) + Image.memory( + widget.previewBytes!, + fit: widget.fit, + filterQuality: FilterQuality.none, + ) + else + Container(color: Colors.black12), + + if (_fullBytes != null) + Image.memory( + _fullBytes!, + fit: widget.fit, + filterQuality: FilterQuality.high, + ), + + + ], + ), + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; + @override + void didUpdateWidget(covariant _ProgressiveNetworkImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.keepAlive != widget.keepAlive) { + + updateKeepAlive(); + } + } +} + +extension BrightnessExtension on Brightness { + bool get isDark => this == Brightness.dark; +} + +class _CustomEmojiButton extends StatefulWidget { + final Function(String) onCustomEmoji; + + const _CustomEmojiButton({required this.onCustomEmoji}); + + @override + State<_CustomEmojiButton> createState() => _CustomEmojiButtonState(); +} + +class _CustomEmojiButtonState extends State<_CustomEmojiButton> + with TickerProviderStateMixin { + late AnimationController _scaleController; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _scaleController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + + _scaleAnimation = Tween(begin: 1.0, end: 1.15).animate( + CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut), + ); + } + + @override + void dispose() { + _scaleController.dispose(); + super.dispose(); + } + + + void _handleTap() { + + _scaleController.forward().then((_) { + _scaleController.reverse(); + }); + + _showCustomEmojiDialog(); + } + + void _showCustomEmojiDialog() { + showDialog( + context: context, + builder: (context) => _CustomEmojiDialog( + onEmojiSelected: (emoji) { + widget.onCustomEmoji(emoji); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _handleTap, + child: AnimatedBuilder( + animation: _scaleController, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + + child: Icon( + Icons.add_reaction_outlined, + size: 24, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + }, + ), + ); + } +} + +class _CustomEmojiDialog extends StatefulWidget { + final Function(String) onEmojiSelected; + + const _CustomEmojiDialog({required this.onEmojiSelected}); + + @override + State<_CustomEmojiDialog> createState() => _CustomEmojiDialogState(); +} + +class _CustomEmojiDialogState extends State<_CustomEmojiDialog> { + final TextEditingController _controller = TextEditingController(); + String _selectedEmoji = ''; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Icon( + Icons.emoji_emotions, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + const Text('Введите эмодзи'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + ), + ), + child: TextField( + controller: _controller, + maxLength: 1, // Только один символ + decoration: InputDecoration( + hintText: 'Введите эмодзи...', + border: InputBorder.none, + contentPadding: const EdgeInsets.all(16), + counterText: '', + hintStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + ), + ), + onChanged: (value) { + setState(() { + _selectedEmoji = value; + }); + }, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24), + ), + ), + const SizedBox(height: 20), + if (_selectedEmoji.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primaryContainer, + Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.7), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withOpacity(0.3), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Text(_selectedEmoji, style: const TextStyle(fontSize: 48)), + ), + const SizedBox(height: 20), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + FilledButton.icon( + onPressed: _selectedEmoji.isNotEmpty + ? () { + widget.onEmojiSelected(_selectedEmoji); + Navigator.of(context).pop(); + } + : null, + icon: const Icon(Icons.add), + label: const Text('Добавить'), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + ], + ); + } +} + +class _MessageContextMenu extends StatefulWidget { + final Message message; + final Offset position; + final VoidCallback? onReply; + final VoidCallback? onEdit; + final VoidCallback? onDeleteForMe; + final VoidCallback? onDeleteForAll; + final Function(String)? onReaction; + final VoidCallback? onRemoveReaction; + final bool canEditMessage; + final bool hasUserReaction; + + const _MessageContextMenu({ + required this.message, + required this.position, + this.onReply, + this.onEdit, + this.onDeleteForMe, + this.onDeleteForAll, + this.onReaction, + this.onRemoveReaction, + required this.canEditMessage, + required this.hasUserReaction, + }); + + @override + _MessageContextMenuState createState() => _MessageContextMenuState(); +} + +class _MessageContextMenuState extends State<_MessageContextMenu> + with SingleTickerProviderStateMixin { + bool _isEmojiListExpanded = false; + + late AnimationController _animationController; + late Animation _scaleAnimation; + + + static const List _quickReactions = [ + '👍', + '❤️', + '😂', + '🔥', + '👏', + '🤔', + ]; + + + static const List _allReactions = [ + '👍', + '❤️', + '😂', + '🔥', + '👏', + '👌', + '🎉', + '🥰', + '😍', + '🙏', + '🤔', + '🤯', + '💯', + '⚡️', + '🤟', + '🌚', + '🌝', + '🥱', + '🤣', + '🫠', + '🫡', + '🐱', + '💋', + '😘', + '🐶', + '🤝', + '⭐️', + '🍷', + '🍑', + '😁', + '🤷‍♀️', + '🤷‍♂️', + '👩‍❤️‍👨', + '🦄', + '👻', + '🗿', + '❤️‍🩹', + '🛑', + '⛄️', + '❓', + '🙄', + '❗️', + '😉', + '😳', + '🥳', + '😎', + '💪', + '👀', + '🤞', + '🤤', + '🤪', + '🤩', + '😴', + '😐', + '😇', + '🖤', + '👑', + '👋', + '👁️', + ]; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + ); + + _scaleAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + ); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _onCopy() { + Clipboard.setData(ClipboardData(text: widget.message.text)); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Сообщение скопировано'), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final themeProvider = Provider.of(context); + final screenSize = MediaQuery.of(context).size; + + const menuWidth = 250.0; + final double estimatedMenuHeight = _isEmojiListExpanded ? 320.0 : 250.0; + + double left = widget.position.dx - (menuWidth / 4); + if (left + menuWidth > screenSize.width) { + left = screenSize.width - menuWidth - 16; + } + if (left < 16) { + left = 16; + } + + double top = widget.position.dy; + if (top + estimatedMenuHeight > screenSize.height) { + top = widget.position.dy - estimatedMenuHeight - 10; + } + + return Scaffold( + backgroundColor: Colors.black.withOpacity(0.1), + body: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container(color: Colors.transparent), + ), + ), + Positioned( + top: top, + left: left, + child: ScaleTransition( + scale: _scaleAnimation, + alignment: Alignment.topCenter, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: themeProvider.messageMenuBlur, + sigmaY: themeProvider.messageMenuBlur, + ), + child: Card( + elevation: 8, + color: theme.colorScheme.surface.withOpacity( + themeProvider.messageMenuOpacity, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + width: menuWidth, + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: _buildEmojiSection(), + ), + const Divider(height: 12), + _buildActionsSection(theme), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmojiSection() { + if (_isEmojiListExpanded) { + return SizedBox( + height: 150, + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: _allReactions.length, + itemBuilder: (context, index) { + final emoji = _allReactions[index]; + return GestureDetector( + onTap: () { + Navigator.pop(context); + widget.onReaction?.call(emoji); + }, + child: Center( + child: Text(emoji, style: const TextStyle(fontSize: 28)), + ), + ); + }, + ), + ); + } else { + return Wrap( + spacing: 8, + runSpacing: 4, + alignment: WrapAlignment.center, + children: [ + ..._quickReactions.map( + (emoji) => GestureDetector( + onTap: () { + Navigator.pop(context); + widget.onReaction?.call(emoji); + }, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Text(emoji, style: const TextStyle(fontSize: 28)), + ), + ), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 28), + onPressed: () => setState(() => _isEmojiListExpanded = true), + tooltip: 'Больше реакций', + ), + ], + ); + } + } + + Widget _buildActionsSection(ThemeData theme) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.message.text.isNotEmpty) + _buildActionButton( + icon: Icons.copy_rounded, + text: 'Копировать', + onTap: _onCopy, + ), + if (widget.onReply != null) + _buildActionButton( + icon: Icons.reply_rounded, + text: 'Ответить', + onTap: () { + Navigator.pop(context); + widget.onReply!(); + }, + ), + if (widget.onEdit != null) + _buildActionButton( + icon: widget.canEditMessage + ? Icons.edit_rounded + : Icons.edit_off_rounded, + text: 'Редактировать', + onTap: widget.canEditMessage + ? () { + Navigator.pop(context); + widget.onEdit!(); + } + : null, + color: widget.canEditMessage ? null : Colors.grey, + ), + if (widget.hasUserReaction && widget.onRemoveReaction != null) + _buildActionButton( + icon: Icons.remove_circle_outline_rounded, + text: 'Убрать реакцию', + color: theme.colorScheme.error, + onTap: () { + Navigator.pop(context); + widget.onRemoveReaction!(); + }, + ), + if (widget.onDeleteForMe != null) + _buildActionButton( + icon: Icons.person_remove_rounded, + text: 'Удалить у меня', + color: theme.colorScheme.error, + onTap: () { + Navigator.pop(context); + widget.onDeleteForMe!(); + }, + ), + if (widget.onDeleteForAll != null) + _buildActionButton( + icon: Icons.delete_forever_rounded, + text: 'Удалить у всех', + color: theme.colorScheme.error, + onTap: () { + Navigator.pop(context); + widget.onDeleteForAll!(); + }, + ), + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String text, + required VoidCallback? onTap, + Color? color, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 10.0), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: color ?? Theme.of(context).iconTheme.color, + ), + const SizedBox(width: 12), + Text( + text, + style: TextStyle( + color: color, + fontSize: 15, + fontWeight: onTap == null ? FontWeight.normal : FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} + +class FullScreenPhotoViewer extends StatefulWidget { + final Widget imageChild; + final Map? attach; + + const FullScreenPhotoViewer({ + super.key, + required this.imageChild, + this.attach, + }); + + @override + State createState() => _FullScreenPhotoViewerState(); +} + +class _FullScreenPhotoViewerState extends State { + late final TransformationController _transformationController; + + bool _isPanEnabled = false; + + @override + void initState() { + super.initState(); + _transformationController = TransformationController(); + + _transformationController.addListener(_onTransformChanged); + } + + @override + void dispose() { + _transformationController.removeListener(_onTransformChanged); + _transformationController.dispose(); + super.dispose(); + } + + void _onTransformChanged() { + + final currentScale = _transformationController.value.getMaxScaleOnAxis(); + + final shouldPan = currentScale > 1.0; + + + if (shouldPan != _isPanEnabled) { + setState(() { + _isPanEnabled = shouldPan; + }); + } + } + + Future _downloadPhoto() async { + if (widget.attach == null) return; + + try { + + io.Directory? downloadDir; + + if (io.Platform.isAndroid) { + final directory = await getExternalStorageDirectory(); + if (directory != null) { + downloadDir = io.Directory( + '${directory.path.split('Android')[0]}Download', + ); + if (!await downloadDir.exists()) { + downloadDir = io.Directory( + '${directory.path.split('Android')[0]}Downloads', + ); + } + } + } else if (io.Platform.isIOS) { + final directory = await getApplicationDocumentsDirectory(); + downloadDir = directory; + } else if (io.Platform.isWindows || io.Platform.isLinux) { + final homeDir = + io.Platform.environment['HOME'] ?? + io.Platform.environment['USERPROFILE'] ?? + ''; + downloadDir = io.Directory('$homeDir/Downloads'); + } else { + downloadDir = await getApplicationDocumentsDirectory(); + } + + if (downloadDir == null || !await downloadDir.exists()) { + throw Exception('Downloads directory not found'); + } + + + final url = widget.attach!['url'] ?? widget.attach!['baseUrl']; + if (url == null || url.isEmpty) { + throw Exception('Photo URL not found'); + } + + + String extension = 'jpg'; + final uri = Uri.tryParse(url); + if (uri != null && uri.pathSegments.isNotEmpty) { + final lastSegment = uri.pathSegments.last; + final extMatch = RegExp(r'\.([a-zA-Z0-9]+)$').firstMatch(lastSegment); + if (extMatch != null) { + extension = extMatch.group(1)!; + } + } + + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final fileName = 'photo_$timestamp.$extension'; + final filePath = '${downloadDir.path}/$fileName'; + final file = io.File(filePath); + + + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + await file.writeAsBytes(response.bodyBytes); + + + final prefs = await SharedPreferences.getInstance(); + final List downloadedFiles = + prefs.getStringList('downloaded_files') ?? []; + if (!downloadedFiles.contains(filePath)) { + downloadedFiles.add(filePath); + await prefs.setStringList('downloaded_files', downloadedFiles); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Фото сохранено: $fileName'), + duration: const Duration(seconds: 3), + ), + ); + } + } else { + throw Exception('Failed to download photo: ${response.statusCode}'); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при скачивании фото: ${e.toString()}'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + child: InteractiveViewer( + transformationController: _transformationController, + panEnabled: _isPanEnabled, + boundaryMargin: EdgeInsets.zero, + minScale: 1.0, + maxScale: 5.0, + child: Center(child: widget.imageChild), + ), + ), + + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + if (widget.attach != null) + IconButton( + icon: const Icon(Icons.download, color: Colors.white), + onPressed: _downloadPhoto, + tooltip: 'Скачать фото', + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _RotatingIcon extends StatefulWidget { + final IconData icon; + final double size; + final Color color; + + const _RotatingIcon({ + required this.icon, + required this.size, + required this.color, + }); + + @override + State<_RotatingIcon> createState() => _RotatingIconState(); +} + +class _RotatingIconState extends State<_RotatingIcon> + with SingleTickerProviderStateMixin { + + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + + duration: const Duration(seconds: 2), + vsync: this, + )..repeat(); // Запускаем анимацию на бесконечное повторение + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + return RotationTransition( + turns: _controller, // Анимация вращения + child: Icon(widget.icon, size: widget.size, color: widget.color), + ); + } +} diff --git a/lib/widgets/connection_debug_panel.dart b/lib/widgets/connection_debug_panel.dart new file mode 100644 index 0000000..a2ba1b8 --- /dev/null +++ b/lib/widgets/connection_debug_panel.dart @@ -0,0 +1,676 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +import '../connection/connection_logger.dart'; +import '../connection/connection_state.dart' as conn_state; +import '../connection/health_monitor.dart'; +import '../api_service_v2.dart'; + + +class ConnectionDebugPanel extends StatefulWidget { + final bool isVisible; + final VoidCallback? onClose; + + const ConnectionDebugPanel({super.key, this.isVisible = false, this.onClose}); + + @override + State createState() => _ConnectionDebugPanelState(); +} + +class _ConnectionDebugPanelState extends State + with TickerProviderStateMixin { + late TabController _tabController; + + List _logs = []; + final List _stateHistory = []; + final List _healthMetrics = []; + + late StreamSubscription> _logsSubscription; + late StreamSubscription _stateSubscription; + late StreamSubscription _healthSubscription; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + _setupSubscriptions(); + } + + void _setupSubscriptions() { + + _logsSubscription = Stream.periodic(const Duration(seconds: 1)) + .asyncMap((_) async => ApiServiceV2.instance.logs.take(100).toList()) + .listen((logs) { + if (mounted) { + setState(() { + _logs = logs; + }); + } + }); + + + _stateSubscription = ApiServiceV2.instance.connectionState.listen((state) { + if (mounted) { + setState(() { + _stateHistory.add(state); + if (_stateHistory.length > 50) { + _stateHistory.removeAt(0); + } + }); + } + }); + + + _healthSubscription = ApiServiceV2.instance.healthMetrics.listen((health) { + if (mounted) { + setState(() { + _healthMetrics.add(health); + if (_healthMetrics.length > 50) { + _healthMetrics.removeAt(0); + } + }); + } + }); + } + + @override + void dispose() { + _logsSubscription.cancel(); + _stateSubscription.cancel(); + _healthSubscription.cancel(); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.isVisible) return const SizedBox.shrink(); + + return Container( + height: 400, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: Column( + children: [ + _buildHeader(), + _buildTabBar(), + Expanded(child: _buildTabContent()), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.1), + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Row( + children: [ + Icon(Icons.bug_report, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Отладка подключения', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + onPressed: widget.onClose, + icon: const Icon(Icons.close), + iconSize: 20, + ), + ], + ), + ); + } + + Widget _buildTabBar() { + return TabBar( + controller: _tabController, + isScrollable: true, + tabs: const [ + Tab(text: 'Логи'), + Tab(text: 'Состояния'), + Tab(text: 'Здоровье'), + Tab(text: 'Статистика'), + ], + ); + } + + Widget _buildTabContent() { + return TabBarView( + controller: _tabController, + children: [ + _buildLogsTab(), + _buildStatesTab(), + _buildHealthTab(), + _buildStatsTab(), + ], + ); + } + + Widget _buildLogsTab() { + return Column( + children: [ + _buildLogsControls(), + Expanded(child: _buildLogsList()), + ], + ); + } + + Widget _buildLogsControls() { + return Container( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + ElevatedButton.icon( + onPressed: _clearLogs, + icon: const Icon(Icons.clear, size: 16), + label: const Text('Очистить'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _exportLogs, + icon: const Icon(Icons.download, size: 16), + label: const Text('Экспорт'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + const Spacer(), + Text( + 'Логов: ${_logs.length}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + Widget _buildLogsList() { + if (_logs.isEmpty) { + return const Center(child: Text('Нет логов')); + } + + return ListView.builder( + itemCount: _logs.length, + itemBuilder: (context, index) { + final log = _logs[index]; + return _buildLogItem(log); + }, + ); + } + + Widget _buildLogItem(LogEntry log) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _getLogColor(log.level).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _getLogColor(log.level).withOpacity(0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getLogIcon(log.level), + size: 16, + color: _getLogColor(log.level), + ), + const SizedBox(width: 8), + Text( + log.category, + style: TextStyle( + color: _getLogColor(log.level), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + const Spacer(), + Text( + _formatTime(log.timestamp), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 4), + Text(log.message, style: Theme.of(context).textTheme.bodyMedium), + if (log.data != null) ...[ + const SizedBox(height: 4), + Text( + 'Data: ${log.data}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ), + ], + if (log.error != null) ...[ + const SizedBox(height: 4), + Text( + 'Error: ${log.error}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.red, + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ); + } + + Widget _buildStatesTab() { + return ListView.builder( + itemCount: _stateHistory.length, + itemBuilder: (context, index) { + final state = _stateHistory[index]; + return _buildStateItem(state); + }, + ); + } + + Widget _buildStateItem(conn_state.ConnectionInfo state) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _getStateColor(state.state).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _getStateColor(state.state).withOpacity(0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getStateIcon(state.state), + size: 16, + color: _getStateColor(state.state), + ), + const SizedBox(width: 8), + Text( + _getStateText(state.state), + style: TextStyle( + color: _getStateColor(state.state), + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text( + _formatTime(state.timestamp), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + if (state.message != null) ...[ + const SizedBox(height: 4), + Text(state.message!, style: Theme.of(context).textTheme.bodyMedium), + ], + if (state.serverUrl != null) ...[ + const SizedBox(height: 4), + Text( + 'Сервер: ${state.serverUrl}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (state.latency != null) ...[ + const SizedBox(height: 4), + Text( + 'Задержка: ${state.latency}ms', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ); + } + + Widget _buildHealthTab() { + if (_healthMetrics.isEmpty) { + return const Center(child: Text('Нет данных о здоровье')); + } + + final latestHealth = _healthMetrics.last; + + return Column( + children: [ + _buildHealthSummary(latestHealth), + Expanded(child: _buildHealthChart()), + ], + ); + } + + Widget _buildHealthSummary(HealthMetrics health) { + return Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _getHealthColor(health.quality).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getHealthColor(health.quality).withOpacity(0.3), + width: 1, + ), + ), + child: Column( + children: [ + Row( + children: [ + Icon( + _getHealthIcon(health.quality), + size: 24, + color: _getHealthColor(health.quality), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Здоровье соединения', + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + '${health.healthScore}/100 - ${_getHealthText(health.quality)}', + style: TextStyle( + color: _getHealthColor(health.quality), + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildHealthMetric('Задержка', '${health.latency}ms'), + _buildHealthMetric('Потери', '${health.packetLoss}%'), + _buildHealthMetric('Переподключения', '${health.reconnects}'), + _buildHealthMetric('Ошибки', '${health.errors}'), + ], + ), + ], + ), + ); + } + + Widget _buildHealthMetric(String label, String value) { + return Column( + children: [ + Text( + value, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } + + Widget _buildHealthChart() { + return Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.2), + ), + ), + child: const Center( + child: Text('График здоровья соединения\n(в разработке)'), + ), + ); + } + + Widget _buildStatsTab() { + return FutureBuilder>( + future: ApiServiceV2.instance + .getStatistics(), // Указываем Future, который нужно ожидать + builder: (context, snapshot) { + + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + + if (snapshot.hasError) { + return Center( + child: Text('Ошибка загрузки статистики: ${snapshot.error}'), + ); + } + + + if (!snapshot.hasData || snapshot.data == null) { + return const Center(child: Text('Нет данных для отображения')); + } + + + final stats = snapshot.data!; // Теперь это точно Map + return ListView( + padding: const EdgeInsets.all(16), + children: [ + + _buildStatsSection('API Service', stats['api_service']), + const SizedBox(height: 16), + _buildStatsSection('Connection', stats['connection']), + ], + ); + }, + ); + } + + Widget _buildStatsSection(String title, Map? data) { + if (data == null) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + ...data.entries.map((entry) => _buildStatsRow(entry.key, entry.value)), + ], + ); + } + + Widget _buildStatsRow(String key, dynamic value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + flex: 2, + child: Text(key, style: Theme.of(context).textTheme.bodyMedium), + ), + Expanded( + flex: 1, + child: Text( + value.toString(), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ); + } + + + Color _getLogColor(LogLevel level) { + switch (level) { + case LogLevel.debug: + return Colors.blue; + case LogLevel.info: + return Colors.green; + case LogLevel.warning: + return Colors.orange; + case LogLevel.error: + return Colors.red; + case LogLevel.critical: + return Colors.red.shade800; + } + } + + IconData _getLogIcon(LogLevel level) { + switch (level) { + case LogLevel.debug: + return Icons.bug_report; + case LogLevel.info: + return Icons.info; + case LogLevel.warning: + return Icons.warning; + case LogLevel.error: + return Icons.error; + case LogLevel.critical: + return Icons.dangerous; + } + } + + Color _getStateColor(conn_state.ConnectionState state) { + switch (state) { + case conn_state.ConnectionState.ready: + return Colors.green; + case conn_state.ConnectionState.connected: + return Colors.blue; + case conn_state.ConnectionState.connecting: + case conn_state.ConnectionState.reconnecting: + return Colors.orange; + case conn_state.ConnectionState.error: + return Colors.red; + case conn_state.ConnectionState.disconnected: + case conn_state.ConnectionState.disabled: + return Colors.grey; + } + } + + IconData _getStateIcon(conn_state.ConnectionState state) { + switch (state) { + case conn_state.ConnectionState.ready: + return Icons.check_circle; + case conn_state.ConnectionState.connected: + return Icons.link; + case conn_state.ConnectionState.connecting: + case conn_state.ConnectionState.reconnecting: + return Icons.sync; + case conn_state.ConnectionState.error: + return Icons.error; + case conn_state.ConnectionState.disconnected: + case conn_state.ConnectionState.disabled: + return Icons.link_off; + } + } + + String _getStateText(conn_state.ConnectionState state) { + switch (state) { + case conn_state.ConnectionState.ready: + return 'Готов'; + case conn_state.ConnectionState.connected: + return 'Подключен'; + case conn_state.ConnectionState.connecting: + return 'Подключение'; + case conn_state.ConnectionState.reconnecting: + return 'Переподключение'; + case conn_state.ConnectionState.error: + return 'Ошибка'; + case conn_state.ConnectionState.disconnected: + return 'Отключен'; + case conn_state.ConnectionState.disabled: + return 'Отключен'; + } + } + + Color _getHealthColor(ConnectionQuality quality) { + switch (quality) { + case ConnectionQuality.excellent: + return Colors.green; + case ConnectionQuality.good: + return Colors.lightGreen; + case ConnectionQuality.fair: + return Colors.orange; + case ConnectionQuality.poor: + return Colors.red; + case ConnectionQuality.critical: + return Colors.red.shade800; + } + } + + IconData _getHealthIcon(ConnectionQuality quality) { + switch (quality) { + case ConnectionQuality.excellent: + return Icons.signal_cellular_4_bar; + case ConnectionQuality.good: + return Icons.signal_cellular_4_bar; + case ConnectionQuality.fair: + return Icons.signal_cellular_4_bar; + case ConnectionQuality.poor: + return Icons.signal_cellular_0_bar; + case ConnectionQuality.critical: + return Icons.signal_cellular_0_bar; + } + } + + String _getHealthText(ConnectionQuality quality) { + switch (quality) { + case ConnectionQuality.excellent: + return 'Отлично'; + case ConnectionQuality.good: + return 'Хорошо'; + case ConnectionQuality.fair: + return 'Удовлетворительно'; + case ConnectionQuality.poor: + return 'Плохо'; + case ConnectionQuality.critical: + return 'Критично'; + } + } + + String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}:' + '${time.second.toString().padLeft(2, '0')}'; + } + + void _clearLogs() { + + } + + void _exportLogs() { + + } +} diff --git a/lib/widgets/connection_status_widget.dart b/lib/widgets/connection_status_widget.dart new file mode 100644 index 0000000..4a9e5f2 --- /dev/null +++ b/lib/widgets/connection_status_widget.dart @@ -0,0 +1,440 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +import '../connection/connection_state.dart' as conn_state; +import '../connection/health_monitor.dart'; +import '../api_service_v2.dart'; + + +class ConnectionStatusWidget extends StatefulWidget { + final bool showDetails; + final bool showHealthMetrics; + final VoidCallback? onTap; + + const ConnectionStatusWidget({ + super.key, + this.showDetails = false, + this.showHealthMetrics = false, + this.onTap, + }); + + @override + State createState() => _ConnectionStatusWidgetState(); +} + +class _ConnectionStatusWidgetState extends State { + late StreamSubscription _stateSubscription; + late StreamSubscription _healthSubscription; + + conn_state.ConnectionInfo? _currentState; + HealthMetrics? _currentHealth; + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _setupSubscriptions(); + } + + void _setupSubscriptions() { + _stateSubscription = ApiServiceV2.instance.connectionState.listen((state) { + if (mounted) { + setState(() { + _currentState = state; + }); + } + }); + + if (widget.showHealthMetrics) { + _healthSubscription = ApiServiceV2.instance.healthMetrics.listen(( + health, + ) { + if (mounted) { + setState(() { + _currentHealth = health; + }); + } + }); + } + } + + @override + void dispose() { + _stateSubscription.cancel(); + _healthSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_currentState == null) { + return const SizedBox.shrink(); + } + + return GestureDetector( + onTap: widget.onTap ?? _toggleExpanded, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _getStatusColor().withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: _getStatusColor().withOpacity(0.3), + width: 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatusIcon(), + const SizedBox(width: 8), + _buildStatusText(), + if (widget.showDetails) ...[ + const SizedBox(width: 8), + Icon( + _isExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 16, + color: _getStatusColor(), + ), + ], + ], + ), + if (_isExpanded && widget.showDetails) ...[ + const SizedBox(height: 8), + _buildDetails(), + ], + ], + ), + ), + ); + } + + Widget _buildStatusIcon() { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 8, + height: 8, + decoration: BoxDecoration( + color: _getStatusColor(), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: _getStatusColor().withOpacity(0.5), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + ); + } + + Widget _buildStatusText() { + return Text( + _getStatusText(), + style: TextStyle( + color: _getStatusColor(), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ); + } + + Widget _buildDetails() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_currentState?.serverUrl != null) + _buildDetailRow('Сервер', _currentState!.serverUrl!), + if (_currentState?.latency != null) + _buildDetailRow('Задержка', '${_currentState!.latency}ms'), + if (_currentState?.attemptNumber != null) + _buildDetailRow('Попытка', '${_currentState!.attemptNumber}'), + if (_currentState?.reconnectDelay != null) + _buildDetailRow( + 'Переподключение', + 'через ${_currentState!.reconnectDelay!.inSeconds}с', + ), + if (_currentHealth != null) ...[ + const SizedBox(height: 4), + _buildHealthMetrics(), + ], + ], + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$label: ', + style: TextStyle( + color: _getStatusColor().withOpacity(0.7), + fontSize: 10, + ), + ), + Text( + value, + style: TextStyle( + color: _getStatusColor(), + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildHealthMetrics() { + if (_currentHealth == null) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _getHealthColor().withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _getHealthColor().withOpacity(0.3), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_getHealthIcon(), size: 12, color: _getHealthColor()), + const SizedBox(width: 4), + Text( + 'Здоровье: ${_currentHealth!.healthScore}/100', + style: TextStyle( + color: _getHealthColor(), + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 4), + _buildHealthBar(), + ], + ), + ); + } + + Widget _buildHealthBar() { + final score = _currentHealth!.healthScore; + return Container( + height: 4, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.2), + borderRadius: BorderRadius.circular(2), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: score / 100, + child: Container( + decoration: BoxDecoration( + color: _getHealthColor(), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ); + } + + Color _getStatusColor() { + switch (_currentState?.state) { + case conn_state.ConnectionState.ready: + return Colors.green; + case conn_state.ConnectionState.connected: + return Colors.blue; + case conn_state.ConnectionState.connecting: + case conn_state.ConnectionState.reconnecting: + return Colors.orange; + case conn_state.ConnectionState.error: + return Colors.red; + case conn_state.ConnectionState.disconnected: + case conn_state.ConnectionState.disabled: + default: + return Colors.grey; + } + } + + String _getStatusText() { + switch (_currentState?.state) { + case conn_state.ConnectionState.ready: + return 'Готов'; + case conn_state.ConnectionState.connected: + return 'Подключен'; + case conn_state.ConnectionState.connecting: + return 'Подключение...'; + case conn_state.ConnectionState.reconnecting: + return 'Переподключение...'; + case conn_state.ConnectionState.error: + return 'Ошибка'; + case conn_state.ConnectionState.disconnected: + return 'Отключен'; + case conn_state.ConnectionState.disabled: + return 'Отключен'; + default: + return 'Неизвестно'; + } + } + + Color _getHealthColor() { + if (_currentHealth == null) return Colors.grey; + + switch (_currentHealth!.quality) { + case ConnectionQuality.excellent: + return Colors.green; + case ConnectionQuality.good: + return Colors.lightGreen; + case ConnectionQuality.fair: + return Colors.orange; + case ConnectionQuality.poor: + return Colors.red; + case ConnectionQuality.critical: + return Colors.red.shade800; + } + } + + IconData _getHealthIcon() { + if (_currentHealth == null) return Icons.help_outline; + + switch (_currentHealth!.quality) { + case ConnectionQuality.excellent: + return Icons.signal_cellular_4_bar; + case ConnectionQuality.good: + return Icons.signal_cellular_4_bar; + case ConnectionQuality.fair: + return Icons.signal_cellular_4_bar; + case ConnectionQuality.poor: + return Icons.signal_cellular_0_bar; + case ConnectionQuality.critical: + return Icons.signal_cellular_0_bar; + } + } + + void _toggleExpanded() { + setState(() { + _isExpanded = !_isExpanded; + }); + } +} + + +class ConnectionIndicator extends StatefulWidget { + final double size; + final bool showPulse; + + const ConnectionIndicator({ + super.key, + this.size = 12.0, + this.showPulse = true, + }); + + @override + State createState() => _ConnectionIndicatorState(); +} + +class _ConnectionIndicatorState extends State { + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: ApiServiceV2.instance.connectionState, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return SizedBox( + width: widget.size, + height: widget.size, + child: const CircularProgressIndicator(strokeWidth: 2), + ); + } + + final state = snapshot.data!; + final color = _getStatusColor(state.state); + final isActive = + state.state == conn_state.ConnectionState.ready || + state.state == conn_state.ConnectionState.connected; + + if (widget.showPulse && isActive) { + return _buildPulsingIndicator(color); + } else { + return _buildStaticIndicator(color); + } + }, + ); + } + + Widget _buildPulsingIndicator(Color color) { + return TweenAnimationBuilder( + duration: const Duration(seconds: 2), + tween: Tween(begin: 0.0, end: 1.0), + builder: (context, value, child) { + return Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: color.withOpacity(0.3 + (0.7 * value)), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.5 * value), + blurRadius: 8 * value, + spreadRadius: 2 * value, + ), + ], + ), + ); + }, + onEnd: () { + + if (mounted) { + setState(() {}); + } + }, + ); + } + + Widget _buildStaticIndicator(Color color) { + return Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.5), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + ); + } + + Color _getStatusColor(conn_state.ConnectionState state) { + switch (state) { + case conn_state.ConnectionState.ready: + return Colors.green; + case conn_state.ConnectionState.connected: + return Colors.blue; + case conn_state.ConnectionState.connecting: + case conn_state.ConnectionState.reconnecting: + return Colors.orange; + case conn_state.ConnectionState.error: + return Colors.red; + case conn_state.ConnectionState.disconnected: + case conn_state.ConnectionState.disabled: + return Colors.grey; + } + } +} diff --git a/lib/widgets/group_avatars.dart b/lib/widgets/group_avatars.dart new file mode 100644 index 0000000..ecefb6f --- /dev/null +++ b/lib/widgets/group_avatars.dart @@ -0,0 +1,189 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:gwid/models/chat.dart'; +import 'package:gwid/models/contact.dart'; + +class GroupAvatars extends StatelessWidget { + final Chat chat; + final Map contacts; + final int maxAvatars; + final double avatarSize; + final double overlap; + + const GroupAvatars({ + super.key, + required this.chat, + required this.contacts, + this.maxAvatars = 3, + this.avatarSize = 16.0, + this.overlap = 8.0, + }); + + @override + Widget build(BuildContext context) { + if (!chat.isGroup) { + return const SizedBox.shrink(); + } + + final participantIds = chat.groupParticipantIds; + + if (participantIds.isEmpty) { + return const SizedBox.shrink(); + } + + final visibleParticipants = participantIds.take(maxAvatars).toList(); + final remainingCount = participantIds.length - maxAvatars; + + + final totalParticipants = participantIds.length; + double adaptiveAvatarSize; + if (totalParticipants <= 2) { + adaptiveAvatarSize = + avatarSize * 1.5; // Большие аватары для 1-2 участников + } else if (totalParticipants <= 4) { + adaptiveAvatarSize = + avatarSize * 1.2; // Средние аватары для 3-4 участников + } else { + adaptiveAvatarSize = + avatarSize * 0.8; // Маленькие аватары для 5+ участников + } + + return SizedBox( + height: adaptiveAvatarSize * 2.5, + width: adaptiveAvatarSize * 2.5, + child: Stack( + children: [ + + ...visibleParticipants.asMap().entries.map((entry) { + final index = entry.key; + final participantId = entry.value; + final contact = contacts[participantId]; + + + double x, y; + if (visibleParticipants.length == 1) { + + x = adaptiveAvatarSize * 1.25; + y = adaptiveAvatarSize * 1.25; + } else if (visibleParticipants.length == 2) { + + x = adaptiveAvatarSize * (0.5 + index * 1.5); + y = adaptiveAvatarSize * 1.25; + } else { + + final angle = (index * 2 * pi) / visibleParticipants.length; + final radius = adaptiveAvatarSize * 0.6; + final center = adaptiveAvatarSize * 1.25; + x = center + radius * cos(angle); + y = center + radius * sin(angle); + } + + return Positioned( + left: x - adaptiveAvatarSize / 2, + top: y - adaptiveAvatarSize / 2, + child: _buildAvatar( + context, + contact, + participantId, + adaptiveAvatarSize, + ), + ); + }), + + + if (remainingCount > 0) + Positioned( + left: adaptiveAvatarSize * 0.75, + top: adaptiveAvatarSize * 0.75, + child: _buildMoreIndicator( + context, + remainingCount, + adaptiveAvatarSize, + ), + ), + ], + ), + ); + } + + Widget _buildAvatar( + BuildContext context, + Contact? contact, + int participantId, + double size, + ) { + final colors = Theme.of(context).colorScheme; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: colors.surface, width: 2), + boxShadow: [ + BoxShadow( + color: colors.shadow.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: CircleAvatar( + radius: size / 2, + backgroundColor: contact != null + ? colors.primaryContainer + : colors.secondaryContainer, + backgroundImage: contact?.photoBaseUrl != null + ? NetworkImage(contact!.photoBaseUrl!) + : null, + child: contact?.photoBaseUrl == null + ? Text( + contact?.name.isNotEmpty == true + ? contact!.name[0].toUpperCase() + : participantId.toString().substring( + participantId.toString().length - 1, + ), // Последняя цифра ID + style: TextStyle( + color: contact != null + ? colors.onPrimaryContainer + : colors.onSecondaryContainer, + fontSize: size * 0.5, + fontWeight: FontWeight.w600, + ), + ) + : null, + ), + ); + } + + Widget _buildMoreIndicator(BuildContext context, int count, double size) { + final colors = Theme.of(context).colorScheme; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.secondaryContainer, + border: Border.all(color: colors.surface, width: 2), + boxShadow: [ + BoxShadow( + color: colors.shadow.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Text( + '+$count', + style: TextStyle( + color: colors.onSecondaryContainer, + fontSize: size * 0.4, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/group_header.dart b/lib/widgets/group_header.dart new file mode 100644 index 0000000..2c34cd8 --- /dev/null +++ b/lib/widgets/group_header.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:gwid/models/chat.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/widgets/group_avatars.dart'; +import 'package:gwid/widgets/group_management_panel.dart'; + +class GroupHeader extends StatelessWidget { + final Chat chat; + final Map contacts; + final int myId; + final VoidCallback? onParticipantsChanged; + + const GroupHeader({ + super.key, + required this.chat, + required this.contacts, + required this.myId, + this.onParticipantsChanged, + }); + + @override + Widget build(BuildContext context) { + if (!chat.isGroup) { + return const SizedBox.shrink(); + } + + final colors = Theme.of(context).colorScheme; + final onlineCount = chat.onlineParticipantsCount; + final totalCount = chat.participantsCount; + + return GestureDetector( + onTap: () => _showGroupManagementPanel(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + + GroupAvatars( + chat: chat, + contacts: contacts, + maxAvatars: 4, + avatarSize: 20.0, + overlap: 6.0, + ), + + const SizedBox(width: 12), + + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + chat.displayTitle, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + children: [ + + if (onlineCount > 0) ...[ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '$onlineCount онлайн', + style: TextStyle( + fontSize: 12, + color: colors.primary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + ], + + + Text( + '$totalCount участников', + style: TextStyle( + fontSize: 12, + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + void _showGroupManagementPanel(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => GroupManagementPanel( + chat: chat, + contacts: contacts, + myId: myId, + onParticipantsChanged: onParticipantsChanged, + ), + ); + } +} diff --git a/lib/widgets/group_management_panel.dart b/lib/widgets/group_management_panel.dart new file mode 100644 index 0000000..12b8156 --- /dev/null +++ b/lib/widgets/group_management_panel.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:gwid/models/chat.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/screens/group_settings_screen.dart'; + +class GroupManagementPanel extends StatefulWidget { + final Chat chat; + final Map contacts; + final int myId; + final VoidCallback? onParticipantsChanged; + + const GroupManagementPanel({ + super.key, + required this.chat, + required this.contacts, + required this.myId, + this.onParticipantsChanged, + }); + + @override + State createState() => _GroupManagementPanelState(); +} + +class _GroupManagementPanelState extends State { + final ApiService _apiService = ApiService.instance; + bool _isLoading = false; + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.3, + maxChildSize: 1.0, + builder: (context, scrollController) { + return _buildContent(context, scrollController); + }, + ); + } + + Widget _buildContent( + BuildContext context, + ScrollController scrollController, + ) { + final colors = Theme.of(context).colorScheme; + final participantIds = widget.chat.groupParticipantIds; + final participants = participantIds + .map((id) => widget.contacts[id]) + .where((contact) => contact != null) + .cast() + .toList(); + + return Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + + Container( + margin: const EdgeInsets.only(top: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colors.onSurfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: colors.outline.withOpacity(0.2)), + ), + ), + child: Row( + children: [ + Icon(Icons.group, color: colors.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.chat.displayTitle, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: colors.onSurface, + ), + ), + Text( + '${participants.length} участников', + style: TextStyle( + fontSize: 14, + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => GroupSettingsScreen( + chatId: widget.chat.id, + initialContact: + widget.contacts[widget.chat.ownerId] ?? + Contact( + id: 0, + name: widget.chat.displayTitle, + firstName: '', + lastName: '', + ), + myId: widget.myId, + ), + ), + ); + }, + icon: Icon(Icons.settings, color: colors.primary), + tooltip: 'Настройки группы', + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.close, color: colors.onSurfaceVariant), + ), + ], + ), + ), + + + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoading ? null : _showAddParticipantDialog, + icon: const Icon(Icons.person_add), + label: const Text('Добавить участника'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ), + + + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: participants.length, + itemBuilder: (context, index) { + final participant = participants[index]; + final isOwner = participant.id == widget.chat.ownerId; + final isMe = participant.id == widget.myId; + + return ListTile( + leading: CircleAvatar( + backgroundImage: participant.photoBaseUrl != null + ? NetworkImage(participant.photoBaseUrl!) + : null, + child: participant.photoBaseUrl == null + ? Text( + participant.name.isNotEmpty + ? participant.name[0].toUpperCase() + : '?', + style: TextStyle(color: colors.onPrimaryContainer), + ) + : null, + ), + title: Row( + children: [ + Text( + participant.name, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + if (isOwner) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Создатель', + style: TextStyle( + color: colors.onPrimary, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + if (isMe) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.secondary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Вы', + style: TextStyle( + color: colors.onSecondary, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + subtitle: Text( + 'ID: ${participant.id}', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12, + ), + ), + trailing: isOwner || isMe + ? null + : PopupMenuButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onSelected: (value) => + _handleParticipantAction(participant, value), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'remove', + child: Row( + children: [ + Icon(Icons.person_remove, color: Colors.red), + SizedBox(width: 8), + Text('Удалить из группы'), + ], + ), + ), + const PopupMenuItem( + value: 'remove_with_messages', + child: Row( + children: [ + Icon(Icons.delete_forever, color: Colors.red), + SizedBox(width: 8), + Text('Удалить с сообщениями'), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + void _showAddParticipantDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Добавить участника'), + content: const Text('Введите ID пользователя для добавления в группу'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Функция добавления участника в разработке'), + ), + ); + }, + child: const Text('Добавить'), + ), + ], + ), + ); + } + + Future _handleParticipantAction( + Contact participant, + String action, + ) async { + if (_isLoading) return; + + setState(() { + _isLoading = true; + }); + + try { + if (action == 'remove') { + await _removeParticipant(participant.id, cleanMessages: false); + } else if (action == 'remove_with_messages') { + await _removeParticipant(participant.id, cleanMessages: true); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + action == 'remove' + ? '${participant.name} удален из группы' + : '${participant.name} удален с сообщениями', + ), + ), + ); + widget.onParticipantsChanged?.call(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _removeParticipant( + int userId, { + required bool cleanMessages, + }) async { + + print('Удаляем участника $userId, очистка сообщений: $cleanMessages'); + + + _apiService.sendMessage(widget.chat.id, '', replyToMessageId: null); + } +} diff --git a/lib/widgets/reconnection_overlay.dart b/lib/widgets/reconnection_overlay.dart new file mode 100644 index 0000000..32f5025 --- /dev/null +++ b/lib/widgets/reconnection_overlay.dart @@ -0,0 +1,116 @@ + + +import 'package:flutter/material.dart'; + +class ReconnectionOverlay extends StatelessWidget { + final bool isReconnecting; + final String? message; + + const ReconnectionOverlay({ + super.key, + required this.isReconnecting, + this.message, + }); + + @override + Widget build(BuildContext context) { + if (!isReconnecting) { + return const SizedBox.shrink(); + } + + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + color: Colors.black.withOpacity(0.7), + child: Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(height: 16), + + + Text( + message ?? 'Переподключение...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 8), + + + Text( + 'Пожалуйста, подождите', + style: TextStyle( + fontSize: 14, + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } +} + +class ReconnectionOverlayController { + static final ReconnectionOverlayController _instance = + ReconnectionOverlayController._internal(); + factory ReconnectionOverlayController() => _instance; + ReconnectionOverlayController._internal(); + + bool _isReconnecting = false; + String? _message; + VoidCallback? _onStateChanged; + + bool get isReconnecting => _isReconnecting; + String? get message => _message; + + void setOnStateChanged(VoidCallback? callback) { + _onStateChanged = callback; + } + + void showReconnecting({String? message}) { + _isReconnecting = true; + _message = message; + _onStateChanged?.call(); + } + + void hideReconnecting() { + _isReconnecting = false; + _message = null; + _onStateChanged?.call(); + } +} diff --git a/lib/widgets/user_profile_panel.dart b/lib/widgets/user_profile_panel.dart new file mode 100644 index 0000000..c6b3a88 --- /dev/null +++ b/lib/widgets/user_profile_panel.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:gwid/services/avatar_cache_service.dart'; + + + + +class UserProfilePanel extends StatefulWidget { + final int userId; + final String? name; + final String? firstName; + final String? lastName; + final String? avatarUrl; + final String? description; + final int myId; + final int? currentChatId; + final Map? contactData; + final int? dialogChatId; + + const UserProfilePanel({ + super.key, + required this.userId, + this.name, + this.firstName, + this.lastName, + this.avatarUrl, + this.description, + required this.myId, + this.currentChatId, + this.contactData, + this.dialogChatId, + }); + + @override + State createState() => _UserProfilePanelState(); +} + +class _UserProfilePanelState extends State { + final ScrollController _nameScrollController = ScrollController(); + + + + String get _displayName { + if (widget.firstName != null || widget.lastName != null) { + final firstName = widget.firstName ?? ''; + final lastName = widget.lastName ?? ''; + final fullName = '$firstName $lastName'.trim(); + return fullName.isNotEmpty ? fullName : (widget.name ?? 'Неизвестный'); + } + return widget.name ?? 'Неизвестный'; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkNameLength(); + }); + } + + void _checkNameLength() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_nameScrollController.hasClients) { + final maxScroll = _nameScrollController.position.maxScrollExtent; + if (maxScroll > 0) { + _startNameScroll(); + } + } + }); + } + + void _startNameScroll() { + if (!_nameScrollController.hasClients) return; + + Future.delayed(const Duration(seconds: 2), () { + if (!mounted || !_nameScrollController.hasClients) return; + + _nameScrollController + .animateTo( + _nameScrollController.position.maxScrollExtent, + duration: const Duration(seconds: 3), + curve: Curves.easeInOut, + ) + .then((_) { + if (!mounted) return; + Future.delayed(const Duration(seconds: 1), () { + if (!mounted || !_nameScrollController.hasClients) return; + _nameScrollController + .animateTo( + 0, + duration: const Duration(seconds: 3), + curve: Curves.easeInOut, + ) + .then((_) { + if (mounted) { + Future.delayed(const Duration(seconds: 2), () { + if (mounted) _startNameScroll(); + }); + } + }); + }); + }); + }); + } + + @override + void dispose() { + _nameScrollController.dispose(); + super.dispose(); + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colors.onSurfaceVariant.withOpacity(0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + AvatarCacheService().getAvatarWidget( + widget.avatarUrl, + userId: widget.userId, + size: 80, + fallbackText: _displayName, + backgroundColor: colors.primaryContainer, + textColor: colors.onPrimaryContainer, + ), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + final textPainter = TextPainter( + text: TextSpan( + text: _displayName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + maxLines: 1, + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + final textWidth = textPainter.size.width; + final needsScroll = textWidth > constraints.maxWidth; + + if (needsScroll) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkNameLength(); + }); + return SizedBox( + height: 28, + child: SingleChildScrollView( + controller: _nameScrollController, + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + child: Text( + _displayName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ); + } else { + return Text( + _displayName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ); + } + }, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildActionButton( + icon: Icons.phone, + label: 'Позвонить', + onPressed: null, + colors: colors, + ), + _buildActionButton( + icon: Icons.person_add, + label: 'В контакты', + onPressed: null, + colors: colors, + ), + _buildActionButton( + icon: Icons.message, + label: 'Написать', + onPressed: null, + colors: colors, + ), + ], + ), + if (widget.description != null && + widget.description!.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + widget.description!, + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + SizedBox(height: MediaQuery.of(context).padding.bottom), + ], + ), + ), + ], + ), + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required VoidCallback? onPressed, + required ColorScheme colors, + bool isLoading = false, + }) { + return Column( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colors.primaryContainer, + shape: BoxShape.circle, + ), + child: isLoading + ? Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(colors.primary), + ), + ), + ) + : IconButton( + icon: Icon(icon, color: colors.primary), + onPressed: onPressed, + ), + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant), + ), + ], + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..a91e348 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,141 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "Komet") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.github.TeamKomet-Developer-s.app") + + + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Check for Wayland support in GTK +#pkg_check_modules(GTK_WAYLAND REQUIRED IMPORTED_TARGET gtk+-3.0) + +#pkg_check_modules(FLUTTER_INAPPWEBVIEW_PLUGIN REQUIRED IMPORTED_TARGET +# webkit2gtk-4.0 +# gtk+-3.0 +#) + + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === 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) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# 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}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +## Install the AOT library on non-Debug builds only. +#if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") +# install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" +# COMPONENT Runtime) +#endif() + + diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..988d667 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,47 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) dynamic_color_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); + dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); + g_autoptr(FlPluginRegistrar) file_saver_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin"); + file_saver_plugin_register_with_registrar(file_saver_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); + flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) open_file_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); + open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) smart_auth_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin"); + smart_auth_plugin_register_with_registrar(smart_auth_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..25b57ba --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + dynamic_color + file_saver + file_selector_linux + flutter_secure_storage_linux + flutter_timezone + gtk + open_file_linux + smart_auth + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/readme.md b/linux/readme.md new file mode 100644 index 0000000..25ecdbe --- /dev/null +++ b/linux/readme.md @@ -0,0 +1,3 @@ +# Linux Build +Maintenied by: ivan2282 +No modifications to this folder without permission \ No newline at end of file diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..53bea08 --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..7243ee1 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,8 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + + gdk_set_allowed_backends("x11"); + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..62a6c8b --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,143 @@ + +#include "my_application.h" + +#include + + + + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + + +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + + + GError* error = NULL; + GdkPixbuf* icon_pixbuf = gdk_pixbuf_new_from_file("assets/icon/komet_512.png", &error); + if (icon_pixbuf != NULL) { + gtk_window_set_icon(window, icon_pixbuf); + g_object_unref(icon_pixbuf); + } else { + g_warning("Failed to load icon: %s", error->message); + g_clear_error(&error); + + + icon_pixbuf = gdk_pixbuf_new_from_file("data/flutter_assets/assets/icon/komet_512.png", &error); + if (icon_pixbuf != NULL) { + gtk_window_set_icon(window, icon_pixbuf); + g_object_unref(icon_pixbuf); + } else { + g_warning("Failed to load icon: %s", error->message); + g_clear_error(&error); + } + } + + gtk_window_set_title(window, "Komet"); + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + + + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + + +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + + +static void my_application_startup(GApplication* application) { + + + + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + + +static void my_application_shutdown(GApplication* application) { + + + + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + + +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + + + + + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..0118b8a --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,10 @@ +#ifndef MY_APPLICATION_H +#define MY_APPLICATION_H + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, GtkApplication) + +MyApplication *my_application_new(); + +#endif \ No newline at end of file diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..65ea0b3 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,50 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import device_info_plus +import dynamic_color +import file_picker +import file_saver +import file_selector_macos +import flutter_inappwebview_macos +import flutter_secure_storage_macos +import flutter_timezone +import mobile_scanner +import open_file_mac +import package_info_plus +import path_provider_foundation +import share_plus +import shared_preferences_foundation +import smart_auth +import sqflite_darwin +import url_launcher_macos +import video_player_avfoundation +import wakelock_plus + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..325ea1b --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* gwid.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "gwid.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* gwid.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* gwid.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gwid.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gwid"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gwid.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gwid"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gwid.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gwid"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..f359c81 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..d8d0b39 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = gwid + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.gwid.app.gwid + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.gwid.app. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..e573b40 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1543 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + chewie: + dependency: "direct main" + description: + name: chewie + sha256: "44bcfc5f0dfd1de290c87c9d86a61308b3282a70b63435d5557cfd60f54a69ca" + url: "https://pub.dev" + source: hosted + version: "1.13.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + url: "https://pub.dev" + source: hosted + version: "10.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: transitive + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + disable_battery_optimization: + dependency: "direct main" + description: + name: disable_battery_optimization + sha256: "7fbda76cdd30d01d75e091db8869b8cf2fd4c7b7edef3abce0d7206569603051" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c" + url: "https://pub.dev" + source: hosted + version: "1.8.1" + encrypt: + dependency: "direct main" + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + es_compression: + dependency: "direct main" + description: + name: es_compression + sha256: "174d2cee4e182d1a8df92712eaa6ee56a0adf83d4b8a14838c66380caa4e65a7" + url: "https://pub.dev" + source: hosted + version: "2.0.14" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f2d9f173c2c14635cc0e9b14c143c49ef30b4934e8d1d274d6206fcb0086a06f + url: "https://pub.dev" + source: hosted + version: "10.3.3" + file_saver: + dependency: "direct main" + description: + name: file_saver + sha256: "9d93db09bd4da9e43238f9dd485360fc51a5c138eea5ef5f407ec56e58079ac0" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.dev" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_background_service: + dependency: "direct main" + description: + name: flutter_background_service + sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_background_service_android: + dependency: transitive + description: + name: flutter_background_service_android + sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87 + url: "https://pub.dev" + source: hosted + version: "6.3.1" + flutter_background_service_ios: + dependency: transitive + description: + name: flutter_background_service_ios + sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + flutter_background_service_platform_interface: + dependency: transitive + description: + name: flutter_background_service_platform_interface + sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41 + url: "https://pub.dev" + source: hosted + version: "5.1.2" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_highlight: + dependency: "direct main" + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_linkify: + dependency: "direct main" + description: + name: flutter_linkify + sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 + url: "https://pub.dev" + source: hosted + version: "2.0.30" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: ccad42fbb5d01d51d3eb281cc4428fca556cc4063c52bd9fa40f80cd93b8e649 + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + highlight: + dependency: transitive + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8dfe08ea7fcf7467dbaf6889e72eebd5e0d6711caae201fdac780eb45232cd02" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + mask_text_input_formatter: + dependency: "direct main" + description: + name: mask_text_input_formatter + sha256: "978c58ec721c25621ceb468e633f4eef64b64d45424ac4540e0565d4f7c800cd" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "5e7e09d904dc01de071b79b3f3789b302b0ed3c9c963109cd3f83ad90de62ecf" + url: "https://pub.dev" + source: hosted + version: "7.1.2" + msgpack_dart: + dependency: "direct main" + description: + name: msgpack_dart + sha256: c2d235ed01f364719b5296aecf43ac330f0d7bc865fa134d0d7910a40454dffb + url: "https://pub.dev" + source: hosted + version: "1.0.1" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + open_file: + dependency: "direct main" + description: + name: open_file + sha256: d17e2bddf5b278cb2ae18393d0496aa4f162142ba97d1a9e0c30d476adf99c0e + url: "https://pub.dev" + source: hosted + version: "3.5.10" + open_file_android: + dependency: transitive + description: + name: open_file_android + sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + open_file_ios: + dependency: transitive + description: + name: open_file_ios + sha256: "02996f01e5f6863832068e97f8f3a5ef9b613516db6897f373b43b79849e4d07" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_linux: + dependency: transitive + description: + name: open_file_linux + sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550 + url: "https://pub.dev" + source: hosted + version: "0.0.5" + open_file_mac: + dependency: transitive + description: + name: open_file_mac + sha256: "1440b1e37ceb0642208cfeb2c659c6cda27b25187a90635c9d1acb7d0584d324" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_platform_interface: + dependency: transitive + description: + name: open_file_platform_interface + sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_web: + dependency: transitive + description: + name: open_file_web + sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f + url: "https://pub.dev" + source: hosted + version: "0.0.4" + open_file_windows: + dependency: transitive + description: + name: open_file_windows + sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" + source: hosted + version: "2.2.18" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + pinput: + dependency: "direct main" + description: + name: pinput + sha256: "6d571e38a484f7515a52e89024ef416f11fa6171ac6f32303701374ab9890efa" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "3424e9d5c22fd7f7590254ba09465febd6f8827c8b19a44350de4ac31d92d3a6" + url: "https://pub.dev" + source: hosted + version: "12.0.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0b0f98d535319cb5cdd4f65783c2a54ee6d417a2f093dbb18be3e36e4c3d181f" + url: "https://pub.dev" + source: hosted + version: "2.4.14" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + smart_auth: + dependency: transitive + description: + name: smart_auth + sha256: "88aa8fe66e951c78a307f26d1c29672dce2e9eb3da2e12e853864d0e615a73ad" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + socks5_proxy: + dependency: "direct main" + description: + name: socks5_proxy + sha256: "80fa31a9ebfc0dc8de7b0e568c8d8927b65558ef2c7591cbee5afac814fb8f74" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: c0fb544b9ac7efa10254efaf00a951615c362d1ea1877472f8f6c0fa00fcf15b + url: "https://pub.dev" + source: hosted + version: "6.3.23" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: cf768d02924b91e333e2bc1ff928528f57d686445874f383bafab12d0bdfc340 + url: "https://pub.dev" + source: hosted + version: "2.8.17" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "19ed1162a7a5520e7d7791e0b7b73ba03161b6a69428b82e4689e435b325432d" + url: "https://pub.dev" + source: hosted + version: "2.8.5" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "9e372520573311055cb353b9a0da1c9d72b094b7ba01b8ecc66f28473553793b" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..04fe75b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,188 @@ +name: gwid +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 0.2.0+4 + +environment: + sdk: ^3.9.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: # <-- ДОБАВЬ ЭТУ СТРОКУ + sdk: flutter # <-- И ЭТУ СТРОКУ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + + dynamic_color: ^1.7.0 + + + pinput: ^4.0.0 + + + mask_text_input_formatter: ^2.9.0 + + + web_socket_channel: ^2.4.0 + + uuid: ^4.4.0 + + device_info_plus: ^10.1.0 + + package_info_plus: ^8.0.0 + + + shared_preferences: ^2.2.3 + + intl: ^0.20.2 + + provider: ^6.1.2 + flutter_colorpicker: ^1.1.0 + + http: ^1.5.0 + image_picker: ^1.1.2 + path_provider: ^2.1.4 + path: ^1.9.0 + crypto: ^3.0.3 + + cached_network_image: ^3.3.1 + + scrollable_positioned_list: ^0.3.8 + + socks5_proxy: ^2.1.1 + + flutter_launcher_icons: ^0.14.4 + + flutter_timezone: ^5.0.0 + + timezone: ^0.9.2 + + file_saver: ^0.3.1 + + file_picker: ^10.3.3 + + permission_handler: ^11.3.1 + + encrypt: ^5.0.3 + + google_fonts: ^6.2.1 + + share_plus: ^12.0.0 + + url_launcher: 6.3.2 + + flutter_background_service: ^5.0.1 + + flutter_markdown: ^0.7.7 + + video_player: ^2.9.2 + + qr_flutter: ^4.1.0 + + mobile_scanner: ^7.1.2 + + es_compression: ^2.0.14 + + msgpack_dart: ^1.0.1 + + disable_battery_optimization: ^1.1.2 + + flutter_highlight: ^0.7.0 + + flutter_linkify: ^6.0.0 + app_links: ^6.4.1 + + open_file: ^3.5.2 + + flutter_secure_storage: ^9.2.4 + flutter_inappwebview: ^6.1.5 + + chewie: ^1.7.5 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icon/icon1.jpg" + adaptive_icon_background: "assets/icon/bg.png" + adaptive_icon_foreground: "assets/icon/komet_512.png" + windows: + generate: true + image_path: "assets/icon/komet_512.png" + icon_size: 256 + +# The following section is specific to Flutter packages. +flutter: + + # 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. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/ + - assets/icon/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..6fe6c87 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,111 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(gwid LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "Komet") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + +if(TARGET flutter_secure_storage_windows_plugin) + target_link_libraries(flutter_secure_storage_windows_plugin PRIVATE atls) +endif() + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# 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}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..61f4eb0 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,110 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..2f0c299 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,44 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + DynamicColorPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); + FileSaverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSaverPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterTimezonePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + SmartAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SmartAuthPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..d150f00 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,34 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + app_links + dynamic_color + file_saver + file_selector_windows + flutter_inappwebview_windows + flutter_secure_storage_windows + flutter_timezone + permission_handler_windows + share_plus + smart_auth + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..1be8780 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.gwid.app" "\0" + VALUE "FileDescription", "Komet" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "Komet" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.gwid.app. All rights reserved." "\0" + VALUE "OriginalFilename", "Komet.exe" "\0" + VALUE "ProductName", "Komet" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..3761490 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"Komet", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..a2fffd6 Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_